diff --git a/awx/ui_next/.eslintrc b/awx/ui_next/.eslintrc index 94500c2691..614af544bf 100644 --- a/awx/ui_next/.eslintrc +++ b/awx/ui_next/.eslintrc @@ -82,7 +82,8 @@ "rows", "href", "modifier", - "data-cy" + "data-cy", + "fieldName" ], "ignore": ["Ansible", "Tower", "JSON", "YAML", "lg"], "ignoreComponent": [ diff --git a/awx/ui_next/src/components/HostForm/HostForm.jsx b/awx/ui_next/src/components/HostForm/HostForm.jsx index 75615df214..a9e1162909 100644 --- a/awx/ui_next/src/components/HostForm/HostForm.jsx +++ b/awx/ui_next/src/components/HostForm/HostForm.jsx @@ -1,9 +1,7 @@ -import React, { useState } from 'react'; +import React, { useCallback } from 'react'; import { bool, func, shape } from 'prop-types'; -import { Formik, useField } from 'formik'; - +import { Formik, useField, useFormikContext } from 'formik'; import { t } from '@lingui/macro'; - import { Form, FormGroup } from '@patternfly/react-core'; import FormField, { FormSubmitError } from '../FormField'; import FormActionGroup from '../FormActionGroup/FormActionGroup'; @@ -13,15 +11,19 @@ import { FormColumnLayout, FormFullWidthLayout } from '../FormLayout'; import Popover from '../Popover'; import { required } from '../../util/validators'; -const InventoryLookupField = ({ host }) => { - const [inventory, setInventory] = useState( - host ? host.summary_fields.inventory : '' +const InventoryLookupField = () => { + const { setFieldValue, setFieldTouched } = useFormikContext(); + const [inventoryField, inventoryMeta, inventoryHelpers] = useField( + 'inventory' ); - const [, inventoryMeta, inventoryHelpers] = useField({ - name: 'inventory', - validate: required(t`Select a value for this field`), - }); + const handleInventoryUpdate = useCallback( + value => { + setFieldValue('inventory', value); + setFieldTouched('inventory', true, false); + }, + [setFieldValue, setFieldTouched] + ); return ( { > inventoryHelpers.setTouched()} tooltip={t`Select the inventory that this host will belong to.`} isValid={!inventoryMeta.touched || !inventoryMeta.error} helperTextInvalid={inventoryMeta.error} - onChange={value => { - inventoryHelpers.setValue(value.id); - setInventory(value); - }} + onChange={handleInventoryUpdate} required touched={inventoryMeta.touched} error={inventoryMeta.error} + validate={required(t`Select a value for this field`)} /> ); @@ -62,7 +62,6 @@ const HostForm = ({ handleSubmit, host, isInventoryVisible, - submitError, }) => { return ( @@ -70,7 +69,7 @@ const HostForm = ({ initialValues={{ name: host.name, description: host.description, - inventory: host.inventory || '', + inventory: host.summary_fields?.inventory || null, variables: host.variables, }} onSubmit={handleSubmit} @@ -92,7 +91,7 @@ const HostForm = ({ type="text" label={t`Description`} /> - {isInventoryVisible && } + {isInventoryVisible && } { + if (name && name !== '') { + try { + const { + data: { results: nameMatchResults, count: nameMatchCount }, + } = await ApplicationsAPI.read({ name }); + onChange(nameMatchCount ? nameMatchResults[0] : null); + } catch { + onChange(null); + } + } else { + onChange(null); + } + }, + [onChange] + ); + useEffect(() => { fetchApplications(); }, [fetchApplications]); @@ -65,6 +83,9 @@ function ApplicationLookup({ onChange, value, label }) { header={t`Application`} value={value} onChange={onChange} + onDebounce={checkApplicationName} + fieldName={fieldName} + validate={validate} qsConfig={QS_CONFIG} renderOptionsList={({ state, dispatch, canDelete }) => ( undefined, + fieldName: 'application', }; export default withRouter(ApplicationLookup); diff --git a/awx/ui_next/src/components/Lookup/ApplicationLookup.test.jsx b/awx/ui_next/src/components/Lookup/ApplicationLookup.test.jsx index 5d2e2e33a0..45986151f4 100644 --- a/awx/ui_next/src/components/Lookup/ApplicationLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/ApplicationLookup.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import ApplicationLookup from './ApplicationLookup'; import { ApplicationsAPI } from '../../api'; @@ -41,11 +42,13 @@ describe('ApplicationLookup', () => { test('should render successfully', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - /> + + {}} + /> + ); }); expect(wrapper.find('ApplicationLookup')).toHaveLength(1); @@ -54,11 +57,13 @@ describe('ApplicationLookup', () => { test('should fetch applications', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - /> + + {}} + /> + ); }); expect(ApplicationsAPI.read).toHaveBeenCalledTimes(1); @@ -67,11 +72,13 @@ describe('ApplicationLookup', () => { test('should display label', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - /> + + {}} + /> + ); }); const title = wrapper.find('FormGroup .pf-c-form__label-text'); diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx index 0c452d6483..80581dc1da 100644 --- a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx @@ -39,11 +39,12 @@ function CredentialLookup({ credentialTypeKind, credentialTypeNamespace, value, - tooltip, isDisabled, autoPopulate, multiple, + validate, + fieldName, }) { const history = useHistory(); const autoPopulateLookup = useAutoPopulateLookup(onChange); @@ -111,6 +112,39 @@ function CredentialLookup({ } ); + const checkCredentialName = useCallback( + async name => { + if (name && name !== '') { + try { + const typeIdParams = credentialTypeId + ? { credential_type: credentialTypeId } + : {}; + const typeKindParams = credentialTypeKind + ? { credential_type__kind: credentialTypeKind } + : {}; + const typeNamespaceParams = credentialTypeNamespace + ? { credential_type__namespace: credentialTypeNamespace } + : {}; + + const { + data: { results: nameMatchResults, count: nameMatchCount }, + } = await CredentialsAPI.read({ + name, + ...typeIdParams, + ...typeKindParams, + ...typeNamespaceParams, + }); + onChange(nameMatchCount ? nameMatchResults[0] : null); + } catch { + onChange(null); + } + } else { + onChange(null); + } + }, + [onChange, credentialTypeId, credentialTypeKind, credentialTypeNamespace] + ); + useEffect(() => { fetchCredentials(); }, [fetchCredentials]); @@ -132,6 +166,9 @@ function CredentialLookup({ value={value} onBlur={onBlur} onChange={onChange} + onDebounce={checkCredentialName} + fieldName={fieldName} + validate={validate} required={required} qsConfig={QS_CONFIG} isDisabled={isDisabled} @@ -212,6 +249,8 @@ CredentialLookup.propTypes = { value: oneOfType([Credential, arrayOf(Credential)]), isDisabled: bool, autoPopulate: bool, + validate: func, + fieldName: string, }; CredentialLookup.defaultProps = { @@ -225,6 +264,8 @@ CredentialLookup.defaultProps = { value: null, isDisabled: false, autoPopulate: false, + validate: () => undefined, + fieldName: 'credential', }; export { CredentialLookup as _CredentialLookup }; diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.test.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.test.jsx index 9aa1e9a4f1..7ed5910816 100644 --- a/awx/ui_next/src/components/Lookup/CredentialLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import CredentialLookup, { _CredentialLookup } from './CredentialLookup'; import { CredentialsAPI } from '../../api'; @@ -31,11 +32,13 @@ describe('CredentialLookup', () => { test('should render successfully', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - /> + + {}} + /> + ); }); expect(wrapper.find('CredentialLookup')).toHaveLength(1); @@ -44,11 +47,13 @@ describe('CredentialLookup', () => { test('should fetch credentials', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - /> + + {}} + /> + ); }); expect(CredentialsAPI.read).toHaveBeenCalledTimes(1); @@ -63,11 +68,13 @@ describe('CredentialLookup', () => { test('should display label', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - /> + + {}} + /> + ); }); const title = wrapper.find('FormGroup .pf-c-form__label-text'); @@ -77,11 +84,13 @@ describe('CredentialLookup', () => { test('should define default value for function props', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - /> + + {}} + /> + ); }); expect(_CredentialLookup.defaultProps.onBlur).toBeInstanceOf(Function); @@ -98,11 +107,13 @@ describe('CredentialLookup', () => { const onChange = jest.fn(); await act(async () => { wrapper = mountWithContexts( - + + + ); }); expect(onChange).not.toHaveBeenCalled(); @@ -118,12 +129,14 @@ describe('CredentialLookup', () => { const onChange = jest.fn(); await act(async () => { wrapper = mountWithContexts( - + + + ); }); expect(onChange).not.toHaveBeenCalled(); @@ -141,12 +154,14 @@ describe('CredentialLookup auto select', () => { const onChange = jest.fn(); await act(async () => { mountWithContexts( - + + + ); }); expect(onChange).toHaveBeenCalledWith({ id: 1 }); diff --git a/awx/ui_next/src/components/Lookup/ExecutionEnvironmentLookup.jsx b/awx/ui_next/src/components/Lookup/ExecutionEnvironmentLookup.jsx index a017bfb5c5..9c83536cbc 100644 --- a/awx/ui_next/src/components/Lookup/ExecutionEnvironmentLookup.jsx +++ b/awx/ui_next/src/components/Lookup/ExecutionEnvironmentLookup.jsx @@ -1,10 +1,8 @@ import React, { useCallback, useEffect } from 'react'; import { string, func, bool, oneOfType, number } from 'prop-types'; import { useLocation } from 'react-router-dom'; - import { t } from '@lingui/macro'; import { FormGroup, Tooltip } from '@patternfly/react-core'; - import { ExecutionEnvironmentsAPI, ProjectsAPI } from '../../api'; import { ExecutionEnvironment } from '../../types'; import { getQSConfig, parseQueryString, mergeParams } from '../../util/qs'; @@ -23,17 +21,20 @@ const QS_CONFIG = getQSConfig('execution_environments', { function ExecutionEnvironmentLookup({ globallyAvailable, - + helperTextInvalid, isDefaultEnvironment, - isGlobalDefaultEnvironment, isDisabled, + isGlobalDefaultEnvironment, + isValid, onBlur, onChange, organizationId, popoverContent, projectId, tooltip, + validate, value, + fieldName, }) { const location = useLocation(); @@ -113,6 +114,24 @@ function ExecutionEnvironmentLookup({ } ); + const checkExecutionEnvironmentName = useCallback( + async name => { + if (name && name !== '') { + try { + const { + data: { results: nameMatchResults, count: nameMatchCount }, + } = await ExecutionEnvironmentsAPI.read({ name }); + onChange(nameMatchCount ? nameMatchResults[0] : null); + } catch { + onChange(null); + } + } else { + onChange(null); + } + }, + [onChange] + ); + useEffect(() => { fetchExecutionEnvironments(); }, [fetchExecutionEnvironments]); @@ -125,6 +144,9 @@ function ExecutionEnvironmentLookup({ value={value} onBlur={onBlur} onChange={onChange} + onDebounce={checkExecutionEnvironmentName} + fieldName={fieldName} + validate={validate} qsConfig={QS_CONFIG} isLoading={isLoading || fetchProjectLoading} isDisabled={isDisabled} @@ -179,6 +201,8 @@ function ExecutionEnvironmentLookup({ fieldId="execution-environment-lookup" label={renderLabel(isGlobalDefaultEnvironment, isDefaultEnvironment)} labelIcon={popoverContent && } + helperTextInvalid={helperTextInvalid} + validated={isValid ? 'default' : 'error'} > {tooltip && isDisabled ? ( {renderLookup()} @@ -199,6 +223,8 @@ ExecutionEnvironmentLookup.propTypes = { isGlobalDefaultEnvironment: bool, projectId: oneOfType([number, string]), organizationId: oneOfType([number, string]), + validate: func, + fieldName: string, }; ExecutionEnvironmentLookup.defaultProps = { @@ -208,6 +234,8 @@ ExecutionEnvironmentLookup.defaultProps = { value: null, projectId: null, organizationId: null, + validate: () => undefined, + fieldName: 'execution_environment', }; export default ExecutionEnvironmentLookup; diff --git a/awx/ui_next/src/components/Lookup/ExecutionEnvironmentLookup.test.jsx b/awx/ui_next/src/components/Lookup/ExecutionEnvironmentLookup.test.jsx index 858a3385ac..9b100a3c22 100644 --- a/awx/ui_next/src/components/Lookup/ExecutionEnvironmentLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/ExecutionEnvironmentLookup.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import ExecutionEnvironmentLookup from './ExecutionEnvironmentLookup'; import { ExecutionEnvironmentsAPI, ProjectsAPI } from '../../api'; @@ -52,11 +53,13 @@ describe('ExecutionEnvironmentLookup', () => { }); await act(async () => { wrapper = mountWithContexts( - {}} - /> + + {}} + /> + ); }); wrapper.update(); @@ -73,10 +76,12 @@ describe('ExecutionEnvironmentLookup', () => { test('should fetch execution environments', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - /> + + {}} + /> + ); }); expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalledTimes(2); @@ -91,12 +96,14 @@ describe('ExecutionEnvironmentLookup', () => { test('should call api with organization id', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - organizationId={1} - globallyAvailable - /> + + {}} + organizationId={1} + globallyAvailable + /> + ); }); expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalledWith({ @@ -111,12 +118,14 @@ describe('ExecutionEnvironmentLookup', () => { test('should call api with organization id from the related project', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - projectId={12} - globallyAvailable - /> + + {}} + projectId={12} + globallyAvailable + /> + ); }); expect(ProjectsAPI.readDetail).toHaveBeenCalledWith(12); diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx index 3f21abccd8..a6da540a70 100644 --- a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx @@ -19,9 +19,16 @@ const QS_CONFIG = getQSConfig('instance-groups', { order_by: 'name', }); -function InstanceGroupsLookup(props) { - const { value, onChange, tooltip, className, required, history } = props; - +function InstanceGroupsLookup({ + value, + onChange, + tooltip, + className, + required, + history, + fieldName, + validate, +}) { const { result: { instanceGroups, count, relatedSearchableKeys, searchableKeys }, request: fetchInstanceGroups, @@ -69,6 +76,8 @@ function InstanceGroupsLookup(props) { header={t`Instance Groups`} value={value} onChange={onChange} + fieldName={fieldName} + validate={validate} qsConfig={QS_CONFIG} multiple required={required} @@ -118,12 +127,16 @@ InstanceGroupsLookup.propTypes = { onChange: func.isRequired, className: string, required: bool, + validate: func, + fieldName: string, }; InstanceGroupsLookup.defaultProps = { tooltip: '', className: '', required: false, + validate: () => undefined, + fieldName: 'instance_groups', }; export default withRouter(InstanceGroupsLookup); diff --git a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx index 8b68ca2045..befefa8a91 100644 --- a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect } from 'react'; -import { func, bool } from 'prop-types'; +import { func, bool, string } from 'prop-types'; import { withRouter } from 'react-router-dom'; - import { t } from '@lingui/macro'; import { InventoriesAPI } from '../../api'; import { Inventory } from '../../types'; @@ -23,7 +22,6 @@ function InventoryLookup({ value, onChange, onBlur, - history, required, isPromptableField, @@ -31,6 +29,8 @@ function InventoryLookup({ promptId, promptName, isOverrideDisabled, + validate, + fieldName, }) { const { result: { @@ -50,6 +50,7 @@ function InventoryLookup({ InventoriesAPI.read(params), InventoriesAPI.readOptions(), ]); + return { inventories: data.results, count: data.count, @@ -73,6 +74,24 @@ function InventoryLookup({ } ); + const checkInventoryName = useCallback( + async name => { + if (name && name !== '') { + try { + const { + data: { results: nameMatchResults, count: nameMatchCount }, + } = await InventoriesAPI.read({ name }); + onChange(nameMatchCount ? nameMatchResults[0] : null); + } catch { + onChange(null); + } + } else { + onChange(null); + } + }, + [onChange] + ); + useEffect(() => { fetchInventories(); }, [fetchInventories]); @@ -96,6 +115,9 @@ function InventoryLookup({ onChange={onChange} onBlur={onBlur} required={required} + onDebounce={checkInventoryName} + fieldName={fieldName} + validate={validate} isLoading={isLoading} isDisabled={!canEdit} qsConfig={QS_CONFIG} @@ -147,6 +169,9 @@ function InventoryLookup({ header={t`Inventory`} value={value} onChange={onChange} + onDebounce={checkInventoryName} + fieldName={fieldName} + validate={validate} onBlur={onBlur} required={required} isLoading={isLoading} @@ -200,12 +225,16 @@ InventoryLookup.propTypes = { onChange: func.isRequired, required: bool, isOverrideDisabled: bool, + validate: func, + fieldName: string, }; InventoryLookup.defaultProps = { value: null, required: false, isOverrideDisabled: false, + validate: () => {}, + fieldName: 'inventory', }; export default withRouter(InventoryLookup); diff --git a/awx/ui_next/src/components/Lookup/InventoryLookup.test.jsx b/awx/ui_next/src/components/Lookup/InventoryLookup.test.jsx index 1c7d13f488..019bf50616 100644 --- a/awx/ui_next/src/components/Lookup/InventoryLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/InventoryLookup.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import InventoryLookup from './InventoryLookup'; import { InventoriesAPI } from '../../api'; @@ -39,7 +40,11 @@ describe('InventoryLookup', () => { }, }); await act(async () => { - wrapper = mountWithContexts( {}} />); + wrapper = mountWithContexts( + + {}} /> + + ); }); wrapper.update(); expect(InventoriesAPI.read).toHaveBeenCalledTimes(1); @@ -58,7 +63,9 @@ describe('InventoryLookup', () => { }); await act(async () => { wrapper = mountWithContexts( - {}} /> + + {}} /> + ); }); wrapper.update(); @@ -77,7 +84,11 @@ describe('InventoryLookup', () => { }, }); await act(async () => { - wrapper = mountWithContexts( {}} />); + wrapper = mountWithContexts( + + {}} /> + + ); }); wrapper.update(); expect(InventoriesAPI.read).toHaveBeenCalledTimes(1); diff --git a/awx/ui_next/src/components/Lookup/Lookup.jsx b/awx/ui_next/src/components/Lookup/Lookup.jsx index cf3c550cfd..04842332ad 100644 --- a/awx/ui_next/src/components/Lookup/Lookup.jsx +++ b/awx/ui_next/src/components/Lookup/Lookup.jsx @@ -1,4 +1,4 @@ -import React, { Fragment, useReducer, useEffect } from 'react'; +import React, { Fragment, useReducer, useEffect, useState } from 'react'; import { string, bool, @@ -9,6 +9,7 @@ import { shape, } from 'prop-types'; import { withRouter } from 'react-router-dom'; +import { useField } from 'formik'; import { SearchIcon } from '@patternfly/react-icons'; import { Button, @@ -16,12 +17,12 @@ import { Chip, InputGroup, Modal, + TextInput, } from '@patternfly/react-core'; - import { t } from '@lingui/macro'; import styled from 'styled-components'; +import useDebounce from '../../util/useDebounce'; import ChipGroup from '../ChipGroup'; - import reducer, { initReducer } from './shared/reducer'; import { QSConfig } from '../../types'; @@ -44,9 +45,23 @@ function Lookup(props) { renderItemChip, renderOptionsList, history, - isDisabled, + onDebounce, + fieldName, + validate, } = props; + const [typedText, setTypedText] = useState(''); + const debounceRequest = useDebounce(onDebounce, 1000); + + useField({ + name: fieldName, + validate: val => { + if (!multiple && !val && typedText && typedText !== '') { + return t`That value was not found. Please enter or select a valid value.`; + } + return validate(val); + }, + }); const [state, dispatch] = useReducer( reducer, @@ -60,7 +75,16 @@ function Lookup(props) { useEffect(() => { dispatch({ type: 'SET_VALUE', value }); - }, [value]); + if (value?.name) { + setTypedText(value.name); + } + }, [value, multiple]); + + useEffect(() => { + if (!multiple) { + setTypedText(state.selectedItems[0] ? state.selectedItems[0].name : ''); + } + }, [state.selectedItems, multiple]); const clearQSParams = () => { const parts = history.location.search.replace(/^\?/, '').split('&'); @@ -71,19 +95,16 @@ function Lookup(props) { const save = () => { const { selectedItems } = state; - const val = multiple ? selectedItems : selectedItems[0] || null; - onChange(val); + if (multiple) { + onChange(selectedItems); + } else { + onChange(selectedItems[0] || null); + } clearQSParams(); dispatch({ type: 'CLOSE_MODAL' }); }; - const removeItem = item => { - if (multiple) { - onChange(value.filter(i => i.id !== item.id)); - } else { - onChange(null); - } - }; + const removeItem = item => onChange(value.filter(i => i.id !== item.id)); const closeModal = () => { clearQSParams(); @@ -99,6 +120,7 @@ function Lookup(props) { } else if (value) { items.push(value); } + return ( @@ -111,17 +133,31 @@ function Lookup(props) { > - - - {items.map(item => - renderItemChip({ - item, - removeItem, - canDelete, - }) - )} - - + {multiple ? ( + + + {items.map(item => + renderItemChip({ + item, + removeItem, + canDelete, + }) + )} + + + ) : ( + { + setTypedText(inputValue); + if (value?.name !== inputValue) { + debounceRequest(inputValue); + } + }} + isDisabled={isLoading || isDisabled} + /> + )} ), + validate: () => undefined, + onDebounce: () => undefined, }; export { Lookup as _Lookup }; diff --git a/awx/ui_next/src/components/Lookup/Lookup.test.jsx b/awx/ui_next/src/components/Lookup/Lookup.test.jsx index fd067ba4a7..801589f73b 100644 --- a/awx/ui_next/src/components/Lookup/Lookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/Lookup.test.jsx @@ -1,6 +1,7 @@ /* eslint-disable react/jsx-pascal-case */ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; import { mountWithContexts, waitForElement, @@ -56,22 +57,25 @@ describe('', () => { const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }]; await act(async () => { wrapper = mountWithContexts( - ( - - )} - /> + + ( + + )} + fieldName="foo" + /> + ); }); return wrapper; @@ -137,22 +141,25 @@ describe('', () => { await act(async () => { const mockSelected = { name: 'foo', id: 1, url: '/api/v2/item/1' }; wrapper = mountWithContexts( - ( - - )} - /> + + ( + + )} + fieldName="foo" + /> + ); }); wrapper.find('button[aria-label="Search"]').simulate('click'); @@ -163,23 +170,26 @@ describe('', () => { test('should be disabled while isLoading is true', async () => { const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }]; wrapper = mountWithContexts( - ( - - )} - /> + + ( + + )} + fieldName="foo" + /> + ); checkRootElementNotPresent('body div[role="dialog"]'); const button = wrapper.find('button[aria-label="Search"]'); diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index 771a880738..e54a53ca21 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -2,7 +2,6 @@ import 'styled-components/macro'; import React, { Fragment, useState, useCallback, useEffect } from 'react'; import { withRouter } from 'react-router-dom'; import PropTypes from 'prop-types'; - import { t } from '@lingui/macro'; import { ToolbarItem, Alert } from '@patternfly/react-core'; import { CredentialsAPI, CredentialTypesAPI } from '../../api'; @@ -26,8 +25,14 @@ async function loadCredentials(params, selectedCredentialTypeId) { return data; } -function MultiCredentialsLookup(props) { - const { value, onChange, onError, history } = props; +function MultiCredentialsLookup({ + value, + onChange, + onError, + history, + fieldName, + validate, +}) { const [selectedType, setSelectedType] = useState(null); const isMounted = useIsMounted(); @@ -68,9 +73,12 @@ function MultiCredentialsLookup(props) { if (!selectedType) { return { credentials: [], - count: 0, + credentialsCount: 0, + relatedSearchableKeys: [], + searchableKeys: [], }; } + const params = parseQueryString(QS_CONFIG, history.location.search); const [{ results, count }, actionsResponse] = await Promise.all([ loadCredentials(params, selectedType.id), @@ -130,6 +138,8 @@ function MultiCredentialsLookup(props) { id="multiCredential" header={t`Credentials`} value={value} + fieldName={fieldName} + validate={validate} multiple onChange={onChange} qsConfig={QS_CONFIG} @@ -240,10 +250,14 @@ MultiCredentialsLookup.propTypes = { ), onChange: PropTypes.func.isRequired, onError: PropTypes.func.isRequired, + validate: PropTypes.func, + fieldName: PropTypes.string, }; MultiCredentialsLookup.defaultProps = { value: [], + validate: () => undefined, + fieldName: 'credentials', }; export { MultiCredentialsLookup as _MultiCredentialsLookup }; diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx index b805a661f8..994a81b0b3 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; import { mountWithContexts, waitForElement, @@ -9,7 +10,7 @@ import { CredentialsAPI, CredentialTypesAPI } from '../../api'; jest.mock('../../api'); -describe('', () => { +describe('', () => { let wrapper; const credentials = [ @@ -128,12 +129,14 @@ describe('', () => { const onChange = jest.fn(); await act(async () => { wrapper = mountWithContexts( - {}} - /> + + {}} + /> + ); }); wrapper.update(); @@ -145,12 +148,14 @@ describe('', () => { const onChange = jest.fn(); await act(async () => { wrapper = mountWithContexts( - {}} - /> + + {}} + /> + ); }); const chip = wrapper.find('CredentialChip'); @@ -182,12 +187,14 @@ describe('', () => { test('should change credential types', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - onError={() => {}} - /> + + {}} + onError={() => {}} + /> + ); }); const searchButton = await waitForElement( @@ -227,12 +234,14 @@ describe('', () => { const onChange = jest.fn(); await act(async () => { wrapper = mountWithContexts( - {}} - /> + + {}} + /> + ); }); const searchButton = await waitForElement( @@ -294,12 +303,14 @@ describe('', () => { test('should properly render vault credential labels', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - onError={() => {}} - /> + + {}} + onError={() => {}} + /> + ); }); const searchButton = await waitForElement( @@ -325,12 +336,14 @@ describe('', () => { const onChange = jest.fn(); await act(async () => { wrapper = mountWithContexts( - {}} - /> + + {}} + /> + ); }); const searchButton = await waitForElement( @@ -392,12 +405,14 @@ describe('', () => { const onChange = jest.fn(); await act(async () => { wrapper = mountWithContexts( - {}} - /> + + {}} + /> + ); }); const searchButton = await waitForElement( @@ -466,12 +481,14 @@ describe('', () => { const onChange = jest.fn(); await act(async () => { wrapper = mountWithContexts( - {}} - /> + + {}} + /> + ); }); const searchButton = await waitForElement( diff --git a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx index b3fa6ee194..750e361467 100644 --- a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx +++ b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect } from 'react'; -import { node, func, bool } from 'prop-types'; +import { node, func, bool, string } from 'prop-types'; import { withRouter } from 'react-router-dom'; - import { t } from '@lingui/macro'; import { FormGroup } from '@patternfly/react-core'; import { OrganizationsAPI } from '../../api'; @@ -21,7 +20,6 @@ const QS_CONFIG = getQSConfig('organizations', { function OrganizationLookup({ helperTextInvalid, - isValid, onBlur, onChange, @@ -31,6 +29,8 @@ function OrganizationLookup({ autoPopulate, isDisabled, helperText, + validate, + fieldName, }) { const autoPopulateLookup = useAutoPopulateLookup(onChange); @@ -69,6 +69,24 @@ function OrganizationLookup({ } ); + const checkOrganizationName = useCallback( + async name => { + if (name && name !== '') { + try { + const { + data: { results: nameMatchResults, count: nameMatchCount }, + } = await OrganizationsAPI.read({ name }); + onChange(nameMatchCount ? nameMatchResults[0] : null); + } catch { + onChange(null); + } + } else { + onChange(null); + } + }, + [onChange] + ); + useEffect(() => { fetchOrganizations(); }, [fetchOrganizations]); @@ -89,6 +107,9 @@ function OrganizationLookup({ value={value} onBlur={onBlur} onChange={onChange} + onDebounce={checkOrganizationName} + fieldName={fieldName} + validate={validate} qsConfig={QS_CONFIG} required={required} sortedColumnKey="name" @@ -144,6 +165,8 @@ OrganizationLookup.propTypes = { value: Organization, autoPopulate: bool, isDisabled: bool, + validate: func, + fieldName: string, }; OrganizationLookup.defaultProps = { @@ -154,6 +177,8 @@ OrganizationLookup.defaultProps = { value: null, autoPopulate: false, isDisabled: false, + validate: () => undefined, + fieldName: 'organization', }; export { OrganizationLookup as _OrganizationLookup }; diff --git a/awx/ui_next/src/components/Lookup/OrganizationLookup.test.jsx b/awx/ui_next/src/components/Lookup/OrganizationLookup.test.jsx index 81c390a3e3..e6941e71c8 100644 --- a/awx/ui_next/src/components/Lookup/OrganizationLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/OrganizationLookup.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import OrganizationLookup, { _OrganizationLookup } from './OrganizationLookup'; import { OrganizationsAPI } from '../../api'; @@ -16,14 +17,22 @@ describe('OrganizationLookup', () => { test('should render successfully', async () => { await act(async () => { - wrapper = mountWithContexts( {}} />); + wrapper = mountWithContexts( + + {}} /> + + ); }); expect(wrapper).toHaveLength(1); }); test('should fetch organizations', async () => { await act(async () => { - wrapper = mountWithContexts( {}} />); + wrapper = mountWithContexts( + + {}} /> + + ); }); expect(OrganizationsAPI.read).toHaveBeenCalledTimes(1); expect(OrganizationsAPI.read).toHaveBeenCalledWith({ @@ -35,7 +44,11 @@ describe('OrganizationLookup', () => { test('should display "Organization" label', async () => { await act(async () => { - wrapper = mountWithContexts( {}} />); + wrapper = mountWithContexts( + + {}} /> + + ); }); const title = wrapper.find('FormGroup .pf-c-form__label-text'); expect(title.text()).toEqual('Organization'); @@ -43,7 +56,11 @@ describe('OrganizationLookup', () => { test('should define default value for function props', async () => { await act(async () => { - wrapper = mountWithContexts( {}} />); + wrapper = mountWithContexts( + + {}} /> + + ); }); expect(_OrganizationLookup.defaultProps.onBlur).toBeInstanceOf(Function); expect(_OrganizationLookup.defaultProps.onBlur).not.toThrow(); @@ -59,7 +76,9 @@ describe('OrganizationLookup', () => { const onChange = jest.fn(); await act(async () => { wrapper = mountWithContexts( - + + + ); }); expect(onChange).toHaveBeenCalledWith({ id: 1 }); @@ -74,7 +93,11 @@ describe('OrganizationLookup', () => { }); const onChange = jest.fn(); await act(async () => { - wrapper = mountWithContexts(); + wrapper = mountWithContexts( + + + + ); }); expect(onChange).not.toHaveBeenCalled(); }); @@ -89,7 +112,9 @@ describe('OrganizationLookup', () => { const onChange = jest.fn(); await act(async () => { wrapper = mountWithContexts( - + + + ); }); expect(onChange).not.toHaveBeenCalled(); diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx index cf894909d0..89ad789550 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect } from 'react'; import { node, string, func, bool } from 'prop-types'; import { withRouter } from 'react-router-dom'; - import { t } from '@lingui/macro'; import { FormGroup } from '@patternfly/react-core'; import { ProjectsAPI } from '../../api'; @@ -32,6 +31,8 @@ function ProjectLookup({ onBlur, history, isOverrideDisabled, + validate, + fieldName, }) { const autoPopulateLookup = useAutoPopulateLookup(onChange); const { @@ -72,6 +73,24 @@ function ProjectLookup({ } ); + const checkProjectName = useCallback( + async name => { + if (name && name !== '') { + try { + const { + data: { results: nameMatchResults, count: nameMatchCount }, + } = await ProjectsAPI.read({ name }); + onChange(nameMatchCount ? nameMatchResults[0] : null); + } catch { + onChange(null); + } + } else { + onChange(null); + } + }, + [onChange] + ); + useEffect(() => { fetchProjects(); }, [fetchProjects]); @@ -92,6 +111,9 @@ function ProjectLookup({ value={value} onBlur={onBlur} onChange={onChange} + onDebounce={checkProjectName} + fieldName={fieldName} + validate={validate} required={required} isLoading={isLoading} isDisabled={!canEdit} @@ -164,6 +186,8 @@ ProjectLookup.propTypes = { tooltip: string, value: Project, isOverrideDisabled: bool, + validate: func, + fieldName: string, }; ProjectLookup.defaultProps = { @@ -175,6 +199,8 @@ ProjectLookup.defaultProps = { tooltip: '', value: null, isOverrideDisabled: false, + validate: () => undefined, + fieldName: 'project', }; export { ProjectLookup as _ProjectLookup }; diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx index f0611ccdb4..94430074f6 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { ProjectsAPI } from '../../api'; import ProjectLookup from './ProjectLookup'; @@ -14,27 +15,35 @@ describe('', () => { test('should auto-select project when only one available and autoPopulate prop is true', async () => { ProjectsAPI.read.mockReturnValue({ data: { - results: [{ id: 1 }], + results: [{ id: 1, name: 'Test' }], count: 1, }, }); const onChange = jest.fn(); await act(async () => { - mountWithContexts(); + mountWithContexts( + + + + ); }); - expect(onChange).toHaveBeenCalledWith({ id: 1 }); + expect(onChange).toHaveBeenCalledWith({ id: 1, name: 'Test' }); }); test('should not auto-select project when autoPopulate prop is false', async () => { ProjectsAPI.read.mockReturnValue({ data: { - results: [{ id: 1 }], + results: [{ id: 1, name: 'Test' }], count: 1, }, }); const onChange = jest.fn(); await act(async () => { - mountWithContexts(); + mountWithContexts( + + + + ); }); expect(onChange).not.toHaveBeenCalled(); }); @@ -42,13 +51,20 @@ describe('', () => { test('should not auto-select project when multiple available', async () => { ProjectsAPI.read.mockReturnValue({ data: { - results: [{ id: 1 }, { id: 2 }], + results: [ + { id: 1, name: 'Test' }, + { id: 2, name: 'Test 2' }, + ], count: 2, }, }); const onChange = jest.fn(); await act(async () => { - mountWithContexts(); + mountWithContexts( + + + + ); }); expect(onChange).not.toHaveBeenCalled(); }); @@ -57,7 +73,7 @@ describe('', () => { let wrapper; ProjectsAPI.read.mockReturnValue({ data: { - results: [{ id: 1 }], + results: [{ id: 1, name: 'Test' }], count: 1, }, }); @@ -71,7 +87,9 @@ describe('', () => { }); await act(async () => { wrapper = mountWithContexts( - {}} /> + + {}} /> + ); }); wrapper.update(); @@ -92,7 +110,11 @@ describe('', () => { }, }); await act(async () => { - wrapper = mountWithContexts( {}} />); + wrapper = mountWithContexts( + + {}} /> + + ); }); wrapper.update(); expect(ProjectsAPI.read).toHaveBeenCalledTimes(1); @@ -113,11 +135,13 @@ describe('', () => { }); await act(async () => { wrapper = mountWithContexts( - {}} - /> + + {}} + /> + ); }); wrapper.update(); @@ -138,11 +162,13 @@ describe('', () => { }); await act(async () => { wrapper = mountWithContexts( - {}} - /> + + {}} + /> + ); }); wrapper.update(); diff --git a/awx/ui_next/src/components/OptionsList/OptionsList.jsx b/awx/ui_next/src/components/OptionsList/OptionsList.jsx index ccd6fd8ff9..bc663fd0b7 100644 --- a/awx/ui_next/src/components/OptionsList/OptionsList.jsx +++ b/awx/ui_next/src/components/OptionsList/OptionsList.jsx @@ -41,7 +41,6 @@ function OptionsList({ deselectItem, renderItemChip, isLoading, - displayKey, }) { return ( diff --git a/awx/ui_next/src/screens/Application/ApplicationAdd/ApplicationAdd.test.jsx b/awx/ui_next/src/screens/Application/ApplicationAdd/ApplicationAdd.test.jsx index dd480e8ab9..e9012a285e 100644 --- a/awx/ui_next/src/screens/Application/ApplicationAdd/ApplicationAdd.test.jsx +++ b/awx/ui_next/src/screens/Application/ApplicationAdd/ApplicationAdd.test.jsx @@ -98,8 +98,9 @@ describe('', () => { wrapper.update(); expect(wrapper.find('input#name').prop('value')).toBe('new foo'); expect(wrapper.find('input#description').prop('value')).toBe('new bar'); - expect(wrapper.find('Chip').length).toBe(1); - expect(wrapper.find('Chip').text()).toBe('organization'); + expect(wrapper.find('input#organization-input').prop('value')).toBe( + 'organization' + ); expect( wrapper .find('AnsibleSelect[name="authorization_grant_type"]') diff --git a/awx/ui_next/src/screens/Application/ApplicationEdit/ApplicationEdit.test.jsx b/awx/ui_next/src/screens/Application/ApplicationEdit/ApplicationEdit.test.jsx index e3dd4b03ea..69697dbbd8 100644 --- a/awx/ui_next/src/screens/Application/ApplicationEdit/ApplicationEdit.test.jsx +++ b/awx/ui_next/src/screens/Application/ApplicationEdit/ApplicationEdit.test.jsx @@ -188,8 +188,9 @@ describe('', () => { wrapper.update(); expect(wrapper.find('input#name').prop('value')).toBe('new foo'); expect(wrapper.find('input#description').prop('value')).toBe('new bar'); - expect(wrapper.find('Chip').length).toBe(1); - expect(wrapper.find('Chip').text()).toBe('organization'); + expect(wrapper.find('input#organization-input').prop('value')).toBe( + 'organization' + ); expect( wrapper .find('AnsibleSelect[name="authorization_grant_type"]') diff --git a/awx/ui_next/src/screens/Application/shared/ApplicationForm.jsx b/awx/ui_next/src/screens/Application/shared/ApplicationForm.jsx index 5f37614fbe..1cff20d37e 100644 --- a/awx/ui_next/src/screens/Application/shared/ApplicationForm.jsx +++ b/awx/ui_next/src/screens/Application/shared/ApplicationForm.jsx @@ -20,11 +20,10 @@ function ApplicationFormFields({ clientTypeOptions, }) { const match = useRouteMatch(); - const { setFieldValue } = useFormikContext(); - const [organizationField, organizationMeta, organizationHelpers] = useField({ - name: 'organization', - validate: required(null), - }); + const { setFieldValue, setFieldTouched } = useFormikContext(); + const [organizationField, organizationMeta, organizationHelpers] = useField( + 'organization' + ); const [ authorizationTypeField, authorizationTypeMeta, @@ -39,11 +38,12 @@ function ApplicationFormFields({ validate: required(null), }); - const onOrganizationChange = useCallback( + const handleOrganizationUpdate = useCallback( value => { setFieldValue('organization', value); + setFieldTouched('organization', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] ); return ( @@ -66,10 +66,11 @@ function ApplicationFormFields({ helperTextInvalid={organizationMeta.error} isValid={!organizationMeta.touched || !organizationMeta.error} onBlur={() => organizationHelpers.setTouched()} - onChange={onOrganizationChange} + onChange={handleOrganizationUpdate} value={organizationField.value} required autoPopulate={!application?.id} + validate={required(null)} /> { wrapper.update(); expect(wrapper.find('input#name').prop('value')).toBe('new foo'); expect(wrapper.find('input#description').prop('value')).toBe('new bar'); - expect(wrapper.find('Chip').length).toBe(1); - expect(wrapper.find('Chip').text()).toBe('organization'); + expect(wrapper.find('input#organization-input').prop('value')).toBe( + 'organization' + ); expect( wrapper .find('AnsibleSelect[name="authorization_grant_type"]') diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx index e11655856b..bff6be1627 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx @@ -52,12 +52,7 @@ function CredentialFormFields({ initialTypeId, credentialTypes }) { const isGalaxyCredential = !!credentialTypeId && credentialTypes[credentialTypeId]?.kind === 'galaxy'; - const [orgField, orgMeta, orgHelpers] = useField({ - name: 'organization', - validate: - isGalaxyCredential && - required(t`Galaxy credentials must be owned by an Organization.`), - }); + const [orgField, orgMeta, orgHelpers] = useField('organization'); const credentialTypeOptions = Object.keys(credentialTypes) .map(key => { @@ -122,11 +117,12 @@ function CredentialFormFields({ initialTypeId, credentialTypes }) { } }, [resetSubFormFields, credentialTypeId]); - const onOrganizationChange = useCallback( + const handleOrganizationUpdate = useCallback( value => { setFieldValue('organization', value); + setFieldTouched('organization', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] ); const isCredentialTypeDisabled = pathname.includes('edit'); @@ -182,12 +178,17 @@ function CredentialFormFields({ initialTypeId, credentialTypes }) { helperTextInvalid={orgMeta.error} isValid={!orgMeta.touched || !orgMeta.error} onBlur={() => orgHelpers.setTouched()} - onChange={onOrganizationChange} + onChange={handleOrganizationUpdate} value={orgField.value} touched={orgMeta.touched} error={orgMeta.error} required={isGalaxyCredential} isDisabled={initialValues.isOrgLookupDisabled} + validate={ + isGalaxyCredential + ? required(t`Galaxy credentials must be owned by an Organization.`) + : undefined + } /> { @@ -42,20 +39,19 @@ function ExecutionEnvironmentFormFields({ [setFieldValue] ); - const onOrganizationChange = useCallback( + const handleOrganizationUpdate = useCallback( value => { setFieldValue('organization', value); + setFieldTouched('organization', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] ); const [ containerOptionsField, containerOptionsMeta, containerOptionsHelpers, - ] = useField({ - name: 'pull', - }); + ] = useField('pull'); const containerPullChoices = options?.actions?.POST?.pull?.choices.map( ([value, label]) => ({ value, label, key: value }) @@ -67,7 +63,7 @@ function ExecutionEnvironmentFormFields({ helperTextInvalid={organizationMeta.error} isValid={!organizationMeta.touched || !organizationMeta.error} onBlur={() => organizationHelpers.setTouched()} - onChange={onOrganizationChange} + onChange={handleOrganizationUpdate} value={organizationField.value} required={!me.is_superuser} helperText={ @@ -79,6 +75,11 @@ function ExecutionEnvironmentFormFields({ } autoPopulate={!me?.is_superuser ? !executionEnvironment?.id : null} isDisabled={!!isOrgLookupDisabled && isGloballyAvailable.current} + validate={ + !me?.is_superuser + ? required(t`Select a value for this field`) + : undefined + } /> ); }; diff --git a/awx/ui_next/src/screens/ExecutionEnvironment/shared/ExecutionEnvironmentForm.test.jsx b/awx/ui_next/src/screens/ExecutionEnvironment/shared/ExecutionEnvironmentForm.test.jsx index 85c63249cf..fb60d238f8 100644 --- a/awx/ui_next/src/screens/ExecutionEnvironment/shared/ExecutionEnvironmentForm.test.jsx +++ b/awx/ui_next/src/screens/ExecutionEnvironment/shared/ExecutionEnvironmentForm.test.jsx @@ -113,6 +113,7 @@ const containerRegistryCredentialResolve = { kind: 'registry', }, ], + count: 1, }, }; diff --git a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx index a8751be5a5..19a8d6319b 100644 --- a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx +++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx @@ -1,7 +1,6 @@ import React, { useState } from 'react'; import { useHistory } from 'react-router-dom'; import { PageSection, Card } from '@patternfly/react-core'; - import HostForm from '../../../components/HostForm'; import { CardBody } from '../../../components/Card'; import { HostsAPI } from '../../../api'; @@ -12,7 +11,11 @@ function HostAdd() { const handleSubmit = async formData => { try { - const { data: response } = await HostsAPI.create(formData); + const dataToSend = { ...formData }; + if (dataToSend.inventory) { + dataToSend.inventory = dataToSend.inventory.id; + } + const { data: response } = await HostsAPI.create(dataToSend); history.push(`/hosts/${response.id}/details`); } catch (error) { setFormError(error); diff --git a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx index 29ae4469ad..5d7ae901b1 100644 --- a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx +++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx @@ -10,7 +10,10 @@ jest.mock('../../../api'); const hostData = { name: 'new name', description: 'new description', - inventory: 1, + inventory: { + id: 1, + name: 'Demo Inventory', + }, variables: '---\nfoo: bar', }; @@ -44,7 +47,7 @@ describe('', () => { await act(async () => { wrapper.find('HostForm').prop('handleSubmit')(hostData); }); - expect(HostsAPI.create).toHaveBeenCalledWith(hostData); + expect(HostsAPI.create).toHaveBeenCalledWith({ ...hostData, inventory: 1 }); }); test('should navigate to hosts list when cancel is clicked', async () => { diff --git a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx index 7d7ca290df..8e90b6d535 100644 --- a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx +++ b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx @@ -12,7 +12,11 @@ function HostEdit({ host }) { const handleSubmit = async values => { try { - await HostsAPI.update(host.id, values); + const dataToSend = { ...values }; + if (dataToSend.inventory) { + dataToSend.inventory = dataToSend.inventory.id; + } + await HostsAPI.update(host.id, dataToSend); history.push(detailsUrl); } catch (error) { setFormError(error); diff --git a/awx/ui_next/src/screens/InstanceGroup/shared/ContainerGroupForm.jsx b/awx/ui_next/src/screens/InstanceGroup/shared/ContainerGroupForm.jsx index eacb3173b8..ef8caa427a 100644 --- a/awx/ui_next/src/screens/InstanceGroup/shared/ContainerGroupForm.jsx +++ b/awx/ui_next/src/screens/InstanceGroup/shared/ContainerGroupForm.jsx @@ -22,18 +22,19 @@ import CredentialLookup from '../../../components/Lookup/CredentialLookup'; import { VariablesField } from '../../../components/CodeEditor'; function ContainerGroupFormFields({ instanceGroup }) { - const { setFieldValue } = useFormikContext(); - const [credentialField, credentialMeta, credentialHelpers] = useField({ - name: 'credential', - }); + const { setFieldValue, setFieldTouched } = useFormikContext(); + const [credentialField, credentialMeta, credentialHelpers] = useField( + 'credential' + ); const [overrideField] = useField('override'); - const onCredentialChange = useCallback( + const handleCredentialUpdate = useCallback( value => { setFieldValue('credential', value); + setFieldTouched('credential', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] ); return ( @@ -52,7 +53,7 @@ function ContainerGroupFormFields({ instanceGroup }) { helperTextInvalid={credentialMeta.error} isValid={!credentialMeta.touched || !credentialMeta.error} onBlur={() => credentialHelpers.setTouched()} - onChange={onCredentialChange} + onChange={handleCredentialUpdate} value={credentialField.value} tooltip={t`Credential to authenticate with Kubernetes or OpenShift. Must be of type "Kubernetes/OpenShift API Bearer Token". If left blank, the underlying Pod's service account will be used.`} autoPopulate={!instanceGroup?.id} diff --git a/awx/ui_next/src/screens/Inventory/shared/InventoryForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryForm.jsx index b7922841df..078122baa3 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventoryForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventoryForm.jsx @@ -1,9 +1,7 @@ import React, { useCallback } from 'react'; import { Formik, useField, useFormikContext } from 'formik'; - import { t } from '@lingui/macro'; import { func, number, shape } from 'prop-types'; - import { Form } from '@patternfly/react-core'; import { VariablesField } from '../../../components/CodeEditor'; import FormField, { FormSubmitError } from '../../../components/FormField'; @@ -18,26 +16,30 @@ import { } from '../../../components/FormLayout'; function InventoryFormFields({ credentialTypeId, inventory }) { - const { setFieldValue } = useFormikContext(); - const [organizationField, organizationMeta, organizationHelpers] = useField({ - name: 'organization', - validate: required(t`Select a value for this field`), - }); + const { setFieldValue, setFieldTouched } = useFormikContext(); + const [organizationField, organizationMeta, organizationHelpers] = useField( + 'organization' + ); const [instanceGroupsField, , instanceGroupsHelpers] = useField( 'instanceGroups' ); - const [insightsCredentialField] = useField('insights_credential'); - const onOrganizationChange = useCallback( + const [insightsCredentialField, insightsCredentialMeta] = useField( + 'insights_credential' + ); + const handleOrganizationUpdate = useCallback( value => { setFieldValue('organization', value); + setFieldTouched('organization', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] ); - const onCredentialChange = useCallback( + + const handleCredentialUpdate = useCallback( value => { setFieldValue('insights_credential', value); + setFieldTouched('insights_credential', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] ); return ( @@ -60,24 +62,31 @@ function InventoryFormFields({ credentialTypeId, inventory }) { helperTextInvalid={organizationMeta.error} isValid={!organizationMeta.touched || !organizationMeta.error} onBlur={() => organizationHelpers.setTouched()} - onChange={onOrganizationChange} + onChange={handleOrganizationUpdate} value={organizationField.value} touched={organizationMeta.touched} error={organizationMeta.error} required autoPopulate={!inventory?.id} + validate={required(t`Select a value for this field`)} /> { instanceGroupsHelpers.setValue(value); }} + fieldName="instanceGroups" /> { if (sourceType === initialValues.source) { @@ -97,6 +93,14 @@ const InventorySourceFormFields = ({ } }; + const handleExecutionEnvironmentUpdate = useCallback( + value => { + setFieldValue('execution_environment', value); + setFieldTouched('execution_environment', true, false); + }, + [setFieldValue, setFieldTouched] + ); + return ( <> executionEnvironmentHelpers.setTouched()} value={executionEnvironmentField.value} - onChange={value => executionEnvironmentHelpers.setValue(value)} + onChange={handleExecutionEnvironmentUpdate} globallyAvailable organizationId={organizationId} /> diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/AzureSubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/AzureSubForm.jsx index 60ed35a6a7..f47a4beb0c 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/AzureSubForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/AzureSubForm.jsx @@ -1,6 +1,5 @@ import React, { useCallback } from 'react'; import { useField, useFormikContext } from 'formik'; - import { t, Trans } from '@lingui/macro'; import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; import { @@ -16,18 +15,18 @@ import getDocsBaseUrl from '../../../../util/getDocsBaseUrl'; import { useConfig } from '../../../../contexts/Config'; const AzureSubForm = ({ autoPopulateCredential }) => { - const { setFieldValue } = useFormikContext(); - const [credentialField, credentialMeta, credentialHelpers] = useField({ - name: 'credential', - validate: required(t`Select a value for this field`), - }); + const { setFieldValue, setFieldTouched } = useFormikContext(); + const [credentialField, credentialMeta, credentialHelpers] = useField( + 'credential' + ); const config = useConfig(); const handleCredentialUpdate = useCallback( value => { setFieldValue('credential', value); + setFieldTouched('credential', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] ); const pluginLink = `${getDocsBaseUrl( @@ -48,6 +47,7 @@ const AzureSubForm = ({ autoPopulateCredential }) => { value={credentialField.value} required autoPopulate={autoPopulateCredential} + validate={required(t`Select a value for this field`)} /> diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/EC2SubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/EC2SubForm.jsx index e28abc6ef8..61bb8aa788 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/EC2SubForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/EC2SubForm.jsx @@ -1,6 +1,5 @@ import React, { useCallback } from 'react'; import { useField, useFormikContext } from 'formik'; - import { t, Trans } from '@lingui/macro'; import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; import { @@ -15,15 +14,16 @@ import getDocsBaseUrl from '../../../../util/getDocsBaseUrl'; import { useConfig } from '../../../../contexts/Config'; const EC2SubForm = () => { - const { setFieldValue } = useFormikContext(); - const [credentialField] = useField('credential'); + const { setFieldValue, setFieldTouched } = useFormikContext(); + const [credentialField, credentialMeta] = useField('credential'); const config = useConfig(); const handleCredentialUpdate = useCallback( value => { setFieldValue('credential', value); + setFieldTouched('credential', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] ); const pluginLink = `${getDocsBaseUrl( @@ -35,6 +35,8 @@ const EC2SubForm = () => { return ( <> { - const { setFieldValue } = useFormikContext(); - const [credentialField, credentialMeta, credentialHelpers] = useField({ - name: 'credential', - validate: required(t`Select a value for this field`), - }); + const { setFieldValue, setFieldTouched } = useFormikContext(); + const [credentialField, credentialMeta, credentialHelpers] = useField( + 'credential' + ); const config = useConfig(); const handleCredentialUpdate = useCallback( value => { setFieldValue('credential', value); + setFieldTouched('credential', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] ); const pluginLink = `${getDocsBaseUrl( @@ -48,6 +47,7 @@ const GCESubForm = ({ autoPopulateCredential }) => { value={credentialField.value} required autoPopulate={autoPopulateCredential} + validate={required(t`Select a value for this field`)} /> diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/OpenStackSubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/OpenStackSubForm.jsx index f52f50ffa0..861733a329 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/OpenStackSubForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/OpenStackSubForm.jsx @@ -1,6 +1,5 @@ import React, { useCallback } from 'react'; import { useField, useFormikContext } from 'formik'; - import { t, Trans } from '@lingui/macro'; import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; import { @@ -16,18 +15,18 @@ import getDocsBaseUrl from '../../../../util/getDocsBaseUrl'; import { useConfig } from '../../../../contexts/Config'; const OpenStackSubForm = ({ autoPopulateCredential }) => { - const { setFieldValue } = useFormikContext(); - const [credentialField, credentialMeta, credentialHelpers] = useField({ - name: 'credential', - validate: required(t`Select a value for this field`), - }); + const { setFieldValue, setFieldTouched } = useFormikContext(); + const [credentialField, credentialMeta, credentialHelpers] = useField( + 'credential' + ); const config = useConfig(); const handleCredentialUpdate = useCallback( value => { setFieldValue('credential', value); + setFieldTouched('credential', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] ); const pluginLink = `${getDocsBaseUrl( @@ -48,6 +47,7 @@ const OpenStackSubForm = ({ autoPopulateCredential }) => { value={credentialField.value} required autoPopulate={autoPopulateCredential} + validate={required(t`Select a value for this field`)} /> 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 d3be7a3fcb..a2cff3073e 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/SCMSubForm.jsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useField, useFormikContext } from 'formik'; - import { t } from '@lingui/macro'; import { FormGroup, @@ -11,7 +10,6 @@ import { import { ProjectsAPI } from '../../../../api'; import useRequest from '../../../../util/useRequest'; import { required } from '../../../../util/validators'; - import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; import ProjectLookup from '../../../../components/Lookup/ProjectLookup'; import Popover from '../../../../components/Popover'; @@ -29,10 +27,9 @@ const SCMSubForm = ({ autoPopulateProject }) => { const [sourcePath, setSourcePath] = useState([]); const { setFieldValue, setFieldTouched } = useFormikContext(); const [credentialField] = useField('credential'); - const [projectField, projectMeta, projectHelpers] = useField({ - name: 'source_project', - validate: required(t`Select a value for this field`), - }); + const [projectField, projectMeta, projectHelpers] = useField( + 'source_project' + ); const [sourcePathField, sourcePathMeta, sourcePathHelpers] = useField({ name: 'source_path', validate: required(t`Select a value for this field`), @@ -60,7 +57,10 @@ const SCMSubForm = ({ autoPopulateProject }) => { setFieldValue('source_project', value); setFieldValue('source_path', ''); setFieldTouched('source_path', false); - fetchSourcePath(value.id); + setFieldTouched('source_project', true, false); + if (value) { + fetchSourcePath(value.id); + } }, [fetchSourcePath, setFieldValue, setFieldTouched] ); @@ -68,8 +68,9 @@ const SCMSubForm = ({ autoPopulateProject }) => { const handleCredentialUpdate = useCallback( value => { setFieldValue('credential', value); + setFieldTouched('credential', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] ); return ( @@ -88,6 +89,8 @@ const SCMSubForm = ({ autoPopulateProject }) => { onChange={handleProjectUpdate} required autoPopulate={autoPopulateProject} + fieldName="source_project" + validate={required(t`Select a value for this field`)} /> { - const { setFieldValue } = useFormikContext(); - const [credentialField, credentialMeta, credentialHelpers] = useField({ - name: 'credential', - validate: required(t`Select a value for this field`), - }); + const { setFieldValue, setFieldTouched } = useFormikContext(); + const [credentialField, credentialMeta, credentialHelpers] = useField( + 'credential' + ); const config = useConfig(); const handleCredentialUpdate = useCallback( value => { setFieldValue('credential', value); + setFieldTouched('credential', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] ); const pluginLink = `${getDocsBaseUrl( @@ -48,6 +47,7 @@ const SatelliteSubForm = ({ autoPopulateCredential }) => { value={credentialField.value} required autoPopulate={autoPopulateCredential} + validate={required(t`Select a value for this field`)} /> diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/TowerSubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/TowerSubForm.jsx index a6f92ad81c..c1050a7cdc 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/TowerSubForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/TowerSubForm.jsx @@ -16,18 +16,18 @@ import getDocsBaseUrl from '../../../../util/getDocsBaseUrl'; import { useConfig } from '../../../../contexts/Config'; const TowerSubForm = ({ autoPopulateCredential }) => { - const { setFieldValue } = useFormikContext(); - const [credentialField, credentialMeta, credentialHelpers] = useField({ - name: 'credential', - validate: required(t`Select a value for this field`), - }); + const { setFieldValue, setFieldTouched } = useFormikContext(); + const [credentialField, credentialMeta, credentialHelpers] = useField( + 'credential' + ); const config = useConfig(); const handleCredentialUpdate = useCallback( value => { setFieldValue('credential', value); + setFieldTouched('credential', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] ); const pluginLink = `${getDocsBaseUrl( @@ -48,6 +48,7 @@ const TowerSubForm = ({ autoPopulateCredential }) => { value={credentialField.value} required autoPopulate={autoPopulateCredential} + validate={required(t`Select a value for this field`)} /> diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VMwareSubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VMwareSubForm.jsx index 349c36ccf3..8f35176c16 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VMwareSubForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VMwareSubForm.jsx @@ -1,6 +1,5 @@ import React, { useCallback } from 'react'; import { useField, useFormikContext } from 'formik'; - import { t, Trans } from '@lingui/macro'; import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; import { @@ -16,18 +15,18 @@ import getDocsBaseUrl from '../../../../util/getDocsBaseUrl'; import { useConfig } from '../../../../contexts/Config'; const VMwareSubForm = ({ autoPopulateCredential }) => { - const { setFieldValue } = useFormikContext(); - const [credentialField, credentialMeta, credentialHelpers] = useField({ - name: 'credential', - validate: required(t`Select a value for this field`), - }); + const { setFieldValue, setFieldTouched } = useFormikContext(); + const [credentialField, credentialMeta, credentialHelpers] = useField( + 'credential' + ); const config = useConfig(); const handleCredentialUpdate = useCallback( value => { setFieldValue('credential', value); + setFieldTouched('credential', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] ); const pluginLink = `${getDocsBaseUrl( @@ -48,6 +47,7 @@ const VMwareSubForm = ({ autoPopulateCredential }) => { value={credentialField.value} required autoPopulate={autoPopulateCredential} + validate={required(t`Select a value for this field`)} /> diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VirtualizationSubForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VirtualizationSubForm.jsx index b507101d17..757be9a323 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VirtualizationSubForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceSubForms/VirtualizationSubForm.jsx @@ -1,6 +1,5 @@ import React, { useCallback } from 'react'; import { useField, useFormikContext } from 'formik'; - import { t, Trans } from '@lingui/macro'; import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; import { @@ -16,18 +15,18 @@ import getDocsBaseUrl from '../../../../util/getDocsBaseUrl'; import { useConfig } from '../../../../contexts/Config'; const VirtualizationSubForm = ({ autoPopulateCredential }) => { - const { setFieldValue } = useFormikContext(); - const [credentialField, credentialMeta, credentialHelpers] = useField({ - name: 'credential', - validate: required(t`Select a value for this field`), - }); + const { setFieldValue, setFieldTouched } = useFormikContext(); + const [credentialField, credentialMeta, credentialHelpers] = useField( + 'credential' + ); const config = useConfig(); const handleCredentialUpdate = useCallback( value => { setFieldValue('credential', value); + setFieldTouched('credential', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] ); const pluginLink = `${getDocsBaseUrl( @@ -48,6 +47,7 @@ const VirtualizationSubForm = ({ autoPopulateCredential }) => { value={credentialField.value} required autoPopulate={autoPopulateCredential} + validate={required(t`Select a value for this field`)} /> diff --git a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx index f4182086fe..766f169094 100644 --- a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx @@ -1,6 +1,5 @@ import React, { useEffect, useCallback } from 'react'; import { Formik, useField, useFormikContext } from 'formik'; - import { t } from '@lingui/macro'; import { useLocation } from 'react-router-dom'; import { func, shape, arrayOf } from 'prop-types'; @@ -27,23 +26,23 @@ import { required } from '../../../util/validators'; import { InventoriesAPI } from '../../../api'; const SmartInventoryFormFields = ({ inventory }) => { - const { setFieldValue } = useFormikContext(); - const [organizationField, organizationMeta, organizationHelpers] = useField({ - name: 'organization', - validate: required(t`Select a value for this field`), - }); - const [instanceGroupsField, , instanceGroupsHelpers] = useField({ - name: 'instance_groups', - }); + const { setFieldValue, setFieldTouched } = useFormikContext(); + const [organizationField, organizationMeta, organizationHelpers] = useField( + 'organization' + ); + const [instanceGroupsField, , instanceGroupsHelpers] = useField( + 'instance_groups' + ); const [hostFilterField, hostFilterMeta, hostFilterHelpers] = useField({ name: 'host_filter', validate: required(null), }); - const onOrganizationChange = useCallback( + const handleOrganizationUpdate = useCallback( value => { setFieldValue('organization', value); + setFieldTouched('organization', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] ); return ( @@ -66,10 +65,11 @@ const SmartInventoryFormFields = ({ inventory }) => { helperTextInvalid={organizationMeta.error} isValid={!organizationMeta.touched || !organizationMeta.error} onBlur={() => organizationHelpers.setTouched()} - onChange={onOrganizationChange} + onChange={handleOrganizationUpdate} value={organizationField.value} required autoPopulate={!inventory?.id} + validate={required(t`Select a value for this field`)} /> { setFieldValue('organization', value); + setFieldTouched('organization', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] ); return ( @@ -51,12 +52,13 @@ function NotificationTemplateFormFields({ defaultMessages, template }) { helperTextInvalid={orgMeta.error} isValid={!orgMeta.touched || !orgMeta.error} onBlur={() => orgHelpers.setTouched()} - onChange={onOrganizationChange} + onChange={handleOrganizationUpdate} value={orgField.value} touched={orgMeta.touched} error={orgMeta.error} required autoPopulate={!template?.id} + validate={required(t`Select a value for this field`)} /> { @@ -97,6 +95,7 @@ function OrganizationFormFields({ globallyAvailable organizationId={organizationId} isDefaultEnvironment + fieldName="default_environment" /> ); diff --git a/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.jsx b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.jsx index eaaa4274f3..eb4ac136e2 100644 --- a/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.jsx +++ b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.jsx @@ -19,6 +19,8 @@ function ProjectAdd() { // has a zero-length string as its credential field. As a work-around, // normalize falsey credential fields by deleting them. delete values.credential; + } else { + values.credential = values.credential.id; } setFormSubmitError(null); try { diff --git a/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx index 2d7975e149..ae3ae7ec4a 100644 --- a/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx +++ b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx @@ -56,6 +56,7 @@ describe('', () => { kind: 'scm', }, ], + count: 1, }, }; @@ -68,6 +69,7 @@ describe('', () => { kind: 'insights', }, ], + count: 1, }, }; diff --git a/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.jsx b/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.jsx index 3682a01cdc..efd653cea3 100644 --- a/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.jsx +++ b/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.jsx @@ -19,6 +19,8 @@ function ProjectEdit({ project }) { // has a zero-length string as its credential field. As a work-around, // normalize falsey credential fields by deleting them. delete values.credential; + } else { + values.credential = values.credential.id; } try { const { diff --git a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx index a5b0fac0df..37394f83b8 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx @@ -89,24 +89,21 @@ function ProjectFormFields({ scm_update_cache_timeout: 0, }; - const { setFieldValue } = useFormikContext(); + const { setFieldValue, setFieldTouched } = useFormikContext(); const [scmTypeField, scmTypeMeta, scmTypeHelpers] = useField({ name: 'scm_type', validate: required(t`Set a value for this field`), }); - const [organizationField, organizationMeta, organizationHelpers] = useField({ - name: 'organization', - validate: required(t`Select a value for this field`), - }); + const [organizationField, organizationMeta, organizationHelpers] = useField( + 'organization' + ); const [ executionEnvironmentField, executionEnvironmentMeta, executionEnvironmentHelpers, - ] = useField({ - name: 'default_environment', - }); + ] = useField('default_environment'); /* Save current scm subform field values to state */ const saveSubFormState = form => { @@ -153,11 +150,20 @@ function ProjectFormFields({ [credentials, setCredentials] ); - const onOrganizationChange = useCallback( + const handleOrganizationUpdate = useCallback( value => { setFieldValue('organization', value); + setFieldTouched('organization', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] + ); + + const handleExecutionEnvironmentUpdate = useCallback( + value => { + setFieldValue('default_environment', value); + setFieldTouched('default_environment', true, false); + }, + [setFieldValue, setFieldTouched] ); return ( @@ -180,10 +186,11 @@ function ProjectFormFields({ helperTextInvalid={organizationMeta.error} isValid={!organizationMeta.touched || !organizationMeta.error} onBlur={() => organizationHelpers.setTouched()} - onChange={onOrganizationChange} + onChange={handleOrganizationUpdate} value={organizationField.value} required autoPopulate={!project?.id} + validate={required(t`Select a value for this field`)} /> executionEnvironmentHelpers.setTouched()} value={executionEnvironmentField.value} - onChange={value => executionEnvironmentHelpers.setValue(value)} popoverContent={t`The execution environment that will be used for jobs that use this project. This will be used as fallback when an execution environment has not been explicitly assigned at the job template or workflow level.`} + onChange={handleExecutionEnvironmentUpdate} tooltip={t`Select an organization before editing the default execution environment.`} globallyAvailable isDisabled={!organizationField.value} organizationId={organizationField.value?.id} isDefaultEnvironment + fieldName="default_environment" /> { - const { setFieldValue } = useFormikContext(); - const [, credMeta, credHelpers] = useField({ - name: 'credential', - validate: required(t`Select a value for this field`), - }); + const { setFieldValue, setFieldTouched } = useFormikContext(); + const [, credMeta, credHelpers] = useField('credential'); const onCredentialChange = useCallback( value => { onCredentialSelection('insights', value); - setFieldValue('credential', value.id); + setFieldValue('credential', value); + setFieldTouched('credential', true, false); }, - [onCredentialSelection, setFieldValue] + [onCredentialSelection, setFieldValue, setFieldTouched] ); return ( @@ -38,6 +36,7 @@ const InsightsSubForm = ({ value={credential.value} required autoPopulate={autoPopulateCredential} + validate={required(t`Select a value for this field`)} /> diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SharedFields.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SharedFields.jsx index 799b752fdc..1f204fbb12 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SharedFields.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SharedFields.jsx @@ -41,14 +41,15 @@ export const ScmCredentialFormField = ({ credential, onCredentialSelection, }) => { - const { setFieldValue } = useFormikContext(); + const { setFieldValue, setFieldTouched } = useFormikContext(); const onCredentialChange = useCallback( value => { onCredentialSelection('scm', value); - setFieldValue('credential', value ? value.id : ''); + setFieldValue('credential', value); + setFieldTouched('credential', true, false); }, - [onCredentialSelection, setFieldValue] + [onCredentialSelection, setFieldValue, setFieldTouched] ); return ( diff --git a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.jsx b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.jsx index 8206fb0a3c..0def52b079 100644 --- a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.jsx +++ b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.jsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect } from 'react'; import { useHistory } from 'react-router-dom'; - import { t } from '@lingui/macro'; import { Formik } from 'formik'; import { Form } from '@patternfly/react-core'; @@ -238,14 +237,20 @@ function MiscSystemEdit() { formik.setFieldTouched('DEFAULT_EXECUTION_ENVIRONMENT') } value={formik.values.DEFAULT_EXECUTION_ENVIRONMENT} - onChange={value => + onChange={value => { formik.setFieldValue( 'DEFAULT_EXECUTION_ENVIRONMENT', value - ) - } + ); + formik.setFieldTouched( + 'DEFAULT_EXECUTION_ENVIRONMENT', + true, + false + ); + }} popoverContent={t`The Execution Environment to be used when one has not been configured for a job template.`} isGlobalDefaultEnvironment + fieldName="DEFAULT_EXECUTION_ENVIRONMENT" /> { diff --git a/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionStep.jsx b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionStep.jsx index 7bddc5cef2..7be4904a27 100644 --- a/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionStep.jsx +++ b/awx/ui_next/src/screens/Setting/Subscription/SubscriptionEdit/SubscriptionStep.jsx @@ -40,21 +40,13 @@ function SubscriptionStep() { values.subscription ? 'selectSubscription' : 'uploadManifest' ); const { isModalOpen, toggleModal, closeModal } = useModal(); - const [manifest, manifestMeta, manifestHelpers] = useField({ - name: 'manifest_file', - }); - const [manifestFilename, , manifestFilenameHelpers] = useField({ - name: 'manifest_filename', - }); - const [subscription, , subscriptionHelpers] = useField({ - name: 'subscription', - }); - const [username, usernameMeta, usernameHelpers] = useField({ - name: 'username', - }); - const [password, passwordMeta, passwordHelpers] = useField({ - name: 'password', - }); + const [manifest, manifestMeta, manifestHelpers] = useField('manifest_file'); + const [manifestFilename, , manifestFilenameHelpers] = useField( + 'manifest_filename' + ); + const [subscription, , subscriptionHelpers] = useField('subscription'); + const [username, usernameMeta, usernameHelpers] = useField('username'); + const [password, passwordMeta, passwordHelpers] = useField('password'); return ( ', () => { const updatedTeamData = { name: 'new name', description: 'new description', - organization: 1, + organization: { + id: 1, + name: 'Default', + }, }; await act(async () => { wrapper.find('TeamForm').invoke('handleSubmit')(updatedTeamData); }); - expect(TeamsAPI.create).toHaveBeenCalledWith(updatedTeamData); + expect(TeamsAPI.create).toHaveBeenCalledWith({ + ...updatedTeamData, + organization: 1, + }); }); test('should navigate to teams list when cancel is clicked', async () => { @@ -41,7 +47,10 @@ describe('', () => { const teamData = { name: 'new name', description: 'new description', - organization: 1, + organization: { + id: 1, + name: 'Default', + }, }; TeamsAPI.create.mockResolvedValueOnce({ data: { diff --git a/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.jsx b/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.jsx index 55c518b228..c60524f518 100644 --- a/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.jsx +++ b/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.jsx @@ -14,7 +14,11 @@ function TeamEdit({ team }) { const handleSubmit = async values => { try { - await TeamsAPI.update(team.id, values); + const valuesToSend = { ...values }; + if (valuesToSend.organization) { + valuesToSend.organization = valuesToSend.organization.id; + } + await TeamsAPI.update(team.id, valuesToSend); history.push(`/teams/${team.id}/details`); } catch (err) { setError(err); diff --git a/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.test.jsx b/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.test.jsx index 82b980ba7b..324136ea7d 100644 --- a/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.test.jsx +++ b/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.test.jsx @@ -30,12 +30,19 @@ describe('', () => { const updatedTeamData = { name: 'new name', description: 'new description', + organization: { + id: 2, + name: 'Other Org', + }, }; await act(async () => { wrapper.find('TeamForm').invoke('handleSubmit')(updatedTeamData); }); - expect(TeamsAPI.update).toHaveBeenCalledWith(1, updatedTeamData); + expect(TeamsAPI.update).toHaveBeenCalledWith(1, { + ...updatedTeamData, + organization: 2, + }); expect(history.location.pathname).toEqual('/teams/1/details'); }); diff --git a/awx/ui_next/src/screens/Team/shared/TeamForm.jsx b/awx/ui_next/src/screens/Team/shared/TeamForm.jsx index 762667f0cb..7b8da1dddb 100644 --- a/awx/ui_next/src/screens/Team/shared/TeamForm.jsx +++ b/awx/ui_next/src/screens/Team/shared/TeamForm.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; import { t } from '@lingui/macro'; @@ -11,21 +11,15 @@ import { required } from '../../../util/validators'; import { FormColumnLayout } from '../../../components/FormLayout'; function TeamFormFields({ team }) { - const { setFieldValue } = useFormikContext(); - const [organization, setOrganization] = useState( - team.summary_fields ? team.summary_fields.organization : null - ); - const [, orgMeta, orgHelpers] = useField({ - name: 'organization', - validate: required(t`Select a value for this field`), - }); + const { setFieldValue, setFieldTouched } = useFormikContext(); + const [orgField, orgMeta, orgHelpers] = useField('organization'); - const onOrganizationChange = useCallback( + const handleOrganizationUpdate = useCallback( value => { - setFieldValue('organization', value.id); - setOrganization(value); + setFieldValue('organization', value); + setFieldTouched('organization', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] ); return ( @@ -48,10 +42,11 @@ function TeamFormFields({ team }) { helperTextInvalid={orgMeta.error} isValid={!orgMeta.touched || !orgMeta.error} onBlur={() => orgHelpers.setTouched('organization')} - onChange={onOrganizationChange} - value={organization} + onChange={handleOrganizationUpdate} + value={orgField.value} required autoPopulate={!team?.id} + validate={required(t`Select a value for this field`)} /> ); @@ -65,7 +60,7 @@ function TeamForm(props) { initialValues={{ description: team.description || '', name: team.name || '', - organization: team.organization || '', + organization: team.summary_fields?.organization || null, }} onSubmit={handleSubmit} > diff --git a/awx/ui_next/src/screens/Team/shared/TeamForm.test.jsx b/awx/ui_next/src/screens/Team/shared/TeamForm.test.jsx index d2d499292b..874a9f5c73 100644 --- a/awx/ui_next/src/screens/Team/shared/TeamForm.test.jsx +++ b/awx/ui_next/src/screens/Team/shared/TeamForm.test.jsx @@ -22,8 +22,10 @@ describe('', () => { description: 'Bar', organization: 1, summary_fields: { - id: 1, - name: 'Default', + organization: { + id: 1, + name: 'Default', + }, }, }; diff --git a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx index bbb5e47b84..1461cd1fca 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx @@ -14,6 +14,8 @@ function JobTemplateAdd() { labels, instanceGroups, initialInstanceGroups, + inventory, + project, credentials, webhook_credential, webhook_key, @@ -22,8 +24,9 @@ function JobTemplateAdd() { } = values; setFormSubmitError(null); - remainingValues.project = remainingValues.project.id; + remainingValues.project = project.id; remainingValues.webhook_credential = webhook_credential?.id; + remainingValues.inventory = inventory?.id || null; try { const { data: { id, type }, diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx index fa0ad134cb..0882a1519d 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx @@ -46,6 +46,8 @@ function JobTemplateEdit({ template }) { instanceGroups, initialInstanceGroups, credentials, + inventory, + project, webhook_credential, webhook_key, webhook_url, @@ -55,8 +57,9 @@ function JobTemplateEdit({ template }) { setFormSubmitError(null); setIsLoading(true); - remainingValues.project = values.project.id; + remainingValues.project = project.id; remainingValues.webhook_credential = webhook_credential?.id || null; + remainingValues.inventory = inventory?.id || null; remainingValues.execution_environment = execution_environment?.id || null; try { await JobTemplatesAPI.update(template.id, remainingValues); diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx index 62ae734591..b7dfb74864 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx @@ -13,10 +13,20 @@ import { ProjectsAPI, InventoriesAPI, ExecutionEnvironmentsAPI, + InstanceGroupsAPI, } from '../../../api'; import JobTemplateEdit from './JobTemplateEdit'; +import useDebounce from '../../../util/useDebounce'; -jest.mock('../../../api'); +jest.mock('../../../util/useDebounce'); +jest.mock('../../../api/models/Credentials'); +jest.mock('../../../api/models/CredentialTypes'); +jest.mock('../../../api/models/JobTemplates'); +jest.mock('../../../api/models/Labels'); +jest.mock('../../../api/models/Projects'); +jest.mock('../../../api/models/Inventories'); +jest.mock('../../../api/models/ExecutionEnvironments'); +jest.mock('../../../api/models/InstanceGroups'); const mockJobTemplate = { allow_callbacks: false, @@ -66,6 +76,7 @@ const mockJobTemplate = { }, inventory: { id: 2, + name: 'Demo Inventory', organization_id: 1, }, credentials: [ @@ -195,22 +206,55 @@ describe('', () => { JobTemplatesAPI.readCredentials.mockResolvedValue({ data: mockRelatedCredentials, }); - ProjectsAPI.readPlaybooks.mockResolvedValue({ - data: mockRelatedProjectPlaybooks, + JobTemplatesAPI.readInstanceGroups.mockReturnValue({ + data: { results: mockInstanceGroups }, + }); + + InventoriesAPI.read.mockResolvedValue({ + data: { + results: [], + count: 0, + }, }); InventoriesAPI.readOptions.mockResolvedValue({ data: { actions: { GET: {}, POST: {} } }, }); + + InstanceGroupsAPI.read.mockResolvedValue({ + data: { + results: [], + count: 0, + }, + }); + InstanceGroupsAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {}, POST: {} } }, + }); + + ProjectsAPI.read.mockResolvedValue({ + data: { + results: [], + count: 0, + }, + }); ProjectsAPI.readOptions.mockResolvedValue({ data: { actions: { GET: {}, POST: {} } }, }); + ProjectsAPI.readPlaybooks.mockResolvedValue({ + data: mockRelatedProjectPlaybooks, + }); + LabelsAPI.read.mockResolvedValue({ data: { results: [] } }); + CredentialsAPI.read.mockResolvedValue({ data: { results: [], count: 0, }, }); + CredentialsAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {}, POST: {} } }, + }); + CredentialTypesAPI.loadAllTypes.mockResolvedValue([]); ExecutionEnvironmentsAPI.read.mockResolvedValue({ @@ -219,18 +263,11 @@ describe('', () => { count: 1, }, }); - LabelsAPI.read.mockResolvedValue({ data: { results: [] } }); - JobTemplatesAPI.readCredentials.mockResolvedValue({ - data: mockRelatedCredentials, - }); - JobTemplatesAPI.readInstanceGroups.mockReturnValue({ - data: { results: mockInstanceGroups }, - }); - ProjectsAPI.readDetail.mockReturnValue({ - id: 1, - allow_override: true, - name: 'foo', + ExecutionEnvironmentsAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {}, POST: {} } }, }); + + useDebounce.mockImplementation(fn => fn); }); afterEach(() => { @@ -262,7 +299,10 @@ describe('', () => { const updatedTemplateData = { job_type: 'check', name: 'new name', - inventory: 1, + inventory: { + id: 1, + name: 'Other Inventory', + }, }; const labels = [ { id: 3, name: 'Foo' }, @@ -280,20 +320,24 @@ describe('', () => { null, 'check' ); - wrapper.update(); }); + wrapper.update(); act(() => { wrapper.find('InventoryLookup').invoke('onChange')({ id: 1, - organization: 1, + name: 'Other Inventory', }); - wrapper.find('ExecutionEnvironmentLookup').invoke('onChange')(null); - wrapper.update(); + wrapper.find('TextInput#execution-environments-input').invoke('onChange')( + '' + ); }); + wrapper.update(); + wrapper.find('input#template-name').simulate('change', { target: { value: 'new name', name: 'name' }, }); + await act(async () => { wrapper.find('button[aria-label="Save"]').simulate('click'); wrapper.update(); @@ -301,8 +345,9 @@ describe('', () => { const expected = { ...mockJobTemplate, - project: mockJobTemplate.project, ...updatedTemplateData, + inventory: 1, + project: 3, execution_environment: null, }; delete expected.summary_fields; @@ -372,6 +417,7 @@ describe('', () => { }, inventory: { id: 2, + name: 'Demo Inventory', organization_id: 1, }, credentials: [ diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.test.jsx index 58684f6327..4f099b0f76 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.test.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.test.jsx @@ -8,14 +8,22 @@ import { LabelsAPI, ExecutionEnvironmentsAPI, UsersAPI, + InventoriesAPI, } from '../../../api'; import { mountWithContexts, waitForElement, } from '../../../../testUtils/enzymeHelpers'; import WorkflowJobTemplateEdit from './WorkflowJobTemplateEdit'; +import useDebounce from '../../../util/useDebounce'; -jest.mock('../../../api'); +jest.mock('../../../util/useDebounce'); +jest.mock('../../../api/models/WorkflowJobTemplates'); +jest.mock('../../../api/models/Organizations'); +jest.mock('../../../api/models/Labels'); +jest.mock('../../../api/models/ExecutionEnvironments'); +jest.mock('../../../api/models/Users'); +jest.mock('../../../api/models/Inventories'); const mockTemplate = { id: 6, @@ -66,18 +74,40 @@ describe('', () => { ], }, }); - OrganizationsAPI.read.mockResolvedValue({ results: [{ id: 1 }] }); + + InventoriesAPI.read.mockResolvedValue({ + data: { + results: [], + count: 0, + }, + }); + InventoriesAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {}, POST: {} } }, + }); + + OrganizationsAPI.read.mockResolvedValue({ + data: { results: [{ id: 1, name: 'Default' }], count: 1 }, + }); + OrganizationsAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {}, POST: {} } }, + }); + ExecutionEnvironmentsAPI.read.mockResolvedValue({ data: { results: mockExecutionEnvironment, count: 1, }, }); + ExecutionEnvironmentsAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {}, POST: {} } }, + }); UsersAPI.readAdminOfOrganizations.mockResolvedValue({ - data: { count: 1, results: [{ id: 1 }] }, + data: { count: 1, results: [{ id: 1, name: 'Default' }] }, }); + useDebounce.mockImplementation(fn => fn); + await act(async () => { history = createMemoryHistory({ initialEntries: ['/templates/workflow_job_template/6/edit'], @@ -120,7 +150,9 @@ describe('', () => { .find('SelectToggle') .simulate('click'); wrapper.update(); - wrapper.find('ExecutionEnvironmentLookup').invoke('onChange')(null); + wrapper.find('TextInput#execution-environments-input').invoke('onChange')( + '' + ); wrapper.find('input#wfjt-description').simulate('change', { target: { value: 'main', name: 'scm_branch' }, }); @@ -130,6 +162,7 @@ describe('', () => { }); wrapper.update(); + await waitForElement( wrapper, 'SelectOption button[aria-label="Label 3"]', diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index afdb122b80..877c0cabb3 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -54,14 +54,12 @@ function JobTemplateForm({ handleCancel, handleSubmit, setFieldValue, + setFieldTouched, submitError, - + validateField, isOverrideDisabledLookup, }) { const [contentError, setContentError] = useState(false); - const [inventory, setInventory] = useState( - template?.summary_fields?.inventory - ); const [allowCallbacks, setAllowCallbacks] = useState( Boolean(template?.host_config_key) ); @@ -75,15 +73,14 @@ function JobTemplateForm({ name: 'job_type', validate: required(null), }); - const [, inventoryMeta, inventoryHelpers] = useField('inventory'); - const [projectField, projectMeta, projectHelpers] = useField({ - name: 'project', - validate: project => handleProjectValidation(project), - }); + const [inventoryField, inventoryMeta, inventoryHelpers] = useField( + 'inventory' + ); + const [projectField, projectMeta, projectHelpers] = useField('project'); const [scmField, , scmHelpers] = useField('scm_branch'); const [playbookField, playbookMeta, playbookHelpers] = useField({ name: 'playbook', - validate: required(t`Select a value for this field`), + validate: required(null), }); const [credentialField, , credentialHelpers] = useField('credentials'); const [labelsField, , labelsHelpers] = useField('labels'); @@ -109,7 +106,7 @@ function JobTemplateForm({ executionEnvironmentField, executionEnvironmentMeta, executionEnvironmentHelpers, - ] = useField({ name: 'execution_environment' }); + ] = useField('execution_environment'); const { request: loadRelatedInstanceGroups, @@ -149,24 +146,52 @@ function JobTemplateForm({ }, [enableWebhooks]); const handleProjectValidation = project => { - if (!project && projectMeta.touched) { - return t`Select a value for this field`; + if (!project) { + return t`This field must not be blank`; } - if (project?.value?.status === 'never updated') { - return t`This project needs to be updated`; + if (project?.status === 'never updated') { + return t`This Project needs to be updated`; } return undefined; }; const handleProjectUpdate = useCallback( value => { - setFieldValue('playbook', ''); - setFieldValue('scm_branch', ''); setFieldValue('project', value); + setFieldValue('playbook', '', false); + setFieldValue('scm_branch', '', false); + setFieldTouched('project', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] ); + const handleInventoryValidation = inventory => { + if (!inventory && !askInventoryOnLaunchField.value) { + return t`Please select an Inventory or check the Prompt on Launch option`; + } + return undefined; + }; + + const handleInventoryUpdate = useCallback( + value => { + setFieldValue('inventory', value); + setFieldTouched('inventory', true, false); + }, + [setFieldValue, setFieldTouched] + ); + + const handleExecutionEnvironmentUpdate = useCallback( + value => { + setFieldValue('execution_environment', value); + setFieldTouched('execution_environment', true, false); + }, + [setFieldValue, setFieldTouched] + ); + + useEffect(() => { + validateField('inventory'); + }, [askInventoryOnLaunchField.value, validateField]); + const jobTypeOptions = [ { value: '', @@ -254,21 +279,19 @@ function JobTemplateForm({ > inventoryHelpers.setTouched()} - onChange={value => { - inventoryHelpers.setValue(value ? value.id : null); - setInventory(value); - }} + onChange={handleInventoryUpdate} required={!askInventoryOnLaunchField.value} touched={inventoryMeta.touched} error={inventoryMeta.error} isOverrideDisabled={isOverrideDisabledLookup} + validate={handleInventoryValidation} /> @@ -277,14 +300,15 @@ function JobTemplateForm({ onBlur={() => projectHelpers.setTouched()} tooltip={t`Select the project containing the playbook you want this job to execute.`} - isValid={ - !projectMeta.touched || !projectMeta.error || projectField.value - } + isValid={Boolean( + !projectMeta.touched || (!projectMeta.error && projectField.value) + )} helperTextInvalid={projectMeta.error} onChange={handleProjectUpdate} required autoPopulate={!template?.id} isOverrideDisabled={isOverrideDisabledLookup} + validate={handleProjectValidation} /> executionEnvironmentHelpers.setTouched()} value={executionEnvironmentField.value} - onChange={value => executionEnvironmentHelpers.setValue(value)} + onChange={handleExecutionEnvironmentUpdate} popoverContent={t`Select the execution environment for this job template.`} tooltip={t`Select a project before editing the execution environment.`} globallyAvailable @@ -489,6 +513,7 @@ function JobTemplateForm({ onChange={value => instanceGroupsHelpers.setValue(value)} tooltip={t`Select the Instance Groups for this Organization to run on.`} + fieldName="instanceGroups" /> { - const errors = {}; - - if ( - (!values.inventory || values.inventory === '') && - !values.ask_inventory_on_launch - ) { - errors.inventory = t`Please select an Inventory or check the Prompt on Launch option.`; - } - - return errors; - }, })(JobTemplateForm); export { JobTemplateForm as _JobTemplateForm }; diff --git a/awx/ui_next/src/screens/Template/shared/WebhookSubForm.jsx b/awx/ui_next/src/screens/Template/shared/WebhookSubForm.jsx index 4030518cc9..fbfa89c73f 100644 --- a/awx/ui_next/src/screens/Template/shared/WebhookSubForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/WebhookSubForm.jsx @@ -213,6 +213,7 @@ function WebhookSubForm({ templateType }) { isValid={!webhookCredentialMeta.error} helperTextInvalid={webhookCredentialMeta.error} value={webhookCredentialField.value} + fieldName="webhook_credential" /> )} diff --git a/awx/ui_next/src/screens/Template/shared/WebhookSubForm.test.jsx b/awx/ui_next/src/screens/Template/shared/WebhookSubForm.test.jsx index ee747e3168..2f8f92da1c 100644 --- a/awx/ui_next/src/screens/Template/shared/WebhookSubForm.test.jsx +++ b/awx/ui_next/src/screens/Template/shared/WebhookSubForm.test.jsx @@ -69,12 +69,9 @@ describe('', () => { .find('TextInputBase[aria-label="workflow job template webhook key"]') .prop('value') ).toBe('webhook key'); - expect( - wrapper - .find('Chip') - .find('span') - .text() - ).toBe('Github credential'); + expect(wrapper.find('input#credential-input').prop('value')).toBe( + 'Github credential' + ); }); test('should make other credential type available', async () => { CredentialsAPI.read.mockResolvedValue({ diff --git a/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx index 311181bae5..5a99909625 100644 --- a/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import { t } from '@lingui/macro'; import PropTypes, { shape } from 'prop-types'; - import { useField, useFormikContext, withFormik } from 'formik'; import { Form, @@ -11,7 +10,6 @@ import { Title, } from '@patternfly/react-core'; import { required } from '../../../util/validators'; - import FieldWithPrompt from '../../../components/FieldWithPrompt'; import FormField, { FormSubmitError } from '../../../components/FormField'; import { @@ -43,7 +41,7 @@ function WorkflowJobTemplateForm({ submitError, isOrgAdmin, }) { - const { setFieldValue } = useFormikContext(); + const { setFieldValue, setFieldTouched } = useFormikContext(); const [enableWebhooks, setEnableWebhooks] = useState( Boolean(template.webhook_service) ); @@ -71,9 +69,7 @@ function WorkflowJobTemplateForm({ executionEnvironmentField, executionEnvironmentMeta, executionEnvironmentHelpers, - ] = useField({ - name: 'execution_environment', - }); + ] = useField('execution_environment'); useEffect(() => { if (enableWebhooks) { @@ -90,11 +86,28 @@ function WorkflowJobTemplateForm({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [enableWebhooks]); - const onOrganizationChange = useCallback( + const handleOrganizationChange = useCallback( value => { setFieldValue('organization', value); + setFieldTouched('organization', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] + ); + + const handleInventoryUpdate = useCallback( + value => { + setFieldValue('inventory', value); + setFieldTouched('inventory', true, false); + }, + [setFieldValue, setFieldTouched] + ); + + const handleExecutionEnvironmentUpdate = useCallback( + value => { + setFieldValue('execution_environment', value); + setFieldTouched('execution_environment', true, false); + }, + [setFieldValue, setFieldTouched] ); if (hasContentError) { @@ -122,14 +135,26 @@ function WorkflowJobTemplateForm({ helperTextInvalid={organizationMeta.error} isValid={!organizationMeta.touched || !organizationMeta.error} onBlur={() => organizationHelpers.setTouched()} - onChange={onOrganizationChange} + onChange={handleOrganizationChange} value={organizationField.value} touched={organizationMeta.touched} error={organizationMeta.error} required={isOrgAdmin} autoPopulate={isOrgAdmin} + validate={ + isOrgAdmin ? required(t`Select a value for this field`) : undefined + } /> - <> + inventoryHelpers.setTouched()} - onChange={value => { - inventoryHelpers.setValue(value); - }} + onChange={handleInventoryUpdate} touched={inventoryMeta.touched} error={inventoryMeta.error} /> - {(inventoryMeta.touched || askInventoryOnLaunchField.value) && - inventoryMeta.error && ( -
- {inventoryMeta.error} -
- )} - +
executionEnvironmentHelpers.setTouched()} value={executionEnvironmentField.value} - onChange={value => executionEnvironmentHelpers.setValue(value)} + onChange={handleExecutionEnvironmentUpdate} tooltip={t`Select the default execution environment for this organization to run on.`} globallyAvailable organizationId={organizationField.value?.id} diff --git a/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.test.jsx b/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.test.jsx index a9c87e8fd1..a12ee62a65 100644 --- a/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.test.jsx +++ b/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.test.jsx @@ -67,6 +67,7 @@ describe('', () => { { name: 'Label 2', id: 2 }, { name: 'Label 3', id: 3 }, ], + count: 3, }, }); OrganizationsAPI.read.mockResolvedValue({ @@ -75,16 +76,20 @@ describe('', () => { { id: 1, name: 'Organization 1' }, { id: 2, name: 'Organization 2' }, ], + count: 2, }, }); InventoriesAPI.read.mockResolvedValue({ - results: [ - { id: 1, name: 'Foo' }, - { id: 2, name: 'Bar' }, - ], + data: { + results: [ + { id: 1, name: 'Foo' }, + { id: 2, name: 'Bar' }, + ], + count: 2, + }, }); CredentialTypesAPI.read.mockResolvedValue({ - data: { results: [{ id: 1 }] }, + data: { results: [{ id: 1 }], count: 1 }, }); InventoriesAPI.readOptions.mockResolvedValue({ data: { actions: { GET: {}, POST: {} } }, @@ -93,13 +98,13 @@ describe('', () => { data: { actions: { GET: {}, POST: {} } }, }); ExecutionEnvironmentsAPI.read.mockResolvedValue({ - data: { results: [] }, + data: { results: [], count: 0 }, }); ExecutionEnvironmentsAPI.readOptions.mockResolvedValue({ data: { actions: { GET: {}, POST: {} } }, }); CredentialsAPI.read.mockResolvedValue({ - data: { results: [] }, + data: { results: [], count: 0 }, }); CredentialsAPI.readOptions.mockResolvedValue({ data: { actions: { GET: {}, POST: {} } }, diff --git a/awx/ui_next/src/screens/User/UserAdd/UserAdd.jsx b/awx/ui_next/src/screens/User/UserAdd/UserAdd.jsx index 78d18340be..1892a8dd6d 100644 --- a/awx/ui_next/src/screens/User/UserAdd/UserAdd.jsx +++ b/awx/ui_next/src/screens/User/UserAdd/UserAdd.jsx @@ -15,7 +15,7 @@ function UserAdd() { try { const { data: { id }, - } = await OrganizationsAPI.createUser(organization, userValues); + } = await OrganizationsAPI.createUser(organization.id, userValues); history.push(`/users/${id}/details`); } catch (error) { setFormSubmitError(error); diff --git a/awx/ui_next/src/screens/User/UserAdd/UserAdd.test.jsx b/awx/ui_next/src/screens/User/UserAdd/UserAdd.test.jsx index 55327cfcbd..675212816b 100644 --- a/awx/ui_next/src/screens/User/UserAdd/UserAdd.test.jsx +++ b/awx/ui_next/src/screens/User/UserAdd/UserAdd.test.jsx @@ -23,7 +23,10 @@ describe('', () => { first_name: 'System', last_name: 'Administrator', password: 'password', - organization: 1, + organization: { + id: 1, + name: 'Default', + }, is_superuser: true, is_system_auditor: false, }; @@ -33,7 +36,7 @@ describe('', () => { const { organization, ...userData } = updatedUserData; expect(OrganizationsAPI.createUser.mock.calls).toEqual([ - [organization, userData], + [organization.id, userData], ]); }); @@ -58,7 +61,10 @@ describe('', () => { first_name: 'System', last_name: 'Administrator', password: 'password', - organization: 1, + organization: { + id: 1, + name: 'Default', + }, is_superuser: true, is_system_auditor: false, }; diff --git a/awx/ui_next/src/screens/User/UserEdit/UserEdit.jsx b/awx/ui_next/src/screens/User/UserEdit/UserEdit.jsx index bb08f395ad..c416c33ab0 100644 --- a/awx/ui_next/src/screens/User/UserEdit/UserEdit.jsx +++ b/awx/ui_next/src/screens/User/UserEdit/UserEdit.jsx @@ -13,6 +13,7 @@ function UserEdit({ user }) { const handleSubmit = async values => { setFormSubmitError(null); try { + delete values.organization; await UsersAPI.update(user.id, values); history.push(`/users/${user.id}/details`); } catch (error) { diff --git a/awx/ui_next/src/screens/User/shared/UserForm.jsx b/awx/ui_next/src/screens/User/shared/UserForm.jsx index cd189b4cff..002cb5c9d2 100644 --- a/awx/ui_next/src/screens/User/shared/UserForm.jsx +++ b/awx/ui_next/src/screens/User/shared/UserForm.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; import { t } from '@lingui/macro'; @@ -15,8 +15,7 @@ import { required } from '../../../util/validators'; import { FormColumnLayout } from '../../../components/FormLayout'; function UserFormFields({ user }) { - const [organization, setOrganization] = useState(null); - const { setFieldValue } = useFormikContext(); + const { setFieldValue, setFieldTouched } = useFormikContext(); const ldapUser = user.ldap_dn; const socialAuthUser = user.auth?.length > 0; @@ -43,21 +42,18 @@ function UserFormFields({ user }) { }, ]; - const [, organizationMeta, organizationHelpers] = useField({ - name: 'organization', - validate: !user.id - ? required(t`Select a value for this field`) - : () => undefined, - }); + const [organizationField, organizationMeta, organizationHelpers] = useField( + 'organization' + ); const [userTypeField, userTypeMeta] = useField('user_type'); - const onOrganizationChange = useCallback( + const handleOrganizationUpdate = useCallback( value => { - setFieldValue('organization', value.id); - setOrganization(value); + setFieldValue('organization', value); + setFieldTouched('organization', true, false); }, - [setFieldValue] + [setFieldValue, setFieldTouched] ); return ( @@ -116,10 +112,11 @@ function UserFormFields({ user }) { helperTextInvalid={organizationMeta.error} isValid={!organizationMeta.touched || !organizationMeta.error} onBlur={() => organizationHelpers.setTouched()} - onChange={onOrganizationChange} - value={organization} + onChange={handleOrganizationUpdate} + value={organizationField.value} required autoPopulate={!user?.id} + validate={required(t`Select a value for this field`)} /> )} { + setFieldValue('application', value); + setFieldTouched('application', true, false); + }, + [setFieldValue, setFieldTouched] + ); + return ( <> { - applicationHelpers.setValue(value); - }} + onChange={handleApplicationUpdate} label={ {t`Application`} @@ -89,7 +92,6 @@ function UserTokenForm({ handleCancel, handleSubmit, submitError, - token = {}, }) { return ( diff --git a/awx/ui_next/src/util/useInterval.js b/awx/ui_next/src/util/useInterval.js new file mode 100644 index 0000000000..714121e547 --- /dev/null +++ b/awx/ui_next/src/util/useInterval.js @@ -0,0 +1,16 @@ +import { useEffect, useRef } from 'react'; + +export default function useInterval(callback, delay) { + const savedCallbackRef = useRef(); + useEffect(() => { + savedCallbackRef.current = callback; + }, [callback]); + useEffect(() => { + const handler = (...args) => savedCallbackRef.current(...args); + if (delay !== null) { + const intervalId = setInterval(handler, delay); + return () => clearInterval(intervalId); + } + return () => undefined; + }, [delay]); +}