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