Merge pull request #4929 from mabashian/ui-next-inventories

Adds basic inventory list and scaffolding for inv/smart inv details and related tabs

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2019-10-09 13:46:43 +00:00 committed by GitHub
commit bc8f5ad015
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 1820 additions and 30 deletions

View File

@ -1,26 +1,116 @@
import React, { Component, Fragment } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core';
import { Route, withRouter, Switch } from 'react-router-dom';
import { Config } from '@contexts/Config';
import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs';
import { InventoryList } from './InventoryList';
import Inventory from './Inventory';
import SmartInventory from './SmartInventory';
import InventoryAdd from './InventoryAdd';
import SmartInventoryAdd from './SmartInventoryAdd';
class Inventories extends Component {
render() {
constructor(props) {
super(props);
const { i18n } = this.props;
const { light } = PageSectionVariants;
this.state = {
breadcrumbConfig: {
'/inventories': i18n._(t`Inventories`),
'/inventories/inventory/add': i18n._(t`Create New Inventory`),
'/inventories/smart_inventory/add': i18n._(
t`Create New Smart Inventory`
),
},
};
}
setBreadCrumbConfig = inventory => {
const { i18n } = this.props;
if (!inventory) {
return;
}
const inventoryKind =
inventory.kind === 'smart' ? 'smart_inventory' : 'inventory';
const breadcrumbConfig = {
'/inventories': i18n._(t`Inventories`),
'/inventories/inventory/add': i18n._(t`Create New Inventory`),
'/inventories/smart_inventory/add': i18n._(t`Create New Smart Inventory`),
[`/inventories/${inventoryKind}/${inventory.id}`]: `${inventory.name}`,
[`/inventories/${inventoryKind}/${inventory.id}/details`]: i18n._(
t`Details`
),
[`/inventories/${inventoryKind}/${inventory.id}/edit`]: i18n._(
t`Edit Details`
),
[`/inventories/${inventoryKind}/${inventory.id}/access`]: i18n._(
t`Access`
),
[`/inventories/${inventoryKind}/${inventory.id}/completed_jobs`]: i18n._(
t`Completed Jobs`
),
[`/inventories/${inventoryKind}/${inventory.id}/hosts`]: i18n._(t`Hosts`),
[`/inventories/inventory/${inventory.id}/sources`]: i18n._(t`Sources`),
[`/inventories/inventory/${inventory.id}/groups`]: i18n._(t`Groups`),
};
this.setState({ breadcrumbConfig });
};
render() {
const { match, history, location } = this.props;
const { breadcrumbConfig } = this.state;
return (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl">{i18n._(t`Inventories`)}</Title>
</PageSection>
<PageSection />
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<Switch>
<Route
path={`${match.path}/inventory/add`}
render={() => <InventoryAdd />}
/>
<Route
path={`${match.path}/smart_inventory/add`}
render={() => <SmartInventoryAdd />}
/>
<Route
path={`${match.path}/inventory/:id`}
render={({ match: newRouteMatch }) => (
<Config>
{({ me }) => (
<Inventory
history={history}
location={location}
setBreadcrumb={this.setBreadCrumbConfig}
me={me || {}}
match={newRouteMatch}
/>
)}
</Config>
)}
/>
<Route
path={`${match.path}/smart_inventory/:id`}
render={({ match: newRouteMatch }) => (
<Config>
{({ me }) => (
<SmartInventory
history={history}
location={location}
setBreadcrumb={this.setBreadCrumbConfig}
me={me || {}}
match={newRouteMatch}
/>
)}
</Config>
)}
/>
<Route path={`${match.path}`} render={() => <InventoryList />} />
</Switch>
</Fragment>
);
}
}
export default withI18n()(Inventories);
export { Inventories as _Inventories };
export default withI18n()(withRouter(Inventories));

View File

@ -6,13 +6,9 @@ import Inventories from './Inventories';
describe('<Inventories />', () => {
let pageWrapper;
let pageSections;
let title;
beforeEach(() => {
pageWrapper = mountWithContexts(<Inventories />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
});
afterEach(() => {
@ -21,9 +17,5 @@ describe('<Inventories />', () => {
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
expect(pageSections.length).toBe(2);
expect(title.length).toBe(1);
expect(title.props().size).toBe('2xl');
expect(pageSections.first().props().variant).toBe('light');
});
});

View File

@ -0,0 +1,188 @@
import React, { Component } from 'react';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import { Card, CardHeader, PageSection } from '@patternfly/react-core';
import { Switch, Route, Redirect, withRouter, Link } from 'react-router-dom';
import CardCloseButton from '@components/CardCloseButton';
import ContentError from '@components/ContentError';
import RoutedTabs from '@components/RoutedTabs';
import InventoryDetail from './InventoryDetail';
import InventoryAccess from './InventoryAccess';
import InventoryHosts from './InventoryHosts';
import InventoryGroups from './InventoryGroups';
import InventorySources from './InventorySources';
import InventoryCompletedJobs from './InventoryCompletedJobs';
import { InventoriesAPI } from '@api';
import InventoryEdit from './InventoryEdit';
class Inventory extends Component {
constructor(props) {
super(props);
this.state = {
contentError: null,
hasContentLoading: true,
inventory: null,
};
this.loadInventory = this.loadInventory.bind(this);
}
async componentDidMount() {
await this.loadInventory();
}
async componentDidUpdate(prevProps) {
const { location, match } = this.props;
const url = `/inventories/inventory/${match.params.id}/`;
if (
prevProps.location.pathname.startsWith(url) &&
prevProps.location !== location &&
location.pathname === `${url}details`
) {
await this.loadInventory();
}
}
async loadInventory() {
const { setBreadcrumb, match } = this.props;
const { id } = match.params;
this.setState({ contentError: null, hasContentLoading: true });
try {
const { data } = await InventoriesAPI.readDetail(id);
setBreadcrumb(data);
this.setState({ inventory: data });
} catch (err) {
this.setState({ contentError: err });
} finally {
this.setState({ hasContentLoading: false });
}
}
render() {
const { history, i18n, location, match } = this.props;
const { contentError, hasContentLoading, inventory } = this.state;
const tabsArray = [
{ name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 },
{ name: i18n._(t`Access`), link: `${match.url}/access`, id: 1 },
{ name: i18n._(t`Groups`), link: `${match.url}/groups`, id: 2 },
{ name: i18n._(t`Hosts`), link: `${match.url}/hosts`, id: 3 },
{ name: i18n._(t`Sources`), link: `${match.url}/sources`, id: 4 },
{
name: i18n._(t`Completed Jobs`),
link: `${match.url}/completed_jobs`,
id: 5,
},
];
let cardHeader = hasContentLoading ? null : (
<CardHeader style={{ padding: 0 }}>
<RoutedTabs history={history} tabsArray={tabsArray} />
<CardCloseButton linkTo="/inventories" />
</CardHeader>
);
if (location.pathname.endsWith('edit')) {
cardHeader = null;
}
if (!hasContentLoading && contentError) {
return (
<PageSection>
<Card className="awx-c-card">
<ContentError error={contentError}>
{contentError.response.status === 404 && (
<span>
{i18n._(`Inventory not found.`)}{' '}
<Link to="/inventories">
{i18n._(`View all Inventories.`)}
</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
}
return (
<PageSection>
<Card className="awx-c-card">
{cardHeader}
<Switch>
<Redirect
from="/inventories/inventory/:id"
to="/inventories/inventory/:id/details"
exact
/>
{inventory && [
<Route
key="details"
path="/inventories/inventory/:id/details"
render={() => (
<InventoryDetail
match={match}
hasInventoryLoading={hasContentLoading}
inventory={inventory}
/>
)}
/>,
<Route
key="edit"
path="/inventories/inventory/:id/edit"
render={() => <InventoryEdit inventory={inventory} />}
/>,
<Route
key="access"
path="/inventories/inventory/:id/access"
render={() => <InventoryAccess inventory={inventory} />}
/>,
<Route
key="groups"
path="/inventories/inventory/:id/groups"
render={() => <InventoryGroups inventory={inventory} />}
/>,
<Route
key="hosts"
path="/inventories/inventory/:id/hosts"
render={() => <InventoryHosts inventory={inventory} />}
/>,
<Route
key="sources"
path="/inventories/inventory/:id/sources"
render={() => <InventorySources inventory={inventory} />}
/>,
<Route
key="completed_jobs"
path="/inventories/inventory/:id/completed_jobs"
render={() => <InventoryCompletedJobs inventory={inventory} />}
/>,
<Route
key="not-found"
path="*"
render={() =>
!hasContentLoading && (
<ContentError isNotFound>
{match.params.id && (
<Link
to={`/inventories/inventory/${match.params.id}/details`}
>
{i18n._(`View Inventory Details`)}
</Link>
)}
</ContentError>
)
}
/>,
]}
</Switch>
</Card>
</PageSection>
);
}
}
export { Inventory as _Inventory };
export default withI18n()(withRouter(Inventory));

View File

@ -0,0 +1,54 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { InventoriesAPI } from '@api';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import mockInventory from './shared/data.inventory.json';
import Inventory from './Inventory';
jest.mock('@api');
InventoriesAPI.readDetail.mockResolvedValue({
data: mockInventory,
});
describe.only('<Inventory />', () => {
test('initially renders succesfully', async done => {
const wrapper = mountWithContexts(
<Inventory setBreadcrumb={() => {}} match={{ params: { id: 1 } }} />
);
await waitForElement(
wrapper,
'Inventory',
el => el.state('hasContentLoading') === true
);
await waitForElement(
wrapper,
'Inventory',
el => el.state('hasContentLoading') === false
);
await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 6);
done();
});
test('should show content error when user attempts to navigate to erroneous route', async done => {
const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/foobar'],
});
const wrapper = mountWithContexts(<Inventory setBreadcrumb={() => {}} />, {
context: {
router: {
history,
route: {
location: history.location,
match: {
params: { id: 1 },
url: '/inventories/inventory/1/foobar',
path: '/inventories/inventory/1/foobar',
},
},
},
},
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
done();
});
});

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
class InventoryAccess extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
}
export default InventoryAccess;

View File

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

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { PageSection } from '@patternfly/react-core';
class InventoryAdd extends Component {
render() {
return <PageSection>Coming soon :)</PageSection>;
}
}
export default InventoryAdd;

View File

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

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
class InventoryCompletedJobs extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
}
export default InventoryCompletedJobs;

View File

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

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
class InventoryDetail extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
}
export default InventoryDetail;

View File

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

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { PageSection } from '@patternfly/react-core';
class InventoryEdit extends Component {
render() {
return <PageSection>Coming soon :)</PageSection>;
}
}
export default InventoryEdit;

View File

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

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
class InventoryGroups extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
}
export default InventoryGroups;

