diff --git a/awx/ui_next/src/components/Lookup/InventoryScriptLookup.jsx b/awx/ui_next/src/components/Lookup/InventoryScriptLookup.jsx index 52e43ec633..3b74d79109 100644 --- a/awx/ui_next/src/components/Lookup/InventoryScriptLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InventoryScriptLookup.jsx @@ -64,7 +64,7 @@ function InventoryScriptLookup({ fieldId="inventory-script" helperTextInvalid={helperTextInvalid} isRequired={required} - isValid={isValid} + validated={isValid ? 'default' : 'error'} label={i18n._(t`Inventory script`)} > - } - /> + {recentPlaybookJobs?.length > 0 && ( + } + /> + )} ', () => { beforeAll(() => { const readOnlyHost = { ...mockHost }; readOnlyHost.summary_fields.user_capabilities.edit = false; + readOnlyHost.summary_fields.recent_jobs = []; wrapper = mountWithContexts(); }); @@ -84,6 +85,12 @@ describe('', () => { wrapper.unmount(); }); + test('should hide activity stream when there are no recent jobs', async () => { + expect(wrapper.find(`Detail[label="Activity"] Sparkline`)).toHaveLength( + 0 + ); + }); + test('should hide edit button for users without edit permission', async () => { expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0); }); diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx index 9ddf8723c0..0ad93adbe9 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx @@ -1,7 +1,7 @@ -import React, { Component } from 'react'; +import React, { useState, useCallback } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { Route, withRouter, Switch } from 'react-router-dom'; +import { Route, Switch } from 'react-router-dom'; import { Config } from '../../contexts/Config'; import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; @@ -11,131 +11,116 @@ import SmartInventory from './SmartInventory'; import InventoryAdd from './InventoryAdd'; import SmartInventoryAdd from './SmartInventoryAdd'; -class Inventories extends Component { - constructor(props) { - super(props); - const { i18n } = this.props; +function Inventories({ i18n }) { + const [breadcrumbConfig, setBreadcrumbConfig] = useState({ + '/inventories': i18n._(t`Inventories`), + '/inventories/inventory/add': i18n._(t`Create new inventory`), + '/inventories/smart_inventory/add': i18n._(t`Create new smart inventory`), + }); - this.state = { - breadcrumbConfig: { + const buildBreadcrumbConfig = useCallback( + (inventory, nested, schedule) => { + if (!inventory) { + return; + } + + const inventoryKind = + inventory.kind === 'smart' ? 'smart_inventory' : 'inventory'; + + const inventoryPath = `/inventories/${inventoryKind}/${inventory.id}`; + const inventoryHostsPath = `${inventoryPath}/hosts`; + const inventoryGroupsPath = `${inventoryPath}/groups`; + const inventorySourcesPath = `${inventoryPath}/sources`; + + setBreadcrumbConfig({ '/inventories': i18n._(t`Inventories`), '/inventories/inventory/add': i18n._(t`Create new inventory`), '/inventories/smart_inventory/add': i18n._( t`Create new smart inventory` ), - }, - }; - } - setBreadCrumbConfig = (inventory, nested, schedule) => { - const { i18n } = this.props; - if (!inventory) { - return; - } + [inventoryPath]: `${inventory.name}`, + [`${inventoryPath}/access`]: i18n._(t`Access`), + [`${inventoryPath}/completed_jobs`]: i18n._(t`Completed jobs`), + [`${inventoryPath}/details`]: i18n._(t`Details`), + [`${inventoryPath}/edit`]: i18n._(t`Edit details`), - const inventoryKind = - inventory.kind === 'smart' ? 'smart_inventory' : 'inventory'; + [inventoryHostsPath]: i18n._(t`Hosts`), + [`${inventoryHostsPath}/add`]: i18n._(t`Create new host`), + [`${inventoryHostsPath}/${nested?.id}`]: `${nested?.name}`, + [`${inventoryHostsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), + [`${inventoryHostsPath}/${nested?.id}/details`]: i18n._( + t`Host details` + ), + [`${inventoryHostsPath}/${nested?.id}/completed_jobs`]: i18n._( + t`Completed jobs` + ), + [`${inventoryHostsPath}/${nested?.id}/facts`]: i18n._(t`Facts`), + [`${inventoryHostsPath}/${nested?.id}/groups`]: i18n._(t`Groups`), - const inventoryPath = `/inventories/${inventoryKind}/${inventory.id}`; - const inventoryHostsPath = `${inventoryPath}/hosts`; - const inventoryGroupsPath = `${inventoryPath}/groups`; - const inventorySourcesPath = `${inventoryPath}/sources`; + [inventoryGroupsPath]: i18n._(t`Groups`), + [`${inventoryGroupsPath}/add`]: i18n._(t`Create new group`), + [`${inventoryGroupsPath}/${nested?.id}`]: `${nested?.name}`, + [`${inventoryGroupsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), + [`${inventoryGroupsPath}/${nested?.id}/details`]: i18n._( + t`Group details` + ), + [`${inventoryGroupsPath}/${nested?.id}/nested_hosts`]: i18n._(t`Hosts`), + [`${inventoryGroupsPath}/${nested?.id}/nested_hosts/add`]: i18n._( + t`Create new host` + ), - const breadcrumbConfig = { - '/inventories': i18n._(t`Inventories`), - '/inventories/inventory/add': i18n._(t`Create new inventory`), - '/inventories/smart_inventory/add': i18n._(t`Create new smart inventory`), + [`${inventorySourcesPath}`]: i18n._(t`Sources`), + [`${inventorySourcesPath}/add`]: i18n._(t`Create new source`), + [`${inventorySourcesPath}/${nested?.id}`]: `${nested?.name}`, + [`${inventorySourcesPath}/${nested?.id}/details`]: i18n._(t`Details`), + [`${inventorySourcesPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), + [`${inventorySourcesPath}/${nested?.id}/schedules`]: i18n._( + t`Schedules` + ), + [`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}`]: `${schedule?.name}`, + [`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}/details`]: i18n._( + t`Schedule details` + ), + }); + }, + [i18n] + ); - [inventoryPath]: `${inventory.name}`, - [`${inventoryPath}/access`]: i18n._(t`Access`), - [`${inventoryPath}/completed_jobs`]: i18n._(t`Completed jobs`), - [`${inventoryPath}/details`]: i18n._(t`Details`), - [`${inventoryPath}/edit`]: i18n._(t`Edit details`), - - [inventoryHostsPath]: i18n._(t`Hosts`), - [`${inventoryHostsPath}/add`]: i18n._(t`Create new host`), - [`${inventoryHostsPath}/${nested?.id}`]: `${nested?.name}`, - [`${inventoryHostsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), - [`${inventoryHostsPath}/${nested?.id}/details`]: i18n._(t`Host Details`), - [`${inventoryHostsPath}/${nested?.id}/completed_jobs`]: i18n._( - t`Completed jobs` - ), - [`${inventoryHostsPath}/${nested?.id}/facts`]: i18n._(t`Facts`), - [`${inventoryHostsPath}/${nested?.id}/groups`]: i18n._(t`Groups`), - - [inventoryGroupsPath]: i18n._(t`Groups`), - [`${inventoryGroupsPath}/add`]: i18n._(t`Create new group`), - [`${inventoryGroupsPath}/${nested?.id}`]: `${nested?.name}`, - [`${inventoryGroupsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), - [`${inventoryGroupsPath}/${nested?.id}/details`]: i18n._( - t`Group details` - ), - [`${inventoryGroupsPath}/${nested?.id}/nested_hosts`]: i18n._(t`Hosts`), - [`${inventoryGroupsPath}/${nested?.id}/nested_hosts/add`]: i18n._( - t`Create new host` - ), - - [`${inventorySourcesPath}`]: i18n._(t`Sources`), - [`${inventorySourcesPath}/add`]: i18n._(t`Create new source`), - [`${inventorySourcesPath}/${nested?.id}`]: `${nested?.name}`, - [`${inventorySourcesPath}/${nested?.id}/details`]: i18n._(t`Details`), - [`${inventorySourcesPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), - [`${inventorySourcesPath}/${nested?.id}/schedules`]: i18n._(t`Schedules`), - [`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}`]: `${schedule?.name}`, - [`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}/details`]: i18n._( - t`Schedule Details` - ), - }; - this.setState({ breadcrumbConfig }); - }; - - render() { - const { match, history, location } = this.props; - const { breadcrumbConfig } = this.state; - return ( - <> - - - - - - - - - - - {({ me }) => ( - - )} - - - ( - - {({ me }) => ( - - )} - + return ( + <> + + + + + + + + + + + {({ me }) => ( + )} - /> - - - - - - ); - } + + + + + {({ me }) => ( + + )} + + + + + + + + ); } export { Inventories as _Inventories }; -export default withI18n()(withRouter(Inventories)); +export default withI18n()(Inventories); diff --git a/awx/ui_next/src/screens/Inventory/Inventory.jsx b/awx/ui_next/src/screens/Inventory/Inventory.jsx index 2e02d5f03b..d31a3b96aa 100644 --- a/awx/ui_next/src/screens/Inventory/Inventory.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventory.jsx @@ -108,6 +108,12 @@ function Inventory({ i18n, setBreadcrumb }) { showCardHeader = false; } + if (inventory?.kind === 'smart') { + return ( + + ); + } + return ( diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.jsx index f3e0c50637..a2b48fecd1 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.jsx @@ -73,10 +73,12 @@ function InventoryHostDetail({ i18n, host }) { - } - /> + {recentPlaybookJobs?.length > 0 && ( + } + /> + )} ', () => { assertDetail('Description', 'localhost description'); assertDetail('Created', '10/28/2019, 9:26:54 PM'); assertDetail('Last Modified', '10/29/2019, 8:18:41 PM'); + expect(wrapper.find(`Detail[label="Activity"] Sparkline`)).toHaveLength( + 1 + ); }); test('should show edit button for users with edit permission', () => { @@ -76,6 +79,7 @@ describe('', () => { beforeAll(() => { const readOnlyHost = { ...mockHost }; readOnlyHost.summary_fields.user_capabilities.edit = false; + readOnlyHost.summary_fields.recent_jobs = []; wrapper = mountWithContexts(); }); @@ -84,6 +88,12 @@ describe('', () => { wrapper.unmount(); }); + test('should hide activity stream when there are no recent jobs', async () => { + expect(wrapper.find(`Detail[label="Activity"] Sparkline`)).toHaveLength( + 0 + ); + }); + test('should hide edit button for users without edit permission', async () => { expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0); }); diff --git a/awx/ui_next/src/screens/Inventory/SmartInventory.jsx b/awx/ui_next/src/screens/Inventory/SmartInventory.jsx index f35876c768..f9c129c954 100644 --- a/awx/ui_next/src/screens/Inventory/SmartInventory.jsx +++ b/awx/ui_next/src/screens/Inventory/SmartInventory.jsx @@ -1,181 +1,183 @@ -import React, { Component } from 'react'; -import { t } from '@lingui/macro'; +import React, { useCallback, useEffect } from 'react'; import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + Link, + Switch, + Route, + Redirect, + useRouteMatch, + useLocation, +} from 'react-router-dom'; import { CaretLeftIcon } from '@patternfly/react-icons'; import { Card, PageSection } from '@patternfly/react-core'; -import { Switch, Route, Redirect, withRouter, Link } from 'react-router-dom'; -import ContentError from '../../components/ContentError'; -import JobList from '../../components/JobList'; -import RoutedTabs from '../../components/RoutedTabs'; -import { ResourceAccessList } from '../../components/ResourceAccessList'; -import SmartInventoryDetail from './SmartInventoryDetail'; -import SmartInventoryHosts from './SmartInventoryHosts'; + +import useRequest from '../../util/useRequest'; import { InventoriesAPI } from '../../api'; + +import ContentError from '../../components/ContentError'; +import ContentLoading from '../../components/ContentLoading'; +import JobList from '../../components/JobList'; +import { ResourceAccessList } from '../../components/ResourceAccessList'; +import RoutedTabs from '../../components/RoutedTabs'; +import SmartInventoryDetail from './SmartInventoryDetail'; import SmartInventoryEdit from './SmartInventoryEdit'; +import SmartInventoryHosts from './SmartInventoryHosts'; -class SmartInventory extends Component { - constructor(props) { - super(props); +function SmartInventory({ i18n, setBreadcrumb }) { + const location = useLocation(); + const match = useRouteMatch('/inventories/smart_inventory/:id'); - this.state = { - contentError: null, - hasContentLoading: true, + const { + result: { inventory }, + error: contentError, + isLoading: hasContentLoading, + request: fetchInventory, + } = useRequest( + useCallback(async () => { + const { data } = await InventoriesAPI.readDetail(match.params.id); + return { + inventory: data, + }; + }, [match.params.id]), + { 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; + useEffect(() => { + fetchInventory(); + }, [fetchInventory, location.pathname]); - 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 }); + useEffect(() => { + if (inventory) { + setBreadcrumb(inventory); } - } + }, [inventory, setBreadcrumb]); - render() { - const { i18n, location, match } = this.props; - const { contentError, hasContentLoading, inventory } = this.state; + const tabsArray = [ + { + name: ( + <> + + {i18n._(t`Back to Inventories`)} + + ), + link: `/inventories`, + id: 99, + }, + { 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, + }, + ]; - const tabsArray = [ - { - name: ( - <> - - {i18n._(t`Back to Inventories`)} - - ), - link: `/inventories`, - id: 99, - }, - { 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 showCardHeader = true; - - if (location.pathname.endsWith('edit')) { - showCardHeader = false; - } - - if (!hasContentLoading && contentError) { - return ( - - - - {contentError.response.status === 404 && ( - - {i18n._(`Inventory not found.`)}{' '} - - {i18n._(`View all Inventories.`)} - - - )} - - - - ); - } + if (hasContentLoading) { return ( - {showCardHeader && } - - - {inventory && [ - - - , - - - , - - - , - - - , - - - , - - {!hasContentLoading && ( - - {match.params.id && ( - - {i18n._(`View Inventory Details`)} - - )} - - )} - , - ]} - + ); } + + if (contentError) { + return ( + + + + {contentError?.response?.status === 404 && ( + + {i18n._(`Smart Inventory not found.`)}{' '} + {i18n._(`View all Inventories.`)} + + )} + + + + ); + } + + if (inventory?.kind === '') { + return ; + } + + let showCardHeader = true; + + if (location.pathname.endsWith('edit')) { + showCardHeader = false; + } + + return ( + + + {showCardHeader && } + + + {inventory && [ + + + , + + + , + + + , + + + , + + + , + + {!hasContentLoading && ( + + {match.params.id && ( + + {i18n._(`View Inventory Details`)} + + )} + + )} + , + ]} + + + + ); } export { SmartInventory as _SmartInventory }; -export default withI18n()(withRouter(SmartInventory)); +export default withI18n()(SmartInventory); diff --git a/awx/ui_next/src/screens/Inventory/SmartInventory.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventory.test.jsx index e229e1bda8..b4d719b4d2 100644 --- a/awx/ui_next/src/screens/Inventory/SmartInventory.test.jsx +++ b/awx/ui_next/src/screens/Inventory/SmartInventory.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { InventoriesAPI } from '../../api'; import { @@ -9,36 +10,51 @@ import mockSmartInventory from './shared/data.smart_inventory.json'; import SmartInventory from './SmartInventory'; jest.mock('../../api'); - -InventoriesAPI.readDetail.mockResolvedValue({ - data: mockSmartInventory, -}); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useRouteMatch: () => ({ + url: '/inventories/smart_inventory/1', + params: { id: 1 }, + }), +})); describe('', () => { - 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 === 5); - done(); + let wrapper; + + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); }); + + test('initially renders succesfully', async () => { + InventoriesAPI.readDetail.mockResolvedValue({ + data: mockSmartInventory, + }); + await act(async () => { + wrapper = mountWithContexts( {}} />); + }); + await waitForElement(wrapper, 'SmartInventory'); + await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 5); + }); + + test('should show content error when api throws an error', async () => { + const error = new Error(); + error.response = { status: 404 }; + InventoriesAPI.readDetail.mockRejectedValueOnce(error); + await act(async () => { + wrapper = mountWithContexts( {}} />); + }); + expect(InventoriesAPI.readDetail).toHaveBeenCalledTimes(1); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + expect(wrapper.find('ContentError Title').text()).toEqual('Not Found'); + }); + test('should show content error when user attempts to navigate to erroneous route', async () => { const history = createMemoryHistory({ initialEntries: ['/inventories/smart_inventory/1/foobar'], }); - const wrapper = mountWithContexts( - {}} />, - { + await act(async () => { + wrapper = mountWithContexts( {}} />, { context: { router: { history, @@ -52,8 +68,8 @@ describe('', () => { }, }, }, - } - ); + }); + }); await waitForElement(wrapper, 'ContentError', el => el.length === 1); }); }); diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.jsx index be767f9746..3b23b1a2b7 100644 --- a/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.jsx +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.jsx @@ -1,10 +1,193 @@ -import React, { Component } from 'react'; -import { CardBody } from '../../../components/Card'; +import React, { useCallback, useEffect } from 'react'; +import { Link, useHistory } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { shape } from 'prop-types'; +import { Button, Chip, Label } from '@patternfly/react-core'; -class SmartInventoryDetail extends Component { - render() { - return Coming soon :); +import { Inventory } from '../../../types'; +import { InventoriesAPI, UnifiedJobsAPI } from '../../../api'; +import useRequest, { useDismissableError } from '../../../util/useRequest'; + +import AlertModal from '../../../components/AlertModal'; +import { CardBody, CardActionsRow } from '../../../components/Card'; +import ChipGroup from '../../../components/ChipGroup'; +import { VariablesDetail } from '../../../components/CodeMirrorInput'; +import ContentError from '../../../components/ContentError'; +import ContentLoading from '../../../components/ContentLoading'; +import DeleteButton from '../../../components/DeleteButton'; +import { + DetailList, + Detail, + UserDateDetail, +} from '../../../components/DetailList'; +import ErrorDetail from '../../../components/ErrorDetail'; +import Sparkline from '../../../components/Sparkline'; + +function SmartInventoryDetail({ inventory, i18n }) { + const history = useHistory(); + const { + created, + description, + host_filter, + id, + modified, + name, + variables, + summary_fields: { + created_by, + modified_by, + organization, + user_capabilities, + }, + } = inventory; + + const { + error: contentError, + isLoading: hasContentLoading, + request: fetchData, + result: { recentJobs, instanceGroups }, + } = useRequest( + useCallback(async () => { + const params = { + or__job__inventory: id, + or__workflowjob__inventory: id, + order_by: '-finished', + page_size: 10, + }; + const [{ data: jobData }, { data: igData }] = await Promise.all([ + UnifiedJobsAPI.read(params), + InventoriesAPI.readInstanceGroups(id), + ]); + return { + recentJobs: jobData.results, + instanceGroups: igData.results, + }; + }, [id]), + { + recentJobs: [], + instanceGroups: [], + } + ); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const { error: deleteError, isLoading, request: handleDelete } = useRequest( + useCallback(async () => { + await InventoriesAPI.destroy(id); + history.push(`/inventories`); + }, [id, history]) + ); + + const { error, dismissError } = useDismissableError(deleteError); + + if (hasContentLoading) { + return ; } + + if (contentError) { + return ; + } + + return ( + <> + + + + {recentJobs.length > 0 && ( + } + /> + )} + + + + {organization.name} + + } + /> + {host_filter}} + /> + {instanceGroups.length > 0 && ( + + {instanceGroups.map(ig => ( + + {ig.name} + + ))} + + } + /> + )} + + + + + + {user_capabilities?.edit && ( + + )} + {user_capabilities?.delete && ( + + {i18n._(t`Delete`)} + + )} + + + {error && ( + + {i18n._(t`Failed to delete smart inventory.`)} + + + )} + + ); } -export default SmartInventoryDetail; +SmartInventoryDetail.propTypes = { + inventory: Inventory.isRequired, + i18n: shape({}).isRequired, +}; + +export default withI18n()(SmartInventoryDetail); diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.test.jsx new file mode 100644 index 0000000000..5207972921 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.test.jsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import SmartInventoryDetail from './SmartInventoryDetail'; +import { InventoriesAPI, UnifiedJobsAPI } from '../../../api'; + +import mockSmartInventory from '../shared/data.smart_inventory.json'; + +jest.mock('../../../api/models/UnifiedJobs'); +jest.mock('../../../api/models/Inventories'); + +UnifiedJobsAPI.read.mockResolvedValue({ + data: { + results: [ + { + id: 1, + name: 'job 1', + type: 'job', + status: 'successful', + }, + ], + }, +}); +InventoriesAPI.readInstanceGroups.mockResolvedValue({ + data: { + results: [{ id: 1, name: 'mock instance group' }], + }, +}); + +describe('', () => { + let wrapper; + + describe('User has edit permissions', () => { + beforeAll(async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterAll(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + + test('should render Details', async () => { + function assertDetail(label, value) { + expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label); + expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value); + } + + assertDetail('Name', 'Smart Inv'); + assertDetail('Description', 'smart inv description'); + assertDetail('Type', 'Smart inventory'); + assertDetail('Organization', 'Default'); + assertDetail('Smart host filter', 'search=local'); + assertDetail('Instance groups', 'mock instance group'); + expect(wrapper.find(`Detail[label="Activity"] Sparkline`)).toHaveLength( + 1 + ); + const vars = wrapper.find('VariablesDetail'); + expect(vars).toHaveLength(1); + expect(vars.prop('value')).toEqual(mockSmartInventory.variables); + const dates = wrapper.find('UserDateDetail'); + expect(dates).toHaveLength(2); + expect(dates.at(0).prop('date')).toEqual(mockSmartInventory.created); + expect(dates.at(1).prop('date')).toEqual(mockSmartInventory.modified); + }); + + test('should show edit button for users with edit permission', () => { + const editButton = wrapper.find('Button[aria-label="edit"]'); + expect(editButton.text()).toEqual('Edit'); + expect(editButton.prop('to')).toBe( + `/inventories/smart_inventory/${mockSmartInventory.id}/edit` + ); + }); + + test('expected api calls are made on initial render', () => { + expect(InventoriesAPI.readInstanceGroups).toHaveBeenCalledTimes(1); + expect(UnifiedJobsAPI.read).toHaveBeenCalledTimes(1); + }); + + test('expected api call is made for delete', async () => { + expect(InventoriesAPI.destroy).toHaveBeenCalledTimes(0); + await act(async () => { + wrapper.find('DeleteButton').invoke('onConfirm')(); + }); + expect(InventoriesAPI.destroy).toHaveBeenCalledTimes(1); + }); + + test('Error dialog shown for failed deletion', async () => { + InventoriesAPI.destroy.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper.find('DeleteButton').invoke('onConfirm')(); + }); + await waitForElement( + wrapper, + 'Modal[title="Error!"]', + el => el.length === 1 + ); + await act(async () => { + wrapper.find('Modal[title="Error!"]').invoke('onClose')(); + }); + await waitForElement( + wrapper, + 'Modal[title="Error!"]', + el => el.length === 0 + ); + }); + }); + + describe('User has read-only permissions', () => { + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + + test('should hide edit button for users without edit permission', async () => { + const readOnlySmartInv = { ...mockSmartInventory }; + readOnlySmartInv.summary_fields.user_capabilities.edit = false; + + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0); + }); + + test('should show content error when jobs request fails', async () => { + UnifiedJobsAPI.read.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + expect(UnifiedJobsAPI.read).toHaveBeenCalledTimes(0); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + expect(UnifiedJobsAPI.read).toHaveBeenCalledTimes(1); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + expect(wrapper.find('ContentError Title').text()).toEqual( + 'Something went wrong...' + ); + }); + }); +}); 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 index bfc043ba84..0ab15565f6 100644 --- a/awx/ui_next/src/screens/Inventory/shared/data.smart_inventory.json +++ b/awx/ui_next/src/screens/Inventory/shared/data.smart_inventory.json @@ -77,7 +77,7 @@ "created": "2019-10-04T15:29:11.542911Z", "modified": "2019-10-04T15:29:11.542924Z", "name": "Smart Inv", - "description": "", + "description": "smart inv description", "organization": 1, "kind": "smart", "host_filter": "search=local",