diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx index 2562b81f50..32a418a3e7 100644 --- a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { bool, func, node, number, string, oneOfType } from 'prop-types'; import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; @@ -10,6 +10,7 @@ import { getQSConfig, parseQueryString, mergeParams } from '../../util/qs'; import { FieldTooltip } from '../FormField'; import Lookup from './Lookup'; import OptionsList from '../OptionsList'; +import useRequest from '../../util/useRequest'; import LookupErrorMessage from './shared/LookupErrorMessage'; const QS_CONFIG = getQSConfig('credentials', { @@ -32,11 +33,12 @@ function CredentialLookup({ i18n, tooltip, }) { - const [credentials, setCredentials] = useState([]); - const [count, setCount] = useState(0); - const [error, setError] = useState(null); - useEffect(() => { - (async () => { + const { + result: { count, credentials }, + error, + request: fetchCredentials, + } = useRequest( + useCallback(async () => { const params = parseQueryString(QS_CONFIG, history.location.search); const typeIdParams = credentialTypeId ? { credential_type: credentialTypeId } @@ -45,19 +47,23 @@ function CredentialLookup({ ? { credential_type__kind: credentialTypeKind } : {}; - try { - const { data } = await CredentialsAPI.read( - mergeParams(params, { ...typeIdParams, ...typeKindParams }) - ); - setCredentials(data.results); - setCount(data.count); - } catch (err) { - if (setError) { - setError(err); - } - } - })(); - }, [credentialTypeId, credentialTypeKind, history.location.search]); + const { data } = await CredentialsAPI.read( + mergeParams(params, { ...typeIdParams, ...typeKindParams }) + ); + return { + count: data.count, + credentials: data.results, + }; + }, [credentialTypeId, credentialTypeKind, history.location.search]), + { + count: 0, + credentials: [], + } + ); + + useEffect(() => { + fetchCredentials(); + }, [fetchCredentials]); // TODO: replace credential type search with REST-based grabbing of cred types diff --git a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx index 01e25af6e7..52bb7bba21 100644 --- a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { func, bool } from 'prop-types'; import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; @@ -7,6 +7,7 @@ import { InventoriesAPI } from '../../api'; import { Inventory } from '../../types'; import Lookup from './Lookup'; import OptionsList from '../OptionsList'; +import useRequest from '../../util/useRequest'; import { getQSConfig, parseQueryString } from '../../util/qs'; import LookupErrorMessage from './shared/LookupErrorMessage'; @@ -17,22 +18,28 @@ const QS_CONFIG = getQSConfig('inventory', { }); function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) { - const [inventories, setInventories] = useState([]); - const [count, setCount] = useState(0); - const [error, setError] = useState(null); + const { + result: { count, inventories }, + error, + request: fetchInventories, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + const { data } = await InventoriesAPI.read(params); + return { + count: data.count, + inventories: data.results, + }; + }, [history.location.search]), + { + count: 0, + inventories: [], + } + ); useEffect(() => { - (async () => { - const params = parseQueryString(QS_CONFIG, history.location.search); - try { - const { data } = await InventoriesAPI.read(params); - setInventories(data.results); - setCount(data.count); - } catch (err) { - setError(err); - } - })(); - }, [history.location]); + fetchInventories(); + }, [fetchInventories]); return ( <> diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx index a45a5b3429..2b1ee265ac 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { node, string, func, bool } from 'prop-types'; import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; @@ -8,6 +8,7 @@ import { ProjectsAPI } from '../../api'; import { Project } from '../../types'; import { FieldTooltip } from '../FormField'; import OptionsList from '../OptionsList'; +import useRequest from '../../util/useRequest'; import { getQSConfig, parseQueryString } from '../../util/qs'; import Lookup from './Lookup'; import LookupErrorMessage from './shared/LookupErrorMessage'; @@ -20,6 +21,7 @@ const QS_CONFIG = getQSConfig('project', { function ProjectLookup({ helperTextInvalid, + autocomplete, i18n, isValid, onChange, @@ -29,25 +31,31 @@ function ProjectLookup({ onBlur, history, }) { - const [projects, setProjects] = useState([]); - const [count, setCount] = useState(0); - const [error, setError] = useState(null); + const { + result: { count, projects }, + error, + request: fetchProjects, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + const { data } = await ProjectsAPI.read(params); + if (data.count === 1 && autocomplete) { + autocomplete(data.results[0]); + } + return { + count: data.count, + projects: data.results, + }; + }, [history.location.search, autocomplete]), + { + count: 0, + projects: [], + } + ); useEffect(() => { - (async () => { - const params = parseQueryString(QS_CONFIG, history.location.search); - try { - const { data } = await ProjectsAPI.read(params); - setProjects(data.results); - setCount(data.count); - if (data.count === 1) { - onChange(data.results[0]); - } - } catch (err) { - setError(err); - } - })(); - }, [onChange, history.location]); + fetchProjects(); + }, [fetchProjects]); return ( {}, helperTextInvalid: '', isValid: true, + onBlur: () => {}, required: false, tooltip: '', value: null, - onBlur: () => {}, }; export { ProjectLookup as _ProjectLookup }; diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx index b7be75a02e..09bb9e5741 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx @@ -15,12 +15,14 @@ describe('', () => { count: 1, }, }); - const onChange = jest.fn(); + const autocomplete = jest.fn(); await act(async () => { - mountWithContexts(); + mountWithContexts( + {}} /> + ); }); await sleep(0); - expect(onChange).toHaveBeenCalledWith({ id: 1 }); + expect(autocomplete).toHaveBeenCalledWith({ id: 1 }); }); test('should not auto-select project when multiple available', async () => { @@ -30,11 +32,13 @@ describe('', () => { count: 2, }, }); - const onChange = jest.fn(); + const autocomplete = jest.fn(); await act(async () => { - mountWithContexts(); + mountWithContexts( + {}} /> + ); }); await sleep(0); - expect(onChange).not.toHaveBeenCalled(); + expect(autocomplete).not.toHaveBeenCalled(); }); }); diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx index d1a46ca8de..c4c18e76cf 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx @@ -19,9 +19,9 @@ class Inventories extends Component { this.state = { breadcrumbConfig: { '/inventories': i18n._(t`Inventories`), - '/inventories/inventory/add': i18n._(t`Create New Inventory`), + '/inventories/inventory/add': i18n._(t`Create new inventory`), '/inventories/smart_inventory/add': i18n._( - t`Create New Smart Inventory` + t`Create new smart inventory` ), }, }; @@ -43,42 +43,43 @@ class Inventories extends Component { const breadcrumbConfig = { '/inventories': i18n._(t`Inventories`), - '/inventories/inventory/add': i18n._(t`Create New Inventory`), - '/inventories/smart_inventory/add': i18n._(t`Create New Smart Inventory`), + '/inventories/inventory/add': i18n._(t`Create new inventory`), + '/inventories/smart_inventory/add': i18n._(t`Create new smart inventory`), [inventoryPath]: `${inventory.name}`, [`${inventoryPath}/access`]: i18n._(t`Access`), - [`${inventoryPath}/completed_jobs`]: i18n._(t`Completed Jobs`), + [`${inventoryPath}/completed_jobs`]: i18n._(t`Completed jobs`), [`${inventoryPath}/details`]: i18n._(t`Details`), - [`${inventoryPath}/edit`]: i18n._(t`Edit Details`), + [`${inventoryPath}/edit`]: i18n._(t`Edit details`), [inventoryHostsPath]: i18n._(t`Hosts`), - [`${inventoryHostsPath}/add`]: i18n._(t`Create New Host`), + [`${inventoryHostsPath}/add`]: i18n._(t`Create new host`), [`${inventoryHostsPath}/${nested?.id}`]: `${nested?.name}`, - [`${inventoryHostsPath}/${nested?.id}/edit`]: i18n._(t`Edit Details`), + [`${inventoryHostsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), [`${inventoryHostsPath}/${nested?.id}/details`]: i18n._(t`Host Details`), [`${inventoryHostsPath}/${nested?.id}/completed_jobs`]: i18n._( - t`Completed Jobs` + t`Completed jobs` ), [`${inventoryHostsPath}/${nested?.id}/facts`]: i18n._(t`Facts`), [`${inventoryHostsPath}/${nested?.id}/groups`]: i18n._(t`Groups`), [inventoryGroupsPath]: i18n._(t`Groups`), - [`${inventoryGroupsPath}/add`]: i18n._(t`Create New Group`), + [`${inventoryGroupsPath}/add`]: i18n._(t`Create new group`), [`${inventoryGroupsPath}/${nested?.id}`]: `${nested?.name}`, - [`${inventoryGroupsPath}/${nested?.id}/edit`]: i18n._(t`Edit Details`), + [`${inventoryGroupsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), [`${inventoryGroupsPath}/${nested?.id}/details`]: i18n._( - t`Group Details` + t`Group details` ), [`${inventoryGroupsPath}/${nested?.id}/nested_hosts`]: i18n._(t`Hosts`), [`${inventoryGroupsPath}/${nested?.id}/nested_hosts/add`]: i18n._( - t`Create New Host` + t`Create new host` ), [`${inventorySourcesPath}`]: i18n._(t`Sources`), - [`${inventorySourcesPath}/add`]: i18n._(t`Create New Source`), + [`${inventorySourcesPath}/add`]: i18n._(t`Create new source`), [`${inventorySourcesPath}/${nested?.id}`]: `${nested?.name}`, [`${inventorySourcesPath}/${nested?.id}/details`]: i18n._(t`Details`), + [`${inventorySourcesPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), }; this.setState({ breadcrumbConfig }); }; diff --git a/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.jsx b/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.jsx index 1b88478b55..96b0b27fff 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.jsx @@ -20,6 +20,7 @@ import ContentError from '../../../components/ContentError'; import ContentLoading from '../../../components/ContentLoading'; import RoutedTabs from '../../../components/RoutedTabs'; import InventorySourceDetail from '../InventorySourceDetail'; +import InventorySourceEdit from '../InventorySourceEdit'; function InventorySource({ i18n, inventory, setBreadcrumb }) { const location = useLocation(); @@ -38,7 +39,7 @@ function InventorySource({ i18n, inventory, setBreadcrumb }) { useEffect(() => { fetchSource(); - }, [fetchSource, match.params.sourceId]); + }, [fetchSource, location.pathname]); useEffect(() => { if (inventory && source) { @@ -104,6 +105,12 @@ function InventorySource({ i18n, inventory, setBreadcrumb }) { > + + + diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.test.jsx index 8d26c48560..c19c70e12f 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.test.jsx @@ -1,7 +1,10 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; import InventorySourceAdd from './InventorySourceAdd'; import { InventorySourcesAPI, ProjectsAPI } from '../../../api'; @@ -75,6 +78,7 @@ describe('', () => { context: { config }, }); }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); expect(wrapper.find('FormGroup[label="Name"]')).toHaveLength(1); expect(wrapper.find('FormGroup[label="Description"]')).toHaveLength(1); expect(wrapper.find('FormGroup[label="Source"]')).toHaveLength(1); diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.jsx index 8c790a8b71..e95cc448f1 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.jsx @@ -228,7 +228,7 @@ function InventorySourceDetail({ inventorySource, i18n }) { diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx index 1eeab58dca..ca13caaf5e 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx @@ -88,7 +88,7 @@ describe('InventorySourceDetail', () => { const editButton = wrapper.find('Button[aria-label="edit"]'); expect(editButton.text()).toEqual('Edit'); expect(editButton.prop('to')).toBe( - '/inventories/inventory/2/source/123/edit' + '/inventories/inventory/2/sources/123/edit' ); expect(wrapper.find('DeleteButton')).toHaveLength(1); }); diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.jsx new file mode 100644 index 0000000000..5dce012503 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.jsx @@ -0,0 +1,68 @@ +import React, { useCallback, useEffect } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; +import { Card } from '@patternfly/react-core'; +import { CardBody } from '../../../components/Card'; +import useRequest from '../../../util/useRequest'; +import { InventorySourcesAPI } from '../../../api'; +import InventorySourceForm from '../shared/InventorySourceForm'; + +function InventorySourceEdit({ source }) { + const history = useHistory(); + const { id } = useParams(); + const detailsUrl = `/inventories/inventory/${id}/sources/${source.id}/details`; + + const { error, request, result } = useRequest( + useCallback( + async values => { + const { data } = await InventorySourcesAPI.replace(source.id, values); + return data; + }, + [source.id] + ), + null + ); + + useEffect(() => { + if (result) { + history.push(detailsUrl); + } + }, [result, detailsUrl, history]); + + const handleSubmit = async 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, + inventory: id, + ...sourcePath, + ...sourceProject, + ...remainingForm, + }); + }; + + const handleCancel = () => { + history.push(detailsUrl); + }; + + return ( + + + + + + ); +} + +export default InventorySourceEdit; diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.test.jsx new file mode 100644 index 0000000000..c37b72c9f6 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.test.jsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import InventorySourceEdit from './InventorySourceEdit'; +import { CredentialsAPI, InventorySourcesAPI, ProjectsAPI } from '../../../api'; + +jest.mock('../../../api/models/Projects'); +jest.mock('../../../api/models/Credentials'); +jest.mock('../../../api/models/InventorySources'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + }), +})); + +describe('', () => { + let wrapper; + let history; + const mockInvSrc = { + id: 23, + description: 'bar', + inventory: 1, + 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'], + ], + }, + }, + }, + }, + }); + InventorySourcesAPI.replace.mockResolvedValue({ + data: { + ...mockInvSrc, + }, + }); + ProjectsAPI.readInventories.mockResolvedValue({ + data: [], + }); + CredentialsAPI.read.mockResolvedValue({ + data: { count: 0, results: [] }, + }); + ProjectsAPI.read.mockResolvedValue({ + data: { + count: 2, + results: [ + { + id: 1, + name: 'mock proj one', + }, + { + id: 2, + name: 'mock proj two', + }, + ], + }, + }); + + beforeAll(async () => { + history = createMemoryHistory(); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('handleSubmit should call api update', async () => { + expect(InventorySourcesAPI.replace).toHaveBeenCalledTimes(0); + await act(async () => { + wrapper.find('InventorySourceForm').invoke('onSubmit')(mockInvSrc); + }); + expect(InventorySourcesAPI.replace).toHaveBeenCalledTimes(1); + }); + + test('should navigate to inventory source detail after successful submission', () => { + expect(wrapper.find('FormSubmitError').length).toBe(0); + expect(history.location.pathname).toEqual( + '/inventories/inventory/1/sources/23/details' + ); + }); + + test('should navigate to inventory sources list when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual( + '/inventories/inventory/1/sources/23/details' + ); + }); + + test('unsuccessful form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + InventorySourcesAPI.replace.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/InventorySourceEdit/index.js b/awx/ui_next/src/screens/Inventory/InventorySourceEdit/index.js new file mode 100644 index 0000000000..b63d354049 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySourceEdit/index.js @@ -0,0 +1 @@ +export { default } from './InventorySourceEdit'; diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx index 53f51a8f05..a70cd2ee14 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx @@ -133,23 +133,24 @@ const InventorySourceForm = ({ i18n, onCancel, onSubmit, + source, 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, + credential: source?.summary_fields?.credential || null, + custom_virtualenv: source?.custom_virtualenv || '', + description: source?.description || '', + name: source?.name || '', + overwrite: source?.overwrite || false, + overwrite_vars: source?.overwrite_vars || false, + source: source?.source || '', + source_path: source?.source_path === '' ? '/ (project root)' : '', + source_project: source?.summary_fields?.source_project || null, + source_vars: source?.source_vars || '---\n', + update_cache_timeout: source?.update_cache_timeout || 0, + update_on_launch: source?.update_on_launch || false, + update_on_project_update: source?.update_on_project_update || false, + verbosity: source?.verbosity || 1, }; const { @@ -172,21 +173,21 @@ const InventorySourceForm = ({ }; }); }, []), - [] + null ); useEffect(() => { fetchSourceOptions(); }, [fetchSourceOptions]); - if (isSourceOptionsLoading) { - return ; - } - if (sourceOptionsError) { return ; } + if (!sourceOptions || isSourceOptionsLoading) { + return ; + } + return ( { [] ); + useEffect(() => { + if (projectMeta.initialValue) { + fetchSourcePath(projectMeta.initialValue.id); + } + }, [fetchSourcePath, projectMeta.initialValue]); + const handleProjectUpdate = useCallback( value => { sourcePathHelpers.setValue(''); @@ -45,6 +51,16 @@ const SCMSubForm = ({ i18n }) => { [] // eslint-disable-line react-hooks/exhaustive-deps ); + const handleProjectAutocomplete = useCallback( + val => { + projectHelpers.setValue(val); + if (!projectMeta.initialValue) { + fetchSourcePath(val.id); + } + }, + [] // eslint-disable-line react-hooks/exhaustive-deps + ); + return ( <> { }} /> ', () => { beforeEach(() => { @@ -251,7 +264,7 @@ describe('', () => { const expected = { ...mockJobTemplate, - project: mockJobTemplate.project.id, + project: mockJobTemplate.project, ...updatedTemplateData, }; delete expected.summary_fields; diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index 27c25094f4..11301e743e 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -46,27 +46,25 @@ const { origin } = document.location; function JobTemplateForm({ template, - validateField, handleCancel, handleSubmit, setFieldValue, submitError, i18n, }) { + const { values: formikValues } = useFormikContext(); + const [contentError, setContentError] = useState(false); - const [project, setProject] = useState(template?.summary_fields?.project); const [inventory, setInventory] = useState( template?.summary_fields?.inventory ); const [allowCallbacks, setAllowCallbacks] = useState( Boolean(template?.host_config_key) ); - const [enableWebhooks, setEnableWebhooks] = useState( Boolean(template.webhook_service) ); - const { values: formikValues } = useFormikContext(); const [jobTypeField, jobTypeMeta, jobTypeHelpers] = useField({ name: 'job_type', validate: required(null, i18n), @@ -74,16 +72,13 @@ function JobTemplateForm({ const [, inventoryMeta, inventoryHelpers] = useField('inventory'); const [projectField, projectMeta, projectHelpers] = useField({ name: 'project', - validate: () => handleProjectValidation(), + validate: project => handleProjectValidation(project), }); - const [scmField, , scmHelpers] = useField('scm_branch'); - const [playbookField, playbookMeta, playbookHelpers] = useField({ name: 'playbook', validate: required(i18n._(t`Select a value for this field`), i18n), }); - const [credentialField, , credentialHelpers] = useField('credentials'); const [labelsField, , labelsHelpers] = useField('labels'); const [limitField, limitMeta] = useField('limit'); @@ -101,13 +96,10 @@ function JobTemplateForm({ contentLoading: hasProjectLoading, } = useRequest( useCallback(async () => { - let projectData; if (template?.project) { - projectData = await ProjectsAPI.readDetail(template?.project); - validateField('project'); - setProject(projectData.data); + await ProjectsAPI.readDetail(template?.project); } - }, [template, validateField]) + }, [template]) ); const { @@ -133,26 +125,28 @@ function JobTemplateForm({ loadRelatedInstanceGroups(); }, [loadRelatedInstanceGroups]); - const handleProjectValidation = () => { + const handleProjectValidation = project => { if (!project && projectMeta.touched) { return i18n._(t`Select a value for this field`); } - if (project && project.status === 'never updated') { + if (project?.value?.status === 'never updated') { return i18n._(t`This project needs to be updated`); } return undefined; }; const handleProjectUpdate = useCallback( - newProject => { - if (project?.id !== newProject?.id) { - // Clear the selected playbook value when a different project is selected or - // when the project is deselected. - playbookHelpers.setValue(0); - } - setProject(newProject); - projectHelpers.setValue(newProject); + value => { + playbookHelpers.setValue(0); scmHelpers.setValue(''); + projectHelpers.setValue(value); + }, + [] // eslint-disable-line react-hooks/exhaustive-deps + ); + + const handleProjectAutocomplete = useCallback( + val => { + projectHelpers.setValue(val); }, [] // eslint-disable-line react-hooks/exhaustive-deps ); @@ -189,8 +183,12 @@ function JobTemplateForm({ return ; } - if (instanceGroupError || projectContentError) { - return ; + if (contentError || instanceGroupError || projectContentError) { + return ( + + ); } return ( @@ -261,18 +259,18 @@ function JobTemplateForm({ )} - projectHelpers.setTouched()} tooltip={i18n._(t`Select the project containing the playbook you want this job to execute.`)} isValid={!projectMeta.touched || !projectMeta.error} helperTextInvalid={projectMeta.error} onChange={handleProjectUpdate} + autocomplete={handleProjectAutocomplete} required /> - {project?.allow_override && ( + {projectField.value?.allow_override && ( playbookHelpers.setTouched()} @@ -615,6 +613,8 @@ const FormikApp = withFormik({ } = template; return { + allow_callbacks: template.allow_callbacks || false, + allow_simultaneous: template.allow_simultaneous || false, ask_credential_on_launch: template.ask_credential_on_launch || false, ask_diff_mode_on_launch: template.ask_diff_mode_on_launch || false, ask_inventory_on_launch: template.ask_inventory_on_launch || false, @@ -625,31 +625,29 @@ const FormikApp = withFormik({ ask_tags_on_launch: template.ask_tags_on_launch || false, ask_variables_on_launch: template.ask_variables_on_launch || false, ask_verbosity_on_launch: template.ask_verbosity_on_launch || false, - name: template.name || '', - description: template.description || '', - job_type: template.job_type || 'run', - inventory: template.inventory || null, - project: template.project || null, - scm_branch: template.scm_branch || '', - playbook: template.playbook || '', - labels: summary_fields.labels.results || [], - forks: template.forks || 0, - limit: template.limit || '', - verbosity: template.verbosity || '0', - job_slice_count: template.job_slice_count || 1, - timeout: template.timeout || 0, - diff_mode: template.diff_mode || false, - job_tags: template.job_tags || '', - skip_tags: template.skip_tags || '', become_enabled: template.become_enabled || false, - allow_callbacks: template.allow_callbacks || false, - allow_simultaneous: template.allow_simultaneous || false, - use_fact_cache: template.use_fact_cache || false, + credentials: summary_fields.credentials || [], + description: template.description || '', + diff_mode: template.diff_mode || false, + extra_vars: template.extra_vars || '---\n', + forks: template.forks || 0, host_config_key: template.host_config_key || '', initialInstanceGroups: [], instanceGroups: [], - credentials: summary_fields.credentials || [], - extra_vars: template.extra_vars || '---\n', + inventory: template.inventory || null, + job_slice_count: template.job_slice_count || 1, + job_tags: template.job_tags || '', + job_type: template.job_type || 'run', + labels: summary_fields.labels.results || [], + limit: template.limit || '', + name: template.name || '', + playbook: template.playbook || '', + project: summary_fields?.project || null, + scm_branch: template.scm_branch || '', + skip_tags: template.skip_tags || '', + timeout: template.timeout || 0, + use_fact_cache: template.use_fact_cache || false, + verbosity: template.verbosity || '0', webhook_service: template.webhook_service || '', webhook_url: template?.related?.webhook_receiver ? `${origin}${template.related.webhook_receiver}` diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx index b4664cb2bf..aef6f61c18 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx @@ -13,6 +13,7 @@ import { JobTemplatesAPI, ProjectsAPI, CredentialsAPI, + CredentialTypesAPI, } from '../../../api'; jest.mock('../../../api'); @@ -99,6 +100,7 @@ describe('', () => { LabelsAPI.read.mockReturnValue({ data: mockData.summary_fields.labels, }); + CredentialTypesAPI.loadAllTypes.mockResolvedValue([]); CredentialsAPI.read.mockReturnValue({ data: { results: mockCredentials }, });