mirror of
https://github.com/ansible/awx.git
synced 2026-01-15 03:40:42 -03:30
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:
commit
bc8f5ad015
@ -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));
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
188
awx/ui_next/src/screens/Inventory/Inventory.jsx
Normal file
188
awx/ui_next/src/screens/Inventory/Inventory.jsx
Normal 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));
|
||||
54
awx/ui_next/src/screens/Inventory/Inventory.test.jsx
Normal file
54
awx/ui_next/src/screens/Inventory/Inventory.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './InventoryAccess';
|
||||
@ -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;
|
||||
1
awx/ui_next/src/screens/Inventory/InventoryAdd/index.js
Normal file
1
awx/ui_next/src/screens/Inventory/InventoryAdd/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './InventoryAdd';
|
||||
@ -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;
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './InventoryCompletedJobs';
|
||||
@ -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;
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './InventoryDetail';
|
||||
@ -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;
|
||||
1
awx/ui_next/src/screens/Inventory/InventoryEdit/index.js
Normal file
1
awx/ui_next/src/screens/Inventory/InventoryEdit/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './InventoryEdit';
|
||||
@ -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;
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './InventoryGroups';
|
||||
@ -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;
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './InventoryHosts';
|
||||
@ -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));
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
@ -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>
|
||||
);
|
||||
});
|
||||
});
|
||||
2
awx/ui_next/src/screens/Inventory/InventoryList/index.js
Normal file
2
awx/ui_next/src/screens/Inventory/InventoryList/index.js
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as InventoryList } from './InventoryList';
|
||||
export { default as InventoryListItem } from './InventoryListItem';
|
||||
@ -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;
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './InventorySources';
|
||||
175
awx/ui_next/src/screens/Inventory/SmartInventory.jsx
Normal file
175
awx/ui_next/src/screens/Inventory/SmartInventory.jsx
Normal 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));
|
||||
57
awx/ui_next/src/screens/Inventory/SmartInventory.test.jsx
Normal file
57
awx/ui_next/src/screens/Inventory/SmartInventory.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './SmartInventoryAccess';
|
||||
@ -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;
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './SmartInventoryAdd';
|
||||
@ -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;
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './SmartInventoryCompletedJobs';
|
||||
@ -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;
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './SmartInventoryDetail';
|
||||
@ -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;
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './SmartInventoryEdit';
|
||||
@ -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;
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './SmartInventoryHosts';
|
||||
96
awx/ui_next/src/screens/Inventory/shared/data.inventory.json
Normal file
96
awx/ui_next/src/screens/Inventory/shared/data.inventory.json
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user