diff --git a/awx/ui/src/api/models/Inventories.js b/awx/ui/src/api/models/Inventories.js index 8e586201b5..7e53e161d1 100644 --- a/awx/ui/src/api/models/Inventories.js +++ b/awx/ui/src/api/models/Inventories.js @@ -15,6 +15,7 @@ class Inventories extends InstanceGroupsMixin(Base) { this.promoteGroup = this.promoteGroup.bind(this); this.readInputInventories = this.readInputInventories.bind(this); this.associateInventory = this.associateInventory.bind(this); + this.disassociateInventory = this.disassociateInventory.bind(this); } readAccessList(id, params) { @@ -144,6 +145,13 @@ class Inventories extends InstanceGroupsMixin(Base) { id: inputInventoryId, }); } + + disassociateInventory(id, inputInventoryId) { + return this.http.post(`${this.baseUrl}${id}/input_inventories/`, { + id: inputInventoryId, + disassociate: true, + }); + } } export default Inventories; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventory.js b/awx/ui/src/screens/Inventory/ConstructedInventory.js index 086c755adb..ff02136101 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventory.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventory.js @@ -33,8 +33,8 @@ function ConstructedInventory({ setBreadcrumb }) { const { result: inventory, error: contentError, - isLoading: hasContentLoading, request: fetchInventory, + isLoading, } = useRequest( useCallback(async () => { const { data } = await ConstructedInventoriesAPI.readDetail( @@ -42,7 +42,7 @@ function ConstructedInventory({ setBreadcrumb }) { ); return data; }, [match.params.id]), - { isLoading: true } + { inventory: null, isLoading: true } ); useEffect(() => { @@ -78,7 +78,7 @@ function ConstructedInventory({ setBreadcrumb }) { { name: t`Job Templates`, link: `${match.url}/job_templates`, id: 5 }, ]; - if (hasContentLoading) { + if (isLoading) { return ( @@ -133,16 +133,13 @@ function ConstructedInventory({ setBreadcrumb }) { path="/inventories/constructed_inventory/:id/details" key="details" > - + , - + , ', () => { await act(async () => { wrapper = mountWithContexts(); }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); expect(wrapper.find('FormSubmitError').length).toBe(0); await act(async () => { wrapper.find('ConstructedInventoryForm').invoke('onSubmit')(formData); diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.js b/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.js index a49e7eaaed..bb87534379 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.js @@ -1,11 +1,122 @@ -/* eslint i18next/no-literal-string: "off" */ -import React from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { ConstructedInventoriesAPI, InventoriesAPI } from 'api'; +import useRequest from 'hooks/useRequest'; import { CardBody } from 'components/Card'; +import ContentError from 'components/ContentError'; +import ContentLoading from 'components/ContentLoading'; +import ConstructedInventoryForm from '../shared/ConstructedInventoryForm'; + +function isEqual(array1, array2) { + return ( + array1.length === array2.length && + array1.every((element, index) => element.id === array2[index].id) + ); +} + +function ConstructedInventoryEdit({ inventory }) { + const history = useHistory(); + const [submitError, setSubmitError] = useState(null); + const detailsUrl = `/inventories/constructed_inventory/${inventory.id}/details`; + const constructedInventoryId = inventory.id; + + const { + result: { initialInstanceGroups, initialInputInventories }, + request: fetchedRelatedData, + error: contentError, + isLoading, + } = useRequest( + useCallback(async () => { + const [instanceGroupsResponse, inputInventoriesResponse] = + await Promise.all([ + InventoriesAPI.readInstanceGroups(constructedInventoryId), + InventoriesAPI.readInputInventories(constructedInventoryId), + ]); + + return { + initialInstanceGroups: instanceGroupsResponse.data.results, + initialInputInventories: inputInventoriesResponse.data.results, + }; + }, [constructedInventoryId]), + { + initialInstanceGroups: [], + initialInputInventories: [], + isLoading: true, + } + ); + useEffect(() => { + fetchedRelatedData(); + }, [fetchedRelatedData]); + + const handleSubmit = async (values) => { + const { + instanceGroups, + inputInventories, + organization, + ...remainingValues + } = values; + + remainingValues.organization = organization.id; + remainingValues.kind = 'constructed'; + + try { + await Promise.all([ + ConstructedInventoriesAPI.update( + constructedInventoryId, + remainingValues + ), + InventoriesAPI.orderInstanceGroups( + constructedInventoryId, + instanceGroups, + initialInstanceGroups + ), + ]); + /* eslint-disable no-await-in-loop, no-restricted-syntax */ + // Resolve Promises sequentially to avoid race condition + if (!isEqual(initialInputInventories, values.inputInventories)) { + for (const inputInventory of initialInputInventories) { + await InventoriesAPI.disassociateInventory( + constructedInventoryId, + inputInventory.id + ); + } + for (const inputInventory of values.inputInventories) { + await InventoriesAPI.associateInventory( + constructedInventoryId, + inputInventory.id + ); + } + } + /* eslint-enable no-await-in-loop, no-restricted-syntax */ + + history.push( + `/inventories/constructed_inventory/${constructedInventoryId}/details` + ); + } catch (error) { + setSubmitError(error); + } + }; + + const handleCancel = () => history.push(detailsUrl); + + if (isLoading) { + return ; + } + + if (contentError) { + return ; + } -function ConstructedInventoryEdit() { return ( -
Coming Soon!
+
); } diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.test.js b/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.test.js index 02b0747880..ee52a8ca1b 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.test.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.test.js @@ -1,15 +1,196 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { createMemoryHistory } from 'history'; + +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import { ConstructedInventoriesAPI, InventoriesAPI } from 'api'; + import ConstructedInventoryEdit from './ConstructedInventoryEdit'; +jest.mock('api'); describe('', () => { - test('initially renders successfully', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts(); + let wrapper; + let history; + + const mockInv = { + name: 'Mock', + id: 7, + description: 'Foo', + organization: { id: 1 }, + kind: 'constructed', + source_vars: 'plugin: constructed', + limit: 'product_dev', + }; + const associatedInstanceGroups = [ + { + id: 1, + name: 'Foo', + }, + ]; + const associatedInputInventories = [ + { + id: 123, + name: 'input_inventory_123', + }, + { + id: 456, + name: 'input_inventory_456', + }, + ]; + const mockFormValues = { + kind: 'constructed', + name: 'new constructed inventory', + description: '', + organization: { id: 1, name: 'mock organization' }, + instanceGroups: associatedInstanceGroups, + source_vars: 'plugin: constructed', + inputInventories: associatedInputInventories, + }; + + beforeEach(async () => { + ConstructedInventoriesAPI.readOptions.mockResolvedValue({ + data: { + related: {}, + actions: { + POST: { + limit: { + label: 'Limit', + help_text: '', + }, + update_cache_timeout: { + label: 'Update cache timeout', + help_text: 'help', + }, + verbosity: { + label: 'Verbosity', + help_text: '', + }, + }, + }, + }, }); - expect(wrapper.length).toBe(1); - expect(wrapper.find('ConstructedInventoryEdit').length).toBe(1); + InventoriesAPI.readInstanceGroups.mockResolvedValue({ + data: { + results: associatedInstanceGroups, + }, + }); + InventoriesAPI.readInputInventories.mockResolvedValue({ + data: { + results: [ + { + id: 456, + name: 'input_inventory_456', + }, + ], + }, + }); + history = createMemoryHistory({ + initialEntries: ['/inventories/constructed_inventory/7/edit'], + }); + await act(async () => { + wrapper = mountWithContexts( + , + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('should navigate to inventories details on cancel', async () => { + expect(history.location.pathname).toEqual( + '/inventories/constructed_inventory/7/edit' + ); + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual( + '/inventories/constructed_inventory/7/details' + ); + }); + + test('should navigate to constructed inventory detail after successful submission', async () => { + ConstructedInventoriesAPI.update.mockResolvedValueOnce({ data: { id: 1 } }); + expect(history.location.pathname).toEqual( + '/inventories/constructed_inventory/7/edit' + ); + await act(async () => { + wrapper.find('ConstructedInventoryForm').invoke('onSubmit')( + mockFormValues + ); + }); + wrapper.update(); + expect(history.location.pathname).toEqual( + '/inventories/constructed_inventory/7/details' + ); + }); + + test('should make expected api requests on submit', async () => { + await act(async () => { + wrapper.find('ConstructedInventoryForm').invoke('onSubmit')( + mockFormValues + ); + }); + expect(ConstructedInventoriesAPI.update).toHaveBeenCalledTimes(1); + expect(InventoriesAPI.associateInstanceGroup).not.toHaveBeenCalled(); + expect(InventoriesAPI.disassociateInventory).toHaveBeenCalledTimes(1); + expect(InventoriesAPI.associateInventory).toHaveBeenCalledTimes(2); + expect(InventoriesAPI.associateInventory).toHaveBeenNthCalledWith( + 1, + 7, + 123 + ); + expect(InventoriesAPI.associateInventory).toHaveBeenNthCalledWith( + 2, + 7, + 456 + ); + }); + + 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( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); + + test('unsuccessful form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + ConstructedInventoriesAPI.update.mockImplementationOnce(() => + Promise.reject(error) + ); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + expect(wrapper.find('FormSubmitError').length).toBe(0); + await act(async () => { + wrapper.find('ConstructedInventoryForm').invoke('onSubmit')( + mockFormValues + ); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); }); }); diff --git a/awx/ui/src/screens/Inventory/InventoryList/InventoryListItem.js b/awx/ui/src/screens/Inventory/InventoryList/InventoryListItem.js index 3828401045..b0a8bdfc38 100644 --- a/awx/ui/src/screens/Inventory/InventoryList/InventoryListItem.js +++ b/awx/ui/src/screens/Inventory/InventoryList/InventoryListItem.js @@ -146,7 +146,7 @@ function InventoryListItem({ aria-label={t`Edit Inventory`} variant="plain" component={Link} - to={`${getInventoryPath(inventory)}edit`} + to={`${getInventoryPath(inventory)}/edit`} > diff --git a/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js index 6c1832c1d1..470ab61366 100644 --- a/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js +++ b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js @@ -158,18 +158,25 @@ function ConstructedInventoryFormFields({ inventory = {}, options }) { ); } -function ConstructedInventoryForm({ onCancel, onSubmit, submitError }) { +function ConstructedInventoryForm({ + constructedInventory, + instanceGroups, + inputInventories, + onCancel, + onSubmit, + submitError, +}) { const initialValues = { - description: '', - instanceGroups: [], kind: 'constructed', - inputInventories: [], - limit: '', - name: '', - organization: null, - source_vars: '---', - update_cache_timeout: 0, - verbosity: 0, + description: constructedInventory?.description || '', + instanceGroups: instanceGroups || [], + inputInventories: inputInventories || [], + limit: constructedInventory?.limit || '', + name: constructedInventory?.name || '', + organization: constructedInventory?.summary_fields?.organization || null, + update_cache_timeout: constructedInventory?.update_cache_timeout || 0, + verbosity: constructedInventory?.verbosity || 0, + source_vars: constructedInventory?.source_vars || '---', }; const { @@ -204,7 +211,7 @@ function ConstructedInventoryForm({ onCancel, onSubmit, submitError }) {
- + {submitError && } { export default parseHostFilter; export function getInventoryPath(inventory) { + if (!inventory) return '/inventories'; const url = { '': `/inventories/inventory/${inventory.id}`, smart: `/inventories/smart_inventory/${inventory.id}`,