diff --git a/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx b/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx
new file mode 100644
index 0000000000..e819973083
--- /dev/null
+++ b/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx
@@ -0,0 +1,342 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { withRouter, useHistory, useLocation } from 'react-router-dom';
+import { number, func, bool, string } from 'prop-types';
+import { withI18n } from '@lingui/react';
+import styled from 'styled-components';
+import { t } from '@lingui/macro';
+import { SearchIcon } from '@patternfly/react-icons';
+import {
+ Button,
+ ButtonVariant,
+ Chip,
+ FormGroup,
+ InputGroup,
+ Modal,
+} from '@patternfly/react-core';
+import ChipGroup from '../ChipGroup';
+import DataListToolbar from '../DataListToolbar';
+import LookupErrorMessage from './shared/LookupErrorMessage';
+import PaginatedDataList, { PaginatedDataListItem } from '../PaginatedDataList';
+import { HostsAPI } from '../../api';
+import { getQSConfig, mergeParams, parseQueryString } from '../../util/qs';
+import useRequest, { useDismissableError } from '../../util/useRequest';
+import {
+ removeDefaultParams,
+ removeNamespacedKeys,
+ toHostFilter,
+ toQueryString,
+ toSearchParams,
+} from './shared/HostFilterUtils';
+
+const ChipHolder = styled.div`
+ --pf-c-form-control--Height: auto;
+ .pf-c-chip-group {
+ margin-right: 8px;
+ }
+`;
+
+const ModalList = styled.div`
+ .pf-c-toolbar__content {
+ padding: 0 !important;
+ }
+`;
+
+const useModal = () => {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
+ function toggleModal() {
+ setIsModalOpen(!isModalOpen);
+ }
+
+ function closeModal() {
+ setIsModalOpen(false);
+ }
+
+ return {
+ isModalOpen,
+ toggleModal,
+ closeModal,
+ };
+};
+
+const QS_CONFIG = getQSConfig(
+ 'smart_hosts',
+ {
+ page: 1,
+ page_size: 5,
+ order_by: 'name',
+ },
+ ['id', 'page', 'page_size', 'inventory']
+);
+
+const buildSearchColumns = i18n => [
+ {
+ name: i18n._(t`Name`),
+ key: 'name',
+ isDefault: true,
+ },
+ {
+ name: i18n._(t`ID`),
+ key: 'id',
+ },
+ {
+ name: i18n._(t`Group`),
+ key: 'groups__name',
+ },
+ {
+ name: i18n._(t`Inventory ID`),
+ key: 'inventory',
+ },
+ {
+ name: i18n._(t`Enabled`),
+ key: 'enabled',
+ isBoolean: true,
+ },
+ {
+ name: i18n._(t`Instance ID`),
+ key: 'instance_id',
+ },
+ {
+ name: i18n._(t`Last job`),
+ key: 'last_job',
+ },
+ {
+ name: i18n._(t`Insights system ID`),
+ key: 'insights_system_id',
+ },
+];
+
+function HostFilterLookup({
+ helperTextInvalid,
+ i18n,
+ isValid,
+ isDisabled,
+ onBlur,
+ onChange,
+ organizationId,
+ value,
+}) {
+ const history = useHistory();
+ const location = useLocation();
+ const [chips, setChips] = useState({});
+ const [queryString, setQueryString] = useState('');
+ const { isModalOpen, toggleModal, closeModal } = useModal();
+ const searchColumns = buildSearchColumns(i18n);
+
+ const {
+ result: { count, hosts },
+ error: contentError,
+ request: fetchHosts,
+ isLoading,
+ } = useRequest(
+ useCallback(
+ async orgId => {
+ const params = parseQueryString(QS_CONFIG, location.search);
+ const { data } = await HostsAPI.read(
+ mergeParams(params, { inventory__organization: orgId })
+ );
+ return {
+ count: data.count,
+ hosts: data.results,
+ };
+ },
+ [location.search]
+ ),
+ {
+ count: 0,
+ hosts: [],
+ }
+ );
+
+ const { error, dismissError } = useDismissableError(contentError);
+
+ useEffect(() => {
+ if (isModalOpen && organizationId) {
+ dismissError();
+ fetchHosts(organizationId);
+ }
+ }, [fetchHosts, organizationId, isModalOpen]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => {
+ const filters = toSearchParams(value);
+ setQueryString(toQueryString(QS_CONFIG, filters));
+ setChips(buildChips(filters));
+ }, [value]);
+
+ function qsToHostFilter(qs) {
+ const searchParams = toSearchParams(qs);
+ const withoutNamespace = removeNamespacedKeys(QS_CONFIG, searchParams);
+ const withoutDefaultParams = removeDefaultParams(
+ QS_CONFIG,
+ withoutNamespace
+ );
+ return toHostFilter(withoutDefaultParams);
+ }
+
+ const save = () => {
+ const hostFilterString = qsToHostFilter(location.search);
+ onChange(hostFilterString);
+ closeModal();
+ history.replace({
+ pathname: `${location.pathname}`,
+ search: '',
+ });
+ };
+
+ function buildChips(filter = {}) {
+ const inputGroupChips = Object.keys(filter).reduce((obj, param) => {
+ const parsedKey = param.replace('__icontains', '').replace('or__', '');
+ const chipsArray = [];
+
+ if (Array.isArray(filter[param])) {
+ filter[param].forEach(val =>
+ chipsArray.push({
+ key: `${param}:${val}`,
+ node: `${val}`,
+ })
+ );
+ } else {
+ chipsArray.push({
+ key: `${param}:${filter[param]}`,
+ node: `${filter[param]}`,
+ });
+ }
+
+ obj[parsedKey] = {
+ key: parsedKey,
+ label: filter[param],
+ chips: [...chipsArray],
+ };
+
+ return obj;
+ }, {});
+
+ return inputGroupChips;
+ }
+
+ const handleOpenModal = () => {
+ history.replace({
+ pathname: `${location.pathname}`,
+ search: queryString,
+ });
+ toggleModal();
+ };
+
+ const handleClose = () => {
+ closeModal();
+ history.replace({
+ pathname: `${location.pathname}`,
+ search: '',
+ });
+ };
+
+ return (
+
+
+
+
+ {searchColumns.map(({ name, key }) => (
+
+ {chips[key]?.chips?.map((chip, index) => (
+
+ {chip.node}
+
+ ))}
+
+ ))}
+
+
+
+ {i18n._(t`Select`)}
+ ,
+ ,
+ ]}
+ >
+
+ {}}
+ pluralizedItemName={i18n._(t`hosts`)}
+ qsConfig={QS_CONFIG}
+ renderItem={item => (
+
+ )}
+ renderToolbar={props => }
+ toolbarSearchColumns={searchColumns}
+ toolbarSortColumns={[
+ {
+ name: i18n._(t`Name`),
+ key: 'name',
+ },
+ {
+ name: i18n._(t`Created`),
+ key: 'created',
+ },
+ {
+ name: i18n._(t`Modified`),
+ key: 'modified',
+ },
+ ]}
+ />
+
+
+
+
+ );
+}
+
+HostFilterLookup.propTypes = {
+ isValid: bool,
+ onBlur: func,
+ onChange: func,
+ organizationId: number,
+ value: string,
+};
+HostFilterLookup.defaultProps = {
+ isValid: true,
+ onBlur: () => {},
+ onChange: () => {},
+ organizationId: null,
+ value: '',
+};
+
+export default withI18n()(withRouter(HostFilterLookup));
diff --git a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx
index 82274cd7fc..ec60c553cd 100644
--- a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx
+++ b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx
@@ -40,7 +40,7 @@ function OrganizationLookup({
organizations: data.results,
itemCount: data.count,
};
- }, [history.location]),
+ }, [history.location.search]),
{
organizations: [],
itemCount: 0,
diff --git a/awx/ui_next/src/components/Lookup/index.js b/awx/ui_next/src/components/Lookup/index.js
index fb99cd5681..2b9b147941 100644
--- a/awx/ui_next/src/components/Lookup/index.js
+++ b/awx/ui_next/src/components/Lookup/index.js
@@ -5,3 +5,4 @@ export { default as ProjectLookup } from './ProjectLookup';
export { default as MultiCredentialsLookup } from './MultiCredentialsLookup';
export { default as CredentialLookup } from './CredentialLookup';
export { default as ApplicationLookup } from './ApplicationLookup';
+export { default as HostFilterLookup } from './HostFilterLookup';
diff --git a/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx
new file mode 100644
index 0000000000..27be7b3c99
--- /dev/null
+++ b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx
@@ -0,0 +1,104 @@
+/**
+ * Convert host filter string to params object
+ * @param {string} string host filter string
+ * @return {object} A string or array of strings keyed by query param key
+ */
+export function toSearchParams(string = '') {
+ if (string === '') {
+ return {};
+ }
+ return string
+ .replace(/^\?/, '')
+ .replace(/&/g, ' and ')
+ .split(/ and | or /)
+ .map(s => s.split('='))
+ .reduce((searchParams, [k, v]) => {
+ const key = decodeURIComponent(k);
+ const value = decodeURIComponent(v);
+ if (searchParams[key] === undefined) {
+ searchParams[key] = value;
+ } else if (Array.isArray(searchParams[key])) {
+ searchParams[key] = [...searchParams[key], value];
+ } else {
+ searchParams[key] = [searchParams[key], value];
+ }
+ return searchParams;
+ }, {});
+}
+
+/**
+ * Convert params object to an encoded namespaced url query string
+ * Used to put into url bar when modal opens
+ * @param {object} config Config object for namespacing params
+ * @param {object} searchParams A string or array of strings keyed by query param key
+ * @return {string} URL query string
+ */
+export function toQueryString(config, searchParams = {}) {
+ if (Object.keys(searchParams).length === 0) return '';
+
+ return Object.keys(searchParams)
+ .flatMap(key => {
+ if (Array.isArray(searchParams[key])) {
+ return searchParams[key].map(
+ val =>
+ `${config.namespace}.${encodeURIComponent(
+ key
+ )}=${encodeURIComponent(val)}`
+ );
+ }
+ return `${config.namespace}.${encodeURIComponent(
+ key
+ )}=${encodeURIComponent(searchParams[key])}`;
+ })
+ .join('&');
+}
+
+/**
+ * Convert params object to host filter string
+ * @param {object} searchParams A string or array of strings keyed by query param key
+ * @return {string} Host filter string
+ */
+export function toHostFilter(searchParams = {}) {
+ return Object.keys(searchParams)
+ .flatMap(key => {
+ if (Array.isArray(searchParams[key])) {
+ return searchParams[key].map(val => `${key}=${val}`);
+ }
+ return `${key}=${searchParams[key]}`;
+ })
+ .join(' and ');
+}
+
+/**
+ * Helper function to remove namespace from params object
+ * @param {object} config Config object with namespace param
+ * @param {object} obj A string or array of strings keyed by query param key
+ * @return {object} Params object without namespaced keys
+ */
+export function removeNamespacedKeys(config, obj = {}) {
+ const clonedObj = Object.assign({}, obj);
+ const newObj = {};
+ Object.keys(clonedObj).forEach(nsKey => {
+ let key = nsKey;
+ if (nsKey.startsWith(config.namespace)) {
+ key = nsKey.substr(config.namespace.length + 1);
+ }
+ newObj[key] = clonedObj[nsKey];
+ });
+ return newObj;
+}
+
+/**
+ * Helper function to remove default params from params object
+ * @param {object} config Config object with default params
+ * @param {object} obj A string or array of strings keyed by query param key
+ * @return {string} Params object without default params
+ */
+export function removeDefaultParams(config, obj = {}) {
+ const clonedObj = Object.assign({}, obj);
+ const defaultKeys = Object.keys(config.defaultParams);
+ defaultKeys.forEach(keyToOmit => {
+ delete clonedObj[keyToOmit];
+ });
+ return clonedObj;
+}
diff --git a/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.test.jsx b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.test.jsx
new file mode 100644
index 0000000000..c381ec4082
--- /dev/null
+++ b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.test.jsx
@@ -0,0 +1,109 @@
+import {
+ removeDefaultParams,
+ removeNamespacedKeys,
+ toHostFilter,
+ toQueryString,
+ toSearchParams,
+} from './HostFilterUtils';
+
+const QS_CONFIG = {
+ namespace: 'mock',
+ defaultParams: { page: 1, page_size: 5, order_by: 'name' },
+ integerFields: ['page', 'page_size', 'id', 'inventory'],
+};
+
+describe('toSearchParams', () => {
+ let string;
+ let paramsObject;
+
+ test('should return an empty object', () => {
+ expect(toSearchParams(undefined)).toEqual({});
+ expect(toSearchParams('')).toEqual({});
+ });
+ test('should take a query string and return search params object', () => {
+ string = '?foo=bar';
+ paramsObject = { foo: 'bar' };
+ expect(toSearchParams(string)).toEqual(paramsObject);
+ });
+ test('should take a host filter string and return search params object', () => {
+ string = 'foo=bar and foo=baz and foo=qux and isa=sampu';
+ paramsObject = {
+ foo: ['bar', 'baz', 'qux'],
+ isa: 'sampu',
+ };
+ expect(toSearchParams(string)).toEqual(paramsObject);
+ });
+});
+
+describe('toQueryString', () => {
+ test('should return an empty string', () => {
+ expect(toQueryString(QS_CONFIG, undefined)).toEqual('');
+ });
+ test('should return namespaced query string with a single key-value pair', () => {
+ const object = {
+ foo: 'bar',
+ };
+ expect(toQueryString(QS_CONFIG, object)).toEqual('mock.foo=bar');
+ });
+ test('should return namespaced query string with multiple values per key', () => {
+ const object = {
+ foo: ['bar', 'baz'],
+ };
+ expect(toQueryString(QS_CONFIG, object)).toEqual(
+ 'mock.foo=bar&mock.foo=baz'
+ );
+ });
+ test('should return namespaced query string with multiple key-value pairs', () => {
+ const object = {
+ foo: ['bar', 'baz', 'qux'],
+ isa: 'sampu',
+ };
+ expect(toQueryString(QS_CONFIG, object)).toEqual(
+ 'mock.foo=bar&mock.foo=baz&mock.foo=qux&mock.isa=sampu'
+ );
+ });
+});
+
+describe('toHostFilter', () => {
+ test('should return an empty string', () => {
+ expect(toHostFilter(undefined)).toEqual('');
+ });
+ test('should return a host filter string', () => {
+ const object = {
+ isa: '2',
+ tatlo: ['foo', 'bar', 'baz'],
+ };
+ expect(toHostFilter(object)).toEqual(
+ 'isa=2 and tatlo=foo and tatlo=bar and tatlo=baz'
+ );
+ });
+});
+
+describe('removeNamespacedKeys', () => {
+ test('should return an empty object', () => {
+ expect(removeNamespacedKeys(QS_CONFIG, undefined)).toEqual({});
+ });
+ test('should remove namespace from keys', () => {
+ expect(removeNamespacedKeys(QS_CONFIG, { 'mock.foo': 'bar' })).toEqual({
+ foo: 'bar',
+ });
+ });
+});
+
+describe('removeDefaultParams', () => {
+ test('should return an empty object', () => {
+ expect(removeDefaultParams(QS_CONFIG, undefined)).toEqual({});
+ });
+ test('should remove default params', () => {
+ const object = {
+ foo: ['bar', 'baz', 'qux'],
+ apat: 'lima',
+ page: 10,
+ order_by: '-name',
+ };
+ expect(removeDefaultParams(QS_CONFIG, object)).toEqual({
+ foo: ['bar', 'baz', 'qux'],
+ apat: 'lima',
+ });
+ });
+});
diff --git a/awx/ui_next/src/components/Search/Search.jsx b/awx/ui_next/src/components/Search/Search.jsx
index 916629c691..8049a326e7 100644
--- a/awx/ui_next/src/components/Search/Search.jsx
+++ b/awx/ui_next/src/components/Search/Search.jsx
@@ -97,13 +97,16 @@ function Search({
};
const getLabelFromValue = (value, colKey) => {
+ let label = value;
const currentSearchColumn = columns.find(({ key }) => key === colKey);
if (currentSearchColumn?.options?.length) {
- return currentSearchColumn.options.find(
+ [, label] = currentSearchColumn.options.find(
([optVal]) => optVal === value
- )[1];
+ );
+ } else if (currentSearchColumn?.booleanLabels) {
+ label = currentSearchColumn.booleanLabels[value];
}
- return value.toString();
+ return label.toString();
};
const getChipsByKey = () => {
@@ -227,7 +230,7 @@ function Search({
aria-label={name}
onToggle={setIsFilterDropdownOpen}
onSelect={(event, selection) => onReplaceSearch(key, selection)}
- selections={chipsByKey[key].chips[0]}
+ selections={chipsByKey[key].chips[0]?.label}
isOpen={isFilterDropdownOpen}
placeholderText={`Filter By ${name}`}
>
diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.test.jsx
index 0d2e88d592..5198c523d3 100644
--- a/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.test.jsx
@@ -117,7 +117,7 @@ describe('', () => {
);
});
- test('should navigate to inventory sources list when cancel is clicked', async () => {
+ test('should navigate to inventory source detail when cancel is clicked', async () => {
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
});
diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.jsx
index d29aa3ee5d..3accf3d0c5 100644
--- a/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.jsx
+++ b/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.jsx
@@ -1,10 +1,74 @@
-import React, { Component } from 'react';
-import { PageSection } from '@patternfly/react-core';
+import React, { useCallback, useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
+import { Card, PageSection } from '@patternfly/react-core';
+import { CardBody } from '../../../components/Card';
+import SmartInventoryForm from '../shared/SmartInventoryForm';
+import useRequest from '../../../util/useRequest';
+import { InventoriesAPI } from '../../../api';
-class SmartInventoryAdd extends Component {
- render() {
- return Coming soon :);
- }
+function SmartInventoryAdd() {
+ const history = useHistory();
+
+ const {
+ error: submitError,
+ request: submitRequest,
+ result: inventoryId,
+ } = useRequest(
+ useCallback(async (values, groupsToAssociate) => {
+ const {
+ data: { id: invId },
+ } = await InventoriesAPI.create(values);
+
+ await Promise.all(
+ groupsToAssociate.map(({ id }) =>
+ InventoriesAPI.associateInstanceGroup(invId, id)
+ )
+ );
+ return invId;
+ }, [])
+ );
+
+ const handleSubmit = async form => {
+ const { instance_groups, organization, ...remainingForm } = form;
+
+ await submitRequest(
+ {
+ organization: organization?.id,
+ ...remainingForm,
+ },
+ instance_groups
+ );
+ };
+
+ const handleCancel = () => {
+ history.push({
+ pathname: '/inventories',
+ search: '',
+ });
+ };
+
+ useEffect(() => {
+ if (inventoryId) {
+ history.push({
+ pathname: `/inventories/smart_inventory/${inventoryId}/details`,
+ search: '',
+ });
+ }
+ }, [inventoryId, history]);
+
+ return (
+
+
+
+
+
+
+
+ );
}
export default SmartInventoryAdd;
diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.test.jsx
new file mode 100644
index 0000000000..b25ba03559
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.test.jsx
@@ -0,0 +1,139 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../testUtils/enzymeHelpers';
+import SmartInventoryAdd from './SmartInventoryAdd';
+import {
+ InventoriesAPI,
+ OrganizationsAPI,
+ InstanceGroupsAPI,
+} from '../../../api';
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useParams: () => ({
+ id: 1,
+ }),
+}));
+
+jest.mock('../../../api/models/Inventories');
+jest.mock('../../../api/models/Organizations');
+jest.mock('../../../api/models/InstanceGroups');
+OrganizationsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } });
+InstanceGroupsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } });
+
+const formData = {
+ name: 'Mock',
+ description: 'Foo',
+ organization: { id: 1 },
+ kind: 'smart',
+ host_filter: 'name__icontains=mock',
+ variables: '---',
+ instance_groups: [{ id: 2 }],
+};
+
+describe('', () => {
+ describe('when initialized by users with POST capability', () => {
+ let history;
+ let wrapper;
+
+ beforeAll(async () => {
+ InventoriesAPI.create.mockResolvedValueOnce({ data: { id: 1 } });
+ InventoriesAPI.readOptions.mockResolvedValue({
+ data: { actions: { POST: true } },
+ });
+ history = createMemoryHistory({
+ initialEntries: [`/inventories/smart_inventory/add`],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(, {
+ context: { router: { history } },
+ });
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+
+ afterAll(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
+ test('should enable save button', () => {
+ expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe(
+ false
+ );
+ });
+
+ test('should post to the api when submit is clicked', async () => {
+ await act(async () => {
+ wrapper.find('SmartInventoryForm').invoke('onSubmit')(formData);
+ });
+ const { instance_groups, ...formRequest } = formData;
+ expect(InventoriesAPI.create).toHaveBeenCalledTimes(1);
+ expect(InventoriesAPI.create).toHaveBeenCalledWith({
+ ...formRequest,
+ organization: formRequest.organization.id,
+ });
+ expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledTimes(1);
+ expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 2);
+ });
+
+ test('successful form submission should trigger redirect to details', async () => {
+ expect(history.location.pathname).toEqual(
+ '/inventories/smart_inventory/1/details'
+ );
+ });
+
+ test('should navigate to inventory list when cancel is clicked', async () => {
+ await act(async () => {
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ });
+ expect(history.location.pathname).toEqual('/inventories');
+ });
+
+ test('unsuccessful form submission should show an error message', async () => {
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ InventoriesAPI.create.mockImplementationOnce(() => Promise.reject(error));
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
+ await act(async () => {
+ wrapper.find('SmartInventoryForm').invoke('onSubmit')({});
+ });
+ wrapper.update();
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
+ });
+ });
+
+ describe('when initialized by users without POST capability', () => {
+ let wrapper;
+
+ beforeAll(async () => {
+ InventoriesAPI.readOptions.mockResolvedValueOnce({
+ data: { actions: { POST: false } },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+ afterAll(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
+ test('should disable save button', () => {
+ expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe(
+ true
+ );
+ });
+ });
+});
diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.test.jsx
index 5207972921..988c3b99a8 100644
--- a/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.test.jsx
@@ -58,7 +58,7 @@ describe('', () => {
assertDetail('Description', 'smart inv description');
assertDetail('Type', 'Smart inventory');
assertDetail('Organization', 'Default');
- assertDetail('Smart host filter', 'search=local');
+ assertDetail('Smart host filter', 'name__icontains=local');
assertDetail('Instance groups', 'mock instance group');
expect(wrapper.find(`Detail[label="Activity"] Sparkline`)).toHaveLength(
1
diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.jsx
index 3d179fbc25..b499efd3f7 100644
--- a/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.jsx
+++ b/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.jsx
@@ -1,10 +1,120 @@
-import React, { Component } from 'react';
-import { PageSection } from '@patternfly/react-core';
+import React, { useCallback, useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
+import { Inventory } from '../../../types';
+import { getAddedAndRemoved } from '../../../util/lists';
+import useRequest from '../../../util/useRequest';
+import { InventoriesAPI } from '../../../api';
+import { CardBody } from '../../../components/Card';
+import ContentError from '../../../components/ContentError';
+import ContentLoading from '../../../components/ContentLoading';
+import SmartInventoryForm from '../shared/SmartInventoryForm';
-class SmartInventoryEdit extends Component {
- render() {
- return Coming soon :);
+function SmartInventoryEdit({ inventory }) {
+ const history = useHistory();
+ const detailsUrl = `/inventories/smart_inventory/${inventory.id}/details`;
+
+ const {
+ error: contentError,
+ isLoading: hasContentLoading,
+ request: fetchInstanceGroups,
+ result: instanceGroups,
+ } = useRequest(
+ useCallback(async () => {
+ const {
+ data: { results },
+ } = await InventoriesAPI.readInstanceGroups(inventory.id);
+ return results;
+ }, [inventory.id]),
+ []
+ );
+
+ useEffect(() => {
+ fetchInstanceGroups();
+ }, [fetchInstanceGroups]);
+
+ const {
+ error: submitError,
+ request: submitRequest,
+ result: submitResult,
+ } = useRequest(
+ useCallback(
+ async (values, groupsToAssociate, groupsToDisassociate) => {
+ const { data } = await InventoriesAPI.update(inventory.id, values);
+ await Promise.all(
+ groupsToAssociate.map(id =>
+ InventoriesAPI.associateInstanceGroup(inventory.id, id)
+ )
+ );
+ await Promise.all(
+ groupsToDisassociate.map(id =>
+ InventoriesAPI.disassociateInstanceGroup(inventory.id, id)
+ )
+ );
+ return data;
+ },
+ [inventory.id]
+ )
+ );
+
+ useEffect(() => {
+ if (submitResult) {
+ history.push({
+ pathname: detailsUrl,
+ search: '',
+ });
+ }
+ }, [submitResult, detailsUrl, history]);
+
+ const handleSubmit = async form => {
+ const { instance_groups, organization, ...remainingForm } = form;
+
+ const { added, removed } = getAddedAndRemoved(
+ instanceGroups,
+ instance_groups
+ );
+ const addedIds = added.map(({ id }) => id);
+ const removedIds = removed.map(({ id }) => id);
+
+ await submitRequest(
+ {
+ organization: organization?.id,
+ ...remainingForm,
+ },
+ addedIds,
+ removedIds
+ );
+ };
+
+ const handleCancel = () => {
+ history.push({
+ pathname: detailsUrl,
+ search: '',
+ });
+ };
+
+ if (hasContentLoading) {
+ return ;
}
+
+ if (contentError) {
+ return ;
+ }
+
+ return (
+
+
+
+ );
}
+SmartInventoryEdit.propTypes = {
+ inventory: Inventory.isRequired,
+};
+
export default SmartInventoryEdit;
diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.test.jsx
new file mode 100644
index 0000000000..dea1b1e1ba
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.test.jsx
@@ -0,0 +1,160 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../testUtils/enzymeHelpers';
+import SmartInventoryEdit from './SmartInventoryEdit';
+import mockSmartInventory from '../shared/data.smart_inventory.json';
+import {
+ InventoriesAPI,
+ OrganizationsAPI,
+ InstanceGroupsAPI,
+} from '../../../api';
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useParams: () => ({
+ id: 2,
+ }),
+}));
+jest.mock('../../../api/models/Inventories');
+jest.mock('../../../api/models/Organizations');
+jest.mock('../../../api/models/InstanceGroups');
+OrganizationsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } });
+InstanceGroupsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } });
+
+const mockSmartInv = Object.assign(
+ {},
+ {
+ ...mockSmartInventory,
+ organization: {
+ id: mockSmartInventory.organization,
+ },
+ }
+);
+
+describe('', () => {
+ let history;
+ let wrapper;
+
+ beforeAll(async () => {
+ InventoriesAPI.associateInstanceGroup.mockResolvedValue();
+ InventoriesAPI.disassociateInstanceGroup.mockResolvedValue();
+ InventoriesAPI.update.mockResolvedValue({ data: mockSmartInv });
+ InventoriesAPI.readOptions.mockResolvedValue({
+ data: { actions: { POST: true } },
+ });
+ InventoriesAPI.readInstanceGroups.mockResolvedValue({
+ data: { count: 0, results: [{ id: 10 }, { id: 20 }] },
+ });
+ history = createMemoryHistory({
+ initialEntries: [`/inventories/smart_inventory/${mockSmartInv.id}/edit`],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+ ,
+ {
+ context: { router: { history } },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+
+ afterAll(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
+ test('should fetch related instance groups on initial render', async () => {
+ expect(InventoriesAPI.readInstanceGroups).toHaveBeenCalledTimes(1);
+ });
+
+ test('save button should be enabled for users with POST capability', () => {
+ expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe(
+ false
+ );
+ });
+
+ test('should post to the api when submit is clicked', async () => {
+ expect(InventoriesAPI.update).toHaveBeenCalledTimes(0);
+ expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledTimes(0);
+ expect(InventoriesAPI.disassociateInstanceGroup).toHaveBeenCalledTimes(0);
+ await act(async () => {
+ wrapper.find('SmartInventoryForm').invoke('onSubmit')({
+ ...mockSmartInv,
+ instance_groups: [{ id: 10 }, { id: 30 }],
+ });
+ });
+ expect(InventoriesAPI.update).toHaveBeenCalledTimes(1);
+ expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledTimes(1);
+ expect(InventoriesAPI.disassociateInstanceGroup).toHaveBeenCalledTimes(1);
+ });
+
+ test('successful form submission should trigger redirect to details', async () => {
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
+ expect(history.location.pathname).toEqual(
+ '/inventories/smart_inventory/2/details'
+ );
+ });
+
+ test('should navigate to inventory details when cancel is clicked', async () => {
+ await act(async () => {
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ });
+ expect(history.location.pathname).toEqual(
+ '/inventories/smart_inventory/2/details'
+ );
+ });
+
+ test('unsuccessful form submission should show an error message', async () => {
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ InventoriesAPI.update.mockImplementationOnce(() => Promise.reject(error));
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
+ await act(async () => {
+ wrapper.find('SmartInventoryForm').invoke('onSubmit')({});
+ });
+ wrapper.update();
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
+ });
+
+ test('should throw content error', async () => {
+ expect(wrapper.find('ContentError').length).toBe(0);
+ InventoriesAPI.readInstanceGroups.mockImplementationOnce(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('ContentError').length).toBe(1);
+ });
+
+ test('save button should be disabled for users without POST capability', async () => {
+ InventoriesAPI.readOptions.mockResolvedValue({
+ data: { actions: { POST: false } },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe(
+ true
+ );
+ });
+});
diff --git a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx
new file mode 100644
index 0000000000..12cd3ee215
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx
@@ -0,0 +1,175 @@
+import React, { useEffect, useCallback } from 'react';
+import { Formik, useField } from 'formik';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { func, shape, object, arrayOf } from 'prop-types';
+import { Form } from '@patternfly/react-core';
+import { VariablesField } from '../../../components/CodeMirrorInput';
+import ContentError from '../../../components/ContentError';
+import ContentLoading from '../../../components/ContentLoading';
+import FormActionGroup from '../../../components/FormActionGroup';
+import FormField, { FormSubmitError } from '../../../components/FormField';
+import {
+ FormColumnLayout,
+ FormFullWidthLayout,
+} from '../../../components/FormLayout';
+import HostFilterLookup from '../../../components/Lookup/HostFilterLookup';
+import InstanceGroupsLookup from '../../../components/Lookup/InstanceGroupsLookup';
+import OrganizationLookup from '../../../components/Lookup/OrganizationLookup';
+import useRequest from '../../../util/useRequest';
+import { required } from '../../../util/validators';
+import { InventoriesAPI } from '../../../api';
+
+const SmartInventoryFormFields = withI18n()(({ i18n }) => {
+ const [organizationField, organizationMeta, organizationHelpers] = useField({
+ name: 'organization',
+ validate: required(i18n._(t`Select a value for this field`), i18n),
+ });
+ const [instanceGroupsField, , instanceGroupsHelpers] = useField({
+ name: 'instance_groups',
+ });
+ const [hostFilterField, hostFilterMeta, hostFilterHelpers] = useField({
+ name: 'host_filter',
+ validate: required(null, i18n),
+ });
+
+ return (
+ <>
+
+
+ organizationHelpers.setTouched()}
+ onChange={value => {
+ organizationHelpers.setValue(value);
+ }}
+ value={organizationField.value}
+ required
+ />
+ {
+ hostFilterHelpers.setValue(value);
+ }}
+ onBlur={() => hostFilterHelpers.setTouched()}
+ isValid={!hostFilterMeta.touched || !hostFilterMeta.error}
+ isDisabled={!organizationField.value}
+ />
+ {
+ instanceGroupsHelpers.setValue(value);
+ }}
+ />
+
+
+
+ >
+ );
+});
+
+function SmartInventoryForm({
+ inventory,
+ instanceGroups,
+ onSubmit,
+ onCancel,
+ submitError,
+}) {
+ const initialValues = {
+ description: inventory.description || '',
+ host_filter: inventory.host_filter || '',
+ instance_groups: instanceGroups || [],
+ kind: 'smart',
+ name: inventory.name || '',
+ organization: inventory.summary_fields?.organization || null,
+ variables: inventory.variables || '---',
+ };
+
+ const {
+ isLoading,
+ error: optionsError,
+ request: fetchOptions,
+ result: options,
+ } = useRequest(
+ useCallback(async () => {
+ const { data } = await InventoriesAPI.readOptions();
+ return data;
+ }, []),
+ null
+ );
+
+ useEffect(() => {
+ fetchOptions();
+ }, [fetchOptions]);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (optionsError) {
+ return ;
+ }
+
+ return (
+ {
+ onSubmit(values);
+ }}
+ >
+ {formik => (
+
+ )}
+
+ );
+}
+
+SmartInventoryForm.propTypes = {
+ instanceGroups: arrayOf(object),
+ inventory: shape({}),
+ onCancel: func.isRequired,
+ onSubmit: func.isRequired,
+ submitError: shape({}),
+};
+
+SmartInventoryForm.defaultProps = {
+ instanceGroups: [],
+ inventory: {},
+ submitError: null,
+};
+
+export default withI18n()(SmartInventoryForm);
diff --git a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx
new file mode 100644
index 0000000000..34d7fb72c7
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx
@@ -0,0 +1,176 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../testUtils/enzymeHelpers';
+import SmartInventoryForm from './SmartInventoryForm';
+import {
+ InventoriesAPI,
+ OrganizationsAPI,
+ InstanceGroupsAPI,
+} from '../../../api';
+
+jest.mock('../../../api/models/Inventories');
+jest.mock('../../../api/models/Organizations');
+jest.mock('../../../api/models/InstanceGroups');
+OrganizationsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } });
+InstanceGroupsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } });
+InventoriesAPI.readOptions.mockResolvedValue({
+ data: { actions: { POST: true } },
+});
+
+const mockFormValues = {
+ kind: 'smart',
+ name: 'new smart inventory',
+ description: '',
+ organization: { id: 1, name: 'mock organization' },
+ host_filter:
+ 'name__icontains=mock and name__icontains=foo and groups__name=mock group',
+ instance_groups: [{ id: 123 }],
+ variables: '---',
+};
+
+describe('', () => {
+ describe('when initialized by users with POST capability', () => {
+ let wrapper;
+ const onSubmit = jest.fn();
+
+ beforeAll(async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+ {}} onSubmit={onSubmit} />
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+
+ afterAll(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
+ test('should enable save button', () => {
+ expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe(
+ false
+ );
+ });
+
+ test('should show expected form fields', () => {
+ expect(wrapper.find('FormGroup[label="Name"]')).toHaveLength(1);
+ expect(wrapper.find('FormGroup[label="Description"]')).toHaveLength(1);
+ expect(wrapper.find('FormGroup[label="Organization"]')).toHaveLength(1);
+ expect(wrapper.find('FormGroup[label="Host filter"]')).toHaveLength(1);
+ expect(wrapper.find('FormGroup[label="Instance Groups"]')).toHaveLength(
+ 1
+ );
+ expect(wrapper.find('VariablesField[label="Variables"]')).toHaveLength(1);
+ expect(wrapper.find('Button[aria-label="Save"]')).toHaveLength(1);
+ expect(wrapper.find('Button[aria-label="Cancel"]')).toHaveLength(1);
+ });
+
+ test('should enable host filter field when organization field has a value', async () => {
+ expect(wrapper.find('HostFilterLookup').prop('isDisabled')).toBe(true);
+ await act(async () => {
+ wrapper.find('OrganizationLookup').invoke('onBlur')();
+ wrapper.find('OrganizationLookup').invoke('onChange')(
+ mockFormValues.organization
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('HostFilterLookup').prop('isDisabled')).toBe(false);
+ });
+
+ test('should show error when form is saved without a host filter value', async () => {
+ expect(wrapper.find('HostFilterLookup #host-filter-helper').length).toBe(
+ 0
+ );
+ wrapper.find('input#name').simulate('change', {
+ target: { value: mockFormValues.name, name: 'name' },
+ });
+ await act(async () => {
+ wrapper.find('button[aria-label="Save"]').simulate('click');
+ });
+ wrapper.update();
+ const hostFilterError = wrapper.find(
+ 'HostFilterLookup #host-filter-helper'
+ );
+ expect(hostFilterError.length).toBe(1);
+ expect(hostFilterError.text()).toContain('This field must not be blank');
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ test('should display filter chips when host filter has a value', async () => {
+ await act(async () => {
+ wrapper.find('HostFilterLookup').invoke('onBlur')();
+ wrapper.find('HostFilterLookup').invoke('onChange')(
+ mockFormValues.host_filter
+ );
+ });
+ wrapper.update();
+ const nameChipGroup = wrapper.find(
+ 'HostFilterLookup ChipGroup[categoryName="Name"]'
+ );
+ const groupChipGroup = wrapper.find(
+ 'HostFilterLookup ChipGroup[categoryName="Group"]'
+ );
+ expect(nameChipGroup.find('Chip').length).toBe(2);
+ expect(groupChipGroup.find('Chip').length).toBe(1);
+ });
+
+ test('should submit expected form values on save', async () => {
+ await act(async () => {
+ wrapper.find('InstanceGroupsLookup').invoke('onChange')(
+ mockFormValues.instance_groups
+ );
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('button[aria-label="Save"]').simulate('click');
+ });
+ wrapper.update();
+ expect(onSubmit).toHaveBeenCalledWith(mockFormValues);
+ });
+ });
+
+ test('should throw content error when option request fails', async () => {
+ let wrapper;
+ InventoriesAPI.readOptions.mockImplementationOnce(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper = mountWithContexts(
+ {}} onSubmit={() => {}} />
+ );
+ });
+ expect(wrapper.find('ContentError').length).toBe(0);
+ wrapper.update();
+ expect(wrapper.find('ContentError').length).toBe(1);
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ test('should throw content error when option request fails', async () => {
+ let wrapper;
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ await act(async () => {
+ wrapper = mountWithContexts(
+ {}}
+ onSubmit={() => {}}
+ />
+ );
+ });
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
+ expect(wrapper.find('SmartInventoryForm').prop('submitError')).toEqual(
+ error
+ );
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+});
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 0ab15565f6..204f616b7e 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
@@ -80,7 +80,7 @@
"description": "smart inv description",
"organization": 1,
"kind": "smart",
- "host_filter": "search=local",
+ "host_filter": "name__icontains=local",
"variables": "",
"has_active_failures": false,
"total_hosts": 1,