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';