From d9ad906167be884a9c3552641ac0b7f229ceaec5 Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 7 Oct 2019 10:26:46 -0400 Subject: [PATCH 1/3] Adds basic inventory list and scaffolding for inv/smart inv details+related tabs --- .../src/screens/Inventory/Inventories.jsx | 114 ++++++- .../screens/Inventory/Inventories.test.jsx | 8 - .../src/screens/Inventory/Inventory.jsx | 188 +++++++++++ .../src/screens/Inventory/Inventory.test.jsx | 54 +++ .../InventoryAccess/InventoryAccess.jsx | 10 + .../Inventory/InventoryAccess/index.js | 1 + .../Inventory/InventoryAdd/InventoryAdd.jsx | 10 + .../screens/Inventory/InventoryAdd/index.js | 1 + .../InventoryCompletedJobs.jsx | 10 + .../Inventory/InventoryCompletedJobs/index.js | 1 + .../InventoryDetail/InventoryDetail.jsx | 10 + .../Inventory/InventoryDetail/index.js | 1 + .../Inventory/InventoryEdit/InventoryEdit.jsx | 10 + .../screens/Inventory/InventoryEdit/index.js | 1 + .../InventoryGroups/InventoryGroups.jsx | 10 + .../Inventory/InventoryGroups/index.js | 1 + .../InventoryHosts/InventoryHosts.jsx | 10 + .../screens/Inventory/InventoryHosts/index.js | 1 + .../Inventory/InventoryList/InventoryList.jsx | 315 ++++++++++++++++++ .../InventoryList/InventoryList.test.jsx | 283 ++++++++++++++++ .../InventoryList/InventoryListItem.jsx | 57 ++++ .../InventoryList/InventoryListItem.test.jsx | 31 ++ .../screens/Inventory/InventoryList/index.js | 2 + .../InventorySources/InventorySources.jsx | 10 + .../Inventory/InventorySources/index.js | 1 + .../src/screens/Inventory/SmartInventory.jsx | 175 ++++++++++ .../screens/Inventory/SmartInventory.test.jsx | 57 ++++ .../SmartInventoryAccess.jsx | 10 + .../Inventory/SmartInventoryAccess/index.js | 1 + .../SmartInventoryAdd/SmartInventoryAdd.jsx | 10 + .../Inventory/SmartInventoryAdd/index.js | 1 + .../SmartInventoryCompletedJobs.jsx | 10 + .../SmartInventoryCompletedJobs/index.js | 1 + .../SmartInventoryDetail.jsx | 10 + .../Inventory/SmartInventoryDetail/index.js | 1 + .../SmartInventoryEdit/SmartInventoryEdit.jsx | 10 + .../Inventory/SmartInventoryEdit/index.js | 1 + .../SmartInventoryHosts.jsx | 10 + .../Inventory/SmartInventoryHosts/index.js | 1 + .../Inventory/shared/data.inventory.json | 96 ++++++ .../shared/data.smart_inventory.json | 95 ++++++ .../Organization/Organization.test.jsx | 30 +- .../src/screens/Project/Project.test.jsx | 30 +- .../src/screens/Template/Template.test.jsx | 28 +- 44 files changed, 1690 insertions(+), 27 deletions(-) create mode 100644 awx/ui_next/src/screens/Inventory/Inventory.jsx create mode 100644 awx/ui_next/src/screens/Inventory/Inventory.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryAccess/InventoryAccess.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryAccess/index.js create mode 100644 awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryAdd/index.js create mode 100644 awx/ui_next/src/screens/Inventory/InventoryCompletedJobs/InventoryCompletedJobs.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryCompletedJobs/index.js create mode 100644 awx/ui_next/src/screens/Inventory/InventoryDetail/InventoryDetail.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryDetail/index.js create mode 100644 awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryEdit/index.js create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/index.js create mode 100644 awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryHosts/index.js create mode 100644 awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryList/index.js create mode 100644 awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventorySources/index.js create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventory.jsx create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventory.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventoryAccess/SmartInventoryAccess.jsx create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventoryAccess/index.js create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.jsx create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventoryAdd/index.js create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventoryCompletedJobs/SmartInventoryCompletedJobs.jsx create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventoryCompletedJobs/index.js create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.jsx create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventoryDetail/index.js create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.jsx create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventoryEdit/index.js create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.jsx create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventoryHosts/index.js create mode 100644 awx/ui_next/src/screens/Inventory/shared/data.inventory.json create mode 100644 awx/ui_next/src/screens/Inventory/shared/data.smart_inventory.json diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx index 1b43f39b7c..bf669f094d 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx @@ -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 ( - - {i18n._(t`Inventories`)} - - + + + } + /> + } + /> + ( + + {({ me }) => ( + + )} + + )} + /> + ( + + {({ me }) => ( + + )} + + )} + /> + } /> + ); } } -export default withI18n()(Inventories); +export { Inventories as _Inventories }; +export default withI18n()(withRouter(Inventories)); diff --git a/awx/ui_next/src/screens/Inventory/Inventories.test.jsx b/awx/ui_next/src/screens/Inventory/Inventories.test.jsx index 2ba7a44d18..9b7579c043 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.test.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.test.jsx @@ -6,13 +6,9 @@ import Inventories from './Inventories'; describe('', () => { let pageWrapper; - let pageSections; - let title; beforeEach(() => { pageWrapper = mountWithContexts(); - pageSections = pageWrapper.find('PageSection'); - title = pageWrapper.find('Title'); }); afterEach(() => { @@ -21,9 +17,5 @@ describe('', () => { 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'); }); }); diff --git a/awx/ui_next/src/screens/Inventory/Inventory.jsx b/awx/ui_next/src/screens/Inventory/Inventory.jsx new file mode 100644 index 0000000000..e1398e5ea5 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/Inventory.jsx @@ -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 : ( + + + + + ); + + if (location.pathname.endsWith('edit')) { + cardHeader = null; + } + + if (!hasContentLoading && contentError) { + return ( + + + + {contentError.response.status === 404 && ( + + {i18n._(`Inventory not found.`)}{' '} + + {i18n._(`View all Inventories.`)} + + + )} + + + + ); + } + + return ( + + + {cardHeader} + + + {inventory && [ + ( + + )} + />, + } + />, + } + />, + } + />, + } + />, + } + />, + } + />, + + !hasContentLoading && ( + + {match.params.id && ( + + {i18n._(`View Inventory Details`)} + + )} + + ) + } + />, + ]} + + + + ); + } +} + +export { Inventory as _Inventory }; +export default withI18n()(withRouter(Inventory)); diff --git a/awx/ui_next/src/screens/Inventory/Inventory.test.jsx b/awx/ui_next/src/screens/Inventory/Inventory.test.jsx new file mode 100644 index 0000000000..b1b7abd112 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/Inventory.test.jsx @@ -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('', () => { + test('initially renders succesfully', async done => { + const wrapper = mountWithContexts( + {}} 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( {}} />, { + 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(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryAccess/InventoryAccess.jsx b/awx/ui_next/src/screens/Inventory/InventoryAccess/InventoryAccess.jsx new file mode 100644 index 0000000000..328483ce7d --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryAccess/InventoryAccess.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class InventoryAccess extends Component { + render() { + return Coming soon :); + } +} + +export default InventoryAccess; diff --git a/awx/ui_next/src/screens/Inventory/InventoryAccess/index.js b/awx/ui_next/src/screens/Inventory/InventoryAccess/index.js new file mode 100644 index 0000000000..153f828893 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryAccess/index.js @@ -0,0 +1 @@ +export { default } from './InventoryAccess'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.jsx b/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.jsx new file mode 100644 index 0000000000..34299be4bb --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { PageSection } from '@patternfly/react-core'; + +class InventoryAdd extends Component { + render() { + return Coming soon :); + } +} + +export default InventoryAdd; diff --git a/awx/ui_next/src/screens/Inventory/InventoryAdd/index.js b/awx/ui_next/src/screens/Inventory/InventoryAdd/index.js new file mode 100644 index 0000000000..2b5872b91a --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryAdd/index.js @@ -0,0 +1 @@ +export { default } from './InventoryAdd'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryCompletedJobs/InventoryCompletedJobs.jsx b/awx/ui_next/src/screens/Inventory/InventoryCompletedJobs/InventoryCompletedJobs.jsx new file mode 100644 index 0000000000..376edb63e6 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryCompletedJobs/InventoryCompletedJobs.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class InventoryCompletedJobs extends Component { + render() { + return Coming soon :); + } +} + +export default InventoryCompletedJobs; diff --git a/awx/ui_next/src/screens/Inventory/InventoryCompletedJobs/index.js b/awx/ui_next/src/screens/Inventory/InventoryCompletedJobs/index.js new file mode 100644 index 0000000000..17e7b7be0b --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryCompletedJobs/index.js @@ -0,0 +1 @@ +export { default } from './InventoryCompletedJobs'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryDetail/InventoryDetail.jsx b/awx/ui_next/src/screens/Inventory/InventoryDetail/InventoryDetail.jsx new file mode 100644 index 0000000000..5d9d9789b6 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryDetail/InventoryDetail.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class InventoryDetail extends Component { + render() { + return Coming soon :); + } +} + +export default InventoryDetail; diff --git a/awx/ui_next/src/screens/Inventory/InventoryDetail/index.js b/awx/ui_next/src/screens/Inventory/InventoryDetail/index.js new file mode 100644 index 0000000000..de1946de63 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryDetail/index.js @@ -0,0 +1 @@ +export { default } from './InventoryDetail'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx b/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx new file mode 100644 index 0000000000..cc4540749f --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { PageSection } from '@patternfly/react-core'; + +class InventoryEdit extends Component { + render() { + return Coming soon :); + } +} + +export default InventoryEdit; diff --git a/awx/ui_next/src/screens/Inventory/InventoryEdit/index.js b/awx/ui_next/src/screens/Inventory/InventoryEdit/index.js new file mode 100644 index 0000000000..9cdfac1390 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryEdit/index.js @@ -0,0 +1 @@ +export { default } from './InventoryEdit'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx new file mode 100644 index 0000000000..eb512861e6 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class InventoryGroups extends Component { + render() { + return Coming soon :); + } +} + +export default InventoryGroups; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroups/index.js new file mode 100644 index 0000000000..3402bebb52 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/index.js @@ -0,0 +1 @@ +export { default } from './InventoryGroups'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx new file mode 100644 index 0000000000..ba26d61975 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class InventoryHosts extends Component { + render() { + return Coming soon :); + } +} + +export default InventoryHosts; diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/index.js b/awx/ui_next/src/screens/Inventory/InventoryHosts/index.js new file mode 100644 index 0000000000..6d33814f29 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/index.js @@ -0,0 +1 @@ +export { default } from './InventoryHosts'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx new file mode 100644 index 0000000000..3b146e48cf --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx @@ -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 ( + + + ( + , + canAdd && ( +
{ + this.node = node; + }} + key="add" + > + + } + dropdownItems={[ + + + {i18n._(t`Inventory`)} + + , + + + {i18n._(t`Smart Inventory`)} + + , + ]} + /> +
+ ), + ]} + /> + )} + renderItem={inventory => ( + this.handleSelect(inventory)} + isSelected={selected.some(row => row.id === inventory.id)} + /> + )} + emptyStateControls={ + canAdd && ( +
{ + this.node = node; + }} + key="add" + > + } + dropdownItems={[ + + + {i18n._(t`Inventory`)} + + , + + + {i18n._(t`Smart Inventory`)} + + , + ]} + /> +
+ ) + } + /> +
+ + {i18n._(t`Failed to delete one or more inventory.`)} + + +
+ ); + } +} + +export { InventoriesList as _InventoriesList }; +export default withI18n()(withRouter(InventoriesList)); diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.test.jsx new file mode 100644 index 0000000000..c92966c62b --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.test.jsx @@ -0,0 +1,283 @@ +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('', () => { + beforeEach(() => { + InventoriesAPI.read.mockResolvedValue({ + data: { + count: mockInventories.length, + results: mockInventories, + }, + }); + + InventoriesAPI.readOptions.mockResolvedValue({ + data: { + actions: [], + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('initially renders successfully', () => { + mountWithContexts( + + ); + }); + + test('Inventories are retrieved from the api and the components finishes loading', async done => { + const loadInventories = jest.spyOn( + _InventoriesList.prototype, + 'loadInventories' + ); + const wrapper = mountWithContexts(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx new file mode 100644 index 0000000000..4aba0a820e --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx @@ -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 ( + + + + + + + {inventory.name} + + , + + {inventory.kind === 'smart' + ? i18n._(t`Smart Inventory`) + : i18n._(t`Inventory`)} + , + ]} + /> + + + ); + } +} +export default withI18n()(InventoryListItem); diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.test.jsx new file mode 100644 index 0000000000..bdccf68f6d --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.test.jsx @@ -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('', () => { + test('initially renders succesfully', () => { + mountWithContexts( + + + {}} + /> + + + ); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/index.js b/awx/ui_next/src/screens/Inventory/InventoryList/index.js new file mode 100644 index 0000000000..81762ff30f --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryList/index.js @@ -0,0 +1,2 @@ +export { default as InventoryList } from './InventoryList'; +export { default as InventoryListItem } from './InventoryListItem'; diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx new file mode 100644 index 0000000000..7bdd0030c2 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class InventorySources extends Component { + render() { + return Coming soon :); + } +} + +export default InventorySources; diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/index.js b/awx/ui_next/src/screens/Inventory/InventorySources/index.js new file mode 100644 index 0000000000..1d73a6f9b1 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySources/index.js @@ -0,0 +1 @@ +export { default } from './InventorySources'; diff --git a/awx/ui_next/src/screens/Inventory/SmartInventory.jsx b/awx/ui_next/src/screens/Inventory/SmartInventory.jsx new file mode 100644 index 0000000000..aacce2a14a --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventory.jsx @@ -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 : ( + + + + + ); + + if (location.pathname.endsWith('edit')) { + cardHeader = null; + } + + if (!hasContentLoading && contentError) { + return ( + + + + {contentError.response.status === 404 && ( + + {i18n._(`Inventory not found.`)}{' '} + + {i18n._(`View all Inventories.`)} + + + )} + + + + ); + } + return ( + + + {cardHeader} + + + {inventory && [ + ( + + )} + />, + } + />, + } + />, + } + />, + ( + + )} + />, + + !hasContentLoading && ( + + {match.params.id && ( + + {i18n._(`View Inventory Details`)} + + )} + + ) + } + />, + ]} + + + + ); + } +} + +export { SmartInventory as _SmartInventory }; +export default withI18n()(withRouter(SmartInventory)); diff --git a/awx/ui_next/src/screens/Inventory/SmartInventory.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventory.test.jsx new file mode 100644 index 0000000000..1f81d26ebf --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventory.test.jsx @@ -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('', () => { + test('initially renders succesfully', async done => { + const wrapper = mountWithContexts( + {}} 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( + {}} />, + { + 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(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryAccess/SmartInventoryAccess.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryAccess/SmartInventoryAccess.jsx new file mode 100644 index 0000000000..bccaa7c7ca --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryAccess/SmartInventoryAccess.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class SmartInventoryAccess extends Component { + render() { + return Coming soon :); + } +} + +export default SmartInventoryAccess; diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryAccess/index.js b/awx/ui_next/src/screens/Inventory/SmartInventoryAccess/index.js new file mode 100644 index 0000000000..a013b55783 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryAccess/index.js @@ -0,0 +1 @@ +export { default } from './SmartInventoryAccess'; diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.jsx new file mode 100644 index 0000000000..d29aa3ee5d --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { PageSection } from '@patternfly/react-core'; + +class SmartInventoryAdd extends Component { + render() { + return Coming soon :); + } +} + +export default SmartInventoryAdd; diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/index.js b/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/index.js new file mode 100644 index 0000000000..01155c3321 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/index.js @@ -0,0 +1 @@ +export { default } from './SmartInventoryAdd'; diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryCompletedJobs/SmartInventoryCompletedJobs.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryCompletedJobs/SmartInventoryCompletedJobs.jsx new file mode 100644 index 0000000000..b2234eebe2 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryCompletedJobs/SmartInventoryCompletedJobs.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class SmartInventoryCompletedJobs extends Component { + render() { + return Coming soon :); + } +} + +export default SmartInventoryCompletedJobs; diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryCompletedJobs/index.js b/awx/ui_next/src/screens/Inventory/SmartInventoryCompletedJobs/index.js new file mode 100644 index 0000000000..9c7ec9bc49 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryCompletedJobs/index.js @@ -0,0 +1 @@ +export { default } from './SmartInventoryCompletedJobs'; diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.jsx new file mode 100644 index 0000000000..28715e88fd --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class SmartInventoryDetail extends Component { + render() { + return Coming soon :); + } +} + +export default SmartInventoryDetail; diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/index.js b/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/index.js new file mode 100644 index 0000000000..48a9225181 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/index.js @@ -0,0 +1 @@ +export { default } from './SmartInventoryDetail'; diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.jsx new file mode 100644 index 0000000000..3d179fbc25 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { PageSection } from '@patternfly/react-core'; + +class SmartInventoryEdit extends Component { + render() { + return Coming soon :); + } +} + +export default SmartInventoryEdit; diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/index.js b/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/index.js new file mode 100644 index 0000000000..6fbd0d3bde --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/index.js @@ -0,0 +1 @@ +export { default } from './SmartInventoryEdit'; diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.jsx new file mode 100644 index 0000000000..638e8b03ed --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class SmartInventoryHosts extends Component { + render() { + return Coming soon :); + } +} + +export default SmartInventoryHosts; diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/index.js b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/index.js new file mode 100644 index 0000000000..95af99ffe3 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/index.js @@ -0,0 +1 @@ +export { default } from './SmartInventoryHosts'; diff --git a/awx/ui_next/src/screens/Inventory/shared/data.inventory.json b/awx/ui_next/src/screens/Inventory/shared/data.inventory.json new file mode 100644 index 0000000000..e1b7a3bfa0 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/data.inventory.json @@ -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 +} \ No newline at end of file diff --git a/awx/ui_next/src/screens/Inventory/shared/data.smart_inventory.json b/awx/ui_next/src/screens/Inventory/shared/data.smart_inventory.json new file mode 100644 index 0000000000..bfc043ba84 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/data.smart_inventory.json @@ -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 +} \ No newline at end of file diff --git a/awx/ui_next/src/screens/Organization/Organization.test.jsx b/awx/ui_next/src/screens/Organization/Organization.test.jsx index 8c13bb78a1..4c78bf94ed 100644 --- a/awx/ui_next/src/screens/Organization/Organization.test.jsx +++ b/awx/ui_next/src/screens/Organization/Organization.test.jsx @@ -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('', () => { 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( + {}} 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(); + }); }); diff --git a/awx/ui_next/src/screens/Project/Project.test.jsx b/awx/ui_next/src/screens/Project/Project.test.jsx index 5205276ac3..2e21629923 100644 --- a/awx/ui_next/src/screens/Project/Project.test.jsx +++ b/awx/ui_next/src/screens/Project/Project.test.jsx @@ -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('', () => { 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( + {}} 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(); + }); }); diff --git a/awx/ui_next/src/screens/Template/Template.test.jsx b/awx/ui_next/src/screens/Template/Template.test.jsx index b019a67836..a76309ccb9 100644 --- a/awx/ui_next/src/screens/Template/Template.test.jsx +++ b/awx/ui_next/src/screens/Template/Template.test.jsx @@ -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('