View File

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

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
class InventoryHosts extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
}
export default InventoryHosts;

View File

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

View File

@ -0,0 +1,315 @@
import React, { Component } from 'react';
import { withRouter, Link } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Card,
PageSection,
Dropdown,
DropdownItem,
DropdownPosition,
} from '@patternfly/react-core';
import { InventoriesAPI } from '@api';
import AlertModal from '@components/AlertModal';
import DatalistToolbar from '@components/DataListToolbar';
import ErrorDetail from '@components/ErrorDetail';
import PaginatedDataList, {
ToolbarDeleteButton,
ToolbarAddButton,
} from '@components/PaginatedDataList';
import { getQSConfig, parseQueryString } from '@util/qs';
import InventoryListItem from './InventoryListItem';
// The type value in const QS_CONFIG below does not have a space between job_inventory and
// workflow_job_inventory so the params sent to the API match what the api expects.
const QS_CONFIG = getQSConfig('inventory', {
page: 1,
page_size: 20,
order_by: 'name',
});
class InventoriesList extends Component {
constructor(props) {
super(props);
this.state = {
hasContentLoading: true,
contentError: null,
deletionError: null,
selected: [],
inventories: [],
itemCount: 0,
isAddOpen: false,
};
this.loadInventories = this.loadInventories.bind(this);
this.handleSelectAll = this.handleSelectAll.bind(this);
this.handleSelect = this.handleSelect.bind(this);
this.handleInventoryDelete = this.handleInventoryDelete.bind(this);
this.handleDeleteErrorClose = this.handleDeleteErrorClose.bind(this);
this.handleAddToggle = this.handleAddToggle.bind(this);
}
componentDidMount() {
this.loadInventories();
}
componentDidUpdate(prevProps) {
const { location } = this.props;
if (location !== prevProps.location) {
this.loadInventories();
}
}
componentWillUnmount() {
document.removeEventListener('click', this.handleAddToggle, false);
}
handleDeleteErrorClose() {
this.setState({ deletionError: null });
}
handleSelectAll(isSelected) {
const { inventories } = this.state;
const selected = isSelected ? [...inventories] : [];
this.setState({ selected });
}
handleSelect(inventory) {
const { selected } = this.state;
if (selected.some(s => s.id === inventory.id)) {
this.setState({ selected: selected.filter(s => s.id !== inventory.id) });
} else {
this.setState({ selected: selected.concat(inventory) });
}
}
handleAddToggle(e) {
const { isAddOpen } = this.state;
document.addEventListener('click', this.handleAddToggle, false);
if (this.node && this.node.contains(e.target) && isAddOpen) {
document.removeEventListener('click', this.handleAddToggle, false);
this.setState({ isAddOpen: false });
} else if (this.node && this.node.contains(e.target) && !isAddOpen) {
this.setState({ isAddOpen: true });
} else {
this.setState({ isAddOpen: false });
document.removeEventListener('click', this.handleAddToggle, false);
}
}
async handleInventoryDelete() {
const { selected, itemCount } = this.state;
this.setState({ hasContentLoading: true });
try {
await Promise.all(
selected.map(({ id }) => {
return InventoriesAPI.destroy(id);
})
);
this.setState({ itemCount: itemCount - selected.length });
} catch (err) {
this.setState({ deletionError: err });
} finally {
await this.loadInventories();
}
}
async loadInventories() {
const { location } = this.props;
const { actions: cachedActions } = this.state;
const params = parseQueryString(QS_CONFIG, location.search);
let optionsPromise;
if (cachedActions) {
optionsPromise = Promise.resolve({ data: { actions: cachedActions } });
} else {
optionsPromise = InventoriesAPI.readOptions();
}
const promises = Promise.all([InventoriesAPI.read(params), optionsPromise]);
this.setState({ contentError: null, hasContentLoading: true });
try {
const [
{
data: { count, results },
},
{
data: { actions },
},
] = await promises;
this.setState({
actions,
itemCount: count,
inventories: results,
selected: [],
});
} catch (err) {
this.setState({ contentError: err });
} finally {
this.setState({ hasContentLoading: false });
}
}
render() {
const {
contentError,
hasContentLoading,
deletionError,
inventories,
itemCount,
selected,
isAddOpen,
actions,
} = this.state;
const { match, i18n } = this.props;
const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
const isAllSelected = selected.length === inventories.length;
return (
<PageSection>
<Card>
<PaginatedDataList
contentError={contentError}
hasContentLoading={hasContentLoading}
items={inventories}
itemCount={itemCount}
pluralizedItemName={i18n._(t`Inventories`)}
qsConfig={QS_CONFIG}
toolbarColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
},
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: true,
isNumeric: true,
},
{
name: i18n._(t`Created`),
key: 'created',
isSortable: true,
isNumeric: true,
},
]}
renderToolbar={props => (
<DatalistToolbar
{...props}
showSelectAll
showExpandCollapse
isAllSelected={isAllSelected}
onSelectAll={this.handleSelectAll}
qsConfig={QS_CONFIG}
additionalControls={[
<ToolbarDeleteButton
key="delete"
onDelete={this.handleInventoryDelete}
itemsToDelete={selected}
pluralizedItemName="Inventories"
/>,
canAdd && (
<div
ref={node => {
this.node = node;
}}
key="add"
>
<Dropdown
isPlain
isOpen={isAddOpen}
position={DropdownPosition.right}
toggle={
<ToolbarAddButton onClick={this.handleAddToggle} />
}
dropdownItems={[
<DropdownItem key="inventory">
<Link to={`${match.url}/inventory/add/`}>
{i18n._(t`Inventory`)}
</Link>
</DropdownItem>,
<DropdownItem key="smart_inventory">
<Link to={`${match.url}/smart_inventory/add/`}>
{i18n._(t`Smart Inventory`)}
</Link>
</DropdownItem>,
]}
/>
</div>
),
]}
/>
)}
renderItem={inventory => (
<InventoryListItem
key={inventory.id}
value={inventory.name}
inventory={inventory}
detailUrl={
inventory.kind === 'smart'
? `${match.url}/smart_inventory/${inventory.id}`
: `${match.url}/inventory/${inventory.id}`
}
onSelect={() => this.handleSelect(inventory)}
isSelected={selected.some(row => row.id === inventory.id)}
/>
)}
emptyStateControls={
canAdd && (
<div
ref={node => {
this.node = node;
}}
key="add"
>
<Dropdown
isPlain
isOpen={isAddOpen}
position={DropdownPosition.right}
toggle={<ToolbarAddButton onClick={this.handleAddToggle} />}
dropdownItems={[
<DropdownItem key="inventory">
<Link to={`${match.url}/inventory/add/`}>
{i18n._(t`Inventory`)}
</Link>
</DropdownItem>,
<DropdownItem key="smart_inventory">
<Link to={`${match.url}/smart_inventory/add/`}>
{i18n._(t`Smart Inventory`)}
</Link>
</DropdownItem>,
]}
/>
</div>
)
}
/>
</Card>
<AlertModal
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={this.handleDeleteErrorClose}
>
{i18n._(t`Failed to delete one or more inventories.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
</PageSection>
);
}
}
export { InventoriesList as _InventoriesList };
export default withI18n()(withRouter(InventoriesList));

View File

@ -0,0 +1,325 @@
import React from 'react';
import { InventoriesAPI } from '@api';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import InventoriesList, { _InventoriesList } from './InventoryList';
jest.mock('@api');
const mockInventories = [
{
id: 1,
type: 'inventory',
url: '/api/v2/inventories/1/',
summary_fields: {
organization: {
id: 1,
name: 'Default',
description: '',
},
user_capabilities: {
edit: true,
delete: true,
copy: true,
adhoc: true,
},
},
created: '2019-10-04T16:56:48.025455Z',
modified: '2019-10-04T16:56:48.025468Z',
name: 'Inv no hosts',
description: '',
organization: 1,
kind: '',
host_filter: null,
variables: '---',
has_active_failures: false,
total_hosts: 0,
hosts_with_active_failures: 0,
total_groups: 0,
groups_with_active_failures: 0,
has_inventory_sources: false,
total_inventory_sources: 0,
inventory_sources_with_failures: 0,
insights_credential: null,
pending_deletion: false,
},
{
id: 2,
type: 'inventory',
url: '/api/v2/inventories/2/',
summary_fields: {
organization: {
id: 1,
name: 'Default',
description: '',
},
user_capabilities: {
edit: true,
delete: true,
copy: true,
adhoc: true,
},
},
created: '2019-10-04T14:28:04.765571Z',
modified: '2019-10-04T14:28:04.765594Z',
name: "Mike's Inventory",
description: '',
organization: 1,
kind: '',
host_filter: null,
variables: '---',
has_active_failures: false,
total_hosts: 1,
hosts_with_active_failures: 0,
total_groups: 0,
groups_with_active_failures: 0,
has_inventory_sources: false,
total_inventory_sources: 0,
inventory_sources_with_failures: 0,
insights_credential: null,
pending_deletion: false,
},
{
id: 3,
type: 'inventory',
url: '/api/v2/inventories/3/',
summary_fields: {
organization: {
id: 1,
name: 'Default',
description: '',
},
user_capabilities: {
edit: true,
delete: false,
copy: true,
adhoc: true,
},
},
created: '2019-10-04T15:29:11.542911Z',
modified: '2019-10-04T15:29:11.542924Z',
name: 'Smart Inv',
description: '',
organization: 1,
kind: 'smart',
host_filter: 'search=local',
variables: '',
has_active_failures: false,
total_hosts: 1,
hosts_with_active_failures: 0,
total_groups: 0,
groups_with_active_failures: 0,
has_inventory_sources: false,
total_inventory_sources: 0,
inventory_sources_with_failures: 0,
insights_credential: null,
pending_deletion: false,
},
];
describe('<InventoriesList />', () => {
beforeEach(() => {
InventoriesAPI.read.mockResolvedValue({
data: {
count: mockInventories.length,
results: mockInventories,
},
});
InventoriesAPI.readOptions.mockResolvedValue({
data: {
actions: {
GET: {},
POST: {},
},
},
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('initially renders successfully', () => {
mountWithContexts(
<InventoriesList
match={{ path: '/inventories', url: '/inventories' }}
location={{ search: '', pathname: '/inventories' }}
/>
);
});
test('Inventories are retrieved from the api and the components finishes loading', async done => {
const loadInventories = jest.spyOn(
_InventoriesList.prototype,
'loadInventories'
);
const wrapper = mountWithContexts(<InventoriesList />);
await waitForElement(
wrapper,
'InventoriesList',
el => el.state('hasContentLoading') === true
);
expect(loadInventories).toHaveBeenCalled();
await waitForElement(
wrapper,
'InventoriesList',
el => el.state('hasContentLoading') === false
);
expect(wrapper.find('InventoryListItem').length).toBe(3);
done();
});
test('handleSelect is called when a inventory list item is selected', async done => {
const handleSelect = jest.spyOn(_InventoriesList.prototype, 'handleSelect');
const wrapper = mountWithContexts(<InventoriesList />);
await waitForElement(
wrapper,
'InventoriesList',
el => el.state('hasContentLoading') === false
);
await wrapper
.find('input#select-inventory-1')
.closest('DataListCheck')
.props()
.onChange();
expect(handleSelect).toBeCalled();
await waitForElement(
wrapper,
'InventoriesList',
el => el.state('selected').length === 1
);
done();
});
test('handleSelectAll is called when a inventory list item is selected', async done => {
const handleSelectAll = jest.spyOn(
_InventoriesList.prototype,
'handleSelectAll'
);
const wrapper = mountWithContexts(<InventoriesList />);
await waitForElement(
wrapper,
'InventoriesList',
el => el.state('hasContentLoading') === false
);
wrapper
.find('Checkbox#select-all')
.props()
.onChange(true);
expect(handleSelectAll).toBeCalled();
await waitForElement(
wrapper,
'InventoriesList',
el => el.state('selected').length === 3
);
done();
});
test('delete button is disabled if user does not have delete capabilities on a selected inventory', async done => {
const wrapper = mountWithContexts(<InventoriesList />);
wrapper.find('InventoriesList').setState({
inventories: mockInventories,
itemCount: 3,
isInitialized: true,
selected: mockInventories.slice(0, 2),
});
await waitForElement(
wrapper,
'ToolbarDeleteButton * button',
el => el.getDOMNode().disabled === false
);
wrapper.find('InventoriesList').setState({
selected: mockInventories,
});
await waitForElement(
wrapper,
'ToolbarDeleteButton * button',
el => el.getDOMNode().disabled === true
);
done();
});
test('api is called to delete inventories for each selected inventory.', () => {
InventoriesAPI.destroy = jest.fn();
const wrapper = mountWithContexts(<InventoriesList />);
wrapper.find('InventoriesList').setState({
inventories: mockInventories,
itemCount: 3,
isInitialized: true,
isModalOpen: true,
selected: mockInventories.slice(0, 2),
});
wrapper.find('ToolbarDeleteButton').prop('onDelete')();
expect(InventoriesAPI.destroy).toHaveBeenCalledTimes(2);
});
test('error is shown when inventory not successfully deleted from api', async done => {
InventoriesAPI.destroy.mockRejectedValue(
new Error({
response: {
config: {
method: 'delete',
url: '/api/v2/inventories/1',
},
data: 'An error occurred',
},
})
);
const wrapper = mountWithContexts(<InventoriesList />);
wrapper.find('InventoriesList').setState({
inventories: mockInventories,
itemCount: 1,
isInitialized: true,
isModalOpen: true,
selected: mockInventories.slice(0, 1),
});
wrapper.find('ToolbarDeleteButton').prop('onDelete')();
await waitForElement(
wrapper,
'Modal',
el => el.props().isOpen === true && el.props().title === 'Error!'
);
done();
});
test('Add button shown for users without ability to POST', async done => {
const wrapper = mountWithContexts(<InventoriesList />);
await waitForElement(
wrapper,
'InventoriesList',
el => el.state('hasContentLoading') === true
);
await waitForElement(
wrapper,
'InventoriesList',
el => el.state('hasContentLoading') === false
);
expect(wrapper.find('ToolbarAddButton').length).toBe(1);
done();
});
test('Add button hidden for users without ability to POST', async done => {
InventoriesAPI.readOptions.mockResolvedValue({
data: {
actions: {
GET: {},
},
},
});
const wrapper = mountWithContexts(<InventoriesList />);
await waitForElement(
wrapper,
'InventoriesList',
el => el.state('hasContentLoading') === true
);
await waitForElement(
wrapper,
'InventoriesList',
el => el.state('hasContentLoading') === false
);
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
done();
});
});

View File

@ -0,0 +1,57 @@
import React from 'react';
import { string, bool, func } from 'prop-types';
import { withI18n } from '@lingui/react';
import {
DataListItem,
DataListItemRow,
DataListItemCells,
} from '@patternfly/react-core';
import { t } from '@lingui/macro';
import { Link } from 'react-router-dom';
import DataListCell from '@components/DataListCell';
import DataListCheck from '@components/DataListCheck';
import VerticalSeparator from '@components/VerticalSeparator';
import { Inventory } from '@types';
class InventoryListItem extends React.Component {
static propTypes = {
inventory: Inventory.isRequired,
detailUrl: string.isRequired,
isSelected: bool.isRequired,
onSelect: func.isRequired,
};
render() {
const { inventory, isSelected, onSelect, detailUrl, i18n } = this.props;
const labelId = `check-action-${inventory.id}`;
return (
<DataListItem key={inventory.id} aria-labelledby={labelId}>
<DataListItemRow>
<DataListCheck
id={`select-inventory-${inventory.id}`}
checked={isSelected}
onChange={onSelect}
aria-labelledby={labelId}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="divider">
<VerticalSeparator />
<Link to={`${detailUrl}`}>
<b>{inventory.name}</b>
</Link>
</DataListCell>,
<DataListCell key="kind">
{inventory.kind === 'smart'
? i18n._(t`Smart Inventory`)
: i18n._(t`Inventory`)}
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
);
}
}
export default withI18n()(InventoryListItem);

View File

@ -0,0 +1,31 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { I18nProvider } from '@lingui/react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import InventoryListItem from './InventoryListItem';
describe('<InventoryListItem />', () => {
test('initially renders succesfully', () => {
mountWithContexts(
<I18nProvider>
<MemoryRouter initialEntries={['/inventories']} initialIndex={0}>
<InventoryListItem
inventory={{
id: 1,
name: 'Inventory',
summary_fields: {
organization: {
id: 1,
name: 'Default',
},
},
}}
detailUrl="/inventories/inventory/1"
isSelected
onSelect={() => {}}
/>
</MemoryRouter>
</I18nProvider>
);
});
});

View File

@ -0,0 +1,2 @@
export { default as InventoryList } from './InventoryList';
export { default as InventoryListItem } from './InventoryListItem';

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
class InventorySources extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
}
export default InventorySources;

View File

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

View File

@ -0,0 +1,175 @@
import React, { Component } from 'react';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import { Card, CardHeader, PageSection } from '@patternfly/react-core';
import { Switch, Route, Redirect, withRouter, Link } from 'react-router-dom';
import CardCloseButton from '@components/CardCloseButton';
import ContentError from '@components/ContentError';
import RoutedTabs from '@components/RoutedTabs';
import SmartInventoryDetail from './SmartInventoryDetail';
import SmartInventoryAccess from './SmartInventoryAccess';
import SmartInventoryHosts from './SmartInventoryHosts';
import SmartInventoryCompletedJobs from './SmartInventoryCompletedJobs';
import { InventoriesAPI } from '@api';
import SmartInventoryEdit from './SmartInventoryEdit';
class SmartInventory extends Component {
constructor(props) {
super(props);
this.state = {
contentError: null,
hasContentLoading: true,
inventory: null,
};
this.loadSmartInventory = this.loadSmartInventory.bind(this);
}
async componentDidMount() {
await this.loadSmartInventory();
}
async componentDidUpdate(prevProps) {
const { location, match } = this.props;
const url = `/inventories/smart_inventory/${match.params.id}/`;
if (
prevProps.location.pathname.startsWith(url) &&
prevProps.location !== location &&
location.pathname === `${url}details`
) {
await this.loadSmartInventory();
}
}
async loadSmartInventory() {
const { setBreadcrumb, match } = this.props;
const { id } = match.params;
this.setState({ contentError: null, hasContentLoading: true });
try {
const { data } = await InventoriesAPI.readDetail(id);
setBreadcrumb(data);
this.setState({ inventory: data });
} catch (err) {
this.setState({ contentError: err });
} finally {
this.setState({ hasContentLoading: false });
}
}
render() {
const { history, i18n, location, match } = this.props;
const { contentError, hasContentLoading, inventory } = this.state;
const tabsArray = [
{ name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 },
{ name: i18n._(t`Access`), link: `${match.url}/access`, id: 1 },
{ name: i18n._(t`Hosts`), link: `${match.url}/hosts`, id: 2 },
{
name: i18n._(t`Completed Jobs`),
link: `${match.url}/completed_jobs`,
id: 3,
},
];
let cardHeader = hasContentLoading ? null : (
<CardHeader style={{ padding: 0 }}>
<RoutedTabs history={history} tabsArray={tabsArray} />
<CardCloseButton linkTo="/inventories" />
</CardHeader>
);
if (location.pathname.endsWith('edit')) {
cardHeader = null;
}
if (!hasContentLoading && contentError) {
return (
<PageSection>
<Card className="awx-c-card">
<ContentError error={contentError}>
{contentError.response.status === 404 && (
<span>
{i18n._(`Inventory not found.`)}{' '}
<Link to="/inventories">
{i18n._(`View all Inventories.`)}
</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
}
return (
<PageSection>
<Card className="awx-c-card">
{cardHeader}
<Switch>
<Redirect
from="/inventories/smart_inventory/:id"
to="/inventories/smart_inventory/:id/details"
exact
/>
{inventory && [
<Route
key="details"
path="/inventories/smart_inventory/:id/details"
render={() => (
<SmartInventoryDetail
match={match}
hasSmartInventoryLoading={hasContentLoading}
inventory={inventory}
/>
)}
/>,
<Route
key="edit"
path="/inventories/smart_inventory/:id/edit"
render={() => <SmartInventoryEdit inventory={inventory} />}
/>,
<Route
key="access"
path="/inventories/smart_inventory/:id/access"
render={() => <SmartInventoryAccess inventory={inventory} />}
/>,
<Route
key="hosts"
path="/inventories/smart_inventory/:id/hosts"
render={() => <SmartInventoryHosts inventory={inventory} />}
/>,
<Route
key="completed_jobs"
path="/inventories/smart_inventory/:id/completed_jobs"
render={() => (
<SmartInventoryCompletedJobs inventory={inventory} />
)}
/>,
<Route
key="not-found"
path="*"
render={() =>
!hasContentLoading && (
<ContentError isNotFound>
{match.params.id && (
<Link
to={`/inventories/smart_inventory/${match.params.id}/details`}
>
{i18n._(`View Inventory Details`)}
</Link>
)}
</ContentError>
)
}
/>,
]}
</Switch>
</Card>
</PageSection>
);
}
}
export { SmartInventory as _SmartInventory };
export default withI18n()(withRouter(SmartInventory));

View File

@ -0,0 +1,57 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { InventoriesAPI } from '@api';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import mockSmartInventory from './shared/data.smart_inventory.json';
import SmartInventory from './SmartInventory';
jest.mock('@api');
InventoriesAPI.readDetail.mockResolvedValue({
data: mockSmartInventory,
});
describe.only('<SmartInventory />', () => {
test('initially renders succesfully', async done => {
const wrapper = mountWithContexts(
<SmartInventory setBreadcrumb={() => {}} match={{ params: { id: 1 } }} />
);
await waitForElement(
wrapper,
'SmartInventory',
el => el.state('hasContentLoading') === true
);
await waitForElement(
wrapper,
'SmartInventory',
el => el.state('hasContentLoading') === false
);
await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 4);
done();
});
test('should show content error when user attempts to navigate to erroneous route', async done => {
const history = createMemoryHistory({
initialEntries: ['/inventories/smart_inventory/1/foobar'],
});
const wrapper = mountWithContexts(
<SmartInventory setBreadcrumb={() => {}} />,
{
context: {
router: {
history,
route: {
location: history.location,
match: {
params: { id: 1 },
url: '/inventories/smart_inventory/1/foobar',
path: '/inventories/smart_inventory/1/foobar',
},
},
},
},
}
);
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
done();
});
});

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
class SmartInventoryAccess extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
}
export default SmartInventoryAccess;

View File

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

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { PageSection } from '@patternfly/react-core';
class SmartInventoryAdd extends Component {
render() {
return <PageSection>Coming soon :)</PageSection>;
}
}
export default SmartInventoryAdd;

View File

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

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
class SmartInventoryCompletedJobs extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
}
export default SmartInventoryCompletedJobs;

View File

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

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
class SmartInventoryDetail extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
}
export default SmartInventoryDetail;

View File

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

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { PageSection } from '@patternfly/react-core';
class SmartInventoryEdit extends Component {
render() {
return <PageSection>Coming soon :)</PageSection>;
}
}
export default SmartInventoryEdit;

View File

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

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
class SmartInventoryHosts extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
}
export default SmartInventoryHosts;

View File

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

View File

@ -0,0 +1,96 @@
{
"id": 1,
"type": "inventory",
"url": "/api/v2/inventories/1/",
"related": {
"named_url": "/api/v2/inventories/Mike's Inventory++Default/",
"created_by": "/api/v2/users/1/",
"modified_by": "/api/v2/users/1/",
"hosts": "/api/v2/inventories/1/hosts/",
"groups": "/api/v2/inventories/1/groups/",
"root_groups": "/api/v2/inventories/1/root_groups/",
"variable_data": "/api/v2/inventories/1/variable_data/",
"script": "/api/v2/inventories/1/script/",
"tree": "/api/v2/inventories/1/tree/",
"inventory_sources": "/api/v2/inventories/1/inventory_sources/",
"update_inventory_sources": "/api/v2/inventories/1/update_inventory_sources/",
"activity_stream": "/api/v2/inventories/1/activity_stream/",
"job_templates": "/api/v2/inventories/1/job_templates/",
"ad_hoc_commands": "/api/v2/inventories/1/ad_hoc_commands/",
"access_list": "/api/v2/inventories/1/access_list/",
"object_roles": "/api/v2/inventories/1/object_roles/",
"instance_groups": "/api/v2/inventories/1/instance_groups/",
"copy": "/api/v2/inventories/1/copy/",
"organization": "/api/v2/organizations/1/"
},
"summary_fields": {
"organization": {
"id": 1,
"name": "Default",
"description": ""
},
"created_by": {
"id": 1,
"username": "admin",
"first_name": "",
"last_name": ""
},
"modified_by": {
"id": 1,
"username": "admin",
"first_name": "",
"last_name": ""
},
"object_roles": {
"admin_role": {
"description": "Can manage all aspects of the inventory",
"name": "Admin",
"id": 19
},
"update_role": {
"description": "May update the inventory",
"name": "Update",
"id": 20
},
"adhoc_role": {
"description": "May run ad hoc commands on the inventory",
"name": "Ad Hoc",
"id": 21
},
"use_role": {
"description": "Can use the inventory in a job template",
"name": "Use",
"id": 22
},
"read_role": {
"description": "May view settings for the inventory",
"name": "Read",
"id": 23
}
},
"user_capabilities": {
"edit": true,
"delete": true,
"copy": true,
"adhoc": true
}
},
"created": "2019-10-04T14:28:04.765571Z",
"modified": "2019-10-04T14:28:04.765594Z",
"name": "Mike's Inventory",
"description": "",
"organization": 1,
"kind": "",
"host_filter": null,
"variables": "---",
"has_active_failures": false,
"total_hosts": 1,
"hosts_with_active_failures": 0,
"total_groups": 0,
"groups_with_active_failures": 0,
"has_inventory_sources": false,
"total_inventory_sources": 0,
"inventory_sources_with_failures": 0,
"insights_credential": null,
"pending_deletion": false
}

View File

@ -0,0 +1,95 @@
{
"id": 2,
"type": "inventory",
"url": "/api/v2/inventories/2/",
"related": {
"created_by": "/api/v2/users/1/",
"modified_by": "/api/v2/users/1/",
"hosts": "/api/v2/inventories/2/hosts/",
"groups": "/api/v2/inventories/2/groups/",
"root_groups": "/api/v2/inventories/2/root_groups/",
"variable_data": "/api/v2/inventories/2/variable_data/",
"script": "/api/v2/inventories/2/script/",
"tree": "/api/v2/inventories/2/tree/",
"inventory_sources": "/api/v2/inventories/2/inventory_sources/",
"update_inventory_sources": "/api/v2/inventories/2/update_inventory_sources/",
"activity_stream": "/api/v2/inventories/2/activity_stream/",
"job_templates": "/api/v2/inventories/2/job_templates/",
"ad_hoc_commands": "/api/v2/inventories/2/ad_hoc_commands/",
"access_list": "/api/v2/inventories/2/access_list/",
"object_roles": "/api/v2/inventories/2/object_roles/",
"instance_groups": "/api/v2/inventories/2/instance_groups/",
"copy": "/api/v2/inventories/2/copy/",
"organization": "/api/v2/organizations/1/"
},
"summary_fields": {
"organization": {
"id": 1,
"name": "Default",
"description": ""
},
"created_by": {
"id": 1,
"username": "admin",
"first_name": "",
"last_name": ""
},
"modified_by": {
"id": 1,
"username": "admin",
"first_name": "",
"last_name": ""
},
"object_roles": {
"admin_role": {
"description": "Can manage all aspects of the inventory",
"name": "Admin",
"id": 27
},
"update_role": {
"description": "May update the inventory",
"name": "Update",
"id": 28
},
"adhoc_role": {
"description": "May run ad hoc commands on the inventory",
"name": "Ad Hoc",
"id": 29
},
"use_role": {
"description": "Can use the inventory in a job template",
"name": "Use",
"id": 30
},
"read_role": {
"description": "May view settings for the inventory",
"name": "Read",
"id": 31
}
},
"user_capabilities": {
"edit": true,
"delete": true,
"copy": true,
"adhoc": true
}
},
"created": "2019-10-04T15:29:11.542911Z",
"modified": "2019-10-04T15:29:11.542924Z",
"name": "Smart Inv",
"description": "",
"organization": 1,
"kind": "smart",
"host_filter": "search=local",
"variables": "",
"has_active_failures": false,
"total_hosts": 1,
"hosts_with_active_failures": 0,
"total_groups": 0,
"groups_with_active_failures": 0,
"has_inventory_sources": false,
"total_inventory_sources": 0,
"inventory_sources_with_failures": 0,
"insights_credential": null,
"pending_deletion": false
}

View File

@ -1,10 +1,8 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { OrganizationsAPI } from '@api';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import mockOrganization from '@util/data.organization.json';
import Organization from './Organization';
jest.mock('@api');
@ -78,4 +76,30 @@ describe.only('<Organization />', () => {
tabs.forEach(tab => expect(tab.text()).not.toEqual('Notifications'));
done();
});
test('should show content error when user attempts to navigate to erroneous route', async done => {
const history = createMemoryHistory({
initialEntries: ['/organizations/1/foobar'],
});
const wrapper = mountWithContexts(
<Organization setBreadcrumb={() => {}} me={mockMe} />,
{
context: {
router: {
history,
route: {
location: history.location,
match: {
params: { id: 1 },
url: '/organizations/1/foobar',
path: '/organizations/1/foobar',
},
},
},
},
}
);
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
done();
});
});

View File

@ -73,7 +73,10 @@ describe('<OrganizationsList />', () => {
OrganizationsAPI.readOptions = () =>
Promise.resolve({
data: {
actions: [],
actions: {
GET: {},
POST: {},
},
},
});
});
@ -176,4 +179,44 @@ describe('<OrganizationsList />', () => {
);
done();
});
test('Add button shown for users without ability to POST', async done => {
wrapper = mountWithContexts(<OrganizationsList />);
await waitForElement(
wrapper,
'OrganizationsList',
el => el.state('hasContentLoading') === true
);
await waitForElement(
wrapper,
'OrganizationsList',
el => el.state('hasContentLoading') === false
);
expect(wrapper.find('ToolbarAddButton').length).toBe(1);
done();
});
test('Add button hidden for users without ability to POST', async done => {
OrganizationsAPI.readOptions = () =>
Promise.resolve({
data: {
actions: {
GET: {},
},
},
});
wrapper = mountWithContexts(<OrganizationsList />);
await waitForElement(
wrapper,
'OrganizationsList',
el => el.state('hasContentLoading') === true
);
await waitForElement(
wrapper,
'OrganizationsList',
el => el.state('hasContentLoading') === false
);
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
done();
});
});

View File

@ -1,11 +1,9 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { OrganizationsAPI, ProjectsAPI } from '@api';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import mockOrganization from '@util/data.organization.json';
import mockDetails from './data.project.json';
import Project from './Project';
jest.mock('@api');
@ -69,4 +67,30 @@ describe.only('<Project />', () => {
tabs.forEach(tab => expect(tab.text()).not.toEqual('Notifications'));
done();
});
test('should show content error when user attempts to navigate to erroneous route', async done => {
const history = createMemoryHistory({
initialEntries: ['/projects/1/foobar'],
});
const wrapper = mountWithContexts(
<Project setBreadcrumb={() => {}} me={mockMe} />,
{
context: {
router: {
history,
route: {
location: history.location,
match: {
params: { id: 1 },
url: '/projects/1/foobar',
path: '/project/1/foobar',
},
},
},
},
}
);
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
done();
});
});

View File

@ -71,7 +71,10 @@ describe('<ProjectsList />', () => {
ProjectsAPI.readOptions.mockResolvedValue({
data: {
actions: [],
actions: {
GET: {},
POST: {},
},
},
});
});
@ -219,4 +222,43 @@ describe('<ProjectsList />', () => {
done();
});
test('Add button shown for users without ability to POST', async done => {
const wrapper = mountWithContexts(<ProjectsList />);
await waitForElement(
wrapper,
'ProjectsList',
el => el.state('hasContentLoading') === true
);
await waitForElement(
wrapper,
'ProjectsList',
el => el.state('hasContentLoading') === false
);
expect(wrapper.find('ToolbarAddButton').length).toBe(1);
done();
});
test('Add button hidden for users without ability to POST', async done => {
ProjectsAPI.readOptions.mockResolvedValue({
data: {
actions: {
GET: {},
},
},
});
const wrapper = mountWithContexts(<ProjectsList />);
await waitForElement(
wrapper,
'ProjectsList',
el => el.state('hasContentLoading') === true
);
await waitForElement(
wrapper,
'ProjectsList',
el => el.state('hasContentLoading') === false
);
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
done();
});
});

View File

@ -1,8 +1,8 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { JobTemplatesAPI, OrganizationsAPI } from '@api';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import Template, { _Template } from './Template';
import mockJobTemplateData from './shared/data.job_template.json';
jest.mock('@api');
@ -87,4 +87,30 @@ describe('<Template />', () => {
tabs.forEach(tab => expect(tab.text()).not.toEqual('Notifications'));
done();
});
test('should show content error when user attempts to navigate to erroneous route', async done => {
const history = createMemoryHistory({
initialEntries: ['/templates/job_template/1/foobar'],
});
const wrapper = mountWithContexts(
<Template setBreadcrumb={() => {}} me={mockMe} />,
{
context: {
router: {
history,
route: {
location: history.location,
match: {
params: { id: 1 },
url: '/templates/job_template/1/foobar',
path: '/templates/job_template/1/foobar',
},
},
},
},
}
);
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
done();
});
});

View File

@ -309,7 +309,7 @@ class TemplatesList extends Component {
title={i18n._(t`Error!`)}
onClose={this.handleDeleteErrorClose}
>
{i18n._(t`Failed to delete one or more template.`)}
{i18n._(t`Failed to delete one or more templates.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
</PageSection>