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:
softwarefactory-project-zuul[bot] 2019-12-13 22:23:14 +00:00 committed by GitHub
commit 04c535e3f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1360 additions and 440 deletions

View File

@ -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>

View File

@ -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`),

View File

@ -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 });
};

View File

@ -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"

View File

@ -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) {

View File

@ -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));

View File

@ -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);
});
});

View File

@ -0,0 +1 @@
export { default } from './InventoryGroup';

View File

@ -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 };

View File

@ -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();
});
});

View File

@ -0,0 +1 @@
export { default } from './InventoryGroupAdd';

View File

@ -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));

View File

@ -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');
});
});

View File

@ -0,0 +1 @@
export { default } from './InventoryGroupDetail';

View File

@ -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 };

View File

@ -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();
});
});

View File

@ -0,0 +1 @@
export { default } from './InventoryGroupEdit';

View File

@ -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 (

View File

@ -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));

View File

@ -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);
});
});

View File

@ -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));

View File

@ -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')();
});
});
});

View File

@ -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));

View File

@ -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);
});
});