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

@ -61,9 +61,11 @@ class Inventories extends Component {
t`Create New Group`
),
[`/inventories/inventory/${inventory.id}/groups/${group &&
group.id}/details`]: i18n._(t`Details`),
group.id}`]: `${group && group.name}`,
[`/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 });
};

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

View File

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

View File

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

View File

@ -5,14 +5,21 @@ 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 } from '@components/CodeMirrorInput';
import ContentError from '@components/ContentError';
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;
@ -26,6 +33,7 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const handleDelete = async () => {
setIsDeleteModalOpen(false);
try {
await GroupsAPI.destroy(inventoryGroup.id);
history.push(`/inventories/inventory/${match.params.id}/groups`);
@ -34,7 +42,17 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) {
}
};
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) {
return (
@ -77,21 +95,14 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) {
label={i18n._(t`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>
<VariablesInput
id="inventoryGroup-variables"
readOnly
value={inventoryGroup.variables}
rows={4}
label={i18n._(t`Variables`)}
/>
<DetailList>
<Detail
label={i18n._(t`Created`)}

View File

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

View File

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