Moves inventoryGroupForm into shared directory

Updates InventoryGroups tests
Adds ContentError functionalist to catch a case where a use might navigate to an Inventory
that isn't associated to the shown inventoryGroup.
This commit is contained in:
Alex Corey
2019-12-13 10:05:17 -05:00
parent ef5ce0b082
commit 4b62d77015
11 changed files with 297 additions and 215 deletions

View File

@@ -15,7 +15,7 @@ import { getAddedAndRemoved } from '../../../util/lists';
function InventoryEdit({ history, i18n, inventory }) { function InventoryEdit({ history, i18n, inventory }) {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [associatedInstanceGroups, setInstanceGroups] = useState(null); const [associatedInstanceGroups, setInstanceGroups] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [contentLoading, setContentLoading] = useState(true);
const [credentialTypeId, setCredentialTypeId] = useState(null); const [credentialTypeId, setCredentialTypeId] = useState(null);
useEffect(() => { useEffect(() => {
@@ -39,11 +39,11 @@ function InventoryEdit({ history, i18n, inventory }) {
} catch (err) { } catch (err) {
setError(err); setError(err);
} finally { } finally {
setIsLoading(false); setContentLoading(false);
} }
}; };
loadData(); loadData();
}, [inventory.id, isLoading, inventory, credentialTypeId]); }, [inventory.id, contentLoading, inventory, credentialTypeId]);
const handleCancel = () => { const handleCancel = () => {
history.push('/inventories'); history.push('/inventories');
@@ -85,7 +85,7 @@ function InventoryEdit({ history, i18n, inventory }) {
history.push(`${url}`); history.push(`${url}`);
} }
}; };
if (isLoading) { if (contentLoading) {
return <ContentLoading />; return <ContentLoading />;
} }
if (error) { if (error) {

View File

@@ -14,8 +14,8 @@ import InventoryGroupDetail from '../InventoryGroupDetail/InventoryGroupDetail';
function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) { function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) {
const [inventoryGroup, setInventoryGroup] = useState(null); const [inventoryGroup, setInventoryGroup] = useState(null);
const [hasContentLoading, setHasContentLoading] = useState(true); const [contentLoading, setContentLoading] = useState(true);
const [contentError, setHasContentError] = useState(null); const [contentError, setContentError] = useState(null);
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
@@ -24,9 +24,9 @@ function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) {
setInventoryGroup(data); setInventoryGroup(data);
setBreadcrumb(inventory, data); setBreadcrumb(inventory, data);
} catch (err) { } catch (err) {
setHasContentError(err); setContentError(err);
} finally { } finally {
setHasContentLoading(false); setContentLoading(false);
} }
}; };
@@ -64,12 +64,32 @@ function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) {
id: 2, 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) { if (contentError) {
return <ContentError error={contentError} />; return <ContentError error={contentError} />;
} }
if (hasContentLoading) {
return <ContentLoading />;
}
let cardHeader = null; let cardHeader = null;
if ( if (
@@ -80,12 +100,11 @@ function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) {
<CardHeader style={{ padding: 0 }}> <CardHeader style={{ padding: 0 }}>
<RoutedTabs history={history} tabsArray={tabsArray} /> <RoutedTabs history={history} tabsArray={tabsArray} />
<CardCloseButton <CardCloseButton
linkTo={`/inventories/inventory/${inventory.id}/group`} linkTo={`/inventories/inventory/${inventory.id}/groups`}
/> />
</CardHeader> </CardHeader>
); );
} }
return ( return (
<> <>
{cardHeader} {cardHeader}

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { GroupsAPI } from '@api'; import { GroupsAPI } from '@api';
import { Route } from 'react-router-dom';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
@@ -14,6 +15,7 @@ GroupsAPI.readDetail.mockResolvedValue({
description: 'Bar', description: 'Bar',
variables: 'bizz: buzz', variables: 'bizz: buzz',
summary_fields: { summary_fields: {
inventory: { id: 1 },
created_by: { id: 1, name: 'Athena' }, created_by: { id: 1, name: 'Athena' },
modified_by: { id: 1, name: 'Apollo' }, modified_by: { id: 1, name: 'Apollo' },
}, },
@@ -29,7 +31,12 @@ describe('<InventoryGroup />', () => {
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<InventoryGroup inventory={inventory} setBreadcrumb={() => {}} />, <Route
path="/inventories/inventory/:id/groups"
component={() => (
<InventoryGroup setBreadcrumb={() => {}} inventory={inventory} />
)}
/>,
{ {
context: { context: {
router: { router: {

View File

@@ -4,7 +4,7 @@ import { withRouter } from 'react-router-dom';
import { GroupsAPI } from '@api'; import { GroupsAPI } from '@api';
import { Card } from '@patternfly/react-core'; import { Card } from '@patternfly/react-core';
import InventoryGroupForm from '../InventoryGroupForm/InventoryGroupForm'; import InventoryGroupForm from '../shared/InventoryGroupForm';
function InventoryGroupsAdd({ history, inventory, setBreadcrumb }) { function InventoryGroupsAdd({ history, inventory, setBreadcrumb }) {
const [error, setError] = useState(null); const [error, setError] = useState(null);

View File

@@ -33,6 +33,9 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) {
summary_fields: { created_by, modified_by }, summary_fields: { created_by, modified_by },
created, created,
modified, modified,
name,
description,
variables,
} = inventoryGroup; } = inventoryGroup;
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@@ -78,16 +81,13 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) {
return ( return (
<CardBody css="padding-top: 20px"> <CardBody css="padding-top: 20px">
<DetailList gutter="sm"> <DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={inventoryGroup.name} /> <Detail label={i18n._(t`Name`)} value={name} />
<Detail <Detail label={i18n._(t`Description`)} value={description} />
label={i18n._(t`Description`)}
value={inventoryGroup.description}
/>
</DetailList> </DetailList>
<VariablesInput <VariablesInput
id="inventoryGroup-variables" id="inventoryGroup-variables"
readOnly readOnly
value={inventoryGroup.variables} value={variables}
rows={4} rows={4}
label={i18n._(t`Variables`)} label={i18n._(t`Variables`)}
/> />

View File

@@ -3,7 +3,7 @@ import { withI18n } from '@lingui/react';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { GroupsAPI } from '@api'; import { GroupsAPI } from '@api';
import InventoryGroupForm from '../InventoryGroupForm/InventoryGroupForm'; import InventoryGroupForm from '../shared/InventoryGroupForm';
function InventoryGroupEdit({ history, inventoryGroup, inventory, match }) { function InventoryGroupEdit({ history, inventoryGroup, inventory, match }) {
const [error, setError] = useState(null); const [error, setError] = useState(null);

View File

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

View File

@@ -1,81 +1,25 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { Route } from 'react-router-dom'; import { Route } from 'react-router-dom';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import InventoryGroups from './InventoryGroups';
import { InventoriesAPI, GroupsAPI } from '@api';
import InventoryGroupsList from './InventoryGroupsList';
jest.mock('@api'); describe('<InventoryGroups />', () => {
test('initially renders successfully', async () => {
const mockGroups = [ let wrapper;
{
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({ const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/3/groups'], initialEntries: ['/inventories/inventory/1/groups'],
}); });
const inventory = { id: 1, name: 'Foo' };
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Route <Route
path="/inventories/inventory/:id/groups" path="/inventories/inventory/:id/groups"
component={() => <InventoryGroupsList />} component={() => (
<InventoryGroups setBreadcrumb={() => {}} inventory={inventory} />
)}
/>, />,
{ {
context: { context: {
@@ -84,134 +28,30 @@ describe('<InventoryGroupsList />', () => {
} }
); );
}); });
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); expect(wrapper.length).toBe(1);
});
test('initially renders successfully', () => {
expect(wrapper.find('InventoryGroupsList').length).toBe(1); expect(wrapper.find('InventoryGroupsList').length).toBe(1);
}); });
test('test that InventoryGroupsAdd renders', async () => {
test('should fetch groups from api and render them in the list', async () => { const history = createMemoryHistory({
expect(InventoriesAPI.readGroups).toHaveBeenCalled(); initialEntries: ['/inventories/inventory/1/groups/add'],
expect(wrapper.find('InventoryGroupItem').length).toBe(3); });
}); const inventory = { id: 1, name: 'Foo' };
let wrapper;
test('should check and uncheck the row item', async () => {
expect(
wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked
).toBe(false);
await act(async () => { await act(async () => {
wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')( wrapper = mountWithContexts(
true <Route
); path="/inventories/inventory/:id/groups/add"
}); component={() => (
wrapper.update(); <InventoryGroups setBreadcrumb={() => {}} inventory={inventory} />
expect( )}
wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked />,
).toBe(true); {
context: {
await act(async () => { router: { history, route: { location: history.location } },
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')();
}); });
expect(wrapper.find('InventoryGroupsAdd').length).toBe(1);
}); });
}); });

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