From 6ed611c27c9fd291545612bafc465f4066f4578e Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Mon, 8 Jun 2020 13:44:46 -0400 Subject: [PATCH] Add inventory source subforms --- awx/api/serializers.py | 2 +- awx/ui_next/src/api/index.js | 3 + .../src/api/models/InventoryScripts.js | 10 + .../components/Lookup/CredentialLookup.jsx | 17 +- .../Lookup/InventoryScriptLookup.jsx | 137 ++++++ .../components/MultiSelect/TagMultiSelect.jsx | 9 +- .../InventorySourceAdd/InventorySourceAdd.jsx | 9 +- .../InventorySourceAdd.test.jsx | 1 + .../InventorySourceEdit.jsx | 10 +- .../Inventory/shared/InventorySourceForm.jsx | 96 ++++- .../InventorySourceSubForms/AzureSubForm.jsx | 44 ++ .../AzureSubForm.test.jsx | 81 ++++ .../CloudFormsSubForm.jsx | 34 ++ .../CloudFormsSubForm.test.jsx | 70 +++ .../CustomScriptSubForm.jsx | 43 ++ .../CustomScriptSubForm.test.jsx | 100 +++++ .../InventorySourceSubForms/EC2SubForm.jsx | 48 +++ .../EC2SubForm.test.jsx | 86 ++++ .../InventorySourceSubForms/GCESubForm.jsx | 38 ++ .../GCESubForm.test.jsx | 78 ++++ .../OpenStackSubForm.jsx | 34 ++ .../OpenStackSubForm.test.jsx | 70 +++ .../InventorySourceSubForms/SCMSubForm.jsx | 2 +- .../SCMSubForm.test.jsx | 11 +- .../SatelliteSubForm.jsx | 34 ++ .../SatelliteSubForm.test.jsx | 70 +++ .../InventorySourceSubForms/SharedFields.jsx | 397 ++++++++++++++---- .../InventorySourceSubForms/TowerSubForm.jsx | 38 ++ .../TowerSubForm.test.jsx | 68 +++ .../InventorySourceSubForms/VMwareSubForm.jsx | 42 ++ .../VMwareSubForm.test.jsx | 82 ++++ .../VirtualizationSubForm.jsx | 33 ++ .../VirtualizationSubForm.test.jsx | 67 +++ .../shared/InventorySourceSubForms/index.js | 12 +- awx/ui_next/src/types.js | 6 + awx/ui_next/src/util/strings.js | 4 + 36 files changed, 1769 insertions(+), 117 deletions(-) create mode 100644 awx/ui_next/src/api/models/InventoryScripts.js create mode 100644 awx/ui_next/src/components/Lookup/InventoryScriptLookup.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/AzureSubForm.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/AzureSubForm.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CloudFormsSubForm.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CloudFormsSubForm.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CustomScriptSubForm.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CustomScriptSubForm.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/EC2SubForm.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/EC2SubForm.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/GCESubForm.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/GCESubForm.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/OpenStackSubForm.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/OpenStackSubForm.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SatelliteSubForm.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SatelliteSubForm.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/TowerSubForm.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/TowerSubForm.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VMwareSubForm.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VMwareSubForm.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VirtualizationSubForm.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VirtualizationSubForm.test.jsx diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 15fb77f848..23d71203bd 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -126,7 +126,7 @@ SUMMARIZABLE_FK_FIELDS = { 'current_job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'), 'inventory_source': ('source', 'last_updated', 'status'), 'custom_inventory_script': DEFAULT_SUMMARY_FIELDS, - 'source_script': ('name', 'description'), + 'source_script': DEFAULT_SUMMARY_FIELDS, 'role': ('id', 'role_field'), 'notification_template': DEFAULT_SUMMARY_FIELDS, 'instance_group': ('id', 'name', 'controller_id', 'is_containerized'), diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index f7567750d6..2de6a235e0 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -8,6 +8,7 @@ import Groups from './models/Groups'; import Hosts from './models/Hosts'; import InstanceGroups from './models/InstanceGroups'; import Inventories from './models/Inventories'; +import InventoryScripts from './models/InventoryScripts'; import InventorySources from './models/InventorySources'; import InventoryUpdates from './models/InventoryUpdates'; import JobTemplates from './models/JobTemplates'; @@ -41,6 +42,7 @@ const GroupsAPI = new Groups(); const HostsAPI = new Hosts(); const InstanceGroupsAPI = new InstanceGroups(); const InventoriesAPI = new Inventories(); +const InventoryScriptsAPI = new InventoryScripts(); const InventorySourcesAPI = new InventorySources(); const InventoryUpdatesAPI = new InventoryUpdates(); const JobTemplatesAPI = new JobTemplates(); @@ -75,6 +77,7 @@ export { HostsAPI, InstanceGroupsAPI, InventoriesAPI, + InventoryScriptsAPI, InventorySourcesAPI, InventoryUpdatesAPI, JobTemplatesAPI, diff --git a/awx/ui_next/src/api/models/InventoryScripts.js b/awx/ui_next/src/api/models/InventoryScripts.js new file mode 100644 index 0000000000..17214cd5fd --- /dev/null +++ b/awx/ui_next/src/api/models/InventoryScripts.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class InventoryScripts extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/inventory_scripts/'; + } +} + +export default InventoryScripts; diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx index 32a418a3e7..07c85e453f 100644 --- a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx @@ -28,6 +28,7 @@ function CredentialLookup({ required, credentialTypeId, credentialTypeKind, + credentialTypeNamespace, value, history, i18n, @@ -46,15 +47,27 @@ function CredentialLookup({ const typeKindParams = credentialTypeKind ? { credential_type__kind: credentialTypeKind } : {}; + const typeNamespaceParams = credentialTypeNamespace + ? { credential_type__namespace: credentialTypeNamespace } + : {}; const { data } = await CredentialsAPI.read( - mergeParams(params, { ...typeIdParams, ...typeKindParams }) + mergeParams(params, { + ...typeIdParams, + ...typeKindParams, + ...typeNamespaceParams, + }) ); return { count: data.count, credentials: data.results, }; - }, [credentialTypeId, credentialTypeKind, history.location.search]), + }, [ + credentialTypeId, + credentialTypeKind, + credentialTypeNamespace, + history.location.search, + ]), { count: 0, credentials: [], diff --git a/awx/ui_next/src/components/Lookup/InventoryScriptLookup.jsx b/awx/ui_next/src/components/Lookup/InventoryScriptLookup.jsx new file mode 100644 index 0000000000..52e43ec633 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/InventoryScriptLookup.jsx @@ -0,0 +1,137 @@ +import React, { useCallback, useEffect } from 'react'; +import { withRouter } from 'react-router-dom'; +import { func, bool, number, node, string, oneOfType } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; + +import { FormGroup } from '@patternfly/react-core'; +import Lookup from './Lookup'; +import LookupErrorMessage from './shared/LookupErrorMessage'; +import OptionsList from '../OptionsList'; +import { InventoriesAPI, InventoryScriptsAPI } from '../../api'; +import { InventoryScript } from '../../types'; +import useRequest from '../../util/useRequest'; +import { getQSConfig, parseQueryString, mergeParams } from '../../util/qs'; + +const QS_CONFIG = getQSConfig('inventory_scripts', { + order_by: 'name', + page: 1, + page_size: 5, + role_level: 'admin_role', +}); + +function InventoryScriptLookup({ + helperTextInvalid, + history, + i18n, + inventoryId, + isValid, + onBlur, + onChange, + required, + value, +}) { + const { + result: { count, inventoryScripts }, + error, + request: fetchInventoryScripts, + } = useRequest( + useCallback(async () => { + const parsedParams = parseQueryString(QS_CONFIG, history.location.search); + const { + data: { organization }, + } = await InventoriesAPI.readDetail(inventoryId); + const { data } = await InventoryScriptsAPI.read( + mergeParams(parsedParams, { organization }) + ); + return { + count: data.count, + inventoryScripts: data.results, + }; + }, [history.location.search, inventoryId]), + { + count: 0, + inventoryScripts: [], + } + ); + + useEffect(() => { + fetchInventoryScripts(); + }, [fetchInventoryScripts]); + + return ( + + ( + dispatch({ type: 'DESELECT_ITEM', item })} + selectItem={item => dispatch({ type: 'SELECT_ITEM', item })} + value={state.selectedItems} + searchColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ]} + sortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + }, + ]} + /> + )} + /> + + + ); +} + +InventoryScriptLookup.propTypes = { + helperTextInvalid: node, + inventoryId: oneOfType([number, string]).isRequired, + isValid: bool, + onBlur: func, + onChange: func.isRequired, + required: bool, + value: InventoryScript, +}; + +InventoryScriptLookup.defaultProps = { + helperTextInvalid: '', + isValid: true, + onBlur: () => {}, + required: false, + value: null, +}; + +export default withI18n()(withRouter(InventoryScriptLookup)); diff --git a/awx/ui_next/src/components/MultiSelect/TagMultiSelect.jsx b/awx/ui_next/src/components/MultiSelect/TagMultiSelect.jsx index 6b57edea96..43a4b11c77 100644 --- a/awx/ui_next/src/components/MultiSelect/TagMultiSelect.jsx +++ b/awx/ui_next/src/components/MultiSelect/TagMultiSelect.jsx @@ -1,14 +1,7 @@ import React, { useState } from 'react'; import { func, string } from 'prop-types'; import { Select, SelectOption, SelectVariant } from '@patternfly/react-core'; - -function arrayToString(tags) { - return tags.join(','); -} - -function stringToArray(value) { - return value.split(',').filter(val => !!val); -} +import { arrayToString, stringToArray } from '../../util/strings'; function TagMultiSelect({ onChange, value }) { const selections = stringToArray(value); diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.jsx index f1a90bcca6..0db70f2dbf 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.jsx @@ -26,7 +26,13 @@ function InventorySourceAdd() { }, [result, history]); const handleSubmit = async form => { - const { credential, source_path, source_project, ...remainingForm } = form; + const { + credential, + source_path, + source_project, + source_script, + ...remainingForm + } = form; const sourcePath = {}; const sourceProject = {}; @@ -39,6 +45,7 @@ function InventorySourceAdd() { await request({ credential: credential?.id || null, inventory: id, + source_script: source_script?.id || null, ...sourcePath, ...sourceProject, ...remainingForm, 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 c19c70e12f..85958be52f 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySourceAdd/InventorySourceAdd.test.jsx @@ -115,6 +115,7 @@ describe('', () => { ...invSourceData, credential: 222, source_project: 999, + source_script: null, }); }); diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.jsx index 5dce012503..12790ec1a2 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.jsx @@ -29,7 +29,13 @@ function InventorySourceEdit({ source }) { }, [result, detailsUrl, history]); const handleSubmit = async form => { - const { credential, source_path, source_project, ...remainingForm } = form; + const { + credential, + source_path, + source_project, + source_script, + ...remainingForm + } = form; const sourcePath = {}; const sourceProject = {}; @@ -38,9 +44,11 @@ function InventorySourceEdit({ source }) { source_path === '/ (project root)' ? '' : source_path; sourceProject.source_project = source_project.id; } + await request({ credential: credential?.id || null, inventory: id, + source_script: source_script?.id || null, ...sourcePath, ...sourceProject, ...remainingForm, diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx index a70cd2ee14..ffcab9f148 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx @@ -22,10 +22,34 @@ import { SubFormLayout, } from '../../../components/FormLayout'; -import SCMSubForm from './InventorySourceSubForms'; +import { + AzureSubForm, + CloudFormsSubForm, + CustomScriptSubForm, + EC2SubForm, + GCESubForm, + OpenStackSubForm, + SCMSubForm, + SatelliteSubForm, + TowerSubForm, + VMwareSubForm, + VirtualizationSubForm, +} from './InventorySourceSubForms'; + +const buildSourceChoiceOptions = options => { + const sourceChoices = options.actions.GET.source.choices.map( + ([choice, label]) => ({ label, key: choice, value: choice }) + ); + return sourceChoices.filter(({ key }) => key !== 'file'); +}; const InventorySourceFormFields = ({ sourceOptions, i18n }) => { - const { values, initialValues, resetForm } = useFormikContext(); + const { + values, + initialValues, + resetForm, + setFieldValue, + } = useFormikContext(); const [sourceField, sourceMeta] = useField({ name: 'source', validate: required(i18n._(t`Set a value for this field`), i18n), @@ -39,15 +63,38 @@ const InventorySourceFormFields = ({ sourceOptions, i18n }) => { }; const resetSubFormFields = sourceType => { - resetForm({ - values: { - ...initialValues, - name: values.name, - description: values.description, - custom_virtualenv: values.custom_virtualenv, + if (sourceType === initialValues.source) { + resetForm({ + values: { + ...initialValues, + name: values.name, + description: values.description, + custom_virtualenv: values.custom_virtualenv, + source: sourceType, + }, + }); + } else { + const defaults = { + credential: null, + group_by: '', + instance_filters: '', + overwrite: false, + overwrite_vars: false, source: sourceType, - }, - }); + source_path: '', + source_project: null, + source_regions: '', + source_script: null, + source_vars: '---\n', + update_cache_timeout: 0, + update_on_launch: false, + update_on_project_update: false, + verbosity: 1, + }; + Object.keys(defaults).forEach(label => { + setFieldValue(label, defaults[label]); + }); + } }; return ( @@ -83,7 +130,7 @@ const InventorySourceFormFields = ({ sourceOptions, i18n }) => { label: i18n._(t`Choose a source`), isDisabled: true, }, - ...sourceOptions, + ...buildSourceChoiceOptions(sourceOptions), ]} onChange={(event, value) => { resetSubFormFields(value); @@ -112,14 +159,23 @@ const InventorySourceFormFields = ({ sourceOptions, i18n }) => { /> )} - {sourceField.value !== '' && ( {i18n._(t`Source details`)} { { + azure_rm: , + cloudforms: , + custom: , + ec2: , + gce: , + openstack: , + rhv: , + satellite6: , scm: , + tower: , + vmware: , }[sourceField.value] } @@ -140,12 +196,16 @@ const InventorySourceForm = ({ credential: source?.summary_fields?.credential || null, custom_virtualenv: source?.custom_virtualenv || '', description: source?.description || '', + group_by: source?.group_by || '', + instance_filters: source?.instance_filters || '', 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_regions: source?.source_regions || '', + source_script: source?.summary_fields?.source_script || null, source_vars: source?.source_vars || '---\n', update_cache_timeout: source?.update_cache_timeout || 0, update_on_launch: source?.update_on_launch || false, @@ -161,17 +221,7 @@ const InventorySourceForm = ({ } = 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], - }; - }); + return data; }, []), null ); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/AzureSubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/AzureSubForm.jsx new file mode 100644 index 0000000000..7bc6b49975 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/AzureSubForm.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { useField } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; +import { + OptionsField, + RegionsField, + SourceVarsField, + VerbosityField, +} from './SharedFields'; + +const AzureSubForm = ({ i18n, sourceOptions }) => { + const [credentialField, credentialMeta, credentialHelpers] = useField( + 'credential' + ); + + return ( + <> + credentialHelpers.setTouched()} + onChange={value => { + credentialHelpers.setValue(value); + }} + value={credentialField.value} + required + /> + + + + + + ); +}; + +export default withI18n()(AzureSubForm); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/AzureSubForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/AzureSubForm.test.jsx new file mode 100644 index 0000000000..a7dae124fd --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/AzureSubForm.test.jsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import AzureSubForm from './AzureSubForm'; +import { CredentialsAPI } from '../../../../api'; + +jest.mock('../../../../api/models/Credentials'); + +const initialValues = { + credential: null, + custom_virtualenv: '', + group_by: '', + instance_filters: '', + overwrite: false, + overwrite_vars: false, + source_path: '', + source_project: null, + source_regions: '', + source_script: null, + source_vars: '---\n', + update_cache_timeout: 0, + update_on_launch: true, + update_on_project_update: false, + verbosity: 1, +}; + +const mockSourceOptions = { + actions: { + POST: { + source_regions: { + azure_rm_region_choices: [], + }, + }, + }, +}; + +describe('', () => { + let wrapper; + CredentialsAPI.read.mockResolvedValue({ + data: { count: 0, results: [] }, + }); + + 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="Regions"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1); + expect( + wrapper.find('FormGroup[label="Cache timeout (seconds)"]') + ).toHaveLength(1); + expect( + wrapper.find('VariablesField[label="Source variables"]') + ).toHaveLength(1); + }); + + test('should make expected api calls', () => { + expect(CredentialsAPI.read).toHaveBeenCalledTimes(1); + expect(CredentialsAPI.read).toHaveBeenCalledWith({ + credential_type__namespace: 'azure_rm', + order_by: 'name', + page: 1, + page_size: 5, + }); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CloudFormsSubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CloudFormsSubForm.jsx new file mode 100644 index 0000000000..68aeed4d76 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CloudFormsSubForm.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { useField } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; +import { OptionsField, SourceVarsField, VerbosityField } from './SharedFields'; + +const CloudFormsSubForm = ({ i18n }) => { + const [credentialField, credentialMeta, credentialHelpers] = useField( + 'credential' + ); + + return ( + <> + credentialHelpers.setTouched()} + onChange={value => { + credentialHelpers.setValue(value); + }} + value={credentialField.value} + required + /> + + + + + ); +}; + +export default withI18n()(CloudFormsSubForm); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CloudFormsSubForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CloudFormsSubForm.test.jsx new file mode 100644 index 0000000000..e46ac8d8fa --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CloudFormsSubForm.test.jsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import CloudFormsSubForm from './CloudFormsSubForm'; +import { CredentialsAPI } from '../../../../api'; + +jest.mock('../../../../api/models/Credentials'); + +const initialValues = { + credential: null, + custom_virtualenv: '', + group_by: '', + instance_filters: '', + overwrite: false, + overwrite_vars: false, + source_path: '', + source_project: null, + source_regions: '', + source_script: null, + source_vars: '---\n', + update_cache_timeout: 0, + update_on_launch: true, + update_on_project_update: false, + verbosity: 1, +}; + +describe('', () => { + let wrapper; + CredentialsAPI.read.mockResolvedValue({ + data: { count: 0, results: [] }, + }); + + 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="Verbosity"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1); + expect( + wrapper.find('FormGroup[label="Cache timeout (seconds)"]') + ).toHaveLength(1); + expect( + wrapper.find('VariablesField[label="Source variables"]') + ).toHaveLength(1); + }); + + test('should make expected api calls', () => { + expect(CredentialsAPI.read).toHaveBeenCalledTimes(1); + expect(CredentialsAPI.read).toHaveBeenCalledWith({ + credential_type__namespace: 'cloudforms', + order_by: 'name', + page: 1, + page_size: 5, + }); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CustomScriptSubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CustomScriptSubForm.jsx new file mode 100644 index 0000000000..2ea29e697f --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CustomScriptSubForm.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { useField } from 'formik'; +import { useParams } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; +import InventoryScriptLookup from '../../../../components/Lookup/InventoryScriptLookup'; +import { OptionsField, SourceVarsField, VerbosityField } from './SharedFields'; + +const CustomScriptSubForm = ({ i18n }) => { + const { id } = useParams(); + const [credentialField, , credentialHelpers] = useField('credential'); + const [scriptField, scriptMeta, scriptHelpers] = useField('source_script'); + + return ( + <> + { + credentialHelpers.setValue(value); + }} + /> + scriptHelpers.setTouched()} + onChange={value => { + scriptHelpers.setValue(value); + }} + inventoryId={id} + value={scriptField.value} + required + /> + + + + + ); +}; + +export default withI18n()(CustomScriptSubForm); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CustomScriptSubForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CustomScriptSubForm.test.jsx new file mode 100644 index 0000000000..d71d866c6d --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/CustomScriptSubForm.test.jsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import CustomScriptSubForm from './CustomScriptSubForm'; +import { + CredentialsAPI, + InventoriesAPI, + InventoryScriptsAPI, +} from '../../../../api'; + +jest.mock('../../../../api/models/Credentials'); +jest.mock('../../../../api/models/Inventories'); +jest.mock('../../../../api/models/InventoryScripts'); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 789, + }), +})); + +const initialValues = { + credential: null, + custom_virtualenv: '', + group_by: '', + instance_filters: '', + overwrite: false, + overwrite_vars: false, + source_path: '', + source_project: null, + source_regions: '', + source_script: null, + source_vars: '---\n', + update_cache_timeout: 0, + update_on_launch: true, + update_on_project_update: false, + verbosity: 1, +}; + +describe('', () => { + let wrapper; + CredentialsAPI.read.mockResolvedValue({ + data: { count: 0, results: [] }, + }); + InventoriesAPI.readDetail.mockResolvedValue({ + data: { organization: 123 }, + }); + InventoryScriptsAPI.read.mockResolvedValue({ + data: { count: 0, results: [] }, + }); + + 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="Inventory script"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1); + expect( + wrapper.find('FormGroup[label="Cache timeout (seconds)"]') + ).toHaveLength(1); + expect( + wrapper.find('VariablesField[label="Source variables"]') + ).toHaveLength(1); + }); + + test('should make expected api calls', () => { + expect(CredentialsAPI.read).toHaveBeenCalledTimes(1); + expect(CredentialsAPI.read).toHaveBeenCalledWith({ + credential_type__namespace: 'cloud', + order_by: 'name', + page: 1, + page_size: 5, + }); + expect(InventoriesAPI.readDetail).toHaveBeenCalledTimes(1); + expect(InventoriesAPI.readDetail).toHaveBeenCalledWith(789); + expect(InventoryScriptsAPI.read).toHaveBeenCalledTimes(1); + expect(InventoryScriptsAPI.read).toHaveBeenCalledWith({ + organization: 123, + role_level: 'admin_role', + order_by: 'name', + page: 1, + page_size: 5, + }); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/EC2SubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/EC2SubForm.jsx new file mode 100644 index 0000000000..d74e770895 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/EC2SubForm.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { useField } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; +import { + GroupByField, + InstanceFiltersField, + OptionsField, + RegionsField, + SourceVarsField, + VerbosityField, +} from './SharedFields'; + +const EC2SubForm = ({ i18n, sourceOptions }) => { + const [credentialField, , credentialHelpers] = useField('credential'); + const groupByOptionsObj = Object.assign( + {}, + ...sourceOptions?.actions?.POST?.group_by?.ec2_group_by_choices.map( + ([key, val]) => ({ [key]: val }) + ) + ); + + return ( + <> + { + credentialHelpers.setValue(value); + }} + /> + + + + + + + + ); +}; + +export default withI18n()(EC2SubForm); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/EC2SubForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/EC2SubForm.test.jsx new file mode 100644 index 0000000000..fc15d03ea9 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/EC2SubForm.test.jsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import EC2SubForm from './EC2SubForm'; +import { CredentialsAPI } from '../../../../api'; + +jest.mock('../../../../api/models/Credentials'); + +const initialValues = { + credential: null, + custom_virtualenv: '', + group_by: '', + instance_filters: '', + overwrite: false, + overwrite_vars: false, + source_path: '', + source_project: null, + source_regions: '', + source_script: null, + source_vars: '---\n', + update_cache_timeout: 0, + update_on_launch: true, + update_on_project_update: false, + verbosity: 1, +}; + +const mockSourceOptions = { + actions: { + POST: { + source_regions: { + ec2_region_choices: [], + }, + group_by: { + ec2_group_by_choices: [], + }, + }, + }, +}; + +describe('', () => { + let wrapper; + CredentialsAPI.read.mockResolvedValue({ + data: { count: 0, results: [] }, + }); + + 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="Regions"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Instance filters"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Only group by"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1); + expect( + wrapper.find('FormGroup[label="Cache timeout (seconds)"]') + ).toHaveLength(1); + expect( + wrapper.find('VariablesField[label="Source variables"]') + ).toHaveLength(1); + }); + + test('should make expected api calls', () => { + expect(CredentialsAPI.read).toHaveBeenCalledTimes(1); + expect(CredentialsAPI.read).toHaveBeenCalledWith({ + credential_type__namespace: 'aws', + order_by: 'name', + page: 1, + page_size: 5, + }); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/GCESubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/GCESubForm.jsx new file mode 100644 index 0000000000..0451e80b86 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/GCESubForm.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { useField } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; +import { OptionsField, RegionsField, VerbosityField } from './SharedFields'; + +const GCESubForm = ({ i18n, sourceOptions }) => { + const [credentialField, credentialMeta, credentialHelpers] = useField( + 'credential' + ); + + return ( + <> + credentialHelpers.setTouched()} + onChange={value => { + credentialHelpers.setValue(value); + }} + value={credentialField.value} + required + /> + + + + + ); +}; + +export default withI18n()(GCESubForm); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/GCESubForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/GCESubForm.test.jsx new file mode 100644 index 0000000000..a7845972c1 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/GCESubForm.test.jsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import GCESubForm from './GCESubForm'; +import { CredentialsAPI } from '../../../../api'; + +jest.mock('../../../../api/models/Credentials'); + +const initialValues = { + credential: null, + custom_virtualenv: '', + group_by: '', + instance_filters: '', + overwrite: false, + overwrite_vars: false, + source_path: '', + source_project: null, + source_regions: '', + source_script: null, + source_vars: '---\n', + update_cache_timeout: 0, + update_on_launch: true, + update_on_project_update: false, + verbosity: 1, +}; + +const mockSourceOptions = { + actions: { + POST: { + source_regions: { + gce_region_choices: [], + }, + }, + }, +}; + +describe('', () => { + let wrapper; + CredentialsAPI.read.mockResolvedValue({ + data: { count: 0, results: [] }, + }); + + 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="Regions"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1); + expect( + wrapper.find('FormGroup[label="Cache timeout (seconds)"]') + ).toHaveLength(1); + }); + + test('should make expected api calls', () => { + expect(CredentialsAPI.read).toHaveBeenCalledTimes(1); + expect(CredentialsAPI.read).toHaveBeenCalledWith({ + credential_type__namespace: 'gce', + order_by: 'name', + page: 1, + page_size: 5, + }); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/OpenStackSubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/OpenStackSubForm.jsx new file mode 100644 index 0000000000..7c61fb5d16 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/OpenStackSubForm.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { useField } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; +import { OptionsField, SourceVarsField, VerbosityField } from './SharedFields'; + +const OpenStackSubForm = ({ i18n }) => { + const [credentialField, credentialMeta, credentialHelpers] = useField( + 'credential' + ); + + return ( + <> + credentialHelpers.setTouched()} + onChange={value => { + credentialHelpers.setValue(value); + }} + value={credentialField.value} + required + /> + + + + + ); +}; + +export default withI18n()(OpenStackSubForm); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/OpenStackSubForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/OpenStackSubForm.test.jsx new file mode 100644 index 0000000000..b4be2c1aff --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/OpenStackSubForm.test.jsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import OpenStackSubForm from './OpenStackSubForm'; +import { CredentialsAPI } from '../../../../api'; + +jest.mock('../../../../api/models/Credentials'); + +const initialValues = { + credential: null, + custom_virtualenv: '', + group_by: '', + instance_filters: '', + overwrite: false, + overwrite_vars: false, + source_path: '', + source_project: null, + source_regions: '', + source_script: null, + source_vars: '---\n', + update_cache_timeout: 0, + update_on_launch: true, + update_on_project_update: false, + verbosity: 1, +}; + +describe('', () => { + let wrapper; + CredentialsAPI.read.mockResolvedValue({ + data: { count: 0, results: [] }, + }); + + 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="Verbosity"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1); + expect( + wrapper.find('FormGroup[label="Cache timeout (seconds)"]') + ).toHaveLength(1); + expect( + wrapper.find('VariablesField[label="Source variables"]') + ).toHaveLength(1); + }); + + test('should make expected api calls', () => { + expect(CredentialsAPI.read).toHaveBeenCalledTimes(1); + expect(CredentialsAPI.read).toHaveBeenCalledWith({ + credential_type__namespace: 'openstack', + order_by: 'name', + page: 1, + page_size: 5, + }); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx index ebfe1dbc2e..e9fe7f6690 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx @@ -117,7 +117,7 @@ const SCMSubForm = ({ i18n }) => { /> - + ); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.test.jsx index 4e9f541a9b..6d763b78a7 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.test.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.test.jsx @@ -11,13 +11,17 @@ jest.mock('../../../../api/models/Credentials'); const initialValues = { credential: null, custom_virtualenv: '', + group_by: '', + instance_filters: '', overwrite: false, overwrite_vars: false, source_path: '', source_project: null, + source_regions: '', + source_script: null, source_vars: '---\n', update_cache_timeout: 0, - update_on_launch: false, + update_on_launch: true, update_on_project_update: false, verbosity: 1, }; @@ -68,7 +72,10 @@ describe('', () => { expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1); expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1); expect( - wrapper.find('VariablesField[label="Environment variables"]') + wrapper.find('FormGroup[label="Cache timeout (seconds)"]') + ).toHaveLength(1); + expect( + wrapper.find('VariablesField[label="Source variables"]') ).toHaveLength(1); }); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SatelliteSubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SatelliteSubForm.jsx new file mode 100644 index 0000000000..641539f978 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SatelliteSubForm.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { useField } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; +import { OptionsField, SourceVarsField, VerbosityField } from './SharedFields'; + +const SatelliteSubForm = ({ i18n }) => { + const [credentialField, credentialMeta, credentialHelpers] = useField( + 'credential' + ); + + return ( + <> + credentialHelpers.setTouched()} + onChange={value => { + credentialHelpers.setValue(value); + }} + value={credentialField.value} + required + /> + + + + + ); +}; + +export default withI18n()(SatelliteSubForm); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SatelliteSubForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SatelliteSubForm.test.jsx new file mode 100644 index 0000000000..5da6c02db6 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SatelliteSubForm.test.jsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import SatelliteSubForm from './SatelliteSubForm'; +import { CredentialsAPI } from '../../../../api'; + +jest.mock('../../../../api/models/Credentials'); + +const initialValues = { + credential: null, + custom_virtualenv: '', + group_by: '', + instance_filters: '', + overwrite: false, + overwrite_vars: false, + source_path: '', + source_project: null, + source_regions: '', + source_script: null, + source_vars: '---\n', + update_cache_timeout: 0, + update_on_launch: true, + update_on_project_update: false, + verbosity: 1, +}; + +describe('', () => { + let wrapper; + CredentialsAPI.read.mockResolvedValue({ + data: { count: 0, results: [] }, + }); + + 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="Verbosity"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1); + expect( + wrapper.find('FormGroup[label="Cache timeout (seconds)"]') + ).toHaveLength(1); + expect( + wrapper.find('VariablesField[label="Source variables"]') + ).toHaveLength(1); + }); + + test('should make expected api calls', () => { + expect(CredentialsAPI.read).toHaveBeenCalledTimes(1); + expect(CredentialsAPI.read).toHaveBeenCalledWith({ + credential_type__namespace: 'satellite6', + order_by: 'name', + page: 1, + page_size: 5, + }); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx index b4b6d05f5b..698799ab62 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SharedFields.jsx @@ -1,9 +1,16 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; +import { t, Trans } from '@lingui/macro'; import { useField } from 'formik'; -import { FormGroup } from '@patternfly/react-core'; +import { + FormGroup, + Select, + SelectOption, + SelectVariant, +} from '@patternfly/react-core'; +import { arrayToString, stringToArray } from '../../../../util/strings'; import { minMaxValue } from '../../../../util/validators'; +import { BrandName } from '../../../../variables'; import AnsibleSelect from '../../../../components/AnsibleSelect'; import { VariablesField } from '../../../../components/CodeMirrorInput'; import FormField, { @@ -20,11 +27,197 @@ export const SourceVarsField = withI18n()(({ i18n }) => ( )); +export const RegionsField = withI18n()(({ i18n, regionOptions }) => { + const [field, meta, helpers] = useField('source_regions'); + const [isOpen, setIsOpen] = useState(false); + const options = Object.assign( + {}, + ...regionOptions.map(([key, val]) => ({ [key]: val })) + ); + const selected = stringToArray(field?.value) + .filter(i => options[i]) + .map(val => options[val]); + + return ( + + + Click on the regions field to see a list of regions for your cloud + provider. You can select multiple regions, or choose + All to include all regions. Only Hosts associated with the + selected regions will be updated. + + } + /> + + + ); +}); + +export const GroupByField = withI18n()( + ({ i18n, fixedOptions, isCreatable = false }) => { + const [field, meta, helpers] = useField('group_by'); + const fixedOptionLabels = fixedOptions && Object.values(fixedOptions); + const selections = fixedOptions + ? stringToArray(field.value).map(o => fixedOptions[o]) + : stringToArray(field.value); + const [options, setOptions] = useState(selections); + const [isOpen, setIsOpen] = useState(false); + + const renderOptions = opts => { + return opts.map(option => ( + + {option} + + )); + }; + + const handleFilter = event => { + const str = event.target.value.toLowerCase(); + let matches; + if (fixedOptions) { + matches = fixedOptionLabels.filter(o => o.toLowerCase().includes(str)); + } else { + matches = options.filter(o => o.toLowerCase().includes(str)); + } + return renderOptions(matches); + }; + + const handleSelect = (e, option) => { + let selectedValues; + if (selections.includes(option)) { + selectedValues = selections.filter(o => o !== option); + } else { + selectedValues = selections.concat(option); + } + if (fixedOptions) { + selectedValues = selectedValues.map(val => + Object.keys(fixedOptions).find(key => fixedOptions[key] === val) + ); + } + helpers.setValue(arrayToString(selectedValues)); + }; + + return ( + + + Select which groups to create automatically. AWX will create group + names similar to the following examples based on the options + selected: +
+
+
    +
  • + Availability Zone: zones » us-east-1b +
  • +
  • + Image ID: images » ami-b007ab1e +
  • +
  • + Instance ID: instances » i-ca11ab1e +
  • +
  • + Instance Type: types » type_m1_medium +
  • +
  • + Key Name: keys » key_testing +
  • +
  • + Region: regions » us-east-1 +
  • +
  • + Security Group:{' '} + + security_groups » security_group_default + +
  • +
  • + Tags: tags » tag_Name_host1 +
  • +
  • + VPC ID: vpcs » vpc-5ca1ab1e +
  • +
  • + Tag None: tags » tag_none +
  • +
+
+ If blank, all groups above are created except Instance ID + . + + } + /> + +
+ ); + } +); + export const VerbosityField = withI18n()(({ i18n }) => { const [field, meta, helpers] = useField('verbosity'); const isValid = !(meta.touched && meta.error); @@ -53,98 +246,148 @@ export const VerbosityField = withI18n()(({ i18n }) => { ); }); -export const OptionsField = withI18n()(({ i18n }) => { - const [updateOnLaunchField] = useField('update_on_launch'); - const [, , updateCacheTimeoutHelper] = useField('update_cache_timeout'); +export const OptionsField = withI18n()( + ({ i18n, showProjectUpdate = false }) => { + 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 + useEffect(() => { + if (!updateOnLaunchField.value) { + updateCacheTimeoutHelper.setValue(0); + } + }, [updateOnLaunchField.value]); // eslint-disable-line react-hooks/exhaustive-deps - return ( - <> - - - - - {i18n._(t`If checked, any hosts and groups that were + return ( + <> + + + + + {i18n._(t`If checked, any hosts and groups that were previously present on the external source but are now removed will be removed from the Tower inventory. Hosts and groups that were not managed by the inventory source will be promoted to the next manually created group or if there is no manually created group to promote them into, they will be left in the "all" default group for the inventory.`)} -
-
- {i18n._(t`When not checked, local child +
+
+ {i18n._(t`When not checked, local child hosts and groups not found on the external source will remain untouched by the inventory update process.`)} - - } - /> - - {i18n._(t`If checked, all variables for child groups + + } + /> + + {i18n._(t`If checked, all variables for child groups and hosts will be removed and replaced by those found on the external source.`)} -
-
- {i18n._(t`When not checked, a merge will be performed, +
+
+ {i18n._(t`When not checked, a merge will be performed, combining local variables with those found on the external source.`)} - - } - /> - + } + /> + - -
-
-
- {updateOnLaunchField.value && ( - + {showProjectUpdate && ( + + )} +
+
+
+ {updateOnLaunchField.value && ( + - )} - + /> + )} + + ); + } +); + +export const InstanceFiltersField = withI18n()(({ i18n }) => { + // Setting BrandName to a variable here is necessary to get the jest tests + // passing. Attempting to use BrandName in the template literal results + // in failing tests. + const brandName = BrandName; + return ( + + Provide a comma-separated list of filter expressions. Hosts are + imported to {brandName} when ANY of the filters match. +
+
+ Limit to hosts having a tag: +
+ tag-key=TowerManaged +
+
+ Limit to hosts using either key pair: +
+ key-name=staging, key-name=production +
+
+ Limit to hosts where the Name tag begins with test:
+ tag:Name=test* +
+
+ View the + + {' '} + Describe Instances documentation{' '} + + for a complete list of supported filters. + + } + /> ); }); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/TowerSubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/TowerSubForm.jsx new file mode 100644 index 0000000000..f3fe26d3a9 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/TowerSubForm.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { useField } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; +import { + InstanceFiltersField, + OptionsField, + VerbosityField, +} from './SharedFields'; + +const TowerSubForm = ({ i18n }) => { + const [credentialField, credentialMeta, credentialHelpers] = useField( + 'credential' + ); + + return ( + <> + credentialHelpers.setTouched()} + onChange={value => { + credentialHelpers.setValue(value); + }} + value={credentialField.value} + required + /> + + + + + ); +}; + +export default withI18n()(TowerSubForm); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/TowerSubForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/TowerSubForm.test.jsx new file mode 100644 index 0000000000..71bc801823 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/TowerSubForm.test.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import TowerSubForm from './TowerSubForm'; +import { CredentialsAPI } from '../../../../api'; + +jest.mock('../../../../api/models/Credentials'); + +const initialValues = { + credential: null, + custom_virtualenv: '', + group_by: '', + instance_filters: '', + overwrite: false, + overwrite_vars: false, + source_path: '', + source_project: null, + source_regions: '', + source_script: null, + source_vars: '---\n', + update_cache_timeout: 0, + update_on_launch: true, + update_on_project_update: false, + verbosity: 1, +}; + +describe('', () => { + let wrapper; + CredentialsAPI.read.mockResolvedValue({ + data: { count: 0, results: [] }, + }); + + 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="Instance filters"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1); + expect( + wrapper.find('FormGroup[label="Cache timeout (seconds)"]') + ).toHaveLength(1); + }); + + test('should make expected api calls', () => { + expect(CredentialsAPI.read).toHaveBeenCalledTimes(1); + expect(CredentialsAPI.read).toHaveBeenCalledWith({ + credential_type__namespace: 'tower', + order_by: 'name', + page: 1, + page_size: 5, + }); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VMwareSubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VMwareSubForm.jsx new file mode 100644 index 0000000000..e975e789b1 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VMwareSubForm.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { useField } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; +import { + InstanceFiltersField, + GroupByField, + OptionsField, + SourceVarsField, + VerbosityField, +} from './SharedFields'; + +const VMwareSubForm = ({ i18n }) => { + const [credentialField, credentialMeta, credentialHelpers] = useField( + 'credential' + ); + + return ( + <> + credentialHelpers.setTouched()} + onChange={value => { + credentialHelpers.setValue(value); + }} + value={credentialField.value} + required + /> + + + + + + + ); +}; + +export default withI18n()(VMwareSubForm); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VMwareSubForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VMwareSubForm.test.jsx new file mode 100644 index 0000000000..ba4777733d --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VMwareSubForm.test.jsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import VMwareSubForm from './VMwareSubForm'; +import { CredentialsAPI } from '../../../../api'; + +jest.mock('../../../../api/models/Credentials'); + +const initialValues = { + credential: null, + custom_virtualenv: '', + group_by: '', + instance_filters: '', + overwrite: false, + overwrite_vars: false, + source_path: '', + source_project: null, + source_regions: '', + source_script: null, + source_vars: '---\n', + update_cache_timeout: 0, + update_on_launch: true, + update_on_project_update: false, + verbosity: 1, +}; + +const mockSourceOptions = { + actions: { + POST: { + source_regions: { + gce_region_choices: [], + }, + }, + }, +}; + +describe('', () => { + let wrapper; + CredentialsAPI.read.mockResolvedValue({ + data: { count: 0, results: [] }, + }); + + 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="Instance filters"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Only group by"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1); + expect( + wrapper.find('FormGroup[label="Cache timeout (seconds)"]') + ).toHaveLength(1); + expect( + wrapper.find('VariablesField[label="Source variables"]') + ).toHaveLength(1); + }); + + test('should make expected api calls', () => { + expect(CredentialsAPI.read).toHaveBeenCalledTimes(1); + expect(CredentialsAPI.read).toHaveBeenCalledWith({ + credential_type__namespace: 'vmware', + order_by: 'name', + page: 1, + page_size: 5, + }); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VirtualizationSubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VirtualizationSubForm.jsx new file mode 100644 index 0000000000..4d558b74a6 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VirtualizationSubForm.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { useField } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; +import { OptionsField, VerbosityField } from './SharedFields'; + +const VirtualizationSubForm = ({ i18n }) => { + const [credentialField, credentialMeta, credentialHelpers] = useField( + 'credential' + ); + + return ( + <> + credentialHelpers.setTouched()} + onChange={value => { + credentialHelpers.setValue(value); + }} + value={credentialField.value} + required + /> + + + + ); +}; + +export default withI18n()(VirtualizationSubForm); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VirtualizationSubForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VirtualizationSubForm.test.jsx new file mode 100644 index 0000000000..1d1526a42d --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VirtualizationSubForm.test.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; +import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; +import VirtualizationSubForm from './VirtualizationSubForm'; +import { CredentialsAPI } from '../../../../api'; + +jest.mock('../../../../api/models/Credentials'); + +const initialValues = { + credential: null, + custom_virtualenv: '', + group_by: '', + instance_filters: '', + overwrite: false, + overwrite_vars: false, + source_path: '', + source_project: null, + source_regions: '', + source_script: null, + source_vars: '---\n', + update_cache_timeout: 0, + update_on_launch: true, + update_on_project_update: false, + verbosity: 1, +}; + +describe('', () => { + let wrapper; + CredentialsAPI.read.mockResolvedValue({ + data: { count: 0, results: [] }, + }); + + 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="Verbosity"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Update options"]')).toHaveLength(1); + expect( + wrapper.find('FormGroup[label="Cache timeout (seconds)"]') + ).toHaveLength(1); + }); + + test('should make expected api calls', () => { + expect(CredentialsAPI.read).toHaveBeenCalledTimes(1); + expect(CredentialsAPI.read).toHaveBeenCalledWith({ + credential_type__namespace: 'rhv', + order_by: 'name', + page: 1, + page_size: 5, + }); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/index.js b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/index.js index 79640d3a4f..936d646673 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/index.js +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/index.js @@ -1 +1,11 @@ -export { default } from './SCMSubForm'; +export { default as AzureSubForm } from './AzureSubForm'; +export { default as CloudFormsSubForm } from './CloudFormsSubForm'; +export { default as CustomScriptSubForm } from './CustomScriptSubForm'; +export { default as EC2SubForm } from './EC2SubForm'; +export { default as GCESubForm } from './GCESubForm'; +export { default as OpenStackSubForm } from './OpenStackSubForm'; +export { default as SCMSubForm } from './SCMSubForm'; +export { default as SatelliteSubForm } from './SatelliteSubForm'; +export { default as TowerSubForm } from './TowerSubForm'; +export { default as VMwareSubForm } from './VMwareSubForm'; +export { default as VirtualizationSubForm } from './VirtualizationSubForm'; diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js index 46dd0298ea..4ba0808d7d 100644 --- a/awx/ui_next/src/types.js +++ b/awx/ui_next/src/types.js @@ -107,6 +107,12 @@ export const Inventory = shape({ total_inventory_sources: number, }); +export const InventoryScript = shape({ + description: string, + id: number.isRequired, + name: string, +}); + export const InstanceGroup = shape({ id: number.isRequired, name: string.isRequired, diff --git a/awx/ui_next/src/util/strings.js b/awx/ui_next/src/util/strings.js index 976151aa71..5e3c23ec54 100644 --- a/awx/ui_next/src/util/strings.js +++ b/awx/ui_next/src/util/strings.js @@ -17,3 +17,7 @@ export const toTitleCase = string => { .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); }; + +export const arrayToString = value => value.join(','); + +export const stringToArray = value => value.split(',').filter(val => !!val);