From b717aabcc9a62b74f764a27aae0dc4fc50c45461 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Thu, 30 Apr 2020 13:15:26 -0400 Subject: [PATCH 1/3] Add inventory source add form --- awx/ui_next/src/api/models/Projects.js | 5 + .../components/Lookup/CredentialLookup.jsx | 38 +++- .../src/screens/Inventory/Inventories.jsx | 9 +- .../InventorySourceAdd/InventorySourceAdd.jsx | 56 +++++ .../InventorySourceAdd.test.jsx | 152 +++++++++++++ .../Inventory/InventorySourceAdd/index.js | 1 + .../InventorySources/InventorySourceList.jsx | 6 +- .../InventorySourceList.test.jsx | 4 +- .../InventorySources/InventorySources.jsx | 5 +- .../InventorySources.test.jsx | 11 + .../Inventory/shared/InventoryForm.test.jsx | 33 +-- .../Inventory/shared/InventorySourceForm.jsx | 200 ++++++++++++++++++ .../shared/InventorySourceForm.test.jsx | 144 +++++++++++++ .../InventorySourceSubForms/SCMSubForm.jsx | 108 ++++++++++ .../SCMSubForm.test.jsx | 68 ++++++ .../InventorySourceSubForms/SharedFields.jsx | 121 +++++++++++ .../shared/InventorySourceSubForms/index.js | 1 + 17 files changed, 936 insertions(+), 26 deletions(-) create mode 100644 awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventorySourceAdd/index.js create mode 100644 awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/index.js diff --git a/awx/ui_next/src/api/models/Projects.js b/awx/ui_next/src/api/models/Projects.js index 3761c61961..269ef18f8a 100644 --- a/awx/ui_next/src/api/models/Projects.js +++ b/awx/ui_next/src/api/models/Projects.js @@ -11,6 +11,7 @@ class Projects extends SchedulesMixin( this.baseUrl = '/api/v2/projects/'; this.readAccessList = this.readAccessList.bind(this); + this.readInventories = this.readInventories.bind(this); this.readPlaybooks = this.readPlaybooks.bind(this); this.readSync = this.readSync.bind(this); this.sync = this.sync.bind(this); @@ -20,6 +21,10 @@ class Projects extends SchedulesMixin( return this.http.get(`${this.baseUrl}${id}/access_list/`, { params }); } + readInventories(id) { + return this.http.get(`${this.baseUrl}${id}/inventories/`); + } + readPlaybooks(id) { return this.http.get(`${this.baseUrl}${id}/playbooks/`); } diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx index 50cf3c27e9..bb4629c697 100644 --- a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx @@ -26,6 +26,7 @@ function CredentialLookup({ onChange, required, credentialTypeId, + credentialTypeKind, value, history, i18n, @@ -34,13 +35,19 @@ function CredentialLookup({ const [credentials, setCredentials] = useState([]); const [count, setCount] = useState(0); const [error, setError] = useState(null); - useEffect(() => { (async () => { const params = parseQueryString(QS_CONFIG, history.location.search); + const typeIdParams = credentialTypeId + ? { credential_type: credentialTypeId } + : {}; + const typeKindParams = credentialTypeKind + ? { credential_type__kind: credentialTypeKind } + : {}; + try { const { data } = await CredentialsAPI.read( - mergeParams(params, { credential_type: credentialTypeId }) + mergeParams(params, { ...typeIdParams, ...typeKindParams }) ); setCredentials(data.results); setCount(data.count); @@ -50,7 +57,7 @@ function CredentialLookup({ } } })(); - }, [credentialTypeId, history.location.search]); + }, [credentialTypeId, credentialTypeKind, history.location.search]); // TODO: replace credential type search with REST-based grabbing of cred types @@ -111,8 +118,29 @@ function CredentialLookup({ ); } +function idOrKind(props, propName, componentName) { + let error; + if ( + !Object.prototype.hasOwnProperty.call(props, 'credentialTypeId') && + !Object.prototype.hasOwnProperty.call(props, 'credentialTypeKind') + ) + error = new Error( + `Either "credentialTypeId" or "credentialTypeKind" is required` + ); + if ( + !Object.prototype.hasOwnProperty.call(props, 'credentialTypeId') && + typeof props[propName] !== 'string' + ) { + error = new Error( + `Invalid prop '${propName}' '${props[propName]}' supplied to '${componentName}'.` + ); + } + return error; +} + CredentialLookup.propTypes = { - credentialTypeId: oneOfType([number, string]).isRequired, + credentialTypeId: oneOfType([number, string]), + credentialTypeKind: idOrKind, helperTextInvalid: node, isValid: bool, label: string.isRequired, @@ -123,6 +151,8 @@ CredentialLookup.propTypes = { }; CredentialLookup.defaultProps = { + credentialTypeId: '', + credentialTypeKind: '', helperTextInvalid: '', isValid: true, onBlur: () => {}, diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx index e93269cf93..a5cedf5897 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx @@ -37,8 +37,9 @@ class Inventories extends Component { inventory.kind === 'smart' ? 'smart_inventory' : 'inventory'; const inventoryPath = `/inventories/${inventoryKind}/${inventory.id}`; - const inventoryHostsPath = `/inventories/${inventoryKind}/${inventory.id}/hosts`; - const inventoryGroupsPath = `/inventories/${inventoryKind}/${inventory.id}/groups`; + const inventoryHostsPath = `${inventoryPath}/hosts`; + const inventoryGroupsPath = `${inventoryPath}/groups`; + const inventorySourcesPath = `${inventoryPath}/sources`; const breadcrumbConfig = { '/inventories': i18n._(t`Inventories`), @@ -50,7 +51,6 @@ class Inventories extends Component { [`${inventoryPath}/completed_jobs`]: i18n._(t`Completed Jobs`), [`${inventoryPath}/details`]: i18n._(t`Details`), [`${inventoryPath}/edit`]: i18n._(t`Edit Details`), - [`${inventoryPath}/sources`]: i18n._(t`Sources`), [inventoryHostsPath]: i18n._(t`Hosts`), [`${inventoryHostsPath}/add`]: i18n._(t`Create New Host`), @@ -74,6 +74,9 @@ class Inventories extends Component { [`${inventoryGroupsPath}/${nested?.id}/nested_hosts/add`]: i18n._( t`Create New Host` ), + + [`${inventorySourcesPath}`]: i18n._(t`Sources`), + [`${inventorySourcesPath}/add`]: i18n._(t`Create New Source`), }; this.setState({ breadcrumbConfig }); }; diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.jsx new file mode 100644 index 0000000000..11cad37c8f --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.jsx @@ -0,0 +1,56 @@ +import React, { useCallback, useEffect } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; +import { InventorySourcesAPI } from '@api'; +import useRequest from '@util/useRequest'; +import { Card } from '@patternfly/react-core'; +import { CardBody } from '@components/Card'; +import InventorySourceForm from '../shared/InventorySourceForm'; + +function InventorySourceAdd() { + const history = useHistory(); + const { id } = useParams(); + + const { error, request, result } = useRequest( + useCallback(async values => { + const { data } = await InventorySourcesAPI.create(values); + return data; + }, []) + ); + + useEffect(() => { + if (result) { + history.push( + `/inventories/inventory/${result.inventory}/sources/${result.id}/details` + ); + } + }, [result, history]); + + const handleSubmit = async form => { + const { credential, source_project, ...remainingForm } = form; + + await request({ + credential: credential?.id || null, + source_project: source_project?.id || null, + inventory: id, + ...remainingForm, + }); + }; + + const handleCancel = () => { + history.push(`/inventories/inventory/${id}/sources`); + }; + + return ( + + + + + + ); +} + +export default InventorySourceAdd; diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.test.jsx new file mode 100644 index 0000000000..80bd08c902 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.test.jsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import InventorySourceAdd from './InventorySourceAdd'; +import { InventorySourcesAPI, ProjectsAPI } from '@api'; + +jest.mock('@api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 111, + }), +})); + +describe('', () => { + let wrapper; + const invSourceData = { + credential: { id: 222 }, + description: 'bar', + inventory: 111, + name: 'foo', + overwrite: false, + overwrite_vars: false, + source: 'scm', + source_path: 'mock/file.sh', + source_project: { id: 999 }, + source_vars: '---↵', + update_cache_timeout: 0, + update_on_launch: false, + update_on_project_update: false, + verbosity: 1, + }; + + InventorySourcesAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: { + source: { + choices: [ + ['file', 'File, Directory or Script'], + ['scm', 'Sourced from a Project'], + ['ec2', 'Amazon EC2'], + ['gce', 'Google Compute Engine'], + ['azure_rm', 'Microsoft Azure Resource Manager'], + ['vmware', 'VMware vCenter'], + ['satellite6', 'Red Hat Satellite 6'], + ['cloudforms', 'Red Hat CloudForms'], + ['openstack', 'OpenStack'], + ['rhv', 'Red Hat Virtualization'], + ['tower', 'Ansible Tower'], + ['custom', 'Custom Script'], + ], + }, + }, + }, + }, + }); + + ProjectsAPI.readInventories.mockResolvedValue({ + data: [], + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('new form displays primary form fields', async () => { + const config = { + custom_virtualenvs: ['venv/foo', 'venv/bar'], + }; + await act(async () => { + wrapper = mountWithContexts(, { + context: { config }, + }); + }); + expect(wrapper.find('FormGroup[label="Name"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Description"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Source"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Ansible Environment"]')).toHaveLength( + 1 + ); + }); + + test('should navigate to inventory sources list when cancel is clicked', async () => { + const history = createMemoryHistory({}); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + await act(async () => { + wrapper.find('InventorySourceForm').invoke('onCancel')(); + }); + expect(history.location.pathname).toEqual( + '/inventories/inventory/111/sources' + ); + }); + + test('should post to the api when submit is clicked', async () => { + InventorySourcesAPI.create.mockResolvedValueOnce({ data: {} }); + await act(async () => { + wrapper = mountWithContexts(); + }); + await act(async () => { + wrapper.find('InventorySourceForm').invoke('onSubmit')(invSourceData); + }); + expect(InventorySourcesAPI.create).toHaveBeenCalledTimes(1); + expect(InventorySourcesAPI.create).toHaveBeenCalledWith({ + ...invSourceData, + credential: 222, + source_project: 999, + }); + }); + + test('successful form submission should trigger redirect', async () => { + const history = createMemoryHistory({}); + InventorySourcesAPI.create.mockResolvedValueOnce({ + data: { id: 123, inventory: 111 }, + }); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + await act(async () => { + wrapper.find('InventorySourceForm').invoke('onSubmit')(invSourceData); + }); + expect(history.location.pathname).toEqual( + '/inventories/inventory/111/sources/123/details' + ); + }); + + test('unsuccessful form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + InventorySourcesAPI.create.mockImplementation(() => Promise.reject(error)); + await act(async () => { + wrapper = mountWithContexts(); + }); + expect(wrapper.find('FormSubmitError').length).toBe(0); + await act(async () => { + wrapper.find('InventorySourceForm').invoke('onSubmit')({}); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceAdd/index.js b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/index.js new file mode 100644 index 0000000000..0a3020f194 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/index.js @@ -0,0 +1 @@ +export { default } from './InventorySourceAdd'; diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx index 65fe8656f6..0a759b94e6 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx @@ -88,7 +88,7 @@ function InventorySourceList({ i18n }) { const canAdd = sourceChoicesOptions && Object.prototype.hasOwnProperty.call(sourceChoicesOptions, 'POST'); - const detailUrl = `/inventories/${inventoryType}/${id}/sources/`; + const listUrl = `/inventories/${inventoryType}/${id}/sources/`; return ( <> ] + ? [] : []), handleSelect(inventorySource)} label={label} - detailUrl={`${detailUrl}${inventorySource.id}`} + detailUrl={`${listUrl}${inventorySource.id}`} isSelected={selected.some(row => row.id === inventorySource.id)} /> ); diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx index b0500e6230..c253f4b83f 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx @@ -158,7 +158,7 @@ describe('', () => { 1 ); }); - test('displays error after unseccessful read sources fetch', async () => { + test('displays error after unsuccessful read sources fetch', async () => { InventorySourcesAPI.readOptions.mockRejectedValue( new Error({ response: { @@ -193,7 +193,7 @@ describe('', () => { expect(wrapper.find('ContentError').length).toBe(1); }); - test('displays error after unseccessful read options fetch', async () => { + test('displays error after unsuccessful read options fetch', async () => { InventorySourcesAPI.readOptions.mockRejectedValue( new Error({ response: { diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx index c2455622ad..3fe2cdc1bd 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx @@ -1,11 +1,14 @@ import React from 'react'; import { Switch, Route } from 'react-router-dom'; - +import InventorySourceAdd from '../InventorySourceAdd'; import InventorySourceList from './InventorySourceList'; function InventorySources() { return ( + + + diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.test.jsx new file mode 100644 index 0000000000..dba54734da --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.test.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import InventorySources from './InventorySources'; + +describe('', () => { + test('initially renders without crashing', () => { + const wrapper = shallow(); + expect(wrapper.length).toBe(1); + wrapper.unmount(); + }); +}); 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 f0add60fe9..1cc056d5a4 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventoryForm.test.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventoryForm.test.jsx @@ -1,9 +1,11 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import InventoryForm from './InventoryForm'; +jest.mock('@api'); + const inventory = { id: 1, type: 'inventory', @@ -50,22 +52,27 @@ describe('', () => { let wrapper; let onCancel; let onSubmit; - beforeEach(() => { + + beforeAll(async () => { onCancel = jest.fn(); onSubmit = jest.fn(); - wrapper = mountWithContexts( - - ); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); }); - afterEach(() => { + afterAll(() => { wrapper.unmount(); + jest.clearAllMocks(); }); test('Initially renders successfully', () => { @@ -83,7 +90,7 @@ describe('', () => { expect(wrapper.find('VariablesField[label="Variables"]').length).toBe(1); }); - test('should update form values', async () => { + test('should update form values', () => { act(() => { wrapper.find('OrganizationLookup').invoke('onBlur')(); wrapper.find('OrganizationLookup').invoke('onChange')({ diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx new file mode 100644 index 0000000000..eb59ed97c7 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx @@ -0,0 +1,200 @@ +import React, { useEffect, useCallback, useContext } from 'react'; +import { Formik, useField } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { InventorySourcesAPI } from '@api'; +import { ConfigContext } from '@contexts/Config'; +import useRequest from '@util/useRequest'; +import { required } from '@util/validators'; + +import { Form, FormGroup, Title } from '@patternfly/react-core'; +import AnsibleSelect from '@components/AnsibleSelect'; +import ContentError from '@components/ContentError'; +import ContentLoading from '@components/ContentLoading'; +import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; +import FormField, { + FieldTooltip, + FormSubmitError, +} from '@components/FormField'; +import { FormColumnLayout, SubFormLayout } from '@components/FormLayout'; + +import SCMSubForm from './InventorySourceSubForms'; + +const InventorySourceFormFields = ({ sourceOptions, i18n }) => { + const [sourceField, sourceMeta, sourceHelpers] = useField({ + name: 'source', + validate: required(i18n._(t`Set a value for this field`), i18n), + }); + const { custom_virtualenvs } = useContext(ConfigContext); + const [venvField] = useField('custom_virtualenv'); + const defaultVenv = { + label: i18n._(t`Use Default Ansible Environment`), + value: '/venv/ansible/', + key: 'default', + }; + + return ( + <> + + + + { + sourceHelpers.setValue(value); + }} + /> + + {custom_virtualenvs && custom_virtualenvs.length > 1 && ( + + + value !== defaultVenv.value) + .map(value => ({ value, label: value, key: value })), + ]} + {...venvField} + /> + + )} + + {sourceField.value !== '' && ( + + {i18n._(t`Source details`)} + + { + { + scm: , + }[sourceField.value] + } + + + )} + + ); +}; + +const InventorySourceForm = ({ + i18n, + onCancel, + onSubmit, + submitError = null, +}) => { + const initialValues = { + credential: null, + custom_virtualenv: '', + description: '', + name: '', + overwrite: false, + overwrite_vars: false, + source: '', + source_path: '', + source_project: null, + source_vars: '---\n', + update_cache_timeout: 0, + update_on_launch: false, + update_on_project_update: false, + verbosity: 1, + }; + + const { + isLoading: isSourceOptionsLoading, + error: sourceOptionsError, + request: fetchSourceOptions, + result: sourceOptions, + } = useRequest( + useCallback(async () => { + const { data } = await InventorySourcesAPI.readOptions(); + const sourceChoices = Object.assign( + ...data.actions.GET.source.choices.map(([key, val]) => ({ [key]: val })) + ); + delete sourceChoices.file; + return Object.keys(sourceChoices).map(choice => { + return { + value: choice, + key: choice, + label: sourceChoices[choice], + }; + }); + }, []), + [] + ); + + useEffect(() => { + fetchSourceOptions(); + }, [fetchSourceOptions]); + + if (isSourceOptionsLoading) { + return ; + } + + if (sourceOptionsError) { + return ; + } + + return ( + { + onSubmit(values); + }} + > + {formik => ( +
+ + + {submitError && } + + +
+ )} +
+ ); +}; + +export default withI18n()(InventorySourceForm); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.test.jsx new file mode 100644 index 0000000000..2e3dae5c8d --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.test.jsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import InventorySourceForm from './InventorySourceForm'; +import { InventorySourcesAPI, ProjectsAPI, CredentialsAPI } from '@api'; + +jest.mock('@api/models/Credentials'); +jest.mock('@api/models/InventorySources'); +jest.mock('@api/models/Projects'); + +describe('', () => { + let wrapper; + CredentialsAPI.read.mockResolvedValue({ + data: { count: 0, results: [] }, + }); + ProjectsAPI.readInventories.mockResolvedValue({ + data: ['foo'], + }); + InventorySourcesAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: { + source: { + choices: [ + ['file', 'File, Directory or Script'], + ['scm', 'Sourced from a Project'], + ['ec2', 'Amazon EC2'], + ['gce', 'Google Compute Engine'], + ['azure_rm', 'Microsoft Azure Resource Manager'], + ['vmware', 'VMware vCenter'], + ['satellite6', 'Red Hat Satellite 6'], + ['cloudforms', 'Red Hat CloudForms'], + ['openstack', 'OpenStack'], + ['rhv', 'Red Hat Virtualization'], + ['tower', 'Ansible Tower'], + ['custom', 'Custom Script'], + ], + }, + }, + }, + }, + }); + + describe('Successful form submission', () => { + const onSubmit = jest.fn(); + + beforeAll(async () => { + const config = { + custom_virtualenvs: ['venv/foo', 'venv/bar'], + }; + await act(async () => { + wrapper = mountWithContexts( + {}} onSubmit={onSubmit} />, + { + context: { config }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('should initially display primary form fields', () => { + expect(wrapper.find('FormGroup[label="Name"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Description"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Source"]')).toHaveLength(1); + expect( + wrapper.find('FormGroup[label="Ansible Environment"]') + ).toHaveLength(1); + }); + + test('should display subform when source dropdown has a value', async () => { + await act(async () => { + wrapper.find('AnsibleSelect#source').prop('onChange')(null, 'scm'); + }); + wrapper.update(); + expect(wrapper.find('Title').text()).toBe('Source details'); + }); + + test('should show field error when form is invalid', async () => { + expect(onSubmit).not.toHaveBeenCalled(); + await act(async () => { + wrapper.find('CredentialLookup').invoke('onChange')({ + id: 1, + name: 'mock cred', + }); + wrapper.find('ProjectLookup').invoke('onChange')({ + id: 2, + name: 'mock proj', + }); + wrapper.find('AnsibleSelect#source_path').prop('onChange')(null, 'foo'); + wrapper.find('AnsibleSelect#verbosity').prop('onChange')(null, '2'); + wrapper.find('button[aria-label="Save"]').simulate('click'); + }); + wrapper.update(); + expect(wrapper.find('FormGroup[label="Name"] .pf-m-error')).toHaveLength( + 1 + ); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + test('should call onSubmit when Save button is clicked', async () => { + expect(onSubmit).not.toHaveBeenCalled(); + wrapper.find('input#name').simulate('change', { + target: { value: 'new foo', name: 'name' }, + }); + await act(async () => { + wrapper.find('button[aria-label="Save"]').simulate('click'); + }); + wrapper.update(); + expect(onSubmit).toHaveBeenCalled(); + }); + }); + + test('should display ContentError on throw', async () => { + InventorySourcesAPI.readOptions.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts( + {}} onSubmit={() => {}} /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); + + test('calls "onCancel" when Cancel button is clicked', async () => { + const onCancel = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + {}} /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(onCancel).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + expect(onCancel).toBeCalled(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx new file mode 100644 index 0000000000..5160c31de4 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx @@ -0,0 +1,108 @@ +import React, { useCallback } from 'react'; +import { useField } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { ProjectsAPI } from '@api'; +import useRequest from '@util/useRequest'; +import { required } from '@util/validators'; + +import { FormGroup } from '@patternfly/react-core'; +import AnsibleSelect from '@components/AnsibleSelect'; +import { FieldTooltip } from '@components/FormField'; +import CredentialLookup from '@components/Lookup/CredentialLookup'; +import ProjectLookup from '@components/Lookup/ProjectLookup'; +import { VerbosityField, OptionsField, SourceVarsField } from './SharedFields'; + +const SCMSubForm = ({ i18n }) => { + const [credentialField, , credentialHelpers] = useField('credential'); + const [projectField, projectMeta, projectHelpers] = useField({ + name: 'source_project', + validate: required(i18n._(t`Select a value for this field`), i18n), + }); + const [sourcePathField, sourcePathMeta, sourcePathHelpers] = useField({ + name: 'source_path', + validate: required(i18n._(t`Select a value for this field`), i18n), + }); + + const { + error: sourcePathError, + request: fetchSourcePath, + result: sourcePath, + } = useRequest( + useCallback(async projectId => { + const { data } = await ProjectsAPI.readInventories(projectId); + data.push('/ (project root)'); + return data; + }, []), + [] + ); + + const handleProjectUpdate = useCallback( + value => { + projectHelpers.setValue(value); + fetchSourcePath(value.id); + sourcePathHelpers.setValue(''); + }, + [] // eslint-disable-line react-hooks/exhaustive-deps + ); + + return ( + <> + { + credentialHelpers.setValue(value); + }} + /> + projectHelpers.setTouched()} + onChange={handleProjectUpdate} + required + /> + + + ({ value, label: value, key: value })), + ]} + onChange={(event, value) => { + sourcePathHelpers.setValue(value); + }} + /> + + + + + + ); +}; + +export default withI18n()(SCMSubForm); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.test.jsx new file mode 100644 index 0000000000..ad4d71aaf3 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.test.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { Formik } from 'formik'; +import SCMSubForm from './SCMSubForm'; +import { ProjectsAPI } from '@api'; + +jest.mock('@api/models/Projects'); + +const initialValues = { + credential: null, + custom_virtualenv: '', + overwrite: false, + overwrite_vars: false, + source_path: '', + source_project: null, + source_vars: '---\n', + update_cache_timeout: 0, + update_on_launch: false, + update_on_project_update: false, + verbosity: 1, +}; + +describe('', () => { + let wrapper; + + ProjectsAPI.readInventories.mockResolvedValue({ + data: ['foo'], + }); + + beforeAll(async () => { + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('should render subform fields', () => { + expect(wrapper.find('FormGroup[label="Credential"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Project"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Inventory file"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1); + expect( + wrapper.find('VariablesField[label="Environment variables"]') + ).toHaveLength(1); + }); + + test('project lookup should fetch project source path list', async () => { + expect(ProjectsAPI.readInventories).not.toHaveBeenCalled(); + await act(async () => { + wrapper.find('ProjectLookup').invoke('onChange')({ + id: 2, + name: 'mock proj', + }); + wrapper.find('ProjectLookup').invoke('onBlur')(); + }); + expect(ProjectsAPI.readInventories).toHaveBeenCalledWith(2); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx new file mode 100644 index 0000000000..a0285f51c0 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useField } from 'formik'; +import { FormGroup } from '@patternfly/react-core'; +import AnsibleSelect from '@components/AnsibleSelect'; +import { VariablesField } from '@components/CodeMirrorInput'; +import FormField, { CheckboxField, FieldTooltip } from '@components/FormField'; +import { + FormFullWidthLayout, + FormCheckboxLayout, +} from '@components/FormLayout'; + +export const SourceVarsField = withI18n()(({ i18n }) => ( + + + +)); + +export const VerbosityField = withI18n()(({ i18n }) => { + const [field, meta, helpers] = useField('verbosity'); + const isValid = !(meta.touched && meta.error); + const options = [ + { value: '0', key: '0', label: i18n._(t`0 (Warning)`) }, + { value: '1', key: '1', label: i18n._(t`1 (Info)`) }, + { value: '2', key: '2', label: i18n._(t`2 (Debug)`) }, + ]; + return ( + + + helpers.setValue(value)} + /> + + ); +}); + +export const OptionsField = withI18n()(({ i18n }) => { + const [updateOnLaunchField] = useField('update_on_launch'); + return ( + <> + + + + + + + + + + + {updateOnLaunchField.value && ( + + )} + + ); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/index.js b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/index.js new file mode 100644 index 0000000000..79640d3a4f --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/index.js @@ -0,0 +1 @@ +export { default } from './SCMSubForm'; From 4b53875a71939ab1bf431070d48cd7f640dba727 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Thu, 7 May 2020 08:57:08 -0400 Subject: [PATCH 2/3] Clear inv src subform values when source value changes * Test that inv file field resets when project value changes * Remove project and inv file path from API request when type is SCM * Update checkbox tooltip to accept node proptypes * Format option field tooltips --- .../components/FormField/CheckboxField.jsx | 4 +- .../InventorySourceAdd/InventorySourceAdd.jsx | 13 ++++- .../Inventory/shared/InventorySourceForm.jsx | 30 +++++++++-- .../shared/InventorySourceForm.test.jsx | 2 +- .../InventorySourceSubForms/SCMSubForm.jsx | 3 +- .../SCMSubForm.test.jsx | 50 +++++++++++++++++-- .../InventorySourceSubForms/SharedFields.jsx | 41 ++++++++++----- 7 files changed, 116 insertions(+), 27 deletions(-) diff --git a/awx/ui_next/src/components/FormField/CheckboxField.jsx b/awx/ui_next/src/components/FormField/CheckboxField.jsx index a04a78b02c..6a46bcaed3 100644 --- a/awx/ui_next/src/components/FormField/CheckboxField.jsx +++ b/awx/ui_next/src/components/FormField/CheckboxField.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { string, func } from 'prop-types'; +import { string, func, node } from 'prop-types'; import { useField } from 'formik'; import { Checkbox, Tooltip } from '@patternfly/react-core'; import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; @@ -40,7 +40,7 @@ CheckboxField.propTypes = { name: string.isRequired, label: string.isRequired, validate: func, - tooltip: string, + tooltip: node, }; CheckboxField.defaultProps = { validate: () => {}, diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.jsx index 11cad37c8f..df12500a91 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.jsx @@ -26,12 +26,21 @@ function InventorySourceAdd() { }, [result, history]); const handleSubmit = async form => { - const { credential, source_project, ...remainingForm } = form; + const { credential, source_path, source_project, ...remainingForm } = form; + + const sourcePath = {}; + const sourceProject = {}; + if (form.source === 'scm') { + sourcePath.source_path = + source_path === '/ (project root)' ? '' : source_path; + sourceProject.source_project = source_project.id; + } await request({ credential: credential?.id || null, - source_project: source_project?.id || null, inventory: id, + ...sourcePath, + ...sourceProject, ...remainingForm, }); }; diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx index eb59ed97c7..3e76f1b223 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx @@ -1,5 +1,6 @@ import React, { useEffect, useCallback, useContext } from 'react'; -import { Formik, useField } from 'formik'; +import { Formik, useField, useFormikContext } from 'formik'; +import { func, shape } from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { InventorySourcesAPI } from '@api'; @@ -21,7 +22,8 @@ import { FormColumnLayout, SubFormLayout } from '@components/FormLayout'; import SCMSubForm from './InventorySourceSubForms'; const InventorySourceFormFields = ({ sourceOptions, i18n }) => { - const [sourceField, sourceMeta, sourceHelpers] = useField({ + const { values, initialValues, resetForm } = useFormikContext(); + const [sourceField, sourceMeta] = useField({ name: 'source', validate: required(i18n._(t`Set a value for this field`), i18n), }); @@ -33,6 +35,18 @@ const InventorySourceFormFields = ({ sourceOptions, i18n }) => { key: 'default', }; + const resetSubFormFields = sourceType => { + resetForm({ + values: { + ...initialValues, + name: values.name, + description: values.description, + custom_virtualenv: values.custom_virtualenv, + source: sourceType, + }, + }); + }; + return ( <> { ...sourceOptions, ]} onChange={(event, value) => { - sourceHelpers.setValue(value); + resetSubFormFields(value); }} /> @@ -197,4 +211,14 @@ const InventorySourceForm = ({ ); }; +InventorySourceForm.propTypes = { + onCancel: func.isRequired, + onSubmit: func.isRequired, + submitError: shape({}), +}; + +InventorySourceForm.defaultProps = { + submitError: null, +}; + export default withI18n()(InventorySourceForm); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.test.jsx index 2e3dae5c8d..d79cf457c9 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.test.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.test.jsx @@ -14,7 +14,7 @@ describe('', () => { data: { count: 0, results: [] }, }); ProjectsAPI.readInventories.mockResolvedValue({ - data: ['foo'], + data: ['foo', 'bar'], }); InventorySourcesAPI.readOptions.mockResolvedValue({ data: { diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx index 5160c31de4..9b6fbb7ba5 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx @@ -31,8 +31,7 @@ const SCMSubForm = ({ i18n }) => { } = useRequest( useCallback(async projectId => { const { data } = await ProjectsAPI.readInventories(projectId); - data.push('/ (project root)'); - return data; + return [...data, '/ (project root)']; }, []), [] ); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.test.jsx index ad4d71aaf3..78ec81ad55 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.test.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.test.jsx @@ -3,9 +3,10 @@ import { act } from 'react-dom/test-utils'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { Formik } from 'formik'; import SCMSubForm from './SCMSubForm'; -import { ProjectsAPI } from '@api'; +import { ProjectsAPI, CredentialsAPI } from '@api'; jest.mock('@api/models/Projects'); +jest.mock('@api/models/Credentials'); const initialValues = { credential: null, @@ -23,9 +24,26 @@ const initialValues = { describe('', () => { let wrapper; - + CredentialsAPI.read.mockResolvedValue({ + data: { count: 0, results: [] }, + }); ProjectsAPI.readInventories.mockResolvedValue({ - data: ['foo'], + data: ['foo', 'bar'], + }); + ProjectsAPI.read.mockResolvedValue({ + data: { + count: 2, + results: [ + { + id: 1, + name: 'mock proj one', + }, + { + id: 2, + name: 'mock proj two', + }, + ], + }, }); beforeAll(async () => { @@ -59,10 +77,34 @@ describe('', () => { await act(async () => { wrapper.find('ProjectLookup').invoke('onChange')({ id: 2, - name: 'mock proj', + name: 'mock proj two', }); wrapper.find('ProjectLookup').invoke('onBlur')(); }); expect(ProjectsAPI.readInventories).toHaveBeenCalledWith(2); }); + + test('changing source project should reset source path dropdown', async () => { + expect(wrapper.find('AnsibleSelect#source_path').prop('value')).toEqual(''); + + await act(async () => { + await wrapper.find('AnsibleSelect#source_path').prop('onChange')( + null, + 'bar' + ); + }); + wrapper.update(); + expect(wrapper.find('AnsibleSelect#source_path').prop('value')).toEqual( + 'bar' + ); + + await act(async () => { + wrapper.find('ProjectLookup').invoke('onChange')({ + id: 1, + name: 'mock proj one', + }); + }); + wrapper.update(); + expect(wrapper.find('AnsibleSelect#source_path').prop('value')).toEqual(''); + }); }); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx index a0285f51c0..b665f792e5 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx @@ -63,24 +63,39 @@ export const OptionsField = withI18n()(({ i18n }) => { id="overwrite" name="overwrite" label={i18n._(t`Overwrite`)} - tooltip={i18n._(t`If checked, any hosts and groups that were - previously present on the external source but are now removed - will be removed from the Tower inventory. Hosts and groups - that were not managed by the inventory source will be promoted - to the next manually created group or if there is no manually - created group to promote them into, they will be left in the "all" - default group for the inventory. When not checked, local child - hosts and groups not found on the external source will remain - untouched by the inventory update process.`)} + tooltip={ + <> + {i18n._(t`If checked, any hosts and groups that were + previously present on the external source but are now removed + will be removed from the Tower inventory. Hosts and groups + that were not managed by the inventory source will be promoted + to the next manually created group or if there is no manually + created group to promote them into, they will be left in the "all" + default group for the inventory.`)} +
+
+ {i18n._(t`When not checked, local child + hosts and groups not found on the external source will remain + untouched by the inventory update process.`)} + + } /> + {i18n._(t`If checked, all variables for child groups + and hosts will be removed and replaced by those found + on the external source.`)} +
+
+ {i18n._(t`When not checked, a merge will be performed, + combining local variables with those found on the + external source.`)} + + } /> Date: Thu, 7 May 2020 23:26:31 -0400 Subject: [PATCH 3/3] Add cache timeout and inventory file validation --- .../shared/InventorySourceSubForms/SCMSubForm.jsx | 12 +++++++----- .../InventorySourceSubForms/SharedFields.jsx | 15 +++++++++++++-- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx index 9b6fbb7ba5..dbc36f19ca 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx @@ -38,9 +38,9 @@ const SCMSubForm = ({ i18n }) => { const handleProjectUpdate = useCallback( value => { + sourcePathHelpers.setValue(''); projectHelpers.setValue(value); fetchSourcePath(value.id); - sourcePathHelpers.setValue(''); }, [] // eslint-disable-line react-hooks/exhaustive-deps ); @@ -67,9 +67,8 @@ const SCMSubForm = ({ i18n }) => { fieldId="source_path" helperTextInvalid={sourcePathError?.message || sourcePathMeta.error} isValid={ - !sourcePathError?.message || - !sourcePathMeta.error || - !sourcePathMeta.touched + (!sourcePathMeta.error || !sourcePathMeta.touched) && + !sourcePathError?.message } isRequired label={i18n._(t`Inventory file`)} @@ -82,7 +81,10 @@ const SCMSubForm = ({ i18n }) => { { label={i18n._(t`Verbosity`)} > { export const OptionsField = withI18n()(({ i18n }) => { const [updateOnLaunchField] = useField('update_on_launch'); + const [, , updateCacheTimeoutHelper] = useField('update_cache_timeout'); + + useEffect(() => { + if (!updateOnLaunchField.value) { + updateCacheTimeoutHelper.setValue(0); + } + }, [updateOnLaunchField.value]); // eslint-disable-line react-hooks/exhaustive-deps + return ( <> @@ -123,6 +132,8 @@ export const OptionsField = withI18n()(({ i18n }) => { name="update_cache_timeout" type="number" min="0" + max="2147483647" + validate={minMaxValue(0, 2147483647, i18n)} label={i18n._(t`Cache timeout (seconds)`)} tooltip={i18n._(t`Time in seconds to consider an inventory sync to be current. During job runs and callbacks the task system will