Adds Alert Modal, Breadcrumb, Nested Tabs and Refactors PR.

This commit is contained in:
Alex Corey
2019-12-09 16:06:56 -05:00
parent 3ea37e1c79
commit f8a754cf44
10 changed files with 255 additions and 108 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 { Tab, Tabs as PFTabs } from '@patternfly/react-core';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import { CaretLeftIcon } from '@patternfly/react-icons';
const Tabs = styled(PFTabs)` const Tabs = styled(PFTabs)`
--pf-c-tabs__button--PaddingLeft: 20px; --pf-c-tabs__button--PaddingLeft: 20px;
@@ -62,7 +63,15 @@ function RoutedTabs(props) {
eventKey={tab.id} eventKey={tab.id}
key={tab.id} key={tab.id}
link={tab.link} link={tab.link}
title={tab.name} title={
tab.isNestedTabs ? (
<>
<CaretLeftIcon /> {tab.name}
</>
) : (
tab.name
)
}
/> />
))} ))}
</Tabs> </Tabs>

View File

@@ -61,9 +61,11 @@ class Inventories extends Component {
t`Create New Group` t`Create New Group`
), ),
[`/inventories/inventory/${inventory.id}/groups/${group && [`/inventories/inventory/${inventory.id}/groups/${group &&
group.id}/details`]: i18n._(t`Details`), group.id}`]: `${group && group.name}`,
[`/inventories/inventory/${inventory.id}/groups/${group && [`/inventories/inventory/${inventory.id}/groups/${group &&
group.id}/edit`]: `${group && group.name}`, group.id}/details`]: i18n._(t`Group Details`),
[`/inventories/inventory/${inventory.id}/groups/${group &&
group.id}/edit`]: i18n._(t`Edit Details`),
}; };
this.setState({ breadcrumbConfig }); this.setState({ breadcrumbConfig });
}; };

View File

