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",