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 + ); + }); +});