@@ -57,7 +57,11 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) {
</CardHeader> </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; cardHeader = null;
} }
@@ -127,6 +131,7 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) {
<InventoryGroups <InventoryGroups
location={location} location={location}
match={match} match={match}
history={history}
setBreadcrumb={setBreadcrumb} setBreadcrumb={setBreadcrumb}
inventory={inventory} inventory={inventory}
/> />

View File

@@ -1,18 +1,18 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { CardHeader } from '@patternfly/react-core';
import { Switch, Route, withRouter, Link, Redirect } from 'react-router-dom'; import { Switch, Route, withRouter, Link, Redirect } from 'react-router-dom';
import { GroupsAPI } from '@api'; import { GroupsAPI } from '@api';
import CardCloseButton from '@components/CardCloseButton';
import RoutedTabs from '@components/RoutedTabs';
import ContentError from '@components/ContentError'; import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading'; import ContentLoading from '@components/ContentLoading';
import InventoryGroupEdit from '../InventoryGroupEdit/InventoryGroupEdit'; import InventoryGroupEdit from '../InventoryGroupEdit/InventoryGroupEdit';
import InventoryGroupDetail from '../InventoryGroupDetail/InventoryGroupDetail'; import InventoryGroupDetail from '../InventoryGroupDetail/InventoryGroupDetail';
function InventoryGroups({ i18n, match, setBreadcrumb, inventory }) { function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) {
const [inventoryGroup, setInventoryGroup] = useState(null); const [inventoryGroup, setInventoryGroup] = useState(null);
const [hasContentLoading, setContentLoading] = useState(true); const [hasContentLoading, setContentLoading] = useState(true);
const [hasContentError, setHasContentError] = useState(false); const [hasContentError, setHasContentError] = useState(false);
@@ -32,64 +32,101 @@ function InventoryGroups({ i18n, match, setBreadcrumb, inventory }) {
loadData(); loadData();
}, [match.params.groupId, setBreadcrumb, inventory]); }, [match.params.groupId, setBreadcrumb, inventory]);
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`RelatedGroups`),
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,
},
];
if (hasContentError) { if (hasContentError) {
return <ContentError />; return <ContentError />;
} }
if (hasContentLoading) { if (hasContentLoading) {
return <ContentLoading />; return <ContentLoading />;
} }
return ( let cardHeader = hasContentLoading ? null : (
<Switch> <CardHeader style={{ padding: 0 }}>
<Redirect <RoutedTabs history={history} tabsArray={tabsArray} />
from="/inventories/inventory/:id/groups/:groupId" <CardCloseButton
to="/inventories/inventory/:id/groups/:groupId/details" linkTo={`/inventories/inventory/${inventory.id}/group`}
exact
/> />
{inventoryGroup && [ </CardHeader>
<Route );
key="edit" if (
path="/inventories/inventory/:id/groups/:groupId/edit" !history.location.pathname.includes('groups/') ||
render={() => { history.location.pathname.endsWith('edit')
return ( ) {
<InventoryGroupEdit cardHeader = null;
inventory={inventory} }
inventoryGroup={inventoryGroup} return (
/> <>
); {cardHeader}
}} <Switch>
/>, <Redirect
<Route from="/inventories/inventory/:id/groups/:groupId"
key="details" to="/inventories/inventory/:id/groups/:groupId/details"
path="/inventories/inventory/:id/groups/:groupId/details" exact
render={() => { />
return ( {inventoryGroup && [
<InventoryGroupDetail <Route
inventory={inventory} key="edit"
inventoryGroup={inventoryGroup} 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 <Route
key="not-found" key="not-found"
path="*" path="*"
render={() => render={() => {
!hasContentLoading && ( return (
<ContentError> !hasContentLoading && (
{match.params.id && ( <ContentError>
<Link {inventory && (
to={`/inventories/inventory/${match.params.id}/details`} <Link to={`/inventories/inventory/${inventory.id}/details`}>
> {i18n._(t`View Inventory Details`)}
{i18n._(t`View Inventory Details`)} </Link>
</Link> )}
)} </ContentError>
</ContentError> )
) );
} }}
/>, />
]} </Switch>
</Switch> </>
); );
} }

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { GroupsAPI } from '@api';
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: {
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(
<InventoryGroup inventory={inventory} setBreadcrumb={() => {}} />,
{
context: {
router: {
history,
route: {
location: history.location,
match: {
params: { id: 1 },
},
},
},
},
}
);
});
});
afterEach(() => {
wrapper.unmount();
});
test('renders successfully', async () => {
await act(async () => {
waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
expect(wrapper.length).toBe(1);
expect(wrapper.find('button[aria-label="Return to Groups"]').length).toBe(
1
);
});
test('expect Return to Groups tab to exist', async () => {
await act(async () => {
waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
expect(wrapper.length).toBe(1);
expect(wrapper.find('button[aria-label="Return to Groups"]').length).toBe(
1
);
});
});

View File

@@ -2,8 +2,8 @@ import React, { useState, useEffect } from 'react';
import { withI18n } from '@lingui/react'; 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 { Card } from '@patternfly/react-core';
import ContentError from '@components/ContentError';
import InventoryGroupForm from '../InventoryGroupForm/InventoryGroupForm'; import InventoryGroupForm from '../InventoryGroupForm/InventoryGroupForm';
function InventoryGroupsAdd({ history, inventory, setBreadcrumb }) { function InventoryGroupsAdd({ history, inventory, setBreadcrumb }) {
@@ -21,15 +21,14 @@ function InventoryGroupsAdd({ history, inventory, setBreadcrumb }) {
const handleCancel = () => { const handleCancel = () => {
history.push(`/inventories/inventory/${inventory.id}/groups`); history.push(`/inventories/inventory/${inventory.id}/groups`);
}; };
if (error) {
return <ContentError />;
}
return ( return (
<InventoryGroupForm <Card>
error={error} <InventoryGroupForm
handleCancel={handleCancel} error={error}
handleSubmit={handleSubmit} handleCancel={handleCancel}
/> handleSubmit={handleSubmit}
/>
</Card>
); );
} }
export default withI18n()(withRouter(InventoryGroupsAdd)); export default withI18n()(withRouter(InventoryGroupsAdd));

View File

@@ -13,7 +13,7 @@ describe('<InventoryGroupAdd />', () => {
let history; let history;
beforeEach(async () => { beforeEach(async () => {
history = createMemoryHistory({ history = createMemoryHistory({
initialEntries: ['/inventories/1/groups'], initialEntries: ['/inventories/inventory/1/groups'],
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
@@ -34,14 +34,16 @@ describe('<InventoryGroupAdd />', () => {
afterEach(() => { afterEach(() => {
wrapper.unmount(); wrapper.unmount();
}); });
test('InventoryGroupEdit renders successfully', () => { test('InventoryGroupAdd renders successfully', () => {
expect(wrapper.length).toBe(1); expect(wrapper.length).toBe(1);
}); });
test('cancel should navigate user to Inventory Groups List', async () => { test('cancel should navigate user to Inventory Groups List', async () => {
await act(async () => { await act(async () => {
waitForElement(wrapper, 'isLoading', el => el.length === 0); waitForElement(wrapper, 'isLoading', el => el.length === 0);
}); });
expect(history.location.pathname).toEqual('/inventories/1/groups'); expect(history.location.pathname).toEqual(
'/inventories/inventory/1/groups'
);
}); });
test('handleSubmit should call api', async () => { test('handleSubmit should call api', async () => {
await act(async () => { await act(async () => {

View File

@@ -5,14 +5,21 @@ import { CardBody, Button } from '@patternfly/react-core';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { withRouter, Link } from 'react-router-dom'; import { withRouter, Link } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import { VariablesInput } from '@components/CodeMirrorInput'; import { VariablesInput as CodeMirrorInput } from '@components/CodeMirrorInput';
import ContentError from '@components/ContentError'; import ErrorDetail from '@components/ErrorDetail';
import AlertModal from '@components/AlertModal'; import AlertModal from '@components/AlertModal';
import { formatDateString } from '@util/dates'; import { formatDateString } from '@util/dates';
import { GroupsAPI } from '@api'; import { GroupsAPI } from '@api';
import { DetailList, Detail } from '@components/DetailList'; 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` const ActionButtonWrapper = styled.div`
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -26,6 +33,7 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const handleDelete = async () => { const handleDelete = async () => {
setIsDeleteModalOpen(false);
try { try {
await GroupsAPI.destroy(inventoryGroup.id); await GroupsAPI.destroy(inventoryGroup.id);
history.push(`/inventories/inventory/${match.params.id}/groups`); history.push(`/inventories/inventory/${match.params.id}/groups`);
@@ -34,7 +42,17 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) {
} }
}; };
if (error) { if (error) {
return <ContentError />; return (
<AlertModal
variant="danger"
title={i18n._(t`Error!`)}
isOpen={error}
onClose={() => setError(false)}
>
{i18n._(t`Failed to delete group ${inventoryGroup.name}.`)}
<ErrorDetail error={error} />
</AlertModal>
);
} }
if (isDeleteModalOpen) { if (isDeleteModalOpen) {
return ( return (
@@ -77,21 +95,14 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) {
label={i18n._(t`Description`)} label={i18n._(t`Description`)}
value={inventoryGroup.description} value={inventoryGroup.description}
/> />
<Detail
fullWidth
label={i18n._(t`Variables`)}
value={
<VariablesInput
css="margin: 20px 0"
id="inventoryGroup-variables"
readOnly
value={inventoryGroup.variables}
rows={4}
label=""
/>
}
/>
</DetailList> </DetailList>
<VariablesInput
id="inventoryGroup-variables"
readOnly
value={inventoryGroup.variables}
rows={4}
label={i18n._(t`Variables`)}
/>
<DetailList> <DetailList>
<Detail <Detail
label={i18n._(t`Created`)} label={i18n._(t`Created`)}

View File

@@ -1,8 +1,9 @@
import React from 'react'; import React from 'react';
import { GroupsAPI } from '@api'; import { GroupsAPI } from '@api';
import { MemoryRouter, Route } from 'react-router-dom'; import { Route } from 'react-router-dom';
import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history';
import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import InventoryGroupDetail from './InventoryGroupDetail'; import InventoryGroupDetail from './InventoryGroupDetail';
@@ -28,19 +29,24 @@ const inventoryGroup = {
}; };
describe('<InventoryGroupDetail />', () => { describe('<InventoryGroupDetail />', () => {
let wrapper; let wrapper;
let history;
beforeEach(async () => { beforeEach(async () => {
await act(async () => { await act(async () => {
history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/groups/1/edit'],
});
wrapper = mountWithContexts( wrapper = mountWithContexts(
<MemoryRouter <Route
initialEntries={['/inventories/inventory/1/groups/1/edit']} path="/inventories/inventory/:id/groups/:groupId"
> component={() => (
<Route <InventoryGroupDetail inventoryGroup={inventoryGroup} />
path="/inventories/inventory/:id/groups/:groupId" )}
component={() => ( />,
<InventoryGroupDetail inventoryGroup={inventoryGroup} /> {
)} context: {
/> router: { history, route: { location: history.location } },
</MemoryRouter> },
}
); );
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
}); });
@@ -51,20 +57,22 @@ describe('<InventoryGroupDetail />', () => {
test('InventoryGroupDetail renders successfully', () => { test('InventoryGroupDetail renders successfully', () => {
expect(wrapper.length).toBe(1); expect(wrapper.length).toBe(1);
}); });
test('should open delete modal and then call api to delete the group', () => { test('should open delete modal and then call api to delete the group', async () => {
wrapper.find('button[aria-label="Delete"]').simulate('click'); 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); expect(wrapper.find('Modal').length).toBe(1);
wrapper.find('button[aria-label="confirm delete"]').simulate('click'); await act(async () => {
wrapper.find('button[aria-label="confirm delete"]').simulate('click');
});
expect(GroupsAPI.destroy).toBeCalledWith(1); expect(GroupsAPI.destroy).toBeCalledWith(1);
}); });
test('should navigate user to edit form on edit button click', async () => { test('should navigate user to edit form on edit button click', async () => {
wrapper.find('button[aria-label="Edit"]').prop('onClick'); wrapper.find('button[aria-label="Edit"]').prop('onClick');
expect( expect(history.location.pathname).toEqual(
wrapper '/inventories/inventory/1/groups/1/edit'
.find('Router') );
.at(1)
.prop('history').location.pathname
).toEqual('/inventories/inventory/1/groups/1/edit');
}); });
test('details shoudld render with the proper values', () => { test('details shoudld render with the proper values', () => {
expect(wrapper.find('Detail[label="Name"]').prop('value')).toBe('Foo'); expect(wrapper.find('Detail[label="Name"]').prop('value')).toBe('Foo');

View File

@@ -14,11 +14,15 @@ function InventoryGroupEdit({ history, inventoryGroup, inventory, match }) {
} catch (err) { } catch (err) {
setError(err); setError(err);
} finally { } finally {
history.push(`/inventories/inventory/${inventory.id}/groups`); history.push(
`/inventories/inventory/${inventory.id}/groups/${inventoryGroup.id}/details`
);
} }
}; };
const handleCancel = () => { const handleCancel = () => {
history.push(`/inventories/inventory/${inventory.id}/groups`); history.push(
`/inventories/inventory/${inventory.id}/groups/${inventoryGroup.id}/details`
);
}; };
return ( return (
<InventoryGroupForm <InventoryGroupForm