diff options
author | softwarefactory-project-zuul[bot] <33884098+softwarefactory-project-zuul[bot]@users.noreply.github.com> | 2020-05-08 16:12:03 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-05-08 16:12:03 +0200 |
commit | 8c57a92a654c713a8c6b79493c6c497aaa749668 (patch) | |
tree | a4e5f68d0b3d2f660042a5c4efaceace29f484d1 | |
parent | Merge pull request #6926 from nixocio/ui_issue_6887 (diff) | |
parent | Add cache timeout and inventory file validation (diff) | |
download | awx-8c57a92a654c713a8c6b79493c6c497aaa749668.tar.xz awx-8c57a92a654c713a8c6b79493c6c497aaa749668.zip |
Merge pull request #6898 from marshmalien/6575-inv-src-add
Add inventory source create form
Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
18 files changed, 1040 insertions, 28 deletions
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/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/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..df12500a91 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.jsx @@ -0,0 +1,65 @@ +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_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(`/inventories/inventory/${id}/sources`); + }; + + return ( + <Card> + <CardBody> + <InventorySourceForm + onCancel={handleCancel} + onSubmit={handleSubmit} + submitError={error} + /> + </CardBody> + </Card> + ); +} + +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('<InventorySourceAdd />', () => { + 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(<InventorySourceAdd />, { + 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(<InventorySourceAdd />, { + 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(<InventorySourceAdd />); + }); + 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(<InventorySourceAdd />, { + 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(<InventorySourceAdd />); + }); + 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 ( <> <PaginatedDataList @@ -109,7 +109,7 @@ function InventorySourceList({ i18n }) { qsConfig={QS_CONFIG} additionalControls={[ ...(canAdd - ? [<ToolbarAddButton key="add" linkTo={`${detailUrl}add`} />] + ? [<ToolbarAddButton key="add" linkTo={`${listUrl}add`} />] : []), <ToolbarDeleteButton key="delete" @@ -133,7 +133,7 @@ function InventorySourceList({ i18n }) { source={inventorySource} onSelect={() => 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('<InventorySourceList />', () => { 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('<InventorySourceList />', () => { 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 ( <Switch> + <Route key="add" path="/inventories/inventory/:id/sources/add"> + <InventorySourceAdd /> + </Route> <Route path="/inventories/:inventoryType/:id/sources"> <InventorySourceList /> </Route> 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('<InventorySources />', () => { + test('initially renders without crashing', () => { + const wrapper = shallow(<InventorySources />); + 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('<InventoryForm />', () => { let wrapper; let onCancel; let onSubmit; - beforeEach(() => { + + beforeAll(async () => { onCancel = jest.fn(); onSubmit = jest.fn(); - wrapper = mountWithContexts( - <InventoryForm - onCancel={onCancel} - onSubmit={onSubmit} - inventory={inventory} - instanceGroups={instanceGroups} - credentialTypeId={14} - /> - ); + await act(async () => { + wrapper = mountWithContexts( + <InventoryForm + onCancel={onCancel} + onSubmit={onSubmit} + inventory={inventory} + instanceGroups={instanceGroups} + credentialTypeId={14} + /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); }); - afterEach(() => { + afterAll(() => { wrapper.unmount(); + jest.clearAllMocks(); }); test('Initially renders successfully', () => { @@ -83,7 +90,7 @@ describe('<InventoryForm />', () => { 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..3e76f1b223 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx @@ -0,0 +1,224 @@ +import React, { useEffect, useCallback, useContext } from 'react'; +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'; +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 { values, initialValues, resetForm } = useFormikContext(); + const [sourceField, sourceMeta] = 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', + }; + + const resetSubFormFields = sourceType => { + resetForm({ + values: { + ...initialValues, + name: values.name, + description: values.description, + custom_virtualenv: values.custom_virtualenv, + source: sourceType, + }, + }); + }; + + return ( + <> + <FormField + id="name" + label={i18n._(t`Name`)} + name="name" + type="text" + validate={required(null, i18n)} + isRequired + /> + <FormField + id="description" + label={i18n._(t`Description`)} + name="description" + type="text" + /> + <FormGroup + fieldId="source" + helperTextInvalid={sourceMeta.error} + isRequired + isValid={!sourceMeta.touched || !sourceMeta.error} + label={i18n._(t`Source`)} + > + <AnsibleSelect + {...sourceField} + id="source" + data={[ + { + value: '', + key: '', + label: i18n._(t`Choose a source`), + isDisabled: true, + }, + ...sourceOptions, + ]} + onChange={(event, value) => { + resetSubFormFields(value); + }} + /> + </FormGroup> + {custom_virtualenvs && custom_virtualenvs.length > 1 && ( + <FormGroup + fieldId="custom-virtualenv" + label={i18n._(t`Ansible Environment`)} + > + <FieldTooltip + content={i18n._(t`Select the custom + Python virtual environment for this + inventory source sync to run on.`)} + /> + <AnsibleSelect + id="custom-virtualenv" + data={[ + defaultVenv, + ...custom_virtualenvs + .filter(value => value !== defaultVenv.value) + .map(value => ({ value, label: value, key: value })), + ]} + {...venvField} + /> + </FormGroup> + )} + + {sourceField.value !== '' && ( + <SubFormLayout> + <Title size="md">{i18n._(t`Source details`)}</Title> + <FormColumnLayout> + { + { + scm: <SCMSubForm />, + }[sourceField.value] + } + </FormColumnLayout> + </SubFormLayout> + )} + </> + ); +}; + +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 <ContentLoading />; + } + + if (sourceOptionsError) { + return <ContentError error={sourceOptionsError} />; + } + + return ( + <Formik + initialValues={initialValues} + onSubmit={values => { + onSubmit(values); + }} + > + {formik => ( + <Form autoComplete="off" onSubmit={formik.handleSubmit}> + <FormColumnLayout> + <InventorySourceFormFields + formik={formik} + i18n={i18n} + sourceOptions={sourceOptions} + /> + {submitError && <FormSubmitError error={submitError} />} + <FormActionGroup + onCancel={onCancel} + onSubmit={formik.handleSubmit} + /> + </FormColumnLayout> + </Form> + )} + </Formik> + ); +}; + +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 new file mode 100644 index 0000000000..d79cf457c9 --- /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('<InventorySourceForm />', () => { + let wrapper; + CredentialsAPI.read.mockResolvedValue({ + data: { count: 0, results: [] }, + }); + ProjectsAPI.readInventories.mockResolvedValue({ + data: ['foo', 'bar'], + }); + 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( + <InventorySourceForm onCancel={() => {}} 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( + <InventorySourceForm onCancel={() => {}} 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( + <InventorySourceForm onCancel={onCancel} onSubmit={() => {}} /> + ); + }); + 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..dbc36f19ca --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx @@ -0,0 +1,109 @@ +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); + return [...data, '/ (project root)']; + }, []), + [] + ); + + const handleProjectUpdate = useCallback( + value => { + sourcePathHelpers.setValue(''); + projectHelpers.setValue(value); + fetchSourcePath(value.id); + }, + [] // eslint-disable-line react-hooks/exhaustive-deps + ); + + return ( + <> + <CredentialLookup + credentialTypeKind="cloud" + label={i18n._(t`Credential`)} + value={credentialField.value} + onChange={value => { + credentialHelpers.setValue(value); + }} + /> + <ProjectLookup + value={projectField.value} + isValid={!projectMeta.touched || !projectMeta.error} + helperTextInvalid={projectMeta.error} + onBlur={() => projectHelpers.setTouched()} + onChange={handleProjectUpdate} + required + /> + <FormGroup + fieldId="source_path" + helperTextInvalid={sourcePathError?.message || sourcePathMeta.error} + isValid={ + (!sourcePathMeta.error || !sourcePathMeta.touched) && + !sourcePathError?.message + } + isRequired + label={i18n._(t`Inventory file`)} + > + <FieldTooltip + content={i18n._(t`Select the inventory file + to be synced by this source. You can select from + the dropdown or enter a file within the input.`)} + /> + <AnsibleSelect + {...sourcePathField} + id="source_path" + isValid={ + (!sourcePathMeta.error || !sourcePathMeta.touched) && + !sourcePathError?.message + } + data={[ + { + value: '', + key: '', + label: i18n._(t`Choose an inventory file`), + isDisabled: true, + }, + ...sourcePath.map(value => ({ value, label: value, key: value })), + ]} + onChange={(event, value) => { + sourcePathHelpers.setValue(value); + }} + /> + </FormGroup> + <VerbosityField /> + <OptionsField /> + <SourceVarsField /> + </> + ); +}; + +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..78ec81ad55 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.test.jsx @@ -0,0 +1,110 @@ +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, CredentialsAPI } from '@api'; + +jest.mock('@api/models/Projects'); +jest.mock('@api/models/Credentials'); + +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('<SCMSubForm />', () => { + let wrapper; + CredentialsAPI.read.mockResolvedValue({ + data: { count: 0, results: [] }, + }); + ProjectsAPI.readInventories.mockResolvedValue({ + data: ['foo', 'bar'], + }); + ProjectsAPI.read.mockResolvedValue({ + data: { + count: 2, + results: [ + { + id: 1, + name: 'mock proj one', + }, + { + id: 2, + name: 'mock proj two', + }, + ], + }, + }); + + beforeAll(async () => { + await act(async () => { + wrapper = mountWithContexts( + <Formik initialValues={initialValues}> + <SCMSubForm /> + </Formik> + ); + }); + }); + + 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 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 new file mode 100644 index 0000000000..92d0933073 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx @@ -0,0 +1,147 @@ +import React, { useEffect } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useField } from 'formik'; +import { minMaxValue } from '@util/validators'; +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 }) => ( + <FormFullWidthLayout> + <VariablesField + id="source_vars" + name="source_vars" + label={i18n._(t`Environment variables`)} + /> + </FormFullWidthLayout> +)); + +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 ( + <FormGroup + fieldId="verbosity" + isValid={isValid} + label={i18n._(t`Verbosity`)} + > + <FieldTooltip + content={i18n._(t`Control the level of output Ansible + will produce for inventory source update jobs.`)} + /> + <AnsibleSelect + id="verbosity" + data={options} + {...field} + onChange={(event, value) => helpers.setValue(value)} + /> + </FormGroup> + ); +}); + +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 ( + <> + <FormFullWidthLayout> + <FormGroup + fieldId="option-checkboxes" + label={i18n._(t`Update options`)} + > + <FormCheckboxLayout> + <CheckboxField + 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.`)} + <br /> + <br /> + {i18n._(t`When not checked, local child + hosts and groups not found on the external source will remain + untouched by the inventory update process.`)} + </> + } + /> + <CheckboxField + id="overwrite_vars" + name="overwrite_vars" + label={i18n._(t`Overwrite variables`)} + tooltip={ + <> + {i18n._(t`If checked, all variables for child groups + and hosts will be removed and replaced by those found + on the external source.`)} + <br /> + <br /> + {i18n._(t`When not checked, a merge will be performed, + combining local variables with those found on the + external source.`)} + </> + } + /> + <CheckboxField + id="update_on_launch" + name="update_on_launch" + label={i18n._(t`Update on launch`)} + tooltip={i18n._(t`Each time a job runs using this inventory, + refresh the inventory from the selected source before + executing job tasks.`)} + /> + <CheckboxField + id="update_on_project_update" + name="update_on_project_update" + label={i18n._(t`Update on project update`)} + tooltip={i18n._(t`After every project update where the SCM revision + changes, refresh the inventory from the selected source + before executing job tasks. This is intended for static content, + like the Ansible inventory .ini file format.`)} + /> + </FormCheckboxLayout> + </FormGroup> + </FormFullWidthLayout> + {updateOnLaunchField.value && ( + <FormField + id="cache-timeout" + 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 + evaluate the timestamp of the latest sync. If it is older than + Cache Timeout, it is not considered current, and a new + inventory sync will be performed.`)} + /> + )} + </> + ); +}); 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'; |