From 19180a1bc4580322c9b66fe8993a2e2c6fe765c4 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Wed, 13 Nov 2019 11:14:29 -0500 Subject: [PATCH 1/2] Adds Add/Edit Inventory and Inventory Form --- awx/ui_next/src/api/models/Inventories.js | 7 +- .../Inventory/InventoryAdd/InventoryAdd.jsx | 103 +++++++++++- .../InventoryAdd/InventoryAdd.test.jsx | 60 +++++++ .../Inventory/InventoryEdit/InventoryEdit.jsx | 123 +++++++++++++- .../InventoryEdit/InventoryEdit.test.jsx | 112 ++++++++++++ .../Inventory/shared/InventoryForm.jsx | 159 ++++++++++++++++++ .../Inventory/shared/InventoryForm.test.jsx | 104 ++++++++++++ .../src/screens/Inventory/shared/index.js | 1 + 8 files changed, 655 insertions(+), 14 deletions(-) create mode 100644 awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventoryForm.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventoryForm.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/index.js diff --git a/awx/ui_next/src/api/models/Inventories.js b/awx/ui_next/src/api/models/Inventories.js index cdd30d4e6f..93fcefa6cc 100644 --- a/awx/ui_next/src/api/models/Inventories.js +++ b/awx/ui_next/src/api/models/Inventories.js @@ -1,6 +1,7 @@ import Base from '../Base'; +import InstanceGroupsMixin from '../mixins/InstanceGroups.mixin'; -class Inventories extends Base { +class Inventories extends InstanceGroupsMixin(Base) { constructor(http) { super(http); this.baseUrl = '/api/v2/inventories/'; @@ -9,7 +10,9 @@ class Inventories extends Base { } readAccessList(id, params) { - return this.http.get(`${this.baseUrl}${id}/access_list/`, { params }); + return this.http.get(`${this.baseUrl}${id}/access_list/`, { + params, + }); } } diff --git a/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.jsx b/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.jsx index 34299be4bb..aaf28bcd57 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.jsx @@ -1,10 +1,101 @@ -import React, { Component } from 'react'; -import { PageSection } from '@patternfly/react-core'; +import React, { useState, useEffect } from 'react'; +import { withI18n } from '@lingui/react'; +import { withRouter } from 'react-router-dom'; +import { t } from '@lingui/macro'; +import { + PageSection, + Card, + CardHeader, + CardBody, + Tooltip, +} from '@patternfly/react-core'; -class InventoryAdd extends Component { - render() { - return Coming soon :); +import ContentError from '@components/ContentError'; +import ContentLoading from '@components/ContentLoading'; + +import CardCloseButton from '@components/CardCloseButton'; +import { InventoriesAPI, CredentialTypesAPI } from '@api'; +import InventoryForm from '../shared/InventoryForm'; + +function InventoryAdd({ history, i18n }) { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [credentialTypeId, setCredentialTypeId] = useState(null); + + useEffect(() => { + const loadData = async () => { + try { + const { + data: { results: loadedCredentialTypeId }, + } = await CredentialTypesAPI.read({ kind: 'insights' }); + setCredentialTypeId(loadedCredentialTypeId[0].id); + } catch (err) { + setError(err); + } finally { + setIsLoading(false); + } + }; + loadData(); + }, [isLoading, credentialTypeId]); + + const handleCancel = () => { + history.push('/inventories'); + }; + + const handleSubmit = async values => { + try { + let response; + if (values.instance_groups) { + response = await InventoriesAPI.create(values); + const associatePromises = values.instance_groups.map(async ig => + InventoriesAPI.associateInstanceGroup(response.data.id, ig.id) + ); + await Promise.all([response, ...associatePromises]); + } else { + response = await InventoriesAPI.create(values); + } + const url = history.location.pathname.search('smart') + ? `/inventories/smart_inventory/${response.data.id}/details` + : `/inventories/inventory/${response.data.id}/details`; + + history.push(`${url}`); + } catch (err) { + setError(err); + } + }; + + if (error) { + return ; } + if (isLoading) { + return ; + } + return ( + + + + + + + + + + + + + ); } -export default InventoryAdd; +export { InventoryAdd as _InventoryAdd }; +export default withI18n()(withRouter(InventoryAdd)); diff --git a/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.test.jsx new file mode 100644 index 0000000000..fcb2d2965c --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.test.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; + +import { InventoriesAPI, CredentialTypesAPI } from '@api'; +import InventoryAdd from './InventoryAdd'; + +jest.mock('@api'); + +CredentialTypesAPI.read.mockResolvedValue({ + data: { + results: [ + { + id: 14, + name: 'insights', + }, + ], + }, +}); + +describe('', () => { + let wrapper; + let history; + beforeEach(async () => { + history = createMemoryHistory({ initialEntries: ['/inventories'] }); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('Initially renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + test('handleSubmit should call the api', async () => { + await waitForElement(wrapper, 'isLoading', el => el.length === 0); + + wrapper.update(); + await act(async () => { + wrapper.find('InventoryForm').prop('handleSubmit')({ + name: 'Foo', + id: 1, + organization: 2, + }); + }); + + expect(InventoriesAPI.create).toHaveBeenCalledTimes(1); + }); + test('handleCancel should return the user back to the inventories list', async () => { + await waitForElement(wrapper, 'isLoading', el => el.length === 0); + wrapper.find('CardCloseButton').simulate('click'); + expect(history.location.pathname).toEqual('/inventories'); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx b/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx index cc4540749f..b60f4f6207 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx @@ -1,10 +1,121 @@ -import React, { Component } from 'react'; -import { PageSection } from '@patternfly/react-core'; +import React, { useState, useEffect } from 'react'; +import { withI18n } from '@lingui/react'; +import { withRouter } from 'react-router-dom'; +import { t } from '@lingui/macro'; +import { CardHeader, CardBody, Tooltip } from '@patternfly/react-core'; +import { object } from 'prop-types'; -class InventoryEdit extends Component { - render() { - return Coming soon :); +import CardCloseButton from '@components/CardCloseButton'; +import { InventoriesAPI, CredentialTypesAPI } from '@api'; +import ContentLoading from '@components/ContentLoading'; +import ContentError from '@components/ContentError'; +import InventoryForm from '../shared/InventoryForm'; +import { getAddedAndRemoved } from '../../../util/lists'; + +function InventoryEdit({ history, i18n, inventory }) { + const [error, setError] = useState(null); + const [instanceGroups, setInstanceGroups] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [credentialTypeId, setCredentialTypeId] = useState(null); + + useEffect(() => { + const loadData = async () => { + try { + const [ + { + data: { results: loadedInstanceGroups }, + }, + { + data: { results: loadedCredentialTypeId }, + }, + ] = await Promise.all([ + InventoriesAPI.readInstanceGroups(inventory.id), + CredentialTypesAPI.read({ + kind: 'insights', + }), + ]); + setInstanceGroups(loadedInstanceGroups); + setCredentialTypeId(loadedCredentialTypeId[0].id); + } catch (err) { + setError(err); + } finally { + setIsLoading(false); + } + }; + loadData(); + }, [inventory.id, isLoading, inventory, credentialTypeId]); + + const handleCancel = () => { + history.push('/inventories'); + }; + + const handleSubmit = async values => { + try { + if (values.instance_groups) { + const { added, removed } = getAddedAndRemoved( + instanceGroups, + values.instance_groups + ); + const update = InventoriesAPI.update(inventory.id, values); + const associatePromises = added.map(async ig => + InventoriesAPI.associateInstanceGroup(inventory.id, ig.id) + ); + const disAssociatePromises = removed.map(async ig => + InventoriesAPI.disassociateInstanceGroup(inventory.id, ig.id) + ); + await Promise.all([ + update, + ...associatePromises, + ...disAssociatePromises, + ]); + } else { + await InventoriesAPI.update(inventory.id, values); + } + } catch (err) { + setError(err); + } finally { + const url = history.location.pathname.search('smart') + ? `/inventories/smart_inventory/${inventory.id}/details` + : `/inventories/inventory/${inventory.id}/details`; + history.push(`${url}`); + } + }; + if (isLoading) { + return ; } + if (error) { + return ; + } + return ( + <> + + + + + + + + + + ); } -export default InventoryEdit; +InventoryEdit.proptype = { + inventory: object.isRequired, +}; + +export { InventoryEdit as _InventoryEdit }; +export default withI18n()(withRouter(InventoryEdit)); diff --git a/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.test.jsx new file mode 100644 index 0000000000..38d36b7848 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.test.jsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; + +import { InventoriesAPI, CredentialTypesAPI } from '@api'; +import InventoryEdit from './InventoryEdit'; + +jest.mock('@api'); + +const mockInventory = { + id: 1, + type: 'inventory', + url: '/api/v2/inventories/1/', + summary_fields: { + organization: { + id: 1, + name: 'Default', + description: '', + }, + user_capabilities: { + edit: true, + delete: true, + copy: true, + adhoc: true, + }, + insights_credential: { + id: 1, + name: 'Foo', + }, + }, + created: '2019-10-04T16:56:48.025455Z', + modified: '2019-10-04T16:56:48.025468Z', + name: 'Inv no hosts', + description: '', + organization: 1, + kind: '', + host_filter: null, + variables: '---', + has_active_failures: false, + total_hosts: 0, + hosts_with_active_failures: 0, + total_groups: 0, + groups_with_active_failures: 0, + has_inventory_sources: false, + total_inventory_sources: 0, + inventory_sources_with_failures: 0, + insights_credential: null, + pending_deletion: false, +}; + +CredentialTypesAPI.read.mockResolvedValue({ + data: { + results: [ + { + id: 14, + name: 'insights', + }, + ], + }, +}); + +InventoriesAPI.readInstanceGroups.mockResolvedValue({ + data: { + results: [ + { + id: 1, + name: 'Foo', + }, + ], + }, +}); + +describe('', () => { + let wrapper; + let history; + beforeEach(async () => { + history = createMemoryHistory({ initialEntries: ['/inventories'] }); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + }); + afterEach(() => { + wrapper.unmount(); + }); + + test('initially renders successfully', async () => { + expect(wrapper.find('InventoryEdit').length).toBe(1); + }); + test('called InventoriesAPI.readInstanceGroups', async () => { + expect(InventoriesAPI.readInstanceGroups).toBeCalledWith(1); + await waitForElement(wrapper, 'isLoading', el => el.length === 0); + }); + test('handleCancel returns the user to the inventories list', async () => { + await waitForElement(wrapper, 'isLoading', el => el.length === 0); + wrapper.find('CardCloseButton').simulate('click'); + expect(history.location.pathname).toEqual('/inventories'); + }); + test('handleSubmit should post to the api', async () => { + await waitForElement(wrapper, 'isLoading', el => el.length === 0); + wrapper.find('InventoryForm').prop('handleSubmit')({ + name: 'Foo', + id: 1, + organization: 2, + }); + wrapper.update(); + + expect(InventoriesAPI.update).toHaveBeenCalledTimes(1); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventoryForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryForm.jsx new file mode 100644 index 0000000000..a28456688b --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventoryForm.jsx @@ -0,0 +1,159 @@ +import React, { useState } from 'react'; +import { Formik, Field } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { func, number, shape } from 'prop-types'; + +import { VariablesField } from '@components/CodeMirrorInput'; +import { Form } from '@patternfly/react-core'; +import FormField from '@components/FormField'; +import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; +import FormRow from '@components/FormRow'; +import { required } from '@util/validators'; +import InstanceGroupsLookup from '@components/Lookup/InstanceGroupsLookup'; +import OrganizationLookup from '@components/Lookup/OrganizationLookup'; +import CredentialLookup from '@components/Lookup/CredentialLookup'; + +function InventoryForm({ + inventory = {}, + i18n, + handleCancel, + handleSubmit, + instanceGroups, + credentialTypeId, +}) { + const [organization, setOrganization] = useState( + inventory.summary_fields ? inventory.summary_fields.organization : null + ); + const [insights_credential, setInsights_Credential] = useState( + inventory.summary_fields + ? inventory.summary_fields.insights_credential + : null + ); + + const initialValues = { + name: inventory.name || '', + description: inventory.description || '', + variables: inventory.variables || '---', + organization: organization ? organization.id : null, + instance_groups: instanceGroups || [], + insights_credential: insights_credential ? insights_credential.id : '', + }; + return ( + { + handleSubmit(values); + }} + render={formik => ( +
+ + + + ( + form.setFieldTouched('organization')} + onChange={value => { + form.setFieldValue('organization', value.id); + setOrganization(value); + }} + value={organization} + required + /> + )} + /> + + + ( + { + form.setFieldValue('insights_credential', value.id); + setInsights_Credential(value); + }} + value={insights_credential} + /> + )} + /> + + + ( + { + form.setFieldValue('instance_groups', value); + }} + /> + )} + /> + + + + + + + +
+ )} + /> + ); +} + +InventoryForm.proptype = { + handleSubmit: func.isRequired, + handleCancel: func.isRequired, + instanceGroups: shape(), + inventory: shape(), + credentialTypeId: number, +}; + +InventoryForm.defaultProps = { + credentialTypeId: 14, + inventory: {}, + instanceGroups: [], +}; + +export default withI18n()(InventoryForm); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventoryForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryForm.test.jsx new file mode 100644 index 0000000000..3efb5a3dbe --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventoryForm.test.jsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; + +import InventoryForm from './InventoryForm'; + +const inventory = { + id: 1, + type: 'inventory', + url: '/api/v2/inventories/1/', + summary_fields: { + organization: { + id: 1, + name: 'Default', + description: '', + }, + user_capabilities: { + edit: true, + delete: true, + copy: true, + adhoc: true, + }, + insights_credential: { + id: 1, + name: 'Foo', + }, + }, + created: '2019-10-04T16:56:48.025455Z', + modified: '2019-10-04T16:56:48.025468Z', + name: 'Inv no hosts', + description: '', + organization: 1, + kind: '', + host_filter: null, + variables: '---', + has_active_failures: false, + total_hosts: 0, + hosts_with_active_failures: 0, + total_groups: 0, + groups_with_active_failures: 0, + has_inventory_sources: false, + total_inventory_sources: 0, + inventory_sources_with_failures: 0, + insights_credential: null, + pending_deletion: false, +}; + +const instanceGroups = [{ name: 'Foo', id: 1 }, { name: 'Bar', id: 2 }]; +describe('', () => { + let wrapper; + let handleCancel; + beforeEach(() => { + handleCancel = jest.fn(); + wrapper = mountWithContexts( + + ); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('Initially renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + test('should display form fields properly', () => { + expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Organization"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Instance Groups"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Insights Credential"]').length).toBe( + 1 + ); + expect(wrapper.find('VariablesField[label="Variables"]').length).toBe(1); + }); + test('should update from values onChange', () => { + const form = wrapper.find('Formik'); + act(() => { + wrapper.find('OrganizationLookup').invoke('onBlur')(); + wrapper.find('OrganizationLookup').invoke('onChange')({ + id: 1, + name: 'organization', + }); + }); + expect(form.state('values').organization).toEqual(1); + act(() => { + wrapper.find('CredentialLookup').invoke('onBlur')(); + wrapper.find('CredentialLookup').invoke('onChange')({ + id: 10, + name: 'credential', + }); + }); + expect(form.state('values').insights_credential).toEqual(10); + }); + + test('should call handleCancel when Cancel button is clicked', async () => { + expect(handleCancel).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + expect(handleCancel).toBeCalled(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/index.js b/awx/ui_next/src/screens/Inventory/shared/index.js new file mode 100644 index 0000000000..fda9943d93 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/index.js @@ -0,0 +1 @@ +export { default } from './InventoryForm'; From 83caf99c58607efe7c5b705f20c8fadc075589eb Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Thu, 21 Nov 2019 13:29:12 -0500 Subject: [PATCH 2/2] Improves Tests and addresses other PR Issues --- .../Inventory/InventoryAdd/InventoryAdd.jsx | 41 ++++++++----- .../InventoryAdd/InventoryAdd.test.jsx | 29 +++++++--- .../Inventory/InventoryEdit/InventoryEdit.jsx | 45 +++++++------- .../InventoryEdit/InventoryEdit.test.jsx | 44 +++++++++----- .../Inventory/shared/InventoryForm.jsx | 58 +++++++++---------- .../Inventory/shared/InventoryForm.test.jsx | 45 ++++++++++---- 6 files changed, 162 insertions(+), 100 deletions(-) diff --git a/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.jsx b/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.jsx index aaf28bcd57..da666db200 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.jsx @@ -43,20 +43,29 @@ function InventoryAdd({ history, i18n }) { }; const handleSubmit = async values => { + const { + instanceGroups, + organization, + insights_credential, + ...remainingValues + } = values; try { - let response; - if (values.instance_groups) { - response = await InventoriesAPI.create(values); - const associatePromises = values.instance_groups.map(async ig => - InventoriesAPI.associateInstanceGroup(response.data.id, ig.id) + const { + data: { id: inventoryId }, + } = await InventoriesAPI.create({ + organization: organization.id, + insights_credential: insights_credential.id, + ...remainingValues, + }); + if (instanceGroups) { + const associatePromises = instanceGroups.map(async ig => + InventoriesAPI.associateInstanceGroup(inventoryId, ig.id) ); - await Promise.all([response, ...associatePromises]); - } else { - response = await InventoriesAPI.create(values); + await Promise.all(associatePromises); } const url = history.location.pathname.search('smart') - ? `/inventories/smart_inventory/${response.data.id}/details` - : `/inventories/inventory/${response.data.id}/details`; + ? `/inventories/smart_inventory/${inventoryId}/details` + : `/inventories/inventory/${inventoryId}/details`; history.push(`${url}`); } catch (err) { @@ -75,11 +84,11 @@ function InventoryAdd({ history, i18n }) { @@ -87,8 +96,8 @@ function InventoryAdd({ history, i18n }) { diff --git a/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.test.jsx index fcb2d2965c..157e6b9a12 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.test.jsx @@ -2,6 +2,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import { sleep } from '@testUtils/testUtils'; import { InventoriesAPI, CredentialTypesAPI } from '@api'; import InventoryAdd from './InventoryAdd'; @@ -18,6 +19,7 @@ CredentialTypesAPI.read.mockResolvedValue({ ], }, }); +InventoriesAPI.create.mockResolvedValue({ data: { id: 13 } }); describe('', () => { let wrapper; @@ -39,18 +41,27 @@ describe('', () => { expect(wrapper.length).toBe(1); }); test('handleSubmit should call the api', async () => { + const instanceGroups = [{ name: 'Bizz', id: 1 }, { name: 'Buzz', id: 2 }]; await waitForElement(wrapper, 'isLoading', el => el.length === 0); - wrapper.update(); - await act(async () => { - wrapper.find('InventoryForm').prop('handleSubmit')({ - name: 'Foo', - id: 1, - organization: 2, - }); + wrapper.find('InventoryForm').prop('onSubmit')({ + name: 'new Foo', + organization: { id: 2 }, + insights_credential: { id: 47 }, + instanceGroups, }); - - expect(InventoriesAPI.create).toHaveBeenCalledTimes(1); + await sleep(1); + expect(InventoriesAPI.create).toHaveBeenCalledWith({ + name: 'new Foo', + organization: 2, + insights_credential: 47, + }); + instanceGroups.map(IG => + expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledWith( + 13, + IG.id + ) + ); }); test('handleCancel should return the user back to the inventories list', async () => { await waitForElement(wrapper, 'isLoading', el => el.length === 0); diff --git a/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx b/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx index b60f4f6207..131787ae95 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx @@ -14,7 +14,7 @@ import { getAddedAndRemoved } from '../../../util/lists'; function InventoryEdit({ history, i18n, inventory }) { const [error, setError] = useState(null); - const [instanceGroups, setInstanceGroups] = useState(null); + const [associatedInstanceGroups, setInstanceGroups] = useState(null); const [isLoading, setIsLoading] = useState(true); const [credentialTypeId, setCredentialTypeId] = useState(null); @@ -50,26 +50,31 @@ function InventoryEdit({ history, i18n, inventory }) { }; const handleSubmit = async values => { + const { + instanceGroups, + insights_credential, + organization, + ...remainingValues + } = values; try { - if (values.instance_groups) { + await InventoriesAPI.update(inventory.id, { + insights_credential: insights_credential.id, + organization: organization.id, + ...remainingValues, + }); + if (instanceGroups) { const { added, removed } = getAddedAndRemoved( - instanceGroups, - values.instance_groups + associatedInstanceGroups, + instanceGroups ); - const update = InventoriesAPI.update(inventory.id, values); + const associatePromises = added.map(async ig => InventoriesAPI.associateInstanceGroup(inventory.id, ig.id) ); - const disAssociatePromises = removed.map(async ig => + const disassociatePromises = removed.map(async ig => InventoriesAPI.disassociateInstanceGroup(inventory.id, ig.id) ); - await Promise.all([ - update, - ...associatePromises, - ...disAssociatePromises, - ]); - } else { - await InventoriesAPI.update(inventory.id, values); + await Promise.all([...associatePromises, ...disassociatePromises]); } } catch (err) { setError(err); @@ -90,11 +95,11 @@ function InventoryEdit({ history, i18n, inventory }) { <> @@ -102,10 +107,10 @@ function InventoryEdit({ history, i18n, inventory }) { diff --git a/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.test.jsx index 38d36b7848..38a2fbdd42 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.test.jsx @@ -2,6 +2,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import { sleep } from '@testUtils/testUtils'; import { InventoriesAPI, CredentialTypesAPI } from '@api'; import InventoryEdit from './InventoryEdit'; @@ -59,15 +60,15 @@ CredentialTypesAPI.read.mockResolvedValue({ ], }, }); - +const associatedInstanceGroups = [ + { + id: 1, + name: 'Foo', + }, +]; InventoriesAPI.readInstanceGroups.mockResolvedValue({ data: { - results: [ - { - id: 1, - name: 'Foo', - }, - ], + results: associatedInstanceGroups, }, }); @@ -89,24 +90,39 @@ describe('', () => { test('initially renders successfully', async () => { expect(wrapper.find('InventoryEdit').length).toBe(1); }); + test('called InventoriesAPI.readInstanceGroups', async () => { expect(InventoriesAPI.readInstanceGroups).toBeCalledWith(1); - await waitForElement(wrapper, 'isLoading', el => el.length === 0); }); + test('handleCancel returns the user to the inventories list', async () => { await waitForElement(wrapper, 'isLoading', el => el.length === 0); wrapper.find('CardCloseButton').simulate('click'); expect(history.location.pathname).toEqual('/inventories'); }); + test('handleSubmit should post to the api', async () => { await waitForElement(wrapper, 'isLoading', el => el.length === 0); - wrapper.find('InventoryForm').prop('handleSubmit')({ + const instanceGroups = [{ name: 'Bizz', id: 2 }, { name: 'Buzz', id: 3 }]; + wrapper.find('InventoryForm').prop('onSubmit')({ name: 'Foo', - id: 1, - organization: 2, + id: 13, + organization: { id: 1 }, + insights_credential: { id: 13 }, + instanceGroups, }); - wrapper.update(); - - expect(InventoriesAPI.update).toHaveBeenCalledTimes(1); + await sleep(0); + instanceGroups.map(IG => + expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledWith( + 1, + IG.id + ) + ); + associatedInstanceGroups.map(async aIG => + expect(InventoriesAPI.disassociateInstanceGroup).toHaveBeenCalledWith( + 1, + aIG.id + ) + ); }); }); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventoryForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryForm.jsx index a28456688b..3f186431ec 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventoryForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventoryForm.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import { Formik, Field } from 'formik'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -17,33 +17,29 @@ import CredentialLookup from '@components/Lookup/CredentialLookup'; function InventoryForm({ inventory = {}, i18n, - handleCancel, - handleSubmit, + onCancel, + onSubmit, instanceGroups, credentialTypeId, }) { - const [organization, setOrganization] = useState( - inventory.summary_fields ? inventory.summary_fields.organization : null - ); - const [insights_credential, setInsights_Credential] = useState( - inventory.summary_fields - ? inventory.summary_fields.insights_credential - : null - ); - const initialValues = { name: inventory.name || '', description: inventory.description || '', variables: inventory.variables || '---', - organization: organization ? organization.id : null, - instance_groups: instanceGroups || [], - insights_credential: insights_credential ? insights_credential.id : '', + organization: + (inventory.summary_fields && inventory.summary_fields.organization) || + null, + instanceGroups: instanceGroups || [], + insights_credential: + (inventory.summary_fields && + inventory.summary_fields.insights_credential) || + null, }; return ( { - handleSubmit(values); + onSubmit(values); }} render={formik => (
@@ -70,7 +66,7 @@ function InventoryForm({ i18n._(t`Select a value for this field`), i18n )} - render={({ form }) => ( + render={({ form, field }) => ( form.setFieldTouched('organization')} onChange={value => { - form.setFieldValue('organization', value.id); - setOrganization(value); + form.setFieldValue('organization', value); }} - value={organization} + value={field.value} required /> )} /> - - ( + render={({ field, form }) => ( { - form.setFieldValue('insights_credential', value.id); - setInsights_Credential(value); + // TODO: BELOW SHOULD BE REFACTORED AND REMOVED ONCE THE LOOKUP REFACTOR + // GOES INTO PLACE. + if (value[0] === field.value) { + return form.setFieldValue('insights_credential', null); + } + return form.setFieldValue('insights_credential', value); }} - value={insights_credential} + value={field.value} /> )} /> @@ -109,12 +106,12 @@ function InventoryForm({ ( { - form.setFieldValue('instance_groups', value); + form.setFieldValue('instanceGroups', value); }} /> )} @@ -132,7 +129,7 @@ function InventoryForm({ @@ -147,11 +144,10 @@ InventoryForm.proptype = { handleCancel: func.isRequired, instanceGroups: shape(), inventory: shape(), - credentialTypeId: number, + credentialTypeId: number.isRequired, }; InventoryForm.defaultProps = { - credentialTypeId: 14, inventory: {}, instanceGroups: [], }; diff --git a/awx/ui_next/src/screens/Inventory/shared/InventoryForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryForm.test.jsx index 3efb5a3dbe..4abf6fb98d 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventoryForm.test.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventoryForm.test.jsx @@ -1,6 +1,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { sleep } from '@testUtils/testUtils'; import InventoryForm from './InventoryForm'; @@ -48,15 +49,18 @@ const inventory = { const instanceGroups = [{ name: 'Foo', id: 1 }, { name: 'Bar', id: 2 }]; describe('', () => { let wrapper; - let handleCancel; + let onCancel; + let onSubmit; beforeEach(() => { - handleCancel = jest.fn(); + onCancel = jest.fn(); + onSubmit = jest.fn(); wrapper = mountWithContexts( ); }); @@ -76,16 +80,23 @@ describe('', () => { ); expect(wrapper.find('VariablesField[label="Variables"]').length).toBe(1); }); - test('should update from values onChange', () => { + test('should update from values onChange', async () => { const form = wrapper.find('Formik'); act(() => { wrapper.find('OrganizationLookup').invoke('onBlur')(); wrapper.find('OrganizationLookup').invoke('onChange')({ - id: 1, + id: 3, name: 'organization', }); }); - expect(form.state('values').organization).toEqual(1); + expect(form.state('values').organization).toEqual({ + id: 3, + name: 'organization', + }); + wrapper.find('input#inventory-name').simulate('change', { + target: { value: 'new Foo', name: 'name' }, + }); + expect(form.state('values').name).toEqual('new Foo'); act(() => { wrapper.find('CredentialLookup').invoke('onBlur')(); wrapper.find('CredentialLookup').invoke('onChange')({ @@ -93,12 +104,26 @@ describe('', () => { name: 'credential', }); }); - expect(form.state('values').insights_credential).toEqual(10); + expect(form.state('values').insights_credential).toEqual({ + id: 10, + name: 'credential', + }); + + form.find('button[aria-label="Save"]').simulate('click'); + await sleep(1); + expect(onSubmit).toHaveBeenCalledWith({ + description: '', + insights_credential: { id: 10, name: 'credential' }, + instanceGroups: [{ id: 1, name: 'Foo' }, { id: 2, name: 'Bar' }], + name: 'new Foo', + organization: { id: 3, name: 'organization' }, + variables: '---', + }); }); test('should call handleCancel when Cancel button is clicked', async () => { - expect(handleCancel).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); - expect(handleCancel).toBeCalled(); + expect(onCancel).toBeCalled(); }); });