diff --git a/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx
index f4b803784d..e8996bf2d6 100644
--- a/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx
+++ b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx
@@ -29,7 +29,7 @@ export function toSearchParams(string = '') {
* 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} obj A string or array of strings keyed by query param key
+ * @param {object} searchParams A string or array of strings keyed by query param key
* @return {string} URL query string
*/
export function toQueryString(config, searchParams = {}) {
@@ -54,7 +54,7 @@ export function toQueryString(config, searchParams = {}) {
/**
* Convert params object to host filter string
- * @param {object} obj A string or array of strings keyed by query param key
+ * @param {object} searchParams A string or array of strings keyed by query param key
* @return {string} Host filter string
*/
export function toHostFilter(searchParams = {}) {
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
+ );
+ });
+});