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..da666db200 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.jsx @@ -1,10 +1,110 @@ -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 => { + const { + instanceGroups, + organization, + insights_credential, + ...remainingValues + } = values; + try { + 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(associatePromises); + } + const url = history.location.pathname.search('smart') + ? `/inventories/smart_inventory/${inventoryId}/details` + : `/inventories/inventory/${inventoryId}/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..157e6b9a12 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryAdd/InventoryAdd.test.jsx @@ -0,0 +1,71 @@ +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'; + +jest.mock('@api'); + +CredentialTypesAPI.read.mockResolvedValue({ + data: { + results: [ + { + id: 14, + name: 'insights', + }, + ], + }, +}); +InventoriesAPI.create.mockResolvedValue({ data: { id: 13 } }); + +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 () => { + const instanceGroups = [{ name: 'Bizz', id: 1 }, { name: 'Buzz', id: 2 }]; + await waitForElement(wrapper, 'isLoading', el => el.length === 0); + + wrapper.find('InventoryForm').prop('onSubmit')({ + name: 'new Foo', + organization: { id: 2 }, + insights_credential: { id: 47 }, + instanceGroups, + }); + 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); + 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..131787ae95 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx @@ -1,10 +1,126 @@ -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 [associatedInstanceGroups, 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 => { + const { + instanceGroups, + insights_credential, + organization, + ...remainingValues + } = values; + try { + await InventoriesAPI.update(inventory.id, { + insights_credential: insights_credential.id, + organization: organization.id, + ...remainingValues, + }); + if (instanceGroups) { + const { added, removed } = getAddedAndRemoved( + associatedInstanceGroups, + instanceGroups + ); + + 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([...associatePromises, ...disassociatePromises]); + } + } 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..38a2fbdd42 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.test.jsx @@ -0,0 +1,128 @@ +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'; + +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', + }, + ], + }, +}); +const associatedInstanceGroups = [ + { + id: 1, + name: 'Foo', + }, +]; +InventoriesAPI.readInstanceGroups.mockResolvedValue({ + data: { + results: associatedInstanceGroups, + }, +}); + +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); + }); + + 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); + const instanceGroups = [{ name: 'Bizz', id: 2 }, { name: 'Buzz', id: 3 }]; + wrapper.find('InventoryForm').prop('onSubmit')({ + name: 'Foo', + id: 13, + organization: { id: 1 }, + insights_credential: { id: 13 }, + instanceGroups, + }); + 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 new file mode 100644 index 0000000000..3f186431ec --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventoryForm.jsx @@ -0,0 +1,155 @@ +import React 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, + onCancel, + onSubmit, + instanceGroups, + credentialTypeId, +}) { + const initialValues = { + name: inventory.name || '', + description: inventory.description || '', + variables: inventory.variables || '---', + organization: + (inventory.summary_fields && inventory.summary_fields.organization) || + null, + instanceGroups: instanceGroups || [], + insights_credential: + (inventory.summary_fields && + inventory.summary_fields.insights_credential) || + null, + }; + return ( + { + onSubmit(values); + }} + render={formik => ( +
+ + + + ( + form.setFieldTouched('organization')} + onChange={value => { + form.setFieldValue('organization', value); + }} + value={field.value} + required + /> + )} + /> + ( + { + // 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={field.value} + /> + )} + /> + + + ( + { + form.setFieldValue('instanceGroups', value); + }} + /> + )} + /> + + + + + + + +
+ )} + /> + ); +} + +InventoryForm.proptype = { + handleSubmit: func.isRequired, + handleCancel: func.isRequired, + instanceGroups: shape(), + inventory: shape(), + credentialTypeId: number.isRequired, +}; + +InventoryForm.defaultProps = { + 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..4abf6fb98d --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventoryForm.test.jsx @@ -0,0 +1,129 @@ +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'; + +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 onCancel; + let onSubmit; + beforeEach(() => { + onCancel = jest.fn(); + onSubmit = 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', async () => { + const form = wrapper.find('Formik'); + act(() => { + wrapper.find('OrganizationLookup').invoke('onBlur')(); + wrapper.find('OrganizationLookup').invoke('onChange')({ + id: 3, + name: 'organization', + }); + }); + 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')({ + id: 10, + name: 'credential', + }); + }); + 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(onCancel).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + expect(onCancel).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';