mirror of
https://github.com/ansible/awx.git
synced 2026-01-17 04:31:21 -03:30
Merge pull request #5424 from AlexSCorey/InventoryGroupAdd/Edit
Adds Inventory Groups Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
commit
04c535e3f9
@ -3,6 +3,7 @@ import { shape, string, number, arrayOf } from 'prop-types';
|
||||
import { Tab, Tabs as PFTabs } from '@patternfly/react-core';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
|
||||
const Tabs = styled(PFTabs)`
|
||||
--pf-c-tabs__button--PaddingLeft: 20px;
|
||||
@ -62,7 +63,15 @@ function RoutedTabs(props) {
|
||||
eventKey={tab.id}
|
||||
key={tab.id}
|
||||
link={tab.link}
|
||||
title={tab.name}
|
||||
title={
|
||||
tab.isNestedTabs ? (
|
||||
<>
|
||||
<CaretLeftIcon /> {tab.name}
|
||||
</>
|
||||
) : (
|
||||
tab.name
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
@ -15,7 +15,7 @@ import CredentialTypes from '@screens/CredentialType';
|
||||
import Dashboard from '@screens/Dashboard';
|
||||
import Hosts from '@screens/Host';
|
||||
import InstanceGroups from '@screens/InstanceGroup';
|
||||
import Inventories from '@screens/Inventory';
|
||||
import Inventory from '@screens/Inventory';
|
||||
import InventoryScripts from '@screens/InventoryScript';
|
||||
import { Jobs } from '@screens/Job';
|
||||
import Login from '@screens/Login';
|
||||
@ -139,7 +139,7 @@ export function main(render) {
|
||||
{
|
||||
title: i18n._(t`Inventories`),
|
||||
path: '/inventories',
|
||||
component: Inventories,
|
||||
component: Inventory,
|
||||
},
|
||||
{
|
||||
title: i18n._(t`Hosts`),
|
||||
|
||||
@ -27,7 +27,7 @@ class Inventories extends Component {
|
||||
};
|
||||
}
|
||||
|
||||
setBreadCrumbConfig = inventory => {
|
||||
setBreadCrumbConfig = (inventory, group) => {
|
||||
const { i18n } = this.props;
|
||||
if (!inventory) {
|
||||
return;
|
||||
@ -57,6 +57,15 @@ class Inventories extends Component {
|
||||
),
|
||||
[`/inventories/inventory/${inventory.id}/sources`]: i18n._(t`Sources`),
|
||||
[`/inventories/inventory/${inventory.id}/groups`]: i18n._(t`Groups`),
|
||||
[`/inventories/inventory/${inventory.id}/groups/add`]: i18n._(
|
||||
t`Create New Group`
|
||||
),
|
||||
[`/inventories/inventory/${inventory.id}/groups/${group &&
|
||||
group.id}`]: `${group && group.name}`,
|
||||
[`/inventories/inventory/${inventory.id}/groups/${group &&
|
||||
group.id}/details`]: i18n._(t`Group Details`),
|
||||
[`/inventories/inventory/${inventory.id}/groups/${group &&
|
||||
group.id}/edit`]: i18n._(t`Edit Details`),
|
||||
};
|
||||
this.setState({ breadcrumbConfig });
|
||||
};
|
||||
|
||||
@ -57,7 +57,11 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) {
|
||||
</CardHeader>
|
||||
);
|
||||
|
||||
if (location.pathname.endsWith('edit') || location.pathname.endsWith('add')) {
|
||||
if (
|
||||
location.pathname.endsWith('edit') ||
|
||||
location.pathname.endsWith('add') ||
|
||||
location.pathname.includes('groups/')
|
||||
) {
|
||||
cardHeader = null;
|
||||
}
|
||||
|
||||
@ -123,7 +127,15 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) {
|
||||
<Route
|
||||
key="groups"
|
||||
path="/inventories/inventory/:id/groups"
|
||||
render={() => <InventoryGroups inventory={inventory} />}
|
||||
render={() => (
|
||||
<InventoryGroups
|
||||
location={location}
|
||||
match={match}
|
||||
history={history}
|
||||
setBreadcrumb={setBreadcrumb}
|
||||
inventory={inventory}
|
||||
/>
|
||||
)}
|
||||
/>,
|
||||
<Route
|
||||
key="hosts"
|
||||
|
||||
@ -15,7 +15,7 @@ import { getAddedAndRemoved } from '../../../util/lists';
|
||||
function InventoryEdit({ history, i18n, inventory }) {
|
||||
const [error, setError] = useState(null);
|
||||
const [associatedInstanceGroups, setInstanceGroups] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [contentLoading, setContentLoading] = useState(true);
|
||||
const [credentialTypeId, setCredentialTypeId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -39,11 +39,11 @@ function InventoryEdit({ history, i18n, inventory }) {
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setContentLoading(false);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}, [inventory.id, isLoading, inventory, credentialTypeId]);
|
||||
}, [inventory.id, contentLoading, inventory, credentialTypeId]);
|
||||
|
||||
const handleCancel = () => {
|
||||
history.push('/inventories');
|
||||
@ -85,7 +85,7 @@ function InventoryEdit({ history, i18n, inventory }) {
|
||||
history.push(`${url}`);
|
||||
}
|
||||
};
|
||||
if (isLoading) {
|
||||
if (contentLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
if (error) {
|
||||
|
||||
@ -0,0 +1,159 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { CardHeader } from '@patternfly/react-core';
|
||||
|
||||
import { Switch, Route, withRouter, Link, Redirect } from 'react-router-dom';
|
||||
import { GroupsAPI } from '@api';
|
||||
import CardCloseButton from '@components/CardCloseButton';
|
||||
import RoutedTabs from '@components/RoutedTabs';
|
||||
import ContentError from '@components/ContentError';
|
||||
import ContentLoading from '@components/ContentLoading';
|
||||
import InventoryGroupEdit from '../InventoryGroupEdit/InventoryGroupEdit';
|
||||
import InventoryGroupDetail from '../InventoryGroupDetail/InventoryGroupDetail';
|
||||
|
||||
function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) {
|
||||
const [inventoryGroup, setInventoryGroup] = useState(null);
|
||||
const [contentLoading, setContentLoading] = useState(true);
|
||||
const [contentError, setContentError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const { data } = await GroupsAPI.readDetail(match.params.groupId);
|
||||
setInventoryGroup(data);
|
||||
setBreadcrumb(inventory, data);
|
||||
} catch (err) {
|
||||
setContentError(err);
|
||||
} finally {
|
||||
setContentLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [
|
||||
history.location.pathname,
|
||||
match.params.groupId,
|
||||
inventory,
|
||||
setBreadcrumb,
|
||||
]);
|
||||
|
||||
const tabsArray = [
|
||||
{
|
||||
name: i18n._(t`Return to Groups`),
|
||||
link: `/inventories/inventory/${inventory.id}/groups`,
|
||||
id: 99,
|
||||
isNestedTabs: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Details`),
|
||||
link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup &&
|
||||
inventoryGroup.id}/details`,
|
||||
id: 0,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Related Groups`),
|
||||
link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup &&
|
||||
inventoryGroup.id}/nested_groups`,
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Hosts`),
|
||||
link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup &&
|
||||
inventoryGroup.id}/nested_hosts`,
|
||||
id: 2,
|
||||
},
|
||||
];
|
||||
|
||||
// In cases where a user manipulates the url such that they try to navigate to a Inventory Group
|
||||
// that is not associated with the Inventory Id in the Url this Content Error is thrown.
|
||||
// Inventory Groups have a 1: 1 relationship to Inventories thus their Ids must corrolate.
|
||||
|
||||
if (contentLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
|
||||
if (
|
||||
inventoryGroup.summary_fields.inventory.id !== parseInt(match.params.id, 10)
|
||||
) {
|
||||
return (
|
||||
<ContentError>
|
||||
{inventoryGroup && (
|
||||
<Link to={`/inventories/inventory/${inventory.id}/groups`}>
|
||||
{i18n._(t`View Inventory Groups`)}
|
||||
</Link>
|
||||
)}
|
||||
</ContentError>
|
||||
);
|
||||
}
|
||||
|
||||
if (contentError) {
|
||||
return <ContentError error={contentError} />;
|
||||
}
|
||||
|
||||
let cardHeader = null;
|
||||
if (
|
||||
history.location.pathname.includes('groups/') &&
|
||||
!history.location.pathname.endsWith('edit')
|
||||
) {
|
||||
cardHeader = (
|
||||
<CardHeader style={{ padding: 0 }}>
|
||||
<RoutedTabs history={history} tabsArray={tabsArray} />
|
||||
<CardCloseButton
|
||||
linkTo={`/inventories/inventory/${inventory.id}/groups`}
|
||||
/>
|
||||
</CardHeader>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{cardHeader}
|
||||
<Switch>
|
||||
<Redirect
|
||||
from="/inventories/inventory/:id/groups/:groupId"
|
||||
to="/inventories/inventory/:id/groups/:groupId/details"
|
||||
exact
|
||||
/>
|
||||
{inventoryGroup && [
|
||||
<Route
|
||||
key="edit"
|
||||
path="/inventories/inventory/:id/groups/:groupId/edit"
|
||||
render={() => {
|
||||
return (
|
||||
<InventoryGroupEdit
|
||||
inventory={inventory}
|
||||
inventoryGroup={inventoryGroup}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>,
|
||||
<Route
|
||||
key="details"
|
||||
path="/inventories/inventory/:id/groups/:groupId/details"
|
||||
render={() => {
|
||||
return <InventoryGroupDetail inventoryGroup={inventoryGroup} />;
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
<Route
|
||||
key="not-found"
|
||||
path="*"
|
||||
render={() => {
|
||||
return (
|
||||
<ContentError>
|
||||
{inventory && (
|
||||
<Link to={`/inventories/inventory/${inventory.id}/details`}>
|
||||
{i18n._(t`View Inventory Details`)}
|
||||
</Link>
|
||||
)}
|
||||
</ContentError>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { InventoryGroups as _InventoryGroups };
|
||||
export default withI18n()(withRouter(InventoryGroups));
|
||||
@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { GroupsAPI } from '@api';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
|
||||
import InventoryGroup from './InventoryGroup';
|
||||
|
||||
jest.mock('@api');
|
||||
GroupsAPI.readDetail.mockResolvedValue({
|
||||
data: {
|
||||
id: 1,
|
||||
name: 'Foo',
|
||||
description: 'Bar',
|
||||
variables: 'bizz: buzz',
|
||||
summary_fields: {
|
||||
inventory: { id: 1 },
|
||||
created_by: { id: 1, name: 'Athena' },
|
||||
modified_by: { id: 1, name: 'Apollo' },
|
||||
},
|
||||
},
|
||||
});
|
||||
describe('<InventoryGroup />', () => {
|
||||
let wrapper;
|
||||
let history;
|
||||
const inventory = { id: 1, name: 'Foo' };
|
||||
beforeEach(async () => {
|
||||
history = createMemoryHistory({
|
||||
initialEntries: ['/inventories/inventory/1/groups/1/details'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Route
|
||||
path="/inventories/inventory/:id/groups"
|
||||
component={() => (
|
||||
<InventoryGroup setBreadcrumb={() => {}} inventory={inventory} />
|
||||
)}
|
||||
/>,
|
||||
{
|
||||
context: {
|
||||
router: {
|
||||
history,
|
||||
route: {
|
||||
location: history.location,
|
||||
match: {
|
||||
params: { id: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('renders successfully', async () => {
|
||||
expect(wrapper.length).toBe(1);
|
||||
});
|
||||
test('expect all tabs to exist, including Return to Groups', async () => {
|
||||
expect(wrapper.find('button[aria-label="Return to Groups"]').length).toBe(
|
||||
1
|
||||
);
|
||||
expect(wrapper.find('button[aria-label="Details"]').length).toBe(1);
|
||||
expect(wrapper.find('button[aria-label="Related Groups"]').length).toBe(1);
|
||||
expect(wrapper.find('button[aria-label="Hosts"]').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './InventoryGroup';
|
||||
@ -0,0 +1,39 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { GroupsAPI } from '@api';
|
||||
import { Card } from '@patternfly/react-core';
|
||||
|
||||
import InventoryGroupForm from '../shared/InventoryGroupForm';
|
||||
|
||||
function InventoryGroupsAdd({ history, inventory, setBreadcrumb }) {
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => setBreadcrumb(inventory), [inventory, setBreadcrumb]);
|
||||
|
||||
const handleSubmit = async values => {
|
||||
values.inventory = inventory.id;
|
||||
try {
|
||||
const { data } = await GroupsAPI.create(values);
|
||||
history.push(`/inventories/inventory/${inventory.id}/groups/${data.id}`);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
history.push(`/inventories/inventory/${inventory.id}/groups`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<InventoryGroupForm
|
||||
error={error}
|
||||
handleCancel={handleCancel}
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
export default withI18n()(withRouter(InventoryGroupsAdd));
|
||||
export { InventoryGroupsAdd as _InventoryGroupsAdd };
|
||||
@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Route } from 'react-router-dom';
|
||||
|
||||
import { GroupsAPI } from '@api';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
|
||||
import InventoryGroupAdd from './InventoryGroupAdd';
|
||||
|
||||
jest.mock('@api');
|
||||
|
||||
describe('<InventoryGroupAdd />', () => {
|
||||
let wrapper;
|
||||
let history;
|
||||
beforeEach(async () => {
|
||||
history = createMemoryHistory({
|
||||
initialEntries: ['/inventories/inventory/1/groups/add'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Route
|
||||
path="/inventories/inventory/:id/groups/add"
|
||||
component={() => (
|
||||
<InventoryGroupAdd setBreadcrumb={() => {}} inventory={{ id: 1 }} />
|
||||
)}
|
||||
/>,
|
||||
{
|
||||
context: {
|
||||
router: { history, route: { location: history.location } },
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('InventoryGroupAdd renders successfully', () => {
|
||||
expect(wrapper.length).toBe(1);
|
||||
});
|
||||
test('cancel should navigate user to Inventory Groups List', async () => {
|
||||
wrapper.find('button[aria-label="Cancel"]').simulate('click');
|
||||
expect(history.location.pathname).toEqual(
|
||||
'/inventories/inventory/1/groups'
|
||||
);
|
||||
});
|
||||
test('handleSubmit should call api', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('InventoryGroupForm').prop('handleSubmit')({
|
||||
name: 'Bar',
|
||||
description: 'Ansible',
|
||||
variables: 'ying: yang',
|
||||
});
|
||||
});
|
||||
|
||||
expect(GroupsAPI.create).toBeCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './InventoryGroupAdd';
|
||||
@ -0,0 +1,165 @@
|
||||
import React, { useState } from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import { CardBody, Button } from '@patternfly/react-core';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { withRouter, Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { VariablesInput as CodeMirrorInput } from '@components/CodeMirrorInput';
|
||||
import ErrorDetail from '@components/ErrorDetail';
|
||||
import AlertModal from '@components/AlertModal';
|
||||
import { formatDateString } from '@util/dates';
|
||||
|
||||
import { GroupsAPI } from '@api';
|
||||
import { DetailList, Detail } from '@components/DetailList';
|
||||
|
||||
const VariablesInput = styled(CodeMirrorInput)`
|
||||
.pf-c-form__label {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
margin: 20px 0;
|
||||
`;
|
||||
const ActionButtonWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
& > :not(:first-child) {
|
||||
margin-left: 20px;
|
||||
}
|
||||
`;
|
||||
function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) {
|
||||
const {
|
||||
summary_fields: { created_by, modified_by },
|
||||
created,
|
||||
modified,
|
||||
name,
|
||||
description,
|
||||
variables,
|
||||
} = inventoryGroup;
|
||||
const [error, setError] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleteModalOpen(false);
|
||||
try {
|
||||
await GroupsAPI.destroy(inventoryGroup.id);
|
||||
history.push(`/inventories/inventory/${match.params.id}/groups`);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
};
|
||||
|
||||
let createdBy = '';
|
||||
if (created) {
|
||||
if (created_by && created_by.username) {
|
||||
createdBy = (
|
||||
<span>
|
||||
{i18n._(t`${formatDateString(inventoryGroup.created)} by`)}{' '}
|
||||
<Link to={`/users/${created_by.id}`}>{created_by.username}</Link>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
createdBy = formatDateString(inventoryGroup.created);
|
||||
}
|
||||
}
|
||||
|
||||
let modifiedBy = '';
|
||||
if (modified) {
|
||||
if (modified_by && modified_by.username) {
|
||||
modifiedBy = (
|
||||
<span>
|
||||
{i18n._(t`${formatDateString(inventoryGroup.modified)} by`)}{' '}
|
||||
<Link to={`/users/${modified_by.id}`}>{modified_by.username}</Link>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
modifiedBy = formatDateString(inventoryGroup.modified);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CardBody css="padding-top: 20px">
|
||||
<DetailList gutter="sm">
|
||||
<Detail label={i18n._(t`Name`)} value={name} />
|
||||
<Detail label={i18n._(t`Description`)} value={description} />
|
||||
</DetailList>
|
||||
<VariablesInput
|
||||
id="inventoryGroup-variables"
|
||||
readOnly
|
||||
value={variables}
|
||||
rows={4}
|
||||
label={i18n._(t`Variables`)}
|
||||
/>
|
||||
<DetailList>
|
||||
{createdBy && <Detail label={i18n._(t`Created`)} value={createdBy} />}
|
||||
{modifiedBy && (
|
||||
<Detail label={i18n._(t`Modified`)} value={modifiedBy} />
|
||||
)}
|
||||
</DetailList>
|
||||
<ActionButtonWrapper>
|
||||
<Button
|
||||
variant="primary"
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
onClick={() =>
|
||||
history.push(
|
||||
`/inventories/inventory/${match.params.id}/groups/${inventoryGroup.id}/edit`
|
||||
)
|
||||
}
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
aria-label={i18n._(t`Delete`)}
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</Button>
|
||||
</ActionButtonWrapper>
|
||||
{isDeleteModalOpen && (
|
||||
<AlertModal
|
||||
variant="danger"
|
||||
title={i18n._(t`Delete Inventory Group`)}
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
actions={[
|
||||
<Button
|
||||
key="delete"
|
||||
variant="danger"
|
||||
aria-label={i18n._(t`confirm delete`)}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</Button>,
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`cancel delete`)}
|
||||
onClick={() => setIsDeleteModalOpen(false)}
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{i18n._(t`Are you sure you want to delete:`)}
|
||||
<br />
|
||||
<strong>{inventoryGroup.name}</strong>
|
||||
<br />
|
||||
</AlertModal>
|
||||
)}
|
||||
{error && (
|
||||
<AlertModal
|
||||
variant="danger"
|
||||
title={i18n._(t`Error!`)}
|
||||
isOpen={error}
|
||||
onClose={() => setError(false)}
|
||||
>
|
||||
{i18n._(t`Failed to delete group ${inventoryGroup.name}.`)}
|
||||
<ErrorDetail error={error} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
export default withI18n()(withRouter(InventoryGroupDetail));
|
||||
@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { GroupsAPI } from '@api';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { createMemoryHistory } from 'history';
|
||||
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||
|
||||
import InventoryGroupDetail from './InventoryGroupDetail';
|
||||
|
||||
jest.mock('@api');
|
||||
const inventoryGroup = {
|
||||
name: 'Foo',
|
||||
description: 'Bar',
|
||||
variables: 'bizz: buzz',
|
||||
id: 1,
|
||||
created: '2019-12-02T15:58:16.276813Z',
|
||||
modified: '2019-12-03T20:33:46.207654Z',
|
||||
summary_fields: {
|
||||
created_by: {
|
||||
username: 'James',
|
||||
id: 13,
|
||||
},
|
||||
modified_by: {
|
||||
username: 'Bond',
|
||||
id: 14,
|
||||
},
|
||||
},
|
||||
};
|
||||
describe('<InventoryGroupDetail />', () => {
|
||||
let wrapper;
|
||||
let history;
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
history = createMemoryHistory({
|
||||
initialEntries: ['/inventories/inventory/1/groups/1/details'],
|
||||
});
|
||||
wrapper = mountWithContexts(
|
||||
<Route
|
||||
path="/inventories/inventory/:id/groups/:groupId"
|
||||
component={() => (
|
||||
<InventoryGroupDetail inventoryGroup={inventoryGroup} />
|
||||
)}
|
||||
/>,
|
||||
{
|
||||
context: {
|
||||
router: { history, route: { location: history.location } },
|
||||
},
|
||||
}
|
||||
);
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('InventoryGroupDetail renders successfully', () => {
|
||||
expect(wrapper.length).toBe(1);
|
||||
});
|
||||
test('should open delete modal and then call api to delete the group', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('button[aria-label="Delete"]').simulate('click');
|
||||
});
|
||||
await waitForElement(wrapper, 'Modal', el => el.length === 1);
|
||||
expect(wrapper.find('Modal').length).toBe(1);
|
||||
await act(async () => {
|
||||
wrapper.find('button[aria-label="confirm delete"]').simulate('click');
|
||||
});
|
||||
expect(GroupsAPI.destroy).toBeCalledWith(1);
|
||||
});
|
||||
test('should navigate user to edit form on edit button click', async () => {
|
||||
wrapper.find('button[aria-label="Edit"]').simulate('click');
|
||||
expect(history.location.pathname).toEqual(
|
||||
'/inventories/inventory/1/groups/1/edit'
|
||||
);
|
||||
});
|
||||
test('details shoudld render with the proper values', () => {
|
||||
expect(wrapper.find('Detail[label="Name"]').prop('value')).toBe('Foo');
|
||||
expect(wrapper.find('Detail[label="Description"]').prop('value')).toBe(
|
||||
'Bar'
|
||||
);
|
||||
expect(wrapper.find('Detail[label="Created"]').length).toBe(1);
|
||||
expect(wrapper.find('Detail[label="Modified"]').length).toBe(1);
|
||||
expect(wrapper.find('VariablesInput').prop('value')).toBe('bizz: buzz');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './InventoryGroupDetail';
|
||||
@ -0,0 +1,38 @@
|
||||
import React, { useState } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { GroupsAPI } from '@api';
|
||||
|
||||
import InventoryGroupForm from '../shared/InventoryGroupForm';
|
||||
|
||||
function InventoryGroupEdit({ history, inventoryGroup, inventory, match }) {
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleSubmit = async values => {
|
||||
try {
|
||||
await GroupsAPI.update(match.params.groupId, values);
|
||||
history.push(
|
||||
`/inventories/inventory/${inventory.id}/groups/${inventoryGroup.id}`
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
history.push(
|
||||
`/inventories/inventory/${inventory.id}/groups/${inventoryGroup.id}`
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<InventoryGroupForm
|
||||
error={error}
|
||||
group={inventoryGroup}
|
||||
handleCancel={handleCancel}
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default withI18n()(withRouter(InventoryGroupEdit));
|
||||
export { InventoryGroupEdit as _InventoryGroupEdit };
|
||||
@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { GroupsAPI } from '@api';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
|
||||
import InventoryGroupEdit from './InventoryGroupEdit';
|
||||
|
||||
jest.mock('@api');
|
||||
GroupsAPI.readDetail.mockResolvedValue({
|
||||
data: {
|
||||
name: 'Foo',
|
||||
description: 'Bar',
|
||||
variables: 'bizz: buzz',
|
||||
},
|
||||
});
|
||||
describe('<InventoryGroupEdit />', () => {
|
||||
let wrapper;
|
||||
let history;
|
||||
beforeEach(async () => {
|
||||
history = createMemoryHistory({
|
||||
initialEntries: ['/inventories/inventory/1/groups/2/edit'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Route
|
||||
path="/inventories/inventory/:id/groups/:groupId/edit"
|
||||
component={() => (
|
||||
<InventoryGroupEdit
|
||||
setBreadcrumb={() => {}}
|
||||
inventory={{ id: 1 }}
|
||||
inventoryGroup={{ id: 2 }}
|
||||
/>
|
||||
)}
|
||||
/>,
|
||||
{
|
||||
context: {
|
||||
router: {
|
||||
history,
|
||||
route: {
|
||||
match: {
|
||||
params: { groupId: 13 },
|
||||
},
|
||||
location: history.location,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('InventoryGroupEdit renders successfully', () => {
|
||||
expect(wrapper.length).toBe(1);
|
||||
});
|
||||
test('cancel should navigate user to Inventory Groups List', async () => {
|
||||
wrapper.find('button[aria-label="Cancel"]').simulate('click');
|
||||
expect(history.location.pathname).toEqual(
|
||||
'/inventories/inventory/1/groups/2'
|
||||
);
|
||||
});
|
||||
test('handleSubmit should call api', async () => {
|
||||
wrapper.find('InventoryGroupForm').prop('handleSubmit')({
|
||||
name: 'Bar',
|
||||
description: 'Ansible',
|
||||
variables: 'ying: yang',
|
||||
});
|
||||
expect(GroupsAPI.update).toBeCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './InventoryGroupEdit';
|
||||
@ -27,7 +27,7 @@ function InventoryGroupItem({
|
||||
onSelect,
|
||||
}) {
|
||||
const labelId = `check-action-${group.id}`;
|
||||
const detailUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/detail`;
|
||||
const detailUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/details`;
|
||||
const editUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/edit`;
|
||||
|
||||
return (
|
||||
|
||||
@ -1,250 +1,45 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { TrashAltIcon } from '@patternfly/react-icons';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { getQSConfig, parseQueryString } from '@util/qs';
|
||||
import { InventoriesAPI, GroupsAPI } from '@api';
|
||||
import { Button, Tooltip } from '@patternfly/react-core';
|
||||
import AlertModal from '@components/AlertModal';
|
||||
import ErrorDetail from '@components/ErrorDetail';
|
||||
import DataListToolbar from '@components/DataListToolbar';
|
||||
import PaginatedDataList, {
|
||||
ToolbarAddButton,
|
||||
} from '@components/PaginatedDataList';
|
||||
import styled from 'styled-components';
|
||||
import InventoryGroupItem from './InventoryGroupItem';
|
||||
import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal';
|
||||
|
||||
const QS_CONFIG = getQSConfig('host', {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
order_by: 'name',
|
||||
});
|
||||
import { Switch, Route, withRouter } from 'react-router-dom';
|
||||
|
||||
const DeleteButton = styled(Button)`
|
||||
padding: 5px 8px;
|
||||
import InventoryGroupAdd from '../InventoryGroupAdd/InventoryGroupAdd';
|
||||
|
||||
&:hover {
|
||||
background-color: #d9534f;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
color: var(--pf-c-button--m-plain--Color);
|
||||
pointer-events: initial;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
function cannotDelete(item) {
|
||||
return !item.summary_fields.user_capabilities.delete;
|
||||
}
|
||||
|
||||
const useModal = () => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
function toggleModal() {
|
||||
setIsModalOpen(!isModalOpen);
|
||||
}
|
||||
|
||||
return {
|
||||
isModalOpen,
|
||||
toggleModal,
|
||||
};
|
||||
};
|
||||
|
||||
function InventoryGroups({ i18n, location, match }) {
|
||||
const [actions, setActions] = useState(null);
|
||||
const [contentError, setContentError] = useState(null);
|
||||
const [deletionError, setDeletionError] = useState(null);
|
||||
const [groupCount, setGroupCount] = useState(0);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const { isModalOpen, toggleModal } = useModal();
|
||||
|
||||
const inventoryId = match.params.id;
|
||||
|
||||
const fetchGroups = (id, queryString) => {
|
||||
const params = parseQueryString(QS_CONFIG, queryString);
|
||||
return InventoriesAPI.readGroups(id, params);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [
|
||||
{
|
||||
data: { count, results },
|
||||
},
|
||||
{
|
||||
data: { actions: optionActions },
|
||||
},
|
||||
] = await Promise.all([
|
||||
fetchGroups(inventoryId, location.search),
|
||||
InventoriesAPI.readGroupsOptions(inventoryId),
|
||||
]);
|
||||
|
||||
setGroups(results);
|
||||
setGroupCount(count);
|
||||
setActions(optionActions);
|
||||
} catch (error) {
|
||||
setContentError(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, [inventoryId, location]);
|
||||
|
||||
const handleSelectAll = isSelected => {
|
||||
setSelected(isSelected ? [...groups] : []);
|
||||
};
|
||||
|
||||
const handleSelect = row => {
|
||||
if (selected.some(s => s.id === row.id)) {
|
||||
setSelected(selected.filter(s => s.id !== row.id));
|
||||
} else {
|
||||
setSelected(selected.concat(row));
|
||||
}
|
||||
};
|
||||
|
||||
const renderTooltip = () => {
|
||||
const itemsUnableToDelete = selected
|
||||
.filter(cannotDelete)
|
||||
.map(item => item.name)
|
||||
.join(', ');
|
||||
|
||||
if (selected.some(cannotDelete)) {
|
||||
return (
|
||||
<div>
|
||||
{i18n._(
|
||||
t`You do not have permission to delete the following Groups: ${itemsUnableToDelete}`
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (selected.length) {
|
||||
return i18n._(t`Delete`);
|
||||
}
|
||||
return i18n._(t`Select a row to delete`);
|
||||
};
|
||||
|
||||
const handleDelete = async option => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
/* eslint-disable no-await-in-loop, no-restricted-syntax */
|
||||
/* Delete groups sequentially to avoid api integrity errors */
|
||||
for (const group of selected) {
|
||||
if (option === 'delete') {
|
||||
await GroupsAPI.destroy(+group.id);
|
||||
} else if (option === 'promote') {
|
||||
await InventoriesAPI.promoteGroup(inventoryId, +group.id);
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop, no-restricted-syntax */
|
||||
} catch (error) {
|
||||
setDeletionError(error);
|
||||
}
|
||||
|
||||
toggleModal();
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { count, results },
|
||||
} = await fetchGroups(inventoryId, location.search);
|
||||
setGroups(results);
|
||||
setGroupCount(count);
|
||||
} catch (error) {
|
||||
setContentError(error);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const canAdd =
|
||||
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
|
||||
const isAllSelected =
|
||||
selected.length > 0 && selected.length === groups.length;
|
||||
import InventoryGroup from '../InventoryGroup/InventoryGroup';
|
||||
import InventoryGroupsList from './InventoryGroupsList';
|
||||
|
||||
function InventoryGroups({ setBreadcrumb, inventory, location, match }) {
|
||||
return (
|
||||
<>
|
||||
<PaginatedDataList
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading}
|
||||
items={groups}
|
||||
itemCount={groupCount}
|
||||
qsConfig={QS_CONFIG}
|
||||
renderItem={item => (
|
||||
<InventoryGroupItem
|
||||
key={item.id}
|
||||
group={item}
|
||||
inventoryId={inventoryId}
|
||||
isSelected={selected.some(row => row.id === item.id)}
|
||||
onSelect={() => handleSelect(item)}
|
||||
/>
|
||||
)}
|
||||
renderToolbar={props => (
|
||||
<DataListToolbar
|
||||
{...props}
|
||||
showSelectAll
|
||||
isAllSelected={isAllSelected}
|
||||
onSelectAll={handleSelectAll}
|
||||
qsConfig={QS_CONFIG}
|
||||
additionalControls={[
|
||||
<Tooltip content={renderTooltip()} position="top" key="delete">
|
||||
<div>
|
||||
<DeleteButton
|
||||
variant="plain"
|
||||
aria-label={i18n._(t`Delete`)}
|
||||
onClick={toggleModal}
|
||||
isDisabled={
|
||||
selected.length === 0 || selected.some(cannotDelete)
|
||||
}
|
||||
>
|
||||
<TrashAltIcon />
|
||||
</DeleteButton>
|
||||
</div>
|
||||
</Tooltip>,
|
||||
canAdd && (
|
||||
<ToolbarAddButton
|
||||
key="add"
|
||||
linkTo={`/inventories/inventory/${inventoryId}/groups/add`}
|
||||
/>
|
||||
),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
emptyStateControls={
|
||||
canAdd && (
|
||||
<ToolbarAddButton
|
||||
key="add"
|
||||
linkTo={`/inventories/inventory/${inventoryId}/groups/add`}
|
||||
<Switch>
|
||||
<Route
|
||||
key="add"
|
||||
path="/inventories/inventory/:id/groups/add"
|
||||
render={() => {
|
||||
return (
|
||||
<InventoryGroupAdd
|
||||
setBreadcrumb={setBreadcrumb}
|
||||
inventory={inventory}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{deletionError && (
|
||||
<AlertModal
|
||||
isOpen={deletionError}
|
||||
variant="danger"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={() => setDeletionError(null)}
|
||||
>
|
||||
{i18n._(t`Failed to delete one or more groups.`)}
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
<InventoryGroupsDeleteModal
|
||||
groups={selected}
|
||||
isModalOpen={isModalOpen}
|
||||
onClose={toggleModal}
|
||||
onDelete={handleDelete}
|
||||
<Route
|
||||
key="details"
|
||||
path="/inventories/inventory/:id/groups/:groupId/"
|
||||
render={() => (
|
||||
<InventoryGroup inventory={inventory} setBreadcrumb={setBreadcrumb} />
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
<Route
|
||||
key="list"
|
||||
path="/inventories/inventory/:id/groups"
|
||||
render={() => {
|
||||
return <InventoryGroupsList location={location} match={match} />;
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
export { InventoryGroups as _InventoryGroups };
|
||||
export default withI18n()(withRouter(InventoryGroups));
|
||||
|
||||
@ -1,82 +1,23 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||
import { InventoriesAPI, GroupsAPI } from '@api';
|
||||
import InventoryGroups from './InventoryGroups';
|
||||
|
||||
jest.mock('@api');
|
||||
|
||||
const mockGroups = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'group',
|
||||
name: 'foo',
|
||||
inventory: 1,
|
||||
url: '/api/v2/groups/1',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'group',
|
||||
name: 'bar',
|
||||
inventory: 1,
|
||||
url: '/api/v2/groups/2',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'group',
|
||||
name: 'baz',
|
||||
inventory: 1,
|
||||
url: '/api/v2/groups/3',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: false,
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('<InventoryGroups />', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(async () => {
|
||||
InventoriesAPI.readGroups.mockResolvedValue({
|
||||
data: {
|
||||
count: mockGroups.length,
|
||||
results: mockGroups,
|
||||
},
|
||||
});
|
||||
InventoriesAPI.readGroupsOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
POST: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
test('initially renders successfully', async () => {
|
||||
let wrapper;
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/inventories/inventory/3/groups'],
|
||||
initialEntries: ['/inventories/inventory/1/groups'],
|
||||
});
|
||||
const inventory = { id: 1, name: 'Foo' };
|
||||
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Route
|
||||
path="/inventories/inventory/:id/groups"
|
||||
component={() => <InventoryGroups />}
|
||||
/>,
|
||||
<InventoryGroups setBreadcrumb={() => {}} inventory={inventory} />,
|
||||
|
||||
{
|
||||
context: {
|
||||
router: { history, route: { location: history.location } },
|
||||
@ -84,134 +25,25 @@ describe('<InventoryGroups />', () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.length).toBe(1);
|
||||
expect(wrapper.find('InventoryGroupsList').length).toBe(1);
|
||||
});
|
||||
|
||||
test('initially renders successfully', () => {
|
||||
expect(wrapper.find('InventoryGroups').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should fetch groups from api and render them in the list', async () => {
|
||||
expect(InventoriesAPI.readGroups).toHaveBeenCalled();
|
||||
expect(wrapper.find('InventoryGroupItem').length).toBe(3);
|
||||
});
|
||||
|
||||
test('should check and uncheck the row item', async () => {
|
||||
expect(
|
||||
wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked
|
||||
).toBe(false);
|
||||
|
||||
test('test that InventoryGroupsAdd renders', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/inventories/inventory/1/groups/add'],
|
||||
});
|
||||
const inventory = { id: 1, name: 'Foo' };
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')(
|
||||
true
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked
|
||||
).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')(
|
||||
false
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('should check all row items when select all is checked', async () => {
|
||||
wrapper.find('PFDataListCheck').forEach(el => {
|
||||
expect(el.props().checked).toBe(false);
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
|
||||
});
|
||||
wrapper.update();
|
||||
wrapper.find('PFDataListCheck').forEach(el => {
|
||||
expect(el.props().checked).toBe(true);
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper.find('Checkbox#select-all').invoke('onChange')(false);
|
||||
});
|
||||
wrapper.update();
|
||||
wrapper.find('PFDataListCheck').forEach(el => {
|
||||
expect(el.props().checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('should show content error when api throws error on initial render', async () => {
|
||||
InventoriesAPI.readGroupsOptions.mockImplementation(() =>
|
||||
Promise.reject(new Error())
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InventoryGroups />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||
});
|
||||
|
||||
test('should show content error if groups are not successfully fetched from api', async () => {
|
||||
InventoriesAPI.readGroups.mockImplementation(() =>
|
||||
Promise.reject(new Error())
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')();
|
||||
});
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')();
|
||||
});
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'InventoryGroupsDeleteModal',
|
||||
el => el.props().isModalOpen === true
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('ModalBoxFooter Button[aria-label="Delete"]')
|
||||
.invoke('onClick')();
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||
});
|
||||
|
||||
test('should show error modal when group is not successfully deleted from api', async () => {
|
||||
GroupsAPI.destroy.mockRejectedValue(
|
||||
new Error({
|
||||
response: {
|
||||
config: {
|
||||
method: 'delete',
|
||||
url: '/api/v2/groups/1',
|
||||
wrapper = mountWithContexts(
|
||||
<InventoryGroups setBreadcrumb={() => {}} inventory={inventory} />,
|
||||
{
|
||||
context: {
|
||||
router: { history, route: { location: history.location } },
|
||||
},
|
||||
data: 'An error occurred',
|
||||
},
|
||||
})
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')();
|
||||
});
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')();
|
||||
});
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'InventoryGroupsDeleteModal',
|
||||
el => el.props().isModalOpen === true
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper.find('Radio[id="radio-delete"]').invoke('onChange')();
|
||||
});
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('ModalBoxFooter Button[aria-label="Delete"]')
|
||||
.invoke('onClick')();
|
||||
});
|
||||
await waitForElement(wrapper, { title: 'Error!', variant: 'danger' });
|
||||
await act(async () => {
|
||||
wrapper.find('ModalBoxCloseButton').invoke('onClose')();
|
||||
}
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('InventoryGroupsAdd').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,248 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { TrashAltIcon } from '@patternfly/react-icons';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { getQSConfig, parseQueryString } from '@util/qs';
|
||||
import { InventoriesAPI, GroupsAPI } from '@api';
|
||||
import { Button, Tooltip } from '@patternfly/react-core';
|
||||
import AlertModal from '@components/AlertModal';
|
||||
import ErrorDetail from '@components/ErrorDetail';
|
||||
import DataListToolbar from '@components/DataListToolbar';
|
||||
import PaginatedDataList, {
|
||||
ToolbarAddButton,
|
||||
} from '@components/PaginatedDataList';
|
||||
import styled from 'styled-components';
|
||||
import InventoryGroupItem from './InventoryGroupItem';
|
||||
import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal';
|
||||
|
||||
const QS_CONFIG = getQSConfig('group', {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
order_by: 'name',
|
||||
});
|
||||
|
||||
const DeleteButton = styled(Button)`
|
||||
padding: 5px 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: #d9534f;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
color: var(--pf-c-button--m-plain--Color);
|
||||
pointer-events: initial;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
function cannotDelete(item) {
|
||||
return !item.summary_fields.user_capabilities.delete;
|
||||
}
|
||||
|
||||
const useModal = () => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
function toggleModal() {
|
||||
setIsModalOpen(!isModalOpen);
|
||||
}
|
||||
|
||||
return {
|
||||
isModalOpen,
|
||||
toggleModal,
|
||||
};
|
||||
};
|
||||
|
||||
function InventoryGroupsList({ i18n, location, match }) {
|
||||
const [actions, setActions] = useState(null);
|
||||
const [contentError, setContentError] = useState(null);
|
||||
const [deletionError, setDeletionError] = useState(null);
|
||||
const [groupCount, setGroupCount] = useState(0);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const { isModalOpen, toggleModal } = useModal();
|
||||
|
||||
const inventoryId = match.params.id;
|
||||
const fetchGroups = (id, queryString) => {
|
||||
const params = parseQueryString(QS_CONFIG, queryString);
|
||||
return InventoriesAPI.readGroups(id, params);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [
|
||||
{
|
||||
data: { count, results },
|
||||
},
|
||||
{
|
||||
data: { actions: optionActions },
|
||||
},
|
||||
] = await Promise.all([
|
||||
fetchGroups(inventoryId, location.search),
|
||||
InventoriesAPI.readGroupsOptions(inventoryId),
|
||||
]);
|
||||
|
||||
setGroups(results);
|
||||
setGroupCount(count);
|
||||
setActions(optionActions);
|
||||
} catch (error) {
|
||||
setContentError(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, [inventoryId, location]);
|
||||
|
||||
const handleSelectAll = isSelected => {
|
||||
setSelected(isSelected ? [...groups] : []);
|
||||
};
|
||||
|
||||
const handleSelect = row => {
|
||||
if (selected.some(s => s.id === row.id)) {
|
||||
setSelected(selected.filter(s => s.id !== row.id));
|
||||
} else {
|
||||
setSelected(selected.concat(row));
|
||||
}
|
||||
};
|
||||
|
||||
const renderTooltip = () => {
|
||||
const itemsUnableToDelete = selected
|
||||
.filter(cannotDelete)
|
||||
.map(item => item.name)
|
||||
.join(', ');
|
||||
|
||||
if (selected.some(cannotDelete)) {
|
||||
return (
|
||||
<div>
|
||||
{i18n._(
|
||||
t`You do not have permission to delete the following Groups: ${itemsUnableToDelete}`
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (selected.length) {
|
||||
return i18n._(t`Delete`);
|
||||
}
|
||||
return i18n._(t`Select a row to delete`);
|
||||
};
|
||||
|
||||
const handleDelete = async option => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
/* eslint-disable no-await-in-loop, no-restricted-syntax */
|
||||
/* Delete groups sequentially to avoid api integrity errors */
|
||||
for (const group of selected) {
|
||||
if (option === 'delete') {
|
||||
await GroupsAPI.destroy(+group.id);
|
||||
} else if (option === 'promote') {
|
||||
await InventoriesAPI.promoteGroup(inventoryId, +group.id);
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop, no-restricted-syntax */
|
||||
} catch (error) {
|
||||
setDeletionError(error);
|
||||
}
|
||||
|
||||
toggleModal();
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { count, results },
|
||||
} = await fetchGroups(inventoryId, location.search);
|
||||
setGroups(results);
|
||||
setGroupCount(count);
|
||||
} catch (error) {
|
||||
setContentError(error);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const canAdd =
|
||||
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
|
||||
const isAllSelected =
|
||||
selected.length > 0 && selected.length === groups.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaginatedDataList
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading}
|
||||
items={groups}
|
||||
itemCount={groupCount}
|
||||
qsConfig={QS_CONFIG}
|
||||
renderItem={item => (
|
||||
<InventoryGroupItem
|
||||
key={item.id}
|
||||
group={item}
|
||||
inventoryId={inventoryId}
|
||||
isSelected={selected.some(row => row.id === item.id)}
|
||||
onSelect={() => handleSelect(item)}
|
||||
/>
|
||||
)}
|
||||
renderToolbar={props => (
|
||||
<DataListToolbar
|
||||
{...props}
|
||||
showSelectAll
|
||||
isAllSelected={isAllSelected}
|
||||
onSelectAll={handleSelectAll}
|
||||
qsConfig={QS_CONFIG}
|
||||
additionalControls={[
|
||||
<Tooltip content={renderTooltip()} position="top" key="delete">
|
||||
<div>
|
||||
<DeleteButton
|
||||
variant="plain"
|
||||
aria-label={i18n._(t`Delete`)}
|
||||
onClick={toggleModal}
|
||||
isDisabled={
|
||||
selected.length === 0 || selected.some(cannotDelete)
|
||||
}
|
||||
>
|
||||
<TrashAltIcon />
|
||||
</DeleteButton>
|
||||
</div>
|
||||
</Tooltip>,
|
||||
canAdd && (
|
||||
<ToolbarAddButton
|
||||
key="add"
|
||||
linkTo={`/inventories/inventory/${inventoryId}/groups/add`}
|
||||
/>
|
||||
),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
emptyStateControls={
|
||||
canAdd && (
|
||||
<ToolbarAddButton
|
||||
key="add"
|
||||
linkTo={`/inventories/inventory/${inventoryId}/groups/add`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{deletionError && (
|
||||
<AlertModal
|
||||
isOpen={deletionError}
|
||||
variant="danger"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={() => setDeletionError(null)}
|
||||
>
|
||||
{i18n._(t`Failed to delete one or more groups.`)}
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
<InventoryGroupsDeleteModal
|
||||
groups={selected}
|
||||
isModalOpen={isModalOpen}
|
||||
onClose={toggleModal}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default withI18n()(withRouter(InventoryGroupsList));
|
||||
@ -0,0 +1,217 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||
import { InventoriesAPI, GroupsAPI } from '@api';
|
||||
import InventoryGroupsList from './InventoryGroupsList';
|
||||
|
||||
jest.mock('@api');
|
||||
|
||||
const mockGroups = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'group',
|
||||
name: 'foo',
|
||||
inventory: 1,
|
||||
url: '/api/v2/groups/1',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'group',
|
||||
name: 'bar',
|
||||
inventory: 1,
|
||||
url: '/api/v2/groups/2',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'group',
|
||||
name: 'baz',
|
||||
inventory: 1,
|
||||
url: '/api/v2/groups/3',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: false,
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('<InventoryGroupsList />', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(async () => {
|
||||
InventoriesAPI.readGroups.mockResolvedValue({
|
||||
data: {
|
||||
count: mockGroups.length,
|
||||
results: mockGroups,
|
||||
},
|
||||
});
|
||||
InventoriesAPI.readGroupsOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
POST: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/inventories/inventory/3/groups'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Route
|
||||
path="/inventories/inventory/:id/groups"
|
||||
component={() => <InventoryGroupsList />}
|
||||
/>,
|
||||
{
|
||||
context: {
|
||||
router: { history, route: { location: history.location } },
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
|
||||
test('initially renders successfully', () => {
|
||||
expect(wrapper.find('InventoryGroupsList').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should fetch groups from api and render them in the list', async () => {
|
||||
expect(InventoriesAPI.readGroups).toHaveBeenCalled();
|
||||
expect(wrapper.find('InventoryGroupItem').length).toBe(3);
|
||||
});
|
||||
|
||||
test('should check and uncheck the row item', async () => {
|
||||
expect(
|
||||
wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked
|
||||
).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')(
|
||||
true
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked
|
||||
).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')(
|
||||
false
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('should check all row items when select all is checked', async () => {
|
||||
wrapper.find('PFDataListCheck').forEach(el => {
|
||||
expect(el.props().checked).toBe(false);
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
|
||||
});
|
||||
wrapper.update();
|
||||
wrapper.find('PFDataListCheck').forEach(el => {
|
||||
expect(el.props().checked).toBe(true);
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper.find('Checkbox#select-all').invoke('onChange')(false);
|
||||
});
|
||||
wrapper.update();
|
||||
wrapper.find('PFDataListCheck').forEach(el => {
|
||||
expect(el.props().checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('should show content error when api throws error on initial render', async () => {
|
||||
InventoriesAPI.readGroupsOptions.mockImplementation(() =>
|
||||
Promise.reject(new Error())
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InventoryGroupsList />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||
});
|
||||
|
||||
test('should show content error if groups are not successfully fetched from api', async () => {
|
||||
InventoriesAPI.readGroups.mockImplementation(() =>
|
||||
Promise.reject(new Error())
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')();
|
||||
});
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')();
|
||||
});
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'InventoryGroupsDeleteModal',
|
||||
el => el.props().isModalOpen === true
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('ModalBoxFooter Button[aria-label="Delete"]')
|
||||
.invoke('onClick')();
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||
});
|
||||
|
||||
test('should show error modal when group is not successfully deleted from api', async () => {
|
||||
GroupsAPI.destroy.mockRejectedValue(
|
||||
new Error({
|
||||
response: {
|
||||
config: {
|
||||
method: 'delete',
|
||||
url: '/api/v2/groups/1',
|
||||
},
|
||||
data: 'An error occurred',
|
||||
},
|
||||
})
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')();
|
||||
});
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')();
|
||||
});
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'InventoryGroupsDeleteModal',
|
||||
el => el.props().isModalOpen === true
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper.find('Radio[id="radio-delete"]').invoke('onChange')();
|
||||
});
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('ModalBoxFooter Button[aria-label="Delete"]')
|
||||
.invoke('onClick')();
|
||||
});
|
||||
await waitForElement(wrapper, { title: 'Error!', variant: 'danger' });
|
||||
await act(async () => {
|
||||
wrapper.find('ModalBoxCloseButton').invoke('onClose')();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { Formik } from 'formik';
|
||||
import { Form, Card, CardBody } from '@patternfly/react-core';
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import FormRow from '@components/FormRow';
|
||||
import FormField from '@components/FormField';
|
||||
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
|
||||
import { VariablesField } from '@components/CodeMirrorInput';
|
||||
import { required } from '@util/validators';
|
||||
|
||||
function InventoryGroupForm({
|
||||
i18n,
|
||||
error,
|
||||
group = {},
|
||||
handleSubmit,
|
||||
handleCancel,
|
||||
}) {
|
||||
const initialValues = {
|
||||
name: group.name || '',
|
||||
description: group.description || '',
|
||||
variables: group.variables || '---',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="awx-c-card">
|
||||
<CardBody>
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
render={formik => (
|
||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormRow css="grid-template-columns: repeat(auto-fit, minmax(300px, 500px));">
|
||||
<FormField
|
||||
id="inventoryGroup-name"
|
||||
name="name"
|
||||
type="text"
|
||||
label={i18n._(t`Name`)}
|
||||
validate={required(null, i18n)}
|
||||
isRequired
|
||||
/>
|
||||
<FormField
|
||||
id="inventoryGroup-description"
|
||||
name="description"
|
||||
type="text"
|
||||
label={i18n._(t`Description`)}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<VariablesField
|
||||
id="host-variables"
|
||||
name="variables"
|
||||
label={i18n._(t`Variables`)}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormActionGroup
|
||||
onCancel={handleCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
/>
|
||||
{error ? <div>error</div> : null}
|
||||
</Form>
|
||||
)}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(withRouter(InventoryGroupForm));
|
||||
@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||
import InventoryGroupForm from './InventoryGroupForm';
|
||||
|
||||
const group = {
|
||||
id: 1,
|
||||
name: 'Foo',
|
||||
description: 'Bar',
|
||||
variables: 'ying: false',
|
||||
};
|
||||
describe('<InventoryGroupForm />', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<InventoryGroupForm
|
||||
handleSubmit={jest.fn()}
|
||||
handleCancel={jest.fn()}
|
||||
group={group}
|
||||
/>
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('initially renders successfully', () => {
|
||||
expect(wrapper.length).toBe(1);
|
||||
});
|
||||
test('should render values for the fields that have them', () => {
|
||||
expect(wrapper.find("FormGroup[label='Name']").length).toBe(1);
|
||||
expect(wrapper.find("FormGroup[label='Description']").length).toBe(1);
|
||||
expect(wrapper.find("VariablesField[label='Variables']").length).toBe(1);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user