Adds support for typing values into single select lookups

This commit is contained in:
mabashian
2021-05-17 09:06:40 -04:00
parent 4e129d3d04
commit 4ec7ba0107
78 changed files with 1190 additions and 631 deletions

View File

@@ -82,7 +82,8 @@
"rows", "rows",
"href", "href",
"modifier", "modifier",
"data-cy" "data-cy",
"fieldName"
], ],
"ignore": ["Ansible", "Tower", "JSON", "YAML", "lg"], "ignore": ["Ansible", "Tower", "JSON", "YAML", "lg"],
"ignoreComponent": [ "ignoreComponent": [

View File

@@ -1,9 +1,7 @@
import React, { useState } from 'react'; import React, { useCallback } from 'react';
import { bool, func, shape } from 'prop-types'; import { bool, func, shape } from 'prop-types';
import { Formik, useField } from 'formik'; import { Formik, useField, useFormikContext } from 'formik';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Form, FormGroup } from '@patternfly/react-core'; import { Form, FormGroup } from '@patternfly/react-core';
import FormField, { FormSubmitError } from '../FormField'; import FormField, { FormSubmitError } from '../FormField';
import FormActionGroup from '../FormActionGroup/FormActionGroup'; import FormActionGroup from '../FormActionGroup/FormActionGroup';
@@ -13,15 +11,19 @@ import { FormColumnLayout, FormFullWidthLayout } from '../FormLayout';
import Popover from '../Popover'; import Popover from '../Popover';
import { required } from '../../util/validators'; import { required } from '../../util/validators';
const InventoryLookupField = ({ host }) => { const InventoryLookupField = () => {
const [inventory, setInventory] = useState( const { setFieldValue, setFieldTouched } = useFormikContext();
host ? host.summary_fields.inventory : '' const [inventoryField, inventoryMeta, inventoryHelpers] = useField(
'inventory'
); );
const [, inventoryMeta, inventoryHelpers] = useField({ const handleInventoryUpdate = useCallback(
name: 'inventory', value => {
validate: required(t`Select a value for this field`), setFieldValue('inventory', value);
}); setFieldTouched('inventory', true, false);
},
[setFieldValue, setFieldTouched]
);
return ( return (
<FormGroup <FormGroup
@@ -40,18 +42,16 @@ const InventoryLookupField = ({ host }) => {
> >
<InventoryLookup <InventoryLookup
fieldId="inventory-lookup" fieldId="inventory-lookup"
value={inventory} value={inventoryField.value}
onBlur={() => inventoryHelpers.setTouched()} onBlur={() => inventoryHelpers.setTouched()}
tooltip={t`Select the inventory that this host will belong to.`} tooltip={t`Select the inventory that this host will belong to.`}
isValid={!inventoryMeta.touched || !inventoryMeta.error} isValid={!inventoryMeta.touched || !inventoryMeta.error}
helperTextInvalid={inventoryMeta.error} helperTextInvalid={inventoryMeta.error}
onChange={value => { onChange={handleInventoryUpdate}
inventoryHelpers.setValue(value.id);
setInventory(value);
}}
required required
touched={inventoryMeta.touched} touched={inventoryMeta.touched}
error={inventoryMeta.error} error={inventoryMeta.error}
validate={required(t`Select a value for this field`)}
/> />
</FormGroup> </FormGroup>
); );
@@ -62,7 +62,6 @@ const HostForm = ({
handleSubmit, handleSubmit,
host, host,
isInventoryVisible, isInventoryVisible,
submitError, submitError,
}) => { }) => {
return ( return (
@@ -70,7 +69,7 @@ const HostForm = ({
initialValues={{ initialValues={{
name: host.name, name: host.name,
description: host.description, description: host.description,
inventory: host.inventory || '', inventory: host.summary_fields?.inventory || null,
variables: host.variables, variables: host.variables,
}} }}
onSubmit={handleSubmit} onSubmit={handleSubmit}
@@ -92,7 +91,7 @@ const HostForm = ({
type="text" type="text"
label={t`Description`} label={t`Description`}
/> />
{isInventoryVisible && <InventoryLookupField host={host} />} {isInventoryVisible && <InventoryLookupField />}
<FormFullWidthLayout> <FormFullWidthLayout>
<VariablesField <VariablesField
id="host-variables" id="host-variables"

View File

@@ -23,9 +23,7 @@ const QS_CONFIG = getQSConfig('inventory', {
}); });
function InventoryStep({ warningMessage = null }) { function InventoryStep({ warningMessage = null }) {
const [field, meta, helpers] = useField({ const [field, meta, helpers] = useField('inventory');
name: 'inventory',
});
const history = useHistory(); const history = useHistory();

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { func, node } from 'prop-types'; import { func, node, string } from 'prop-types';
import { withRouter, useLocation } from 'react-router-dom'; import { withRouter, useLocation } from 'react-router-dom';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { FormGroup } from '@patternfly/react-core'; import { FormGroup } from '@patternfly/react-core';
import { ApplicationsAPI } from '../../api'; import { ApplicationsAPI } from '../../api';
@@ -18,7 +17,7 @@ const QS_CONFIG = getQSConfig('applications', {
order_by: 'name', order_by: 'name',
}); });
function ApplicationLookup({ onChange, value, label }) { function ApplicationLookup({ onChange, value, label, fieldName, validate }) {
const location = useLocation(); const location = useLocation();
const { const {
error, error,
@@ -55,6 +54,25 @@ function ApplicationLookup({ onChange, value, label }) {
searchableKeys: [], searchableKeys: [],
} }
); );
const checkApplicationName = useCallback(
async name => {
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(() => { useEffect(() => {
fetchApplications(); fetchApplications();
}, [fetchApplications]); }, [fetchApplications]);
@@ -65,6 +83,9 @@ function ApplicationLookup({ onChange, value, label }) {
header={t`Application`} header={t`Application`}
value={value} value={value}
onChange={onChange} onChange={onChange}
onDebounce={checkApplicationName}
fieldName={fieldName}
validate={validate}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => ( renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList <OptionsList
@@ -119,10 +140,14 @@ ApplicationLookup.propTypes = {
label: node.isRequired, label: node.isRequired,
onChange: func.isRequired, onChange: func.isRequired,
value: Application, value: Application,
validate: func,
fieldName: string,
}; };
ApplicationLookup.defaultProps = { ApplicationLookup.defaultProps = {
value: null, value: null,
validate: () => undefined,
fieldName: 'application',
}; };
export default withRouter(ApplicationLookup); export default withRouter(ApplicationLookup);

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import ApplicationLookup from './ApplicationLookup'; import ApplicationLookup from './ApplicationLookup';
import { ApplicationsAPI } from '../../api'; import { ApplicationsAPI } from '../../api';
@@ -41,11 +42,13 @@ describe('ApplicationLookup', () => {
test('should render successfully', async () => { test('should render successfully', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<ApplicationLookup <Formik>
label="Application" <ApplicationLookup
value={application} label="Application"
onChange={() => {}} value={application}
/> onChange={() => {}}
/>
</Formik>
); );
}); });
expect(wrapper.find('ApplicationLookup')).toHaveLength(1); expect(wrapper.find('ApplicationLookup')).toHaveLength(1);
@@ -54,11 +57,13 @@ describe('ApplicationLookup', () => {
test('should fetch applications', async () => { test('should fetch applications', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<ApplicationLookup <Formik>
label="Application" <ApplicationLookup
value={application} label="Application"
onChange={() => {}} value={application}
/> onChange={() => {}}
/>
</Formik>
); );
}); });
expect(ApplicationsAPI.read).toHaveBeenCalledTimes(1); expect(ApplicationsAPI.read).toHaveBeenCalledTimes(1);
@@ -67,11 +72,13 @@ describe('ApplicationLookup', () => {
test('should display label', async () => { test('should display label', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<ApplicationLookup <Formik>
label="Application" <ApplicationLookup
value={application} label="Application"
onChange={() => {}} value={application}
/> onChange={() => {}}
/>
</Formik>
); );
}); });
const title = wrapper.find('FormGroup .pf-c-form__label-text'); const title = wrapper.find('FormGroup .pf-c-form__label-text');

View File

@@ -39,11 +39,12 @@ function CredentialLookup({
credentialTypeKind, credentialTypeKind,
credentialTypeNamespace, credentialTypeNamespace,
value, value,
tooltip, tooltip,
isDisabled, isDisabled,
autoPopulate, autoPopulate,
multiple, multiple,
validate,
fieldName,
}) { }) {
const history = useHistory(); const history = useHistory();
const autoPopulateLookup = useAutoPopulateLookup(onChange); 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(() => { useEffect(() => {
fetchCredentials(); fetchCredentials();
}, [fetchCredentials]); }, [fetchCredentials]);
@@ -132,6 +166,9 @@ function CredentialLookup({
value={value} value={value}
onBlur={onBlur} onBlur={onBlur}
onChange={onChange} onChange={onChange}
onDebounce={checkCredentialName}
fieldName={fieldName}
validate={validate}
required={required} required={required}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
isDisabled={isDisabled} isDisabled={isDisabled}
@@ -212,6 +249,8 @@ CredentialLookup.propTypes = {
value: oneOfType([Credential, arrayOf(Credential)]), value: oneOfType([Credential, arrayOf(Credential)]),
isDisabled: bool, isDisabled: bool,
autoPopulate: bool, autoPopulate: bool,
validate: func,
fieldName: string,
}; };
CredentialLookup.defaultProps = { CredentialLookup.defaultProps = {
@@ -225,6 +264,8 @@ CredentialLookup.defaultProps = {
value: null, value: null,
isDisabled: false, isDisabled: false,
autoPopulate: false, autoPopulate: false,
validate: () => undefined,
fieldName: 'credential',
}; };
export { CredentialLookup as _CredentialLookup }; export { CredentialLookup as _CredentialLookup };

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import CredentialLookup, { _CredentialLookup } from './CredentialLookup'; import CredentialLookup, { _CredentialLookup } from './CredentialLookup';
import { CredentialsAPI } from '../../api'; import { CredentialsAPI } from '../../api';
@@ -31,11 +32,13 @@ describe('CredentialLookup', () => {
test('should render successfully', async () => { test('should render successfully', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<CredentialLookup <Formik>
credentialTypeId={1} <CredentialLookup
label="Foo" credentialTypeId={1}
onChange={() => {}} label="Foo"
/> onChange={() => {}}
/>
</Formik>
); );
}); });
expect(wrapper.find('CredentialLookup')).toHaveLength(1); expect(wrapper.find('CredentialLookup')).toHaveLength(1);
@@ -44,11 +47,13 @@ describe('CredentialLookup', () => {
test('should fetch credentials', async () => { test('should fetch credentials', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<CredentialLookup <Formik>
credentialTypeId={1} <CredentialLookup
label="Foo" credentialTypeId={1}
onChange={() => {}} label="Foo"
/> onChange={() => {}}
/>
</Formik>
); );
}); });
expect(CredentialsAPI.read).toHaveBeenCalledTimes(1); expect(CredentialsAPI.read).toHaveBeenCalledTimes(1);
@@ -63,11 +68,13 @@ describe('CredentialLookup', () => {
test('should display label', async () => { test('should display label', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<CredentialLookup <Formik>
credentialTypeId={1} <CredentialLookup
label="Foo" credentialTypeId={1}
onChange={() => {}} label="Foo"
/> onChange={() => {}}
/>
</Formik>
); );
}); });
const title = wrapper.find('FormGroup .pf-c-form__label-text'); 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 () => { test('should define default value for function props', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<CredentialLookup <Formik>
credentialTypeId={1} <CredentialLookup
label="Foo" credentialTypeId={1}
onChange={() => {}} label="Foo"
/> onChange={() => {}}
/>
</Formik>
); );
}); });
expect(_CredentialLookup.defaultProps.onBlur).toBeInstanceOf(Function); expect(_CredentialLookup.defaultProps.onBlur).toBeInstanceOf(Function);
@@ -98,11 +107,13 @@ describe('CredentialLookup', () => {
const onChange = jest.fn(); const onChange = jest.fn();
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<CredentialLookup <Formik>
credentialTypeId={1} <CredentialLookup
label="Foo" credentialTypeId={1}
onChange={onChange} label="Foo"
/> onChange={onChange}
/>
</Formik>
); );
}); });
expect(onChange).not.toHaveBeenCalled(); expect(onChange).not.toHaveBeenCalled();
@@ -118,12 +129,14 @@ describe('CredentialLookup', () => {
const onChange = jest.fn(); const onChange = jest.fn();
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<CredentialLookup <Formik>
credentialTypeId={1} <CredentialLookup
label="Foo" credentialTypeId={1}
autoPopulate label="Foo"
onChange={onChange} autoPopulate
/> onChange={onChange}
/>
</Formik>
); );
}); });
expect(onChange).not.toHaveBeenCalled(); expect(onChange).not.toHaveBeenCalled();
@@ -141,12 +154,14 @@ describe('CredentialLookup auto select', () => {
const onChange = jest.fn(); const onChange = jest.fn();
await act(async () => { await act(async () => {
mountWithContexts( mountWithContexts(
<CredentialLookup <Formik>
autoPopulate <CredentialLookup
credentialTypeId={1} autoPopulate
label="Foo" credentialTypeId={1}
onChange={onChange} label="Foo"
/> onChange={onChange}
/>
</Formik>
); );
}); });
expect(onChange).toHaveBeenCalledWith({ id: 1 }); expect(onChange).toHaveBeenCalledWith({ id: 1 });

View File

@@ -1,10 +1,8 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { string, func, bool, oneOfType, number } from 'prop-types'; import { string, func, bool, oneOfType, number } from 'prop-types';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { FormGroup, Tooltip } from '@patternfly/react-core'; import { FormGroup, Tooltip } from '@patternfly/react-core';
import { ExecutionEnvironmentsAPI, ProjectsAPI } from '../../api'; import { ExecutionEnvironmentsAPI, ProjectsAPI } from '../../api';
import { ExecutionEnvironment } from '../../types'; import { ExecutionEnvironment } from '../../types';
import { getQSConfig, parseQueryString, mergeParams } from '../../util/qs'; import { getQSConfig, parseQueryString, mergeParams } from '../../util/qs';
@@ -23,17 +21,20 @@ const QS_CONFIG = getQSConfig('execution_environments', {
function ExecutionEnvironmentLookup({ function ExecutionEnvironmentLookup({
globallyAvailable, globallyAvailable,
helperTextInvalid,
isDefaultEnvironment, isDefaultEnvironment,
isGlobalDefaultEnvironment,
isDisabled, isDisabled,
isGlobalDefaultEnvironment,
isValid,
onBlur, onBlur,
onChange, onChange,
organizationId, organizationId,
popoverContent, popoverContent,
projectId, projectId,
tooltip, tooltip,
validate,
value, value,
fieldName,
}) { }) {
const location = useLocation(); 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(() => { useEffect(() => {
fetchExecutionEnvironments(); fetchExecutionEnvironments();
}, [fetchExecutionEnvironments]); }, [fetchExecutionEnvironments]);
@@ -125,6 +144,9 @@ function ExecutionEnvironmentLookup({
value={value} value={value}
onBlur={onBlur} onBlur={onBlur}
onChange={onChange} onChange={onChange}
onDebounce={checkExecutionEnvironmentName}
fieldName={fieldName}
validate={validate}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
isLoading={isLoading || fetchProjectLoading} isLoading={isLoading || fetchProjectLoading}
isDisabled={isDisabled} isDisabled={isDisabled}
@@ -179,6 +201,8 @@ function ExecutionEnvironmentLookup({
fieldId="execution-environment-lookup" fieldId="execution-environment-lookup"
label={renderLabel(isGlobalDefaultEnvironment, isDefaultEnvironment)} label={renderLabel(isGlobalDefaultEnvironment, isDefaultEnvironment)}
labelIcon={popoverContent && <Popover content={popoverContent} />} labelIcon={popoverContent && <Popover content={popoverContent} />}
helperTextInvalid={helperTextInvalid}
validated={isValid ? 'default' : 'error'}
> >
{tooltip && isDisabled ? ( {tooltip && isDisabled ? (
<Tooltip content={tooltip}>{renderLookup()}</Tooltip> <Tooltip content={tooltip}>{renderLookup()}</Tooltip>
@@ -199,6 +223,8 @@ ExecutionEnvironmentLookup.propTypes = {
isGlobalDefaultEnvironment: bool, isGlobalDefaultEnvironment: bool,
projectId: oneOfType([number, string]), projectId: oneOfType([number, string]),
organizationId: oneOfType([number, string]), organizationId: oneOfType([number, string]),
validate: func,
fieldName: string,
}; };
ExecutionEnvironmentLookup.defaultProps = { ExecutionEnvironmentLookup.defaultProps = {
@@ -208,6 +234,8 @@ ExecutionEnvironmentLookup.defaultProps = {
value: null, value: null,
projectId: null, projectId: null,
organizationId: null, organizationId: null,
validate: () => undefined,
fieldName: 'execution_environment',
}; };
export default ExecutionEnvironmentLookup; export default ExecutionEnvironmentLookup;

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import ExecutionEnvironmentLookup from './ExecutionEnvironmentLookup'; import ExecutionEnvironmentLookup from './ExecutionEnvironmentLookup';
import { ExecutionEnvironmentsAPI, ProjectsAPI } from '../../api'; import { ExecutionEnvironmentsAPI, ProjectsAPI } from '../../api';
@@ -52,11 +53,13 @@ describe('ExecutionEnvironmentLookup', () => {
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<ExecutionEnvironmentLookup <Formik>
isDefaultEnvironment <ExecutionEnvironmentLookup
value={executionEnvironment} isDefaultEnvironment
onChange={() => {}} value={executionEnvironment}
/> onChange={() => {}}
/>
</Formik>
); );
}); });
wrapper.update(); wrapper.update();
@@ -73,10 +76,12 @@ describe('ExecutionEnvironmentLookup', () => {
test('should fetch execution environments', async () => { test('should fetch execution environments', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<ExecutionEnvironmentLookup <Formik>
value={executionEnvironment} <ExecutionEnvironmentLookup
onChange={() => {}} value={executionEnvironment}
/> onChange={() => {}}
/>
</Formik>
); );
}); });
expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalledTimes(2); expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalledTimes(2);
@@ -91,12 +96,14 @@ describe('ExecutionEnvironmentLookup', () => {
test('should call api with organization id', async () => { test('should call api with organization id', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<ExecutionEnvironmentLookup <Formik>
value={executionEnvironment} <ExecutionEnvironmentLookup
onChange={() => {}} value={executionEnvironment}
organizationId={1} onChange={() => {}}
globallyAvailable organizationId={1}
/> globallyAvailable
/>
</Formik>
); );
}); });
expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalledWith({ expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalledWith({
@@ -111,12 +118,14 @@ describe('ExecutionEnvironmentLookup', () => {
test('should call api with organization id from the related project', async () => { test('should call api with organization id from the related project', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<ExecutionEnvironmentLookup <Formik>
value={executionEnvironment} <ExecutionEnvironmentLookup
onChange={() => {}} value={executionEnvironment}
projectId={12} onChange={() => {}}
globallyAvailable projectId={12}
/> globallyAvailable
/>
</Formik>
); );
}); });
expect(ProjectsAPI.readDetail).toHaveBeenCalledWith(12); expect(ProjectsAPI.readDetail).toHaveBeenCalledWith(12);

View File

@@ -19,9 +19,16 @@ const QS_CONFIG = getQSConfig('instance-groups', {
order_by: 'name', order_by: 'name',
}); });
function InstanceGroupsLookup(props) { function InstanceGroupsLookup({
const { value, onChange, tooltip, className, required, history } = props; value,
onChange,
tooltip,
className,
required,
history,
fieldName,
validate,
}) {
const { const {
result: { instanceGroups, count, relatedSearchableKeys, searchableKeys }, result: { instanceGroups, count, relatedSearchableKeys, searchableKeys },
request: fetchInstanceGroups, request: fetchInstanceGroups,
@@ -69,6 +76,8 @@ function InstanceGroupsLookup(props) {
header={t`Instance Groups`} header={t`Instance Groups`}
value={value} value={value}
onChange={onChange} onChange={onChange}
fieldName={fieldName}
validate={validate}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
multiple multiple
required={required} required={required}
@@ -118,12 +127,16 @@ InstanceGroupsLookup.propTypes = {
onChange: func.isRequired, onChange: func.isRequired,
className: string, className: string,
required: bool, required: bool,
validate: func,
fieldName: string,
}; };
InstanceGroupsLookup.defaultProps = { InstanceGroupsLookup.defaultProps = {
tooltip: '', tooltip: '',
className: '', className: '',
required: false, required: false,
validate: () => undefined,
fieldName: 'instance_groups',
}; };
export default withRouter(InstanceGroupsLookup); export default withRouter(InstanceGroupsLookup);

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useEffect } from 'react'; 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 { withRouter } from 'react-router-dom';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { InventoriesAPI } from '../../api'; import { InventoriesAPI } from '../../api';
import { Inventory } from '../../types'; import { Inventory } from '../../types';
@@ -23,7 +22,6 @@ function InventoryLookup({
value, value,
onChange, onChange,
onBlur, onBlur,
history, history,
required, required,
isPromptableField, isPromptableField,
@@ -31,6 +29,8 @@ function InventoryLookup({
promptId, promptId,
promptName, promptName,
isOverrideDisabled, isOverrideDisabled,
validate,
fieldName,
}) { }) {
const { const {
result: { result: {
@@ -50,6 +50,7 @@ function InventoryLookup({
InventoriesAPI.read(params), InventoriesAPI.read(params),
InventoriesAPI.readOptions(), InventoriesAPI.readOptions(),
]); ]);
return { return {
inventories: data.results, inventories: data.results,
count: data.count, 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(() => { useEffect(() => {
fetchInventories(); fetchInventories();
}, [fetchInventories]); }, [fetchInventories]);
@@ -96,6 +115,9 @@ function InventoryLookup({
onChange={onChange} onChange={onChange}
onBlur={onBlur} onBlur={onBlur}
required={required} required={required}
onDebounce={checkInventoryName}
fieldName={fieldName}
validate={validate}
isLoading={isLoading} isLoading={isLoading}
isDisabled={!canEdit} isDisabled={!canEdit}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
@@ -147,6 +169,9 @@ function InventoryLookup({
header={t`Inventory`} header={t`Inventory`}
value={value} value={value}
onChange={onChange} onChange={onChange}
onDebounce={checkInventoryName}
fieldName={fieldName}
validate={validate}
onBlur={onBlur} onBlur={onBlur}
required={required} required={required}
isLoading={isLoading} isLoading={isLoading}
@@ -200,12 +225,16 @@ InventoryLookup.propTypes = {
onChange: func.isRequired, onChange: func.isRequired,
required: bool, required: bool,
isOverrideDisabled: bool, isOverrideDisabled: bool,
validate: func,
fieldName: string,
}; };
InventoryLookup.defaultProps = { InventoryLookup.defaultProps = {
value: null, value: null,
required: false, required: false,
isOverrideDisabled: false, isOverrideDisabled: false,
validate: () => {},
fieldName: 'inventory',
}; };
export default withRouter(InventoryLookup); export default withRouter(InventoryLookup);

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import InventoryLookup from './InventoryLookup'; import InventoryLookup from './InventoryLookup';
import { InventoriesAPI } from '../../api'; import { InventoriesAPI } from '../../api';
@@ -39,7 +40,11 @@ describe('InventoryLookup', () => {
}, },
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<InventoryLookup onChange={() => {}} />); wrapper = mountWithContexts(
<Formik>
<InventoryLookup onChange={() => {}} />
</Formik>
);
}); });
wrapper.update(); wrapper.update();
expect(InventoriesAPI.read).toHaveBeenCalledTimes(1); expect(InventoriesAPI.read).toHaveBeenCalledTimes(1);
@@ -58,7 +63,9 @@ describe('InventoryLookup', () => {
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<InventoryLookup isOverrideDisabled onChange={() => {}} /> <Formik>
<InventoryLookup isOverrideDisabled onChange={() => {}} />
</Formik>
); );
}); });
wrapper.update(); wrapper.update();
@@ -77,7 +84,11 @@ describe('InventoryLookup', () => {
}, },
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<InventoryLookup onChange={() => {}} />); wrapper = mountWithContexts(
<Formik>
<InventoryLookup onChange={() => {}} />
</Formik>
);
}); });
wrapper.update(); wrapper.update();
expect(InventoriesAPI.read).toHaveBeenCalledTimes(1); expect(InventoriesAPI.read).toHaveBeenCalledTimes(1);

View File

@@ -1,4 +1,4 @@
import React, { Fragment, useReducer, useEffect } from 'react'; import React, { Fragment, useReducer, useEffect, useState } from 'react';
import { import {
string, string,
bool, bool,
@@ -9,6 +9,7 @@ import {
shape, shape,
} from 'prop-types'; } from 'prop-types';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { useField } from 'formik';
import { SearchIcon } from '@patternfly/react-icons'; import { SearchIcon } from '@patternfly/react-icons';
import { import {
Button, Button,
@@ -16,12 +17,12 @@ import {
Chip, Chip,
InputGroup, InputGroup,
Modal, Modal,
TextInput,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import styled from 'styled-components'; import styled from 'styled-components';
import useDebounce from '../../util/useDebounce';
import ChipGroup from '../ChipGroup'; import ChipGroup from '../ChipGroup';
import reducer, { initReducer } from './shared/reducer'; import reducer, { initReducer } from './shared/reducer';
import { QSConfig } from '../../types'; import { QSConfig } from '../../types';
@@ -44,9 +45,23 @@ function Lookup(props) {
renderItemChip, renderItemChip,
renderOptionsList, renderOptionsList,
history, history,
isDisabled, isDisabled,
onDebounce,
fieldName,
validate,
} = props; } = 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( const [state, dispatch] = useReducer(
reducer, reducer,
@@ -60,7 +75,16 @@ function Lookup(props) {
useEffect(() => { useEffect(() => {
dispatch({ type: 'SET_VALUE', value }); 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 clearQSParams = () => {
const parts = history.location.search.replace(/^\?/, '').split('&'); const parts = history.location.search.replace(/^\?/, '').split('&');
@@ -71,19 +95,16 @@ function Lookup(props) {
const save = () => { const save = () => {
const { selectedItems } = state; const { selectedItems } = state;
const val = multiple ? selectedItems : selectedItems[0] || null; if (multiple) {
onChange(val); onChange(selectedItems);
} else {
onChange(selectedItems[0] || null);
}
clearQSParams(); clearQSParams();
dispatch({ type: 'CLOSE_MODAL' }); dispatch({ type: 'CLOSE_MODAL' });
}; };
const removeItem = item => { const removeItem = item => onChange(value.filter(i => i.id !== item.id));
if (multiple) {
onChange(value.filter(i => i.id !== item.id));
} else {
onChange(null);
}
};
const closeModal = () => { const closeModal = () => {
clearQSParams(); clearQSParams();
@@ -99,6 +120,7 @@ function Lookup(props) {
} else if (value) { } else if (value) {
items.push(value); items.push(value);
} }
return ( return (
<Fragment> <Fragment>
<InputGroup onBlur={onBlur}> <InputGroup onBlur={onBlur}>
@@ -111,17 +133,31 @@ function Lookup(props) {
> >
<SearchIcon /> <SearchIcon />
</Button> </Button>
<ChipHolder isDisabled={isDisabled} className="pf-c-form-control"> {multiple ? (
<ChipGroup numChips={5} totalChips={items.length}> <ChipHolder isDisabled={isDisabled} className="pf-c-form-control">
{items.map(item => <ChipGroup numChips={5} totalChips={items.length}>
renderItemChip({ {items.map(item =>
item, renderItemChip({
removeItem, item,
canDelete, removeItem,
}) canDelete,
)} })
</ChipGroup> )}
</ChipHolder> </ChipGroup>
</ChipHolder>
) : (
<TextInput
id={`${id}-input`}
value={typedText}
onChange={inputValue => {
setTypedText(inputValue);
if (value?.name !== inputValue) {
debounceRequest(inputValue);
}
}}
isDisabled={isLoading || isDisabled}
/>
)}
</InputGroup> </InputGroup>
<Modal <Modal
@@ -176,6 +212,9 @@ Lookup.propTypes = {
qsConfig: QSConfig.isRequired, qsConfig: QSConfig.isRequired,
renderItemChip: func, renderItemChip: func,
renderOptionsList: func.isRequired, renderOptionsList: func.isRequired,
fieldName: string.isRequired,
validate: func,
onDebounce: func,
}; };
Lookup.defaultProps = { Lookup.defaultProps = {
@@ -194,6 +233,8 @@ Lookup.defaultProps = {
{item.name} {item.name}
</Chip> </Chip>
), ),
validate: () => undefined,
onDebounce: () => undefined,
}; };
export { Lookup as _Lookup }; export { Lookup as _Lookup };

View File

@@ -1,6 +1,7 @@
/* eslint-disable react/jsx-pascal-case */ /* eslint-disable react/jsx-pascal-case */
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
@@ -56,22 +57,25 @@ describe('<Lookup />', () => {
const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }]; const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }];
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Lookup <Formik>
id="test" <Lookup
multiple id="test"
header="Foo Bar" multiple
value={mockSelected} header="Foo Bar"
onChange={onChange} value={mockSelected}
qsConfig={QS_CONFIG} onChange={onChange}
renderOptionsList={({ state, dispatch, canDelete }) => ( qsConfig={QS_CONFIG}
<TestList renderOptionsList={({ state, dispatch, canDelete }) => (
id="options-list" <TestList
state={state} id="options-list"
dispatch={dispatch} state={state}
canDelete={canDelete} dispatch={dispatch}
/> canDelete={canDelete}
)} />
/> )}
fieldName="foo"
/>
</Formik>
); );
}); });
return wrapper; return wrapper;
@@ -137,22 +141,25 @@ describe('<Lookup />', () => {
await act(async () => { await act(async () => {
const mockSelected = { name: 'foo', id: 1, url: '/api/v2/item/1' }; const mockSelected = { name: 'foo', id: 1, url: '/api/v2/item/1' };
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Lookup <Formik>
id="test" <Lookup
header="Foo Bar" id="test"
required header="Foo Bar"
value={mockSelected} required
onChange={onChange} value={mockSelected}
qsConfig={QS_CONFIG} onChange={onChange}
renderOptionsList={({ state, dispatch, canDelete }) => ( qsConfig={QS_CONFIG}
<TestList renderOptionsList={({ state, dispatch, canDelete }) => (
id="options-list" <TestList
state={state} id="options-list"
dispatch={dispatch} state={state}
canDelete={canDelete} dispatch={dispatch}
/> canDelete={canDelete}
)} />
/> )}
fieldName="foo"
/>
</Formik>
); );
}); });
wrapper.find('button[aria-label="Search"]').simulate('click'); wrapper.find('button[aria-label="Search"]').simulate('click');
@@ -163,23 +170,26 @@ describe('<Lookup />', () => {
test('should be disabled while isLoading is true', async () => { test('should be disabled while isLoading is true', async () => {
const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }]; const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }];
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Lookup <Formik>
id="test" <Lookup
multiple id="test"
header="Foo Bar" multiple
value={mockSelected} header="Foo Bar"
onChange={onChange} value={mockSelected}
qsConfig={QS_CONFIG} onChange={onChange}
isLoading qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => ( isLoading
<TestList renderOptionsList={({ state, dispatch, canDelete }) => (
id="options-list" <TestList
state={state} id="options-list"
dispatch={dispatch} state={state}
canDelete={canDelete} dispatch={dispatch}
/> canDelete={canDelete}
)} />
/> )}
fieldName="foo"
/>
</Formik>
); );
checkRootElementNotPresent('body div[role="dialog"]'); checkRootElementNotPresent('body div[role="dialog"]');
const button = wrapper.find('button[aria-label="Search"]'); const button = wrapper.find('button[aria-label="Search"]');

View File

@@ -2,7 +2,6 @@ import 'styled-components/macro';
import React, { Fragment, useState, useCallback, useEffect } from 'react'; import React, { Fragment, useState, useCallback, useEffect } from 'react';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { ToolbarItem, Alert } from '@patternfly/react-core'; import { ToolbarItem, Alert } from '@patternfly/react-core';
import { CredentialsAPI, CredentialTypesAPI } from '../../api'; import { CredentialsAPI, CredentialTypesAPI } from '../../api';
@@ -26,8 +25,14 @@ async function loadCredentials(params, selectedCredentialTypeId) {
return data; return data;
} }
function MultiCredentialsLookup(props) { function MultiCredentialsLookup({
const { value, onChange, onError, history } = props; value,
onChange,
onError,
history,
fieldName,
validate,
}) {
const [selectedType, setSelectedType] = useState(null); const [selectedType, setSelectedType] = useState(null);
const isMounted = useIsMounted(); const isMounted = useIsMounted();
@@ -68,9 +73,12 @@ function MultiCredentialsLookup(props) {
if (!selectedType) { if (!selectedType) {
return { return {
credentials: [], credentials: [],
count: 0, credentialsCount: 0,
relatedSearchableKeys: [],
searchableKeys: [],
}; };
} }
const params = parseQueryString(QS_CONFIG, history.location.search); const params = parseQueryString(QS_CONFIG, history.location.search);
const [{ results, count }, actionsResponse] = await Promise.all([ const [{ results, count }, actionsResponse] = await Promise.all([
loadCredentials(params, selectedType.id), loadCredentials(params, selectedType.id),
@@ -130,6 +138,8 @@ function MultiCredentialsLookup(props) {
id="multiCredential" id="multiCredential"
header={t`Credentials`} header={t`Credentials`}
value={value} value={value}
fieldName={fieldName}
validate={validate}
multiple multiple
onChange={onChange} onChange={onChange}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
@@ -240,10 +250,14 @@ MultiCredentialsLookup.propTypes = {
), ),
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
onError: PropTypes.func.isRequired, onError: PropTypes.func.isRequired,
validate: PropTypes.func,
fieldName: PropTypes.string,
}; };
MultiCredentialsLookup.defaultProps = { MultiCredentialsLookup.defaultProps = {
value: [], value: [],
validate: () => undefined,
fieldName: 'credentials',
}; };
export { MultiCredentialsLookup as _MultiCredentialsLookup }; export { MultiCredentialsLookup as _MultiCredentialsLookup };

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
@@ -9,7 +10,7 @@ import { CredentialsAPI, CredentialTypesAPI } from '../../api';
jest.mock('../../api'); jest.mock('../../api');
describe('<MultiCredentialsLookup />', () => { describe('<Formik><MultiCredentialsLookup /></Formik>', () => {
let wrapper; let wrapper;
const credentials = [ const credentials = [
@@ -128,12 +129,14 @@ describe('<MultiCredentialsLookup />', () => {
const onChange = jest.fn(); const onChange = jest.fn();
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<MultiCredentialsLookup <Formik>
value={credentials} <MultiCredentialsLookup
tooltip="This is credentials look up" value={credentials}
onChange={onChange} tooltip="This is credentials look up"
onError={() => {}} onChange={onChange}
/> onError={() => {}}
/>
</Formik>
); );
}); });
wrapper.update(); wrapper.update();
@@ -145,12 +148,14 @@ describe('<MultiCredentialsLookup />', () => {
const onChange = jest.fn(); const onChange = jest.fn();
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<MultiCredentialsLookup <Formik>
value={credentials} <MultiCredentialsLookup
tooltip="This is credentials look up" value={credentials}
onChange={onChange} tooltip="This is credentials look up"
onError={() => {}} onChange={onChange}
/> onError={() => {}}
/>
</Formik>
); );
}); });
const chip = wrapper.find('CredentialChip'); const chip = wrapper.find('CredentialChip');
@@ -182,12 +187,14 @@ describe('<MultiCredentialsLookup />', () => {
test('should change credential types', async () => { test('should change credential types', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<MultiCredentialsLookup <Formik>
value={credentials} <MultiCredentialsLookup
tooltip="This is credentials look up" value={credentials}
onChange={() => {}} tooltip="This is credentials look up"
onError={() => {}} onChange={() => {}}
/> onError={() => {}}
/>
</Formik>
); );
}); });
const searchButton = await waitForElement( const searchButton = await waitForElement(
@@ -227,12 +234,14 @@ describe('<MultiCredentialsLookup />', () => {
const onChange = jest.fn(); const onChange = jest.fn();
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<MultiCredentialsLookup <Formik>
value={credentials} <MultiCredentialsLookup
tooltip="This is credentials look up" value={credentials}
onChange={onChange} tooltip="This is credentials look up"
onError={() => {}} onChange={onChange}
/> onError={() => {}}
/>
</Formik>
); );
}); });
const searchButton = await waitForElement( const searchButton = await waitForElement(
@@ -294,12 +303,14 @@ describe('<MultiCredentialsLookup />', () => {
test('should properly render vault credential labels', async () => { test('should properly render vault credential labels', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<MultiCredentialsLookup <Formik>
value={credentials} <MultiCredentialsLookup
tooltip="This is credentials look up" value={credentials}
onChange={() => {}} tooltip="This is credentials look up"
onError={() => {}} onChange={() => {}}
/> onError={() => {}}
/>
</Formik>
); );
}); });
const searchButton = await waitForElement( const searchButton = await waitForElement(
@@ -325,12 +336,14 @@ describe('<MultiCredentialsLookup />', () => {
const onChange = jest.fn(); const onChange = jest.fn();
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<MultiCredentialsLookup <Formik>
value={credentials} <MultiCredentialsLookup
tooltip="This is credentials look up" value={credentials}
onChange={onChange} tooltip="This is credentials look up"
onError={() => {}} onChange={onChange}
/> onError={() => {}}
/>
</Formik>
); );
}); });
const searchButton = await waitForElement( const searchButton = await waitForElement(
@@ -392,12 +405,14 @@ describe('<MultiCredentialsLookup />', () => {
const onChange = jest.fn(); const onChange = jest.fn();
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<MultiCredentialsLookup <Formik>
value={credentials} <MultiCredentialsLookup
tooltip="This is credentials look up" value={credentials}
onChange={onChange} tooltip="This is credentials look up"
onError={() => {}} onChange={onChange}
/> onError={() => {}}
/>
</Formik>
); );
}); });
const searchButton = await waitForElement( const searchButton = await waitForElement(
@@ -466,12 +481,14 @@ describe('<MultiCredentialsLookup />', () => {
const onChange = jest.fn(); const onChange = jest.fn();
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<MultiCredentialsLookup <Formik>
value={credentials} <MultiCredentialsLookup
tooltip="This is credentials look up" value={credentials}
onChange={onChange} tooltip="This is credentials look up"
onError={() => {}} onChange={onChange}
/> onError={() => {}}
/>
</Formik>
); );
}); });
const searchButton = await waitForElement( const searchButton = await waitForElement(

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useEffect } from 'react'; 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 { withRouter } from 'react-router-dom';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { FormGroup } from '@patternfly/react-core'; import { FormGroup } from '@patternfly/react-core';
import { OrganizationsAPI } from '../../api'; import { OrganizationsAPI } from '../../api';
@@ -21,7 +20,6 @@ const QS_CONFIG = getQSConfig('organizations', {
function OrganizationLookup({ function OrganizationLookup({
helperTextInvalid, helperTextInvalid,
isValid, isValid,
onBlur, onBlur,
onChange, onChange,
@@ -31,6 +29,8 @@ function OrganizationLookup({
autoPopulate, autoPopulate,
isDisabled, isDisabled,
helperText, helperText,
validate,
fieldName,
}) { }) {
const autoPopulateLookup = useAutoPopulateLookup(onChange); 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(() => { useEffect(() => {
fetchOrganizations(); fetchOrganizations();
}, [fetchOrganizations]); }, [fetchOrganizations]);
@@ -89,6 +107,9 @@ function OrganizationLookup({
value={value} value={value}
onBlur={onBlur} onBlur={onBlur}
onChange={onChange} onChange={onChange}
onDebounce={checkOrganizationName}
fieldName={fieldName}
validate={validate}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
required={required} required={required}
sortedColumnKey="name" sortedColumnKey="name"
@@ -144,6 +165,8 @@ OrganizationLookup.propTypes = {
value: Organization, value: Organization,
autoPopulate: bool, autoPopulate: bool,
isDisabled: bool, isDisabled: bool,
validate: func,
fieldName: string,
}; };
OrganizationLookup.defaultProps = { OrganizationLookup.defaultProps = {
@@ -154,6 +177,8 @@ OrganizationLookup.defaultProps = {
value: null, value: null,
autoPopulate: false, autoPopulate: false,
isDisabled: false, isDisabled: false,
validate: () => undefined,
fieldName: 'organization',
}; };
export { OrganizationLookup as _OrganizationLookup }; export { OrganizationLookup as _OrganizationLookup };

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import OrganizationLookup, { _OrganizationLookup } from './OrganizationLookup'; import OrganizationLookup, { _OrganizationLookup } from './OrganizationLookup';
import { OrganizationsAPI } from '../../api'; import { OrganizationsAPI } from '../../api';
@@ -16,14 +17,22 @@ describe('OrganizationLookup', () => {
test('should render successfully', async () => { test('should render successfully', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />); wrapper = mountWithContexts(
<Formik>
<OrganizationLookup onChange={() => {}} />
</Formik>
);
}); });
expect(wrapper).toHaveLength(1); expect(wrapper).toHaveLength(1);
}); });
test('should fetch organizations', async () => { test('should fetch organizations', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />); wrapper = mountWithContexts(
<Formik>
<OrganizationLookup onChange={() => {}} />
</Formik>
);
}); });
expect(OrganizationsAPI.read).toHaveBeenCalledTimes(1); expect(OrganizationsAPI.read).toHaveBeenCalledTimes(1);
expect(OrganizationsAPI.read).toHaveBeenCalledWith({ expect(OrganizationsAPI.read).toHaveBeenCalledWith({
@@ -35,7 +44,11 @@ describe('OrganizationLookup', () => {
test('should display "Organization" label', async () => { test('should display "Organization" label', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />); wrapper = mountWithContexts(
<Formik>
<OrganizationLookup onChange={() => {}} />
</Formik>
);
}); });
const title = wrapper.find('FormGroup .pf-c-form__label-text'); const title = wrapper.find('FormGroup .pf-c-form__label-text');
expect(title.text()).toEqual('Organization'); expect(title.text()).toEqual('Organization');
@@ -43,7 +56,11 @@ describe('OrganizationLookup', () => {
test('should define default value for function props', async () => { test('should define default value for function props', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />); wrapper = mountWithContexts(
<Formik>
<OrganizationLookup onChange={() => {}} />
</Formik>
);
}); });
expect(_OrganizationLookup.defaultProps.onBlur).toBeInstanceOf(Function); expect(_OrganizationLookup.defaultProps.onBlur).toBeInstanceOf(Function);
expect(_OrganizationLookup.defaultProps.onBlur).not.toThrow(); expect(_OrganizationLookup.defaultProps.onBlur).not.toThrow();
@@ -59,7 +76,9 @@ describe('OrganizationLookup', () => {
const onChange = jest.fn(); const onChange = jest.fn();
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<OrganizationLookup autoPopulate onChange={onChange} /> <Formik>
<OrganizationLookup autoPopulate onChange={onChange} />
</Formik>
); );
}); });
expect(onChange).toHaveBeenCalledWith({ id: 1 }); expect(onChange).toHaveBeenCalledWith({ id: 1 });
@@ -74,7 +93,11 @@ describe('OrganizationLookup', () => {
}); });
const onChange = jest.fn(); const onChange = jest.fn();
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<OrganizationLookup onChange={onChange} />); wrapper = mountWithContexts(
<Formik>
<OrganizationLookup onChange={onChange} />
</Formik>
);
}); });
expect(onChange).not.toHaveBeenCalled(); expect(onChange).not.toHaveBeenCalled();
}); });
@@ -89,7 +112,9 @@ describe('OrganizationLookup', () => {
const onChange = jest.fn(); const onChange = jest.fn();
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<OrganizationLookup autoPopulate onChange={onChange} /> <Formik>
<OrganizationLookup autoPopulate onChange={onChange} />
</Formik>
); );
}); });
expect(onChange).not.toHaveBeenCalled(); expect(onChange).not.toHaveBeenCalled();

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { node, string, func, bool } from 'prop-types'; import { node, string, func, bool } from 'prop-types';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { FormGroup } from '@patternfly/react-core'; import { FormGroup } from '@patternfly/react-core';
import { ProjectsAPI } from '../../api'; import { ProjectsAPI } from '../../api';
@@ -32,6 +31,8 @@ function ProjectLookup({
onBlur, onBlur,
history, history,
isOverrideDisabled, isOverrideDisabled,
validate,
fieldName,
}) { }) {
const autoPopulateLookup = useAutoPopulateLookup(onChange); const autoPopulateLookup = useAutoPopulateLookup(onChange);
const { 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(() => { useEffect(() => {
fetchProjects(); fetchProjects();
}, [fetchProjects]); }, [fetchProjects]);
@@ -92,6 +111,9 @@ function ProjectLookup({
value={value} value={value}
onBlur={onBlur} onBlur={onBlur}
onChange={onChange} onChange={onChange}
onDebounce={checkProjectName}
fieldName={fieldName}
validate={validate}
required={required} required={required}
isLoading={isLoading} isLoading={isLoading}
isDisabled={!canEdit} isDisabled={!canEdit}
@@ -164,6 +186,8 @@ ProjectLookup.propTypes = {
tooltip: string, tooltip: string,
value: Project, value: Project,
isOverrideDisabled: bool, isOverrideDisabled: bool,
validate: func,
fieldName: string,
}; };
ProjectLookup.defaultProps = { ProjectLookup.defaultProps = {
@@ -175,6 +199,8 @@ ProjectLookup.defaultProps = {
tooltip: '', tooltip: '',
value: null, value: null,
isOverrideDisabled: false, isOverrideDisabled: false,
validate: () => undefined,
fieldName: 'project',
}; };
export { ProjectLookup as _ProjectLookup }; export { ProjectLookup as _ProjectLookup };

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import { ProjectsAPI } from '../../api'; import { ProjectsAPI } from '../../api';
import ProjectLookup from './ProjectLookup'; import ProjectLookup from './ProjectLookup';
@@ -14,27 +15,35 @@ describe('<ProjectLookup />', () => {
test('should auto-select project when only one available and autoPopulate prop is true', async () => { test('should auto-select project when only one available and autoPopulate prop is true', async () => {
ProjectsAPI.read.mockReturnValue({ ProjectsAPI.read.mockReturnValue({
data: { data: {
results: [{ id: 1 }], results: [{ id: 1, name: 'Test' }],
count: 1, count: 1,
}, },
}); });
const onChange = jest.fn(); const onChange = jest.fn();
await act(async () => { await act(async () => {
mountWithContexts(<ProjectLookup autoPopulate onChange={onChange} />); mountWithContexts(
<Formik>
<ProjectLookup autoPopulate onChange={onChange} />
</Formik>
);
}); });
expect(onChange).toHaveBeenCalledWith({ id: 1 }); expect(onChange).toHaveBeenCalledWith({ id: 1, name: 'Test' });
}); });
test('should not auto-select project when autoPopulate prop is false', async () => { test('should not auto-select project when autoPopulate prop is false', async () => {
ProjectsAPI.read.mockReturnValue({ ProjectsAPI.read.mockReturnValue({
data: { data: {
results: [{ id: 1 }], results: [{ id: 1, name: 'Test' }],
count: 1, count: 1,
}, },
}); });
const onChange = jest.fn(); const onChange = jest.fn();
await act(async () => { await act(async () => {
mountWithContexts(<ProjectLookup onChange={onChange} />); mountWithContexts(
<Formik>
<ProjectLookup onChange={onChange} />
</Formik>
);
}); });
expect(onChange).not.toHaveBeenCalled(); expect(onChange).not.toHaveBeenCalled();
}); });
@@ -42,13 +51,20 @@ describe('<ProjectLookup />', () => {
test('should not auto-select project when multiple available', async () => { test('should not auto-select project when multiple available', async () => {
ProjectsAPI.read.mockReturnValue({ ProjectsAPI.read.mockReturnValue({
data: { data: {
results: [{ id: 1 }, { id: 2 }], results: [
{ id: 1, name: 'Test' },
{ id: 2, name: 'Test 2' },
],
count: 2, count: 2,
}, },
}); });
const onChange = jest.fn(); const onChange = jest.fn();
await act(async () => { await act(async () => {
mountWithContexts(<ProjectLookup autoPopulate onChange={onChange} />); mountWithContexts(
<Formik>
<ProjectLookup autoPopulate onChange={onChange} />
</Formik>
);
}); });
expect(onChange).not.toHaveBeenCalled(); expect(onChange).not.toHaveBeenCalled();
}); });
@@ -57,7 +73,7 @@ describe('<ProjectLookup />', () => {
let wrapper; let wrapper;
ProjectsAPI.read.mockReturnValue({ ProjectsAPI.read.mockReturnValue({
data: { data: {
results: [{ id: 1 }], results: [{ id: 1, name: 'Test' }],
count: 1, count: 1,
}, },
}); });
@@ -71,7 +87,9 @@ describe('<ProjectLookup />', () => {
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<ProjectLookup isOverrideDisabled onChange={() => {}} /> <Formik>
<ProjectLookup isOverrideDisabled onChange={() => {}} />
</Formik>
); );
}); });
wrapper.update(); wrapper.update();
@@ -92,7 +110,11 @@ describe('<ProjectLookup />', () => {
}, },
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<ProjectLookup onChange={() => {}} />); wrapper = mountWithContexts(
<Formik>
<ProjectLookup onChange={() => {}} />
</Formik>
);
}); });
wrapper.update(); wrapper.update();
expect(ProjectsAPI.read).toHaveBeenCalledTimes(1); expect(ProjectsAPI.read).toHaveBeenCalledTimes(1);
@@ -113,11 +135,13 @@ describe('<ProjectLookup />', () => {
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<ProjectLookup <Formik>
isValid <ProjectLookup
helperTextInvalid="select value" isValid
onChange={() => {}} helperTextInvalid="select value"
/> onChange={() => {}}
/>
</Formik>
); );
}); });
wrapper.update(); wrapper.update();
@@ -138,11 +162,13 @@ describe('<ProjectLookup />', () => {
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<ProjectLookup <Formik>
isValid={false} <ProjectLookup
helperTextInvalid="select value" isValid={false}
onChange={() => {}} helperTextInvalid="select value"
/> onChange={() => {}}
/>
</Formik>
); );
}); });
wrapper.update(); wrapper.update();

View File

@@ -41,7 +41,6 @@ function OptionsList({
deselectItem, deselectItem,
renderItemChip, renderItemChip,
isLoading, isLoading,
displayKey, displayKey,
}) { }) {
return ( return (

View File

@@ -98,8 +98,9 @@ describe('<ApplicationAdd/>', () => {
wrapper.update(); wrapper.update();
expect(wrapper.find('input#name').prop('value')).toBe('new foo'); expect(wrapper.find('input#name').prop('value')).toBe('new foo');
expect(wrapper.find('input#description').prop('value')).toBe('new bar'); expect(wrapper.find('input#description').prop('value')).toBe('new bar');
expect(wrapper.find('Chip').length).toBe(1); expect(wrapper.find('input#organization-input').prop('value')).toBe(
expect(wrapper.find('Chip').text()).toBe('organization'); 'organization'
);
expect( expect(
wrapper wrapper
.find('AnsibleSelect[name="authorization_grant_type"]') .find('AnsibleSelect[name="authorization_grant_type"]')

View File

@@ -188,8 +188,9 @@ describe('<ApplicationEdit/>', () => {
wrapper.update(); wrapper.update();
expect(wrapper.find('input#name').prop('value')).toBe('new foo'); expect(wrapper.find('input#name').prop('value')).toBe('new foo');
expect(wrapper.find('input#description').prop('value')).toBe('new bar'); expect(wrapper.find('input#description').prop('value')).toBe('new bar');
expect(wrapper.find('Chip').length).toBe(1); expect(wrapper.find('input#organization-input').prop('value')).toBe(
expect(wrapper.find('Chip').text()).toBe('organization'); 'organization'
);
expect( expect(
wrapper wrapper
.find('AnsibleSelect[name="authorization_grant_type"]') .find('AnsibleSelect[name="authorization_grant_type"]')

View File

@@ -20,11 +20,10 @@ function ApplicationFormFields({
clientTypeOptions, clientTypeOptions,
}) { }) {
const match = useRouteMatch(); const match = useRouteMatch();
const { setFieldValue } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const [organizationField, organizationMeta, organizationHelpers] = useField({ const [organizationField, organizationMeta, organizationHelpers] = useField(
name: 'organization', 'organization'
validate: required(null), );
});
const [ const [
authorizationTypeField, authorizationTypeField,
authorizationTypeMeta, authorizationTypeMeta,
@@ -39,11 +38,12 @@ function ApplicationFormFields({
validate: required(null), validate: required(null),
}); });
const onOrganizationChange = useCallback( const handleOrganizationUpdate = useCallback(
value => { value => {
setFieldValue('organization', value); setFieldValue('organization', value);
setFieldTouched('organization', true, false);
}, },
[setFieldValue] [setFieldValue, setFieldTouched]
); );
return ( return (
@@ -66,10 +66,11 @@ function ApplicationFormFields({
helperTextInvalid={organizationMeta.error} helperTextInvalid={organizationMeta.error}
isValid={!organizationMeta.touched || !organizationMeta.error} isValid={!organizationMeta.touched || !organizationMeta.error}
onBlur={() => organizationHelpers.setTouched()} onBlur={() => organizationHelpers.setTouched()}
onChange={onOrganizationChange} onChange={handleOrganizationUpdate}
value={organizationField.value} value={organizationField.value}
required required
autoPopulate={!application?.id} autoPopulate={!application?.id}
validate={required(null)}
/> />
<FormGroup <FormGroup
fieldId="authType" fieldId="authType"

View File

@@ -107,8 +107,9 @@ describe('<ApplicationForm', () => {
wrapper.update(); wrapper.update();
expect(wrapper.find('input#name').prop('value')).toBe('new foo'); expect(wrapper.find('input#name').prop('value')).toBe('new foo');
expect(wrapper.find('input#description').prop('value')).toBe('new bar'); expect(wrapper.find('input#description').prop('value')).toBe('new bar');
expect(wrapper.find('Chip').length).toBe(1); expect(wrapper.find('input#organization-input').prop('value')).toBe(
expect(wrapper.find('Chip').text()).toBe('organization'); 'organization'
);
expect( expect(
wrapper wrapper
.find('AnsibleSelect[name="authorization_grant_type"]') .find('AnsibleSelect[name="authorization_grant_type"]')

View File

@@ -52,12 +52,7 @@ function CredentialFormFields({ initialTypeId, credentialTypes }) {
const isGalaxyCredential = const isGalaxyCredential =
!!credentialTypeId && credentialTypes[credentialTypeId]?.kind === 'galaxy'; !!credentialTypeId && credentialTypes[credentialTypeId]?.kind === 'galaxy';
const [orgField, orgMeta, orgHelpers] = useField({ const [orgField, orgMeta, orgHelpers] = useField('organization');
name: 'organization',
validate:
isGalaxyCredential &&
required(t`Galaxy credentials must be owned by an Organization.`),
});
const credentialTypeOptions = Object.keys(credentialTypes) const credentialTypeOptions = Object.keys(credentialTypes)
.map(key => { .map(key => {
@@ -122,11 +117,12 @@ function CredentialFormFields({ initialTypeId, credentialTypes }) {
} }
}, [resetSubFormFields, credentialTypeId]); }, [resetSubFormFields, credentialTypeId]);
const onOrganizationChange = useCallback( const handleOrganizationUpdate = useCallback(
value => { value => {
setFieldValue('organization', value); setFieldValue('organization', value);
setFieldTouched('organization', true, false);
}, },
[setFieldValue] [setFieldValue, setFieldTouched]
); );
const isCredentialTypeDisabled = pathname.includes('edit'); const isCredentialTypeDisabled = pathname.includes('edit');
@@ -182,12 +178,17 @@ function CredentialFormFields({ initialTypeId, credentialTypes }) {
helperTextInvalid={orgMeta.error} helperTextInvalid={orgMeta.error}
isValid={!orgMeta.touched || !orgMeta.error} isValid={!orgMeta.touched || !orgMeta.error}
onBlur={() => orgHelpers.setTouched()} onBlur={() => orgHelpers.setTouched()}
onChange={onOrganizationChange} onChange={handleOrganizationUpdate}
value={orgField.value} value={orgField.value}
touched={orgMeta.touched} touched={orgMeta.touched}
error={orgMeta.error} error={orgMeta.error}
required={isGalaxyCredential} required={isGalaxyCredential}
isDisabled={initialValues.isOrgLookupDisabled} isDisabled={initialValues.isOrgLookupDisabled}
validate={
isGalaxyCredential
? required(t`Galaxy credentials must be owned by an Organization.`)
: undefined
}
/> />
<FormGroup <FormGroup
fieldId="credential-Type" fieldId="credential-Type"

View File

@@ -60,6 +60,7 @@ const containerRegistryCredentialResolve = {
kind: 'registry', kind: 'registry',
}, },
], ],
count: 1,
}, },
}; };

View File

@@ -53,6 +53,7 @@ const containerRegistryCredentialResolve = {
kind: 'registry', kind: 'registry',
}, },
], ],
count: 1,
}, },
}; };

View File

@@ -1,10 +1,8 @@
import React, { useCallback, useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { func, shape, bool } from 'prop-types'; import { func, shape, bool } from 'prop-types';
import { Formik, useField, useFormikContext } from 'formik'; import { Formik, useField, useFormikContext } from 'formik';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Form, FormGroup, Tooltip } from '@patternfly/react-core'; import { Form, FormGroup, Tooltip } from '@patternfly/react-core';
import { ExecutionEnvironmentsAPI } from '../../../api'; import { ExecutionEnvironmentsAPI } from '../../../api';
import CredentialLookup from '../../../components/Lookup/CredentialLookup'; import CredentialLookup from '../../../components/Lookup/CredentialLookup';
import FormActionGroup from '../../../components/FormActionGroup'; import FormActionGroup from '../../../components/FormActionGroup';
@@ -26,14 +24,13 @@ function ExecutionEnvironmentFormFields({
const [credentialField, credentialMeta, credentialHelpers] = useField( const [credentialField, credentialMeta, credentialHelpers] = useField(
'credential' 'credential'
); );
const [organizationField, organizationMeta, organizationHelpers] = useField({ const [organizationField, organizationMeta, organizationHelpers] = useField(
name: 'organization', 'organization'
validate: !me?.is_superuser && required(t`Select a value for this field`), );
});
const isGloballyAvailable = useRef(!organizationField.value); const isGloballyAvailable = useRef(!organizationField.value);
const { setFieldValue } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const onCredentialChange = useCallback( const onCredentialChange = useCallback(
value => { value => {
@@ -42,20 +39,19 @@ function ExecutionEnvironmentFormFields({
[setFieldValue] [setFieldValue]
); );
const onOrganizationChange = useCallback( const handleOrganizationUpdate = useCallback(
value => { value => {
setFieldValue('organization', value); setFieldValue('organization', value);
setFieldTouched('organization', true, false);
}, },
[setFieldValue] [setFieldValue, setFieldTouched]
); );
const [ const [
containerOptionsField, containerOptionsField,
containerOptionsMeta, containerOptionsMeta,
containerOptionsHelpers, containerOptionsHelpers,
] = useField({ ] = useField('pull');
name: 'pull',
});
const containerPullChoices = options?.actions?.POST?.pull?.choices.map( const containerPullChoices = options?.actions?.POST?.pull?.choices.map(
([value, label]) => ({ value, label, key: value }) ([value, label]) => ({ value, label, key: value })
@@ -67,7 +63,7 @@ function ExecutionEnvironmentFormFields({
helperTextInvalid={organizationMeta.error} helperTextInvalid={organizationMeta.error}
isValid={!organizationMeta.touched || !organizationMeta.error} isValid={!organizationMeta.touched || !organizationMeta.error}
onBlur={() => organizationHelpers.setTouched()} onBlur={() => organizationHelpers.setTouched()}
onChange={onOrganizationChange} onChange={handleOrganizationUpdate}
value={organizationField.value} value={organizationField.value}
required={!me.is_superuser} required={!me.is_superuser}
helperText={ helperText={
@@ -79,6 +75,11 @@ function ExecutionEnvironmentFormFields({
} }
autoPopulate={!me?.is_superuser ? !executionEnvironment?.id : null} autoPopulate={!me?.is_superuser ? !executionEnvironment?.id : null}
isDisabled={!!isOrgLookupDisabled && isGloballyAvailable.current} isDisabled={!!isOrgLookupDisabled && isGloballyAvailable.current}
validate={
!me?.is_superuser
? required(t`Select a value for this field`)
: undefined
}
/> />
); );
}; };

View File

@@ -113,6 +113,7 @@ const containerRegistryCredentialResolve = {
kind: 'registry', kind: 'registry',
}, },
], ],
count: 1,
}, },
}; };

View File

@@ -1,7 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { PageSection, Card } from '@patternfly/react-core'; import { PageSection, Card } from '@patternfly/react-core';
import HostForm from '../../../components/HostForm'; import HostForm from '../../../components/HostForm';
import { CardBody } from '../../../components/Card'; import { CardBody } from '../../../components/Card';
import { HostsAPI } from '../../../api'; import { HostsAPI } from '../../../api';
@@ -12,7 +11,11 @@ function HostAdd() {
const handleSubmit = async formData => { const handleSubmit = async formData => {
try { 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`); history.push(`/hosts/${response.id}/details`);
} catch (error) { } catch (error) {
setFormError(error); setFormError(error);

View File

@@ -10,7 +10,10 @@ jest.mock('../../../api');
const hostData = { const hostData = {
name: 'new name', name: 'new name',
description: 'new description', description: 'new description',
inventory: 1, inventory: {
id: 1,
name: 'Demo Inventory',
},
variables: '---\nfoo: bar', variables: '---\nfoo: bar',
}; };
@@ -44,7 +47,7 @@ describe('<HostAdd />', () => {
await act(async () => { await act(async () => {
wrapper.find('HostForm').prop('handleSubmit')(hostData); 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 () => { test('should navigate to hosts list when cancel is clicked', async () => {

View File

@@ -12,7 +12,11 @@ function HostEdit({ host }) {
const handleSubmit = async values => { const handleSubmit = async values => {
try { 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); history.push(detailsUrl);
} catch (error) { } catch (error) {
setFormError(error); setFormError(error);

View File

@@ -22,18 +22,19 @@ import CredentialLookup from '../../../components/Lookup/CredentialLookup';
import { VariablesField } from '../../../components/CodeEditor'; import { VariablesField } from '../../../components/CodeEditor';
function ContainerGroupFormFields({ instanceGroup }) { function ContainerGroupFormFields({ instanceGroup }) {
const { setFieldValue } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const [credentialField, credentialMeta, credentialHelpers] = useField({ const [credentialField, credentialMeta, credentialHelpers] = useField(
name: 'credential', 'credential'
}); );
const [overrideField] = useField('override'); const [overrideField] = useField('override');
const onCredentialChange = useCallback( const handleCredentialUpdate = useCallback(
value => { value => {
setFieldValue('credential', value); setFieldValue('credential', value);
setFieldTouched('credential', true, false);
}, },
[setFieldValue] [setFieldValue, setFieldTouched]
); );
return ( return (
@@ -52,7 +53,7 @@ function ContainerGroupFormFields({ instanceGroup }) {
helperTextInvalid={credentialMeta.error} helperTextInvalid={credentialMeta.error}
isValid={!credentialMeta.touched || !credentialMeta.error} isValid={!credentialMeta.touched || !credentialMeta.error}
onBlur={() => credentialHelpers.setTouched()} onBlur={() => credentialHelpers.setTouched()}
onChange={onCredentialChange} onChange={handleCredentialUpdate}
value={credentialField.value} 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.`} 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} autoPopulate={!instanceGroup?.id}

View File

@@ -1,9 +1,7 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { Formik, useField, useFormikContext } from 'formik'; import { Formik, useField, useFormikContext } from 'formik';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { func, number, shape } from 'prop-types'; import { func, number, shape } from 'prop-types';
import { Form } from '@patternfly/react-core'; import { Form } from '@patternfly/react-core';
import { VariablesField } from '../../../components/CodeEditor'; import { VariablesField } from '../../../components/CodeEditor';
import FormField, { FormSubmitError } from '../../../components/FormField'; import FormField, { FormSubmitError } from '../../../components/FormField';
@@ -18,26 +16,30 @@ import {
} from '../../../components/FormLayout'; } from '../../../components/FormLayout';
function InventoryFormFields({ credentialTypeId, inventory }) { function InventoryFormFields({ credentialTypeId, inventory }) {
const { setFieldValue } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const [organizationField, organizationMeta, organizationHelpers] = useField({ const [organizationField, organizationMeta, organizationHelpers] = useField(
name: 'organization', 'organization'
validate: required(t`Select a value for this field`), );
});
const [instanceGroupsField, , instanceGroupsHelpers] = useField( const [instanceGroupsField, , instanceGroupsHelpers] = useField(
'instanceGroups' 'instanceGroups'
); );
const [insightsCredentialField] = useField('insights_credential'); const [insightsCredentialField, insightsCredentialMeta] = useField(
const onOrganizationChange = useCallback( 'insights_credential'
);
const handleOrganizationUpdate = useCallback(
value => { value => {
setFieldValue('organization', value); setFieldValue('organization', value);
setFieldTouched('organization', true, false);
}, },
[setFieldValue] [setFieldValue, setFieldTouched]
); );
const onCredentialChange = useCallback(
const handleCredentialUpdate = useCallback(
value => { value => {
setFieldValue('insights_credential', value); setFieldValue('insights_credential', value);
setFieldTouched('insights_credential', true, false);
}, },
[setFieldValue] [setFieldValue, setFieldTouched]
); );
return ( return (
@@ -60,24 +62,31 @@ function InventoryFormFields({ credentialTypeId, inventory }) {
helperTextInvalid={organizationMeta.error} helperTextInvalid={organizationMeta.error}
isValid={!organizationMeta.touched || !organizationMeta.error} isValid={!organizationMeta.touched || !organizationMeta.error}
onBlur={() => organizationHelpers.setTouched()} onBlur={() => organizationHelpers.setTouched()}
onChange={onOrganizationChange} onChange={handleOrganizationUpdate}
value={organizationField.value} value={organizationField.value}
touched={organizationMeta.touched} touched={organizationMeta.touched}
error={organizationMeta.error} error={organizationMeta.error}
required required
autoPopulate={!inventory?.id} autoPopulate={!inventory?.id}
validate={required(t`Select a value for this field`)}
/> />
<CredentialLookup <CredentialLookup
helperTextInvalid={insightsCredentialMeta.error}
isValid={
!insightsCredentialMeta.touched || !insightsCredentialMeta.error
}
label={t`Insights Credential`} label={t`Insights Credential`}
credentialTypeId={credentialTypeId} credentialTypeId={credentialTypeId}
onChange={onCredentialChange} onChange={handleCredentialUpdate}
value={insightsCredentialField.value} value={insightsCredentialField.value}
fieldName="insights_credential"
/> />
<InstanceGroupsLookup <InstanceGroupsLookup
value={instanceGroupsField.value} value={instanceGroupsField.value}
onChange={value => { onChange={value => {
instanceGroupsHelpers.setValue(value); instanceGroupsHelpers.setValue(value);
}} }}
fieldName="instanceGroups"
/> />
<FormFullWidthLayout> <FormFullWidthLayout>
<VariablesField <VariablesField

View File

@@ -1,13 +1,11 @@
import React, { useEffect, useCallback } from 'react'; import React, { useEffect, useCallback } from 'react';
import { Formik, useField, useFormikContext } from 'formik'; import { Formik, useField, useFormikContext } from 'formik';
import { func, shape } from 'prop-types'; import { func, shape } from 'prop-types';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Form, FormGroup, Title } from '@patternfly/react-core'; import { Form, FormGroup, Title } from '@patternfly/react-core';
import { InventorySourcesAPI } from '../../../api'; import { InventorySourcesAPI } from '../../../api';
import useRequest from '../../../util/useRequest'; import useRequest from '../../../util/useRequest';
import { required } from '../../../util/validators'; import { required } from '../../../util/validators';
import AnsibleSelect from '../../../components/AnsibleSelect'; import AnsibleSelect from '../../../components/AnsibleSelect';
import ContentError from '../../../components/ContentError'; import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading'; import ContentLoading from '../../../components/ContentLoading';
@@ -58,9 +56,7 @@ const InventorySourceFormFields = ({
executionEnvironmentField, executionEnvironmentField,
executionEnvironmentMeta, executionEnvironmentMeta,
executionEnvironmentHelpers, executionEnvironmentHelpers,
] = useField({ ] = useField('execution_environment');
name: 'execution_environment',
});
const resetSubFormFields = sourceType => { const resetSubFormFields = sourceType => {
if (sourceType === initialValues.source) { 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 ( return (
<> <>
<FormField <FormField
@@ -120,7 +124,7 @@ const InventorySourceFormFields = ({
} }
onBlur={() => executionEnvironmentHelpers.setTouched()} onBlur={() => executionEnvironmentHelpers.setTouched()}
value={executionEnvironmentField.value} value={executionEnvironmentField.value}
onChange={value => executionEnvironmentHelpers.setValue(value)} onChange={handleExecutionEnvironmentUpdate}
globallyAvailable globallyAvailable
organizationId={organizationId} organizationId={organizationId}
/> />

View File

@@ -1,6 +1,5 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useField, useFormikContext } from 'formik'; import { useField, useFormikContext } from 'formik';
import { t, Trans } from '@lingui/macro'; import { t, Trans } from '@lingui/macro';
import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; import CredentialLookup from '../../../../components/Lookup/CredentialLookup';
import { import {
@@ -16,18 +15,18 @@ import getDocsBaseUrl from '../../../../util/getDocsBaseUrl';
import { useConfig } from '../../../../contexts/Config'; import { useConfig } from '../../../../contexts/Config';
const AzureSubForm = ({ autoPopulateCredential }) => { const AzureSubForm = ({ autoPopulateCredential }) => {
const { setFieldValue } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const [credentialField, credentialMeta, credentialHelpers] = useField({ const [credentialField, credentialMeta, credentialHelpers] = useField(
name: 'credential', 'credential'
validate: required(t`Select a value for this field`), );
});
const config = useConfig(); const config = useConfig();
const handleCredentialUpdate = useCallback( const handleCredentialUpdate = useCallback(
value => { value => {
setFieldValue('credential', value); setFieldValue('credential', value);
setFieldTouched('credential', true, false);
}, },
[setFieldValue] [setFieldValue, setFieldTouched]
); );
const pluginLink = `${getDocsBaseUrl( const pluginLink = `${getDocsBaseUrl(
@@ -48,6 +47,7 @@ const AzureSubForm = ({ autoPopulateCredential }) => {
value={credentialField.value} value={credentialField.value}
required required
autoPopulate={autoPopulateCredential} autoPopulate={autoPopulateCredential}
validate={required(t`Select a value for this field`)}
/> />
<VerbosityField /> <VerbosityField />
<HostFilterField /> <HostFilterField />

View File

@@ -1,6 +1,5 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useField, useFormikContext } from 'formik'; import { useField, useFormikContext } from 'formik';
import { t, Trans } from '@lingui/macro'; import { t, Trans } from '@lingui/macro';
import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; import CredentialLookup from '../../../../components/Lookup/CredentialLookup';
import { import {
@@ -15,15 +14,16 @@ import getDocsBaseUrl from '../../../../util/getDocsBaseUrl';
import { useConfig } from '../../../../contexts/Config'; import { useConfig } from '../../../../contexts/Config';
const EC2SubForm = () => { const EC2SubForm = () => {
const { setFieldValue } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const [credentialField] = useField('credential'); const [credentialField, credentialMeta] = useField('credential');
const config = useConfig(); const config = useConfig();
const handleCredentialUpdate = useCallback( const handleCredentialUpdate = useCallback(
value => { value => {
setFieldValue('credential', value); setFieldValue('credential', value);
setFieldTouched('credential', true, false);
}, },
[setFieldValue] [setFieldValue, setFieldTouched]
); );
const pluginLink = `${getDocsBaseUrl( const pluginLink = `${getDocsBaseUrl(
@@ -35,6 +35,8 @@ const EC2SubForm = () => {
return ( return (
<> <>
<CredentialLookup <CredentialLookup
helperTextInvalid={credentialMeta.error}
isValid={!credentialMeta.touched || !credentialMeta.error}
credentialTypeNamespace="aws" credentialTypeNamespace="aws"
label={t`Credential`} label={t`Credential`}
value={credentialField.value} value={credentialField.value}

View File

@@ -1,6 +1,5 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useField, useFormikContext } from 'formik'; import { useField, useFormikContext } from 'formik';
import { t, Trans } from '@lingui/macro'; import { t, Trans } from '@lingui/macro';
import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; import CredentialLookup from '../../../../components/Lookup/CredentialLookup';
import { import {
@@ -16,18 +15,18 @@ import getDocsBaseUrl from '../../../../util/getDocsBaseUrl';
import { useConfig } from '../../../../contexts/Config'; import { useConfig } from '../../../../contexts/Config';
const GCESubForm = ({ autoPopulateCredential }) => { const GCESubForm = ({ autoPopulateCredential }) => {
const { setFieldValue } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const [credentialField, credentialMeta, credentialHelpers] = useField({ const [credentialField, credentialMeta, credentialHelpers] = useField(
name: 'credential', 'credential'
validate: required(t`Select a value for this field`), );
});
const config = useConfig(); const config = useConfig();
const handleCredentialUpdate = useCallback( const handleCredentialUpdate = useCallback(
value => { value => {
setFieldValue('credential', value); setFieldValue('credential', value);
setFieldTouched('credential', true, false);
}, },
[setFieldValue] [setFieldValue, setFieldTouched]
); );
const pluginLink = `${getDocsBaseUrl( const pluginLink = `${getDocsBaseUrl(
@@ -48,6 +47,7 @@ const GCESubForm = ({ autoPopulateCredential }) => {
value={credentialField.value} value={credentialField.value}
required required
autoPopulate={autoPopulateCredential} autoPopulate={autoPopulateCredential}
validate={required(t`Select a value for this field`)}
/> />
<VerbosityField /> <VerbosityField />
<HostFilterField /> <HostFilterField />

View File

@@ -1,6 +1,5 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useField, useFormikContext } from 'formik'; import { useField, useFormikContext } from 'formik';
import { t, Trans } from '@lingui/macro'; import { t, Trans } from '@lingui/macro';
import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; import CredentialLookup from '../../../../components/Lookup/CredentialLookup';
import { import {
@@ -16,18 +15,18 @@ import getDocsBaseUrl from '../../../../util/getDocsBaseUrl';
import { useConfig } from '../../../../contexts/Config'; import { useConfig } from '../../../../contexts/Config';
const OpenStackSubForm = ({ autoPopulateCredential }) => { const OpenStackSubForm = ({ autoPopulateCredential }) => {
const { setFieldValue } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const [credentialField, credentialMeta, credentialHelpers] = useField({ const [credentialField, credentialMeta, credentialHelpers] = useField(
name: 'credential', 'credential'
validate: required(t`Select a value for this field`), );
});
const config = useConfig(); const config = useConfig();
const handleCredentialUpdate = useCallback( const handleCredentialUpdate = useCallback(
value => { value => {
setFieldValue('credential', value); setFieldValue('credential', value);
setFieldTouched('credential', true, false);
}, },
[setFieldValue] [setFieldValue, setFieldTouched]
); );
const pluginLink = `${getDocsBaseUrl( const pluginLink = `${getDocsBaseUrl(
@@ -48,6 +47,7 @@ const OpenStackSubForm = ({ autoPopulateCredential }) => {
value={credentialField.value} value={credentialField.value}
required required
autoPopulate={autoPopulateCredential} autoPopulate={autoPopulateCredential}
validate={required(t`Select a value for this field`)}
/> />
<VerbosityField /> <VerbosityField />
<HostFilterField /> <HostFilterField />

View File

@@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useField, useFormikContext } from 'formik'; import { useField, useFormikContext } from 'formik';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { import {
FormGroup, FormGroup,
@@ -11,7 +10,6 @@ import {
import { ProjectsAPI } from '../../../../api'; import { ProjectsAPI } from '../../../../api';
import useRequest from '../../../../util/useRequest'; import useRequest from '../../../../util/useRequest';
import { required } from '../../../../util/validators'; import { required } from '../../../../util/validators';
import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; import CredentialLookup from '../../../../components/Lookup/CredentialLookup';
import ProjectLookup from '../../../../components/Lookup/ProjectLookup'; import ProjectLookup from '../../../../components/Lookup/ProjectLookup';
import Popover from '../../../../components/Popover'; import Popover from '../../../../components/Popover';
@@ -29,10 +27,9 @@ const SCMSubForm = ({ autoPopulateProject }) => {
const [sourcePath, setSourcePath] = useState([]); const [sourcePath, setSourcePath] = useState([]);
const { setFieldValue, setFieldTouched } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const [credentialField] = useField('credential'); const [credentialField] = useField('credential');
const [projectField, projectMeta, projectHelpers] = useField({ const [projectField, projectMeta, projectHelpers] = useField(
name: 'source_project', 'source_project'
validate: required(t`Select a value for this field`), );
});
const [sourcePathField, sourcePathMeta, sourcePathHelpers] = useField({ const [sourcePathField, sourcePathMeta, sourcePathHelpers] = useField({
name: 'source_path', name: 'source_path',
validate: required(t`Select a value for this field`), validate: required(t`Select a value for this field`),
@@ -60,7 +57,10 @@ const SCMSubForm = ({ autoPopulateProject }) => {
setFieldValue('source_project', value); setFieldValue('source_project', value);
setFieldValue('source_path', ''); setFieldValue('source_path', '');
setFieldTouched('source_path', false); setFieldTouched('source_path', false);
fetchSourcePath(value.id); setFieldTouched('source_project', true, false);
if (value) {
fetchSourcePath(value.id);
}
}, },
[fetchSourcePath, setFieldValue, setFieldTouched] [fetchSourcePath, setFieldValue, setFieldTouched]
); );
@@ -68,8 +68,9 @@ const SCMSubForm = ({ autoPopulateProject }) => {
const handleCredentialUpdate = useCallback( const handleCredentialUpdate = useCallback(
value => { value => {
setFieldValue('credential', value); setFieldValue('credential', value);
setFieldTouched('credential', true, false);
}, },
[setFieldValue] [setFieldValue, setFieldTouched]
); );
return ( return (
@@ -88,6 +89,8 @@ const SCMSubForm = ({ autoPopulateProject }) => {
onChange={handleProjectUpdate} onChange={handleProjectUpdate}
required required
autoPopulate={autoPopulateProject} autoPopulate={autoPopulateProject}
fieldName="source_project"
validate={required(t`Select a value for this field`)}
/> />
<FormGroup <FormGroup
fieldId="source_path" fieldId="source_path"

View File

@@ -1,6 +1,5 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useField, useFormikContext } from 'formik'; import { useField, useFormikContext } from 'formik';
import { t, Trans } from '@lingui/macro'; import { t, Trans } from '@lingui/macro';
import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; import CredentialLookup from '../../../../components/Lookup/CredentialLookup';
import { import {
@@ -16,18 +15,18 @@ import getDocsBaseUrl from '../../../../util/getDocsBaseUrl';
import { useConfig } from '../../../../contexts/Config'; import { useConfig } from '../../../../contexts/Config';
const SatelliteSubForm = ({ autoPopulateCredential }) => { const SatelliteSubForm = ({ autoPopulateCredential }) => {
const { setFieldValue } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const [credentialField, credentialMeta, credentialHelpers] = useField({ const [credentialField, credentialMeta, credentialHelpers] = useField(
name: 'credential', 'credential'
validate: required(t`Select a value for this field`), );
});
const config = useConfig(); const config = useConfig();
const handleCredentialUpdate = useCallback( const handleCredentialUpdate = useCallback(
value => { value => {
setFieldValue('credential', value); setFieldValue('credential', value);
setFieldTouched('credential', true, false);
}, },
[setFieldValue] [setFieldValue, setFieldTouched]
); );
const pluginLink = `${getDocsBaseUrl( const pluginLink = `${getDocsBaseUrl(
@@ -48,6 +47,7 @@ const SatelliteSubForm = ({ autoPopulateCredential }) => {
value={credentialField.value} value={credentialField.value}
required required
autoPopulate={autoPopulateCredential} autoPopulate={autoPopulateCredential}
validate={required(t`Select a value for this field`)}
/> />
<VerbosityField /> <VerbosityField />
<HostFilterField /> <HostFilterField />

View File

@@ -16,18 +16,18 @@ import getDocsBaseUrl from '../../../../util/getDocsBaseUrl';
import { useConfig } from '../../../../contexts/Config'; import { useConfig } from '../../../../contexts/Config';
const TowerSubForm = ({ autoPopulateCredential }) => { const TowerSubForm = ({ autoPopulateCredential }) => {
const { setFieldValue } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const [credentialField, credentialMeta, credentialHelpers] = useField({ const [credentialField, credentialMeta, credentialHelpers] = useField(
name: 'credential', 'credential'
validate: required(t`Select a value for this field`), );
});
const config = useConfig(); const config = useConfig();
const handleCredentialUpdate = useCallback( const handleCredentialUpdate = useCallback(
value => { value => {
setFieldValue('credential', value); setFieldValue('credential', value);
setFieldTouched('credential', true, false);
}, },
[setFieldValue] [setFieldValue, setFieldTouched]
); );
const pluginLink = `${getDocsBaseUrl( const pluginLink = `${getDocsBaseUrl(
@@ -48,6 +48,7 @@ const TowerSubForm = ({ autoPopulateCredential }) => {
value={credentialField.value} value={credentialField.value}
required required
autoPopulate={autoPopulateCredential} autoPopulate={autoPopulateCredential}
validate={required(t`Select a value for this field`)}
/> />
<VerbosityField /> <VerbosityField />
<HostFilterField /> <HostFilterField />

View File

@@ -1,6 +1,5 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useField, useFormikContext } from 'formik'; import { useField, useFormikContext } from 'formik';
import { t, Trans } from '@lingui/macro'; import { t, Trans } from '@lingui/macro';
import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; import CredentialLookup from '../../../../components/Lookup/CredentialLookup';
import { import {
@@ -16,18 +15,18 @@ import getDocsBaseUrl from '../../../../util/getDocsBaseUrl';
import { useConfig } from '../../../../contexts/Config'; import { useConfig } from '../../../../contexts/Config';
const VMwareSubForm = ({ autoPopulateCredential }) => { const VMwareSubForm = ({ autoPopulateCredential }) => {
const { setFieldValue } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const [credentialField, credentialMeta, credentialHelpers] = useField({ const [credentialField, credentialMeta, credentialHelpers] = useField(
name: 'credential', 'credential'
validate: required(t`Select a value for this field`), );
});
const config = useConfig(); const config = useConfig();
const handleCredentialUpdate = useCallback( const handleCredentialUpdate = useCallback(
value => { value => {
setFieldValue('credential', value); setFieldValue('credential', value);
setFieldTouched('credential', true, false);
}, },
[setFieldValue] [setFieldValue, setFieldTouched]
); );
const pluginLink = `${getDocsBaseUrl( const pluginLink = `${getDocsBaseUrl(
@@ -48,6 +47,7 @@ const VMwareSubForm = ({ autoPopulateCredential }) => {
value={credentialField.value} value={credentialField.value}
required required
autoPopulate={autoPopulateCredential} autoPopulate={autoPopulateCredential}
validate={required(t`Select a value for this field`)}
/> />
<VerbosityField /> <VerbosityField />
<HostFilterField /> <HostFilterField />

View File

@@ -1,6 +1,5 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useField, useFormikContext } from 'formik'; import { useField, useFormikContext } from 'formik';
import { t, Trans } from '@lingui/macro'; import { t, Trans } from '@lingui/macro';
import CredentialLookup from '../../../../components/Lookup/CredentialLookup'; import CredentialLookup from '../../../../components/Lookup/CredentialLookup';
import { import {
@@ -16,18 +15,18 @@ import getDocsBaseUrl from '../../../../util/getDocsBaseUrl';
import { useConfig } from '../../../../contexts/Config'; import { useConfig } from '../../../../contexts/Config';
const VirtualizationSubForm = ({ autoPopulateCredential }) => { const VirtualizationSubForm = ({ autoPopulateCredential }) => {
const { setFieldValue } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const [credentialField, credentialMeta, credentialHelpers] = useField({ const [credentialField, credentialMeta, credentialHelpers] = useField(
name: 'credential', 'credential'
validate: required(t`Select a value for this field`), );
});
const config = useConfig(); const config = useConfig();
const handleCredentialUpdate = useCallback( const handleCredentialUpdate = useCallback(
value => { value => {
setFieldValue('credential', value); setFieldValue('credential', value);
setFieldTouched('credential', true, false);
}, },
[setFieldValue] [setFieldValue, setFieldTouched]
); );
const pluginLink = `${getDocsBaseUrl( const pluginLink = `${getDocsBaseUrl(
@@ -48,6 +47,7 @@ const VirtualizationSubForm = ({ autoPopulateCredential }) => {
value={credentialField.value} value={credentialField.value}
required required
autoPopulate={autoPopulateCredential} autoPopulate={autoPopulateCredential}
validate={required(t`Select a value for this field`)}
/> />
<VerbosityField /> <VerbosityField />
<HostFilterField /> <HostFilterField />

View File

@@ -1,6 +1,5 @@
import React, { useEffect, useCallback } from 'react'; import React, { useEffect, useCallback } from 'react';
import { Formik, useField, useFormikContext } from 'formik'; import { Formik, useField, useFormikContext } from 'formik';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { func, shape, arrayOf } from 'prop-types'; import { func, shape, arrayOf } from 'prop-types';
@@ -27,23 +26,23 @@ import { required } from '../../../util/validators';
import { InventoriesAPI } from '../../../api'; import { InventoriesAPI } from '../../../api';
const SmartInventoryFormFields = ({ inventory }) => { const SmartInventoryFormFields = ({ inventory }) => {
const { setFieldValue } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const [organizationField, organizationMeta, organizationHelpers] = useField({ const [organizationField, organizationMeta, organizationHelpers] = useField(
name: 'organization', 'organization'
validate: required(t`Select a value for this field`), );
}); const [instanceGroupsField, , instanceGroupsHelpers] = useField(
const [instanceGroupsField, , instanceGroupsHelpers] = useField({ 'instance_groups'
name: 'instance_groups', );
});
const [hostFilterField, hostFilterMeta, hostFilterHelpers] = useField({ const [hostFilterField, hostFilterMeta, hostFilterHelpers] = useField({
name: 'host_filter', name: 'host_filter',
validate: required(null), validate: required(null),
}); });
const onOrganizationChange = useCallback( const handleOrganizationUpdate = useCallback(
value => { value => {
setFieldValue('organization', value); setFieldValue('organization', value);
setFieldTouched('organization', true, false);
}, },
[setFieldValue] [setFieldValue, setFieldTouched]
); );
return ( return (
@@ -66,10 +65,11 @@ const SmartInventoryFormFields = ({ inventory }) => {
helperTextInvalid={organizationMeta.error} helperTextInvalid={organizationMeta.error}
isValid={!organizationMeta.touched || !organizationMeta.error} isValid={!organizationMeta.touched || !organizationMeta.error}
onBlur={() => organizationHelpers.setTouched()} onBlur={() => organizationHelpers.setTouched()}
onChange={onOrganizationChange} onChange={handleOrganizationUpdate}
value={organizationField.value} value={organizationField.value}
required required
autoPopulate={!inventory?.id} autoPopulate={!inventory?.id}
validate={required(t`Select a value for this field`)}
/> />
<HostFilterLookup <HostFilterLookup
value={hostFilterField.value} value={hostFilterField.value}

View File

@@ -17,18 +17,19 @@ import hasCustomMessages from './hasCustomMessages';
import typeFieldNames, { initialConfigValues } from './typeFieldNames'; import typeFieldNames, { initialConfigValues } from './typeFieldNames';
function NotificationTemplateFormFields({ defaultMessages, template }) { function NotificationTemplateFormFields({ defaultMessages, template }) {
const { setFieldValue } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const [orgField, orgMeta, orgHelpers] = useField('organization'); const [orgField, orgMeta, orgHelpers] = useField('organization');
const [typeField, typeMeta] = useField({ const [typeField, typeMeta] = useField({
name: 'notification_type', name: 'notification_type',
validate: required(t`Select a value for this field`), validate: required(t`Select a value for this field`),
}); });
const onOrganizationChange = useCallback( const handleOrganizationUpdate = useCallback(
value => { value => {
setFieldValue('organization', value); setFieldValue('organization', value);
setFieldTouched('organization', true, false);
}, },
[setFieldValue] [setFieldValue, setFieldTouched]
); );
return ( return (
@@ -51,12 +52,13 @@ function NotificationTemplateFormFields({ defaultMessages, template }) {
helperTextInvalid={orgMeta.error} helperTextInvalid={orgMeta.error}
isValid={!orgMeta.touched || !orgMeta.error} isValid={!orgMeta.touched || !orgMeta.error}
onBlur={() => orgHelpers.setTouched()} onBlur={() => orgHelpers.setTouched()}
onChange={onOrganizationChange} onChange={handleOrganizationUpdate}
value={orgField.value} value={orgField.value}
touched={orgMeta.touched} touched={orgMeta.touched}
error={orgMeta.error} error={orgMeta.error}
required required
autoPopulate={!template?.id} autoPopulate={!template?.id}
validate={required(t`Select a value for this field`)}
/> />
<FormGroup <FormGroup
fieldId="notification-type" fieldId="notification-type"

View File

@@ -39,9 +39,7 @@ function OrganizationFormFields({
executionEnvironmentField, executionEnvironmentField,
executionEnvironmentMeta, executionEnvironmentMeta,
executionEnvironmentHelpers, executionEnvironmentHelpers,
] = useField({ ] = useField('default_environment');
name: 'default_environment',
});
const handleCredentialUpdate = useCallback( const handleCredentialUpdate = useCallback(
value => { value => {
@@ -97,6 +95,7 @@ function OrganizationFormFields({
globallyAvailable globallyAvailable
organizationId={organizationId} organizationId={organizationId}
isDefaultEnvironment isDefaultEnvironment
fieldName="default_environment"
/> />
<CredentialLookup <CredentialLookup
credentialTypeNamespace="galaxy_api_token" credentialTypeNamespace="galaxy_api_token"
@@ -107,6 +106,7 @@ function OrganizationFormFields({
onChange={handleCredentialUpdate} onChange={handleCredentialUpdate}
value={galaxyCredentialsField.value} value={galaxyCredentialsField.value}
multiple multiple
fieldName="galaxy_credentials"
/> />
</> </>
); );

View File

@@ -19,6 +19,8 @@ function ProjectAdd() {
// has a zero-length string as its credential field. As a work-around, // has a zero-length string as its credential field. As a work-around,
// normalize falsey credential fields by deleting them. // normalize falsey credential fields by deleting them.
delete values.credential; delete values.credential;
} else {
values.credential = values.credential.id;
} }
setFormSubmitError(null); setFormSubmitError(null);
try { try {

View File

@@ -56,6 +56,7 @@ describe('<ProjectAdd />', () => {
kind: 'scm', kind: 'scm',
}, },
], ],
count: 1,
}, },
}; };
@@ -68,6 +69,7 @@ describe('<ProjectAdd />', () => {
kind: 'insights', kind: 'insights',
}, },
], ],
count: 1,
}, },
}; };

View File

@@ -19,6 +19,8 @@ function ProjectEdit({ project }) {
// has a zero-length string as its credential field. As a work-around, // has a zero-length string as its credential field. As a work-around,
// normalize falsey credential fields by deleting them. // normalize falsey credential fields by deleting them.
delete values.credential; delete values.credential;
} else {
values.credential = values.credential.id;
} }
try { try {
const { const {

View File

@@ -89,24 +89,21 @@ function ProjectFormFields({
scm_update_cache_timeout: 0, scm_update_cache_timeout: 0,
}; };
const { setFieldValue } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const [scmTypeField, scmTypeMeta, scmTypeHelpers] = useField({ const [scmTypeField, scmTypeMeta, scmTypeHelpers] = useField({
name: 'scm_type', name: 'scm_type',
validate: required(t`Set a value for this field`), validate: required(t`Set a value for this field`),
}); });
const [organizationField, organizationMeta, organizationHelpers] = useField({ const [organizationField, organizationMeta, organizationHelpers] = useField(
name: 'organization', 'organization'
validate: required(t`Select a value for this field`), );
});
const [ const [
executionEnvironmentField, executionEnvironmentField,
executionEnvironmentMeta, executionEnvironmentMeta,
executionEnvironmentHelpers, executionEnvironmentHelpers,
] = useField({ ] = useField('default_environment');
name: 'default_environment',
});
/* Save current scm subform field values to state */ /* Save current scm subform field values to state */
const saveSubFormState = form => { const saveSubFormState = form => {
@@ -153,11 +150,20 @@ function ProjectFormFields({
[credentials, setCredentials] [credentials, setCredentials]
); );
const onOrganizationChange = useCallback( const handleOrganizationUpdate = useCallback(
value => { value => {
setFieldValue('organization', 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 ( return (
@@ -180,10 +186,11 @@ function ProjectFormFields({
helperTextInvalid={organizationMeta.error} helperTextInvalid={organizationMeta.error}
isValid={!organizationMeta.touched || !organizationMeta.error} isValid={!organizationMeta.touched || !organizationMeta.error}
onBlur={() => organizationHelpers.setTouched()} onBlur={() => organizationHelpers.setTouched()}
onChange={onOrganizationChange} onChange={handleOrganizationUpdate}
value={organizationField.value} value={organizationField.value}
required required
autoPopulate={!project?.id} autoPopulate={!project?.id}
validate={required(t`Select a value for this field`)}
/> />
<ExecutionEnvironmentLookup <ExecutionEnvironmentLookup
helperTextInvalid={executionEnvironmentMeta.error} helperTextInvalid={executionEnvironmentMeta.error}
@@ -192,13 +199,14 @@ function ProjectFormFields({
} }
onBlur={() => executionEnvironmentHelpers.setTouched()} onBlur={() => executionEnvironmentHelpers.setTouched()}
value={executionEnvironmentField.value} 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.`} 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.`} tooltip={t`Select an organization before editing the default execution environment.`}
globallyAvailable globallyAvailable
isDisabled={!organizationField.value} isDisabled={!organizationField.value}
organizationId={organizationField.value?.id} organizationId={organizationField.value?.id}
isDefaultEnvironment isDefaultEnvironment
fieldName="default_environment"
/> />
<FormGroup <FormGroup
fieldId="project-scm-type" fieldId="project-scm-type"

View File

@@ -12,18 +12,16 @@ const InsightsSubForm = ({
scmUpdateOnLaunch, scmUpdateOnLaunch,
autoPopulateCredential, autoPopulateCredential,
}) => { }) => {
const { setFieldValue } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const [, credMeta, credHelpers] = useField({ const [, credMeta, credHelpers] = useField('credential');
name: 'credential',
validate: required(t`Select a value for this field`),
});
const onCredentialChange = useCallback( const onCredentialChange = useCallback(
value => { value => {
onCredentialSelection('insights', value); onCredentialSelection('insights', value);
setFieldValue('credential', value.id); setFieldValue('credential', value);
setFieldTouched('credential', true, false);
}, },
[onCredentialSelection, setFieldValue] [onCredentialSelection, setFieldValue, setFieldTouched]
); );
return ( return (
@@ -38,6 +36,7 @@ const InsightsSubForm = ({
value={credential.value} value={credential.value}
required required
autoPopulate={autoPopulateCredential} autoPopulate={autoPopulateCredential}
validate={required(t`Select a value for this field`)}
/> />
<ScmTypeOptions hideAllowOverride scmUpdateOnLaunch={scmUpdateOnLaunch} /> <ScmTypeOptions hideAllowOverride scmUpdateOnLaunch={scmUpdateOnLaunch} />
</> </>

View File

@@ -41,14 +41,15 @@ export const ScmCredentialFormField = ({
credential, credential,
onCredentialSelection, onCredentialSelection,
}) => { }) => {
const { setFieldValue } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const onCredentialChange = useCallback( const onCredentialChange = useCallback(
value => { value => {
onCredentialSelection('scm', value); onCredentialSelection('scm', value);
setFieldValue('credential', value ? value.id : ''); setFieldValue('credential', value);
setFieldTouched('credential', true, false);
}, },
[onCredentialSelection, setFieldValue] [onCredentialSelection, setFieldValue, setFieldTouched]
); );
return ( return (

View File

@@ -1,6 +1,5 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Formik } from 'formik'; import { Formik } from 'formik';
import { Form } from '@patternfly/react-core'; import { Form } from '@patternfly/react-core';
@@ -238,14 +237,20 @@ function MiscSystemEdit() {
formik.setFieldTouched('DEFAULT_EXECUTION_ENVIRONMENT') formik.setFieldTouched('DEFAULT_EXECUTION_ENVIRONMENT')
} }
value={formik.values.DEFAULT_EXECUTION_ENVIRONMENT} value={formik.values.DEFAULT_EXECUTION_ENVIRONMENT}
onChange={value => onChange={value => {
formik.setFieldValue( formik.setFieldValue(
'DEFAULT_EXECUTION_ENVIRONMENT', 'DEFAULT_EXECUTION_ENVIRONMENT',
value 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.`} popoverContent={t`The Execution Environment to be used when one has not been configured for a job template.`}
isGlobalDefaultEnvironment isGlobalDefaultEnvironment
fieldName="DEFAULT_EXECUTION_ENVIRONMENT"
/> />
<InputField <InputField
name="TOWER_URL_BASE" name="TOWER_URL_BASE"

View File

@@ -15,18 +15,10 @@ const ANALYTICSLINK = 'https://www.ansible.com/products/automation-analytics';
function AnalyticsStep() { function AnalyticsStep() {
const config = useConfig(); const config = useConfig();
const [manifest] = useField({ const [manifest] = useField('manifest_file');
name: 'manifest_file', const [insights] = useField('insights');
}); const [, , usernameHelpers] = useField('username');
const [insights] = useField({ const [, , passwordHelpers] = useField('password');
name: 'insights',
});
const [, , usernameHelpers] = useField({
name: 'username',
});
const [, , passwordHelpers] = useField({
name: 'password',
});
const requireCredentialFields = manifest.value && insights.value; const requireCredentialFields = manifest.value && insights.value;
useEffect(() => { useEffect(() => {

View File

@@ -40,21 +40,13 @@ function SubscriptionStep() {
values.subscription ? 'selectSubscription' : 'uploadManifest' values.subscription ? 'selectSubscription' : 'uploadManifest'
); );
const { isModalOpen, toggleModal, closeModal } = useModal(); const { isModalOpen, toggleModal, closeModal } = useModal();
const [manifest, manifestMeta, manifestHelpers] = useField({ const [manifest, manifestMeta, manifestHelpers] = useField('manifest_file');
name: 'manifest_file', const [manifestFilename, , manifestFilenameHelpers] = useField(
}); 'manifest_filename'
const [manifestFilename, , manifestFilenameHelpers] = useField({ );
name: 'manifest_filename', const [subscription, , subscriptionHelpers] = useField('subscription');
}); const [username, usernameMeta, usernameHelpers] = useField('username');
const [subscription, , subscriptionHelpers] = useField({ const [password, passwordMeta, passwordHelpers] = useField('password');
name: 'subscription',
});
const [username, usernameMeta, usernameHelpers] = useField({
name: 'username',
});
const [password, passwordMeta, passwordHelpers] = useField({
name: 'password',
});
return ( return (
<Flex <Flex

View File

@@ -18,7 +18,9 @@ class TeamAdd extends React.Component {
async handleSubmit(values) { async handleSubmit(values) {
const { history } = this.props; const { history } = this.props;
try { try {
const { data: response } = await TeamsAPI.create(values); const valuesToSend = { ...values };
valuesToSend.organization = valuesToSend.organization.id;
const { data: response } = await TeamsAPI.create(valuesToSend);
history.push(`/teams/${response.id}`); history.push(`/teams/${response.id}`);
} catch (error) { } catch (error) {
this.setState({ error }); this.setState({ error });

View File

@@ -17,12 +17,18 @@ describe('<TeamAdd />', () => {
const updatedTeamData = { const updatedTeamData = {
name: 'new name', name: 'new name',
description: 'new description', description: 'new description',
organization: 1, organization: {
id: 1,
name: 'Default',
},
}; };
await act(async () => { await act(async () => {
wrapper.find('TeamForm').invoke('handleSubmit')(updatedTeamData); 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 () => { test('should navigate to teams list when cancel is clicked', async () => {
@@ -41,7 +47,10 @@ describe('<TeamAdd />', () => {
const teamData = { const teamData = {
name: 'new name', name: 'new name',
description: 'new description', description: 'new description',
organization: 1, organization: {
id: 1,
name: 'Default',
},
}; };
TeamsAPI.create.mockResolvedValueOnce({ TeamsAPI.create.mockResolvedValueOnce({
data: { data: {

View File

@@ -14,7 +14,11 @@ function TeamEdit({ team }) {
const handleSubmit = async values => { const handleSubmit = async values => {
try { 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`); history.push(`/teams/${team.id}/details`);
} catch (err) { } catch (err) {
setError(err); setError(err);

View File

@@ -30,12 +30,19 @@ describe('<TeamEdit />', () => {
const updatedTeamData = { const updatedTeamData = {
name: 'new name', name: 'new name',
description: 'new description', description: 'new description',
organization: {
id: 2,
name: 'Other Org',
},
}; };
await act(async () => { await act(async () => {
wrapper.find('TeamForm').invoke('handleSubmit')(updatedTeamData); 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'); expect(history.location.pathname).toEqual('/teams/1/details');
}); });

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -11,21 +11,15 @@ import { required } from '../../../util/validators';
import { FormColumnLayout } from '../../../components/FormLayout'; import { FormColumnLayout } from '../../../components/FormLayout';
function TeamFormFields({ team }) { function TeamFormFields({ team }) {
const { setFieldValue } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const [organization, setOrganization] = useState( const [orgField, orgMeta, orgHelpers] = useField('organization');
team.summary_fields ? team.summary_fields.organization : null
);
const [, orgMeta, orgHelpers] = useField({
name: 'organization',
validate: required(t`Select a value for this field`),
});
const onOrganizationChange = useCallback( const handleOrganizationUpdate = useCallback(
value => { value => {
setFieldValue('organization', value.id); setFieldValue('organization', value);
setOrganization(value); setFieldTouched('organization', true, false);
}, },
[setFieldValue] [setFieldValue, setFieldTouched]
); );
return ( return (
@@ -48,10 +42,11 @@ function TeamFormFields({ team }) {
helperTextInvalid={orgMeta.error} helperTextInvalid={orgMeta.error}
isValid={!orgMeta.touched || !orgMeta.error} isValid={!orgMeta.touched || !orgMeta.error}
onBlur={() => orgHelpers.setTouched('organization')} onBlur={() => orgHelpers.setTouched('organization')}
onChange={onOrganizationChange} onChange={handleOrganizationUpdate}
value={organization} value={orgField.value}
required required
autoPopulate={!team?.id} autoPopulate={!team?.id}
validate={required(t`Select a value for this field`)}
/> />
</> </>
); );
@@ -65,7 +60,7 @@ function TeamForm(props) {
initialValues={{ initialValues={{
description: team.description || '', description: team.description || '',
name: team.name || '', name: team.name || '',
organization: team.organization || '', organization: team.summary_fields?.organization || null,
}} }}
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >

View File

@@ -22,8 +22,10 @@ describe('<TeamForm />', () => {
description: 'Bar', description: 'Bar',
organization: 1, organization: 1,
summary_fields: { summary_fields: {
id: 1, organization: {
name: 'Default', id: 1,
name: 'Default',
},
}, },
}; };

View File

@@ -14,6 +14,8 @@ function JobTemplateAdd() {
labels, labels,
instanceGroups, instanceGroups,
initialInstanceGroups, initialInstanceGroups,
inventory,
project,
credentials, credentials,
webhook_credential, webhook_credential,
webhook_key, webhook_key,
@@ -22,8 +24,9 @@ function JobTemplateAdd() {
} = values; } = values;
setFormSubmitError(null); setFormSubmitError(null);
remainingValues.project = remainingValues.project.id; remainingValues.project = project.id;
remainingValues.webhook_credential = webhook_credential?.id; remainingValues.webhook_credential = webhook_credential?.id;
remainingValues.inventory = inventory?.id || null;
try { try {
const { const {
data: { id, type }, data: { id, type },

View File

@@ -46,6 +46,8 @@ function JobTemplateEdit({ template }) {
instanceGroups, instanceGroups,
initialInstanceGroups, initialInstanceGroups,
credentials, credentials,
inventory,
project,
webhook_credential, webhook_credential,
webhook_key, webhook_key,
webhook_url, webhook_url,
@@ -55,8 +57,9 @@ function JobTemplateEdit({ template }) {
setFormSubmitError(null); setFormSubmitError(null);
setIsLoading(true); setIsLoading(true);
remainingValues.project = values.project.id; remainingValues.project = project.id;
remainingValues.webhook_credential = webhook_credential?.id || null; remainingValues.webhook_credential = webhook_credential?.id || null;
remainingValues.inventory = inventory?.id || null;
remainingValues.execution_environment = execution_environment?.id || null; remainingValues.execution_environment = execution_environment?.id || null;
try { try {
await JobTemplatesAPI.update(template.id, remainingValues); await JobTemplatesAPI.update(template.id, remainingValues);

View File

@@ -13,10 +13,20 @@ import {
ProjectsAPI, ProjectsAPI,
InventoriesAPI, InventoriesAPI,
ExecutionEnvironmentsAPI, ExecutionEnvironmentsAPI,
InstanceGroupsAPI,
} from '../../../api'; } from '../../../api';
import JobTemplateEdit from './JobTemplateEdit'; 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 = { const mockJobTemplate = {
allow_callbacks: false, allow_callbacks: false,
@@ -66,6 +76,7 @@ const mockJobTemplate = {
}, },
inventory: { inventory: {
id: 2, id: 2,
name: 'Demo Inventory',
organization_id: 1, organization_id: 1,
}, },
credentials: [ credentials: [
@@ -195,22 +206,55 @@ describe('<JobTemplateEdit />', () => {
JobTemplatesAPI.readCredentials.mockResolvedValue({ JobTemplatesAPI.readCredentials.mockResolvedValue({
data: mockRelatedCredentials, data: mockRelatedCredentials,
}); });
ProjectsAPI.readPlaybooks.mockResolvedValue({ JobTemplatesAPI.readInstanceGroups.mockReturnValue({
data: mockRelatedProjectPlaybooks, data: { results: mockInstanceGroups },
});
InventoriesAPI.read.mockResolvedValue({
data: {
results: [],
count: 0,
},
}); });
InventoriesAPI.readOptions.mockResolvedValue({ InventoriesAPI.readOptions.mockResolvedValue({
data: { actions: { GET: {}, POST: {} } }, 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({ ProjectsAPI.readOptions.mockResolvedValue({
data: { actions: { GET: {}, POST: {} } }, data: { actions: { GET: {}, POST: {} } },
}); });
ProjectsAPI.readPlaybooks.mockResolvedValue({
data: mockRelatedProjectPlaybooks,
});
LabelsAPI.read.mockResolvedValue({ data: { results: [] } }); LabelsAPI.read.mockResolvedValue({ data: { results: [] } });
CredentialsAPI.read.mockResolvedValue({ CredentialsAPI.read.mockResolvedValue({
data: { data: {
results: [], results: [],
count: 0, count: 0,
}, },
}); });
CredentialsAPI.readOptions.mockResolvedValue({
data: { actions: { GET: {}, POST: {} } },
});
CredentialTypesAPI.loadAllTypes.mockResolvedValue([]); CredentialTypesAPI.loadAllTypes.mockResolvedValue([]);
ExecutionEnvironmentsAPI.read.mockResolvedValue({ ExecutionEnvironmentsAPI.read.mockResolvedValue({
@@ -219,18 +263,11 @@ describe('<JobTemplateEdit />', () => {
count: 1, count: 1,
}, },
}); });
LabelsAPI.read.mockResolvedValue({ data: { results: [] } }); ExecutionEnvironmentsAPI.readOptions.mockResolvedValue({
JobTemplatesAPI.readCredentials.mockResolvedValue({ data: { actions: { GET: {}, POST: {} } },
data: mockRelatedCredentials,
});
JobTemplatesAPI.readInstanceGroups.mockReturnValue({
data: { results: mockInstanceGroups },
});
ProjectsAPI.readDetail.mockReturnValue({
id: 1,
allow_override: true,
name: 'foo',
}); });
useDebounce.mockImplementation(fn => fn);
}); });
afterEach(() => { afterEach(() => {
@@ -262,7 +299,10 @@ describe('<JobTemplateEdit />', () => {
const updatedTemplateData = { const updatedTemplateData = {
job_type: 'check', job_type: 'check',
name: 'new name', name: 'new name',
inventory: 1, inventory: {
id: 1,
name: 'Other Inventory',
},
}; };
const labels = [ const labels = [
{ id: 3, name: 'Foo' }, { id: 3, name: 'Foo' },
@@ -280,20 +320,24 @@ describe('<JobTemplateEdit />', () => {
null, null,
'check' 'check'
); );
wrapper.update();
}); });
wrapper.update();
act(() => { act(() => {
wrapper.find('InventoryLookup').invoke('onChange')({ wrapper.find('InventoryLookup').invoke('onChange')({
id: 1, id: 1,
organization: 1, name: 'Other Inventory',
}); });
wrapper.find('ExecutionEnvironmentLookup').invoke('onChange')(null); wrapper.find('TextInput#execution-environments-input').invoke('onChange')(
wrapper.update(); ''
);
}); });
wrapper.update();
wrapper.find('input#template-name').simulate('change', { wrapper.find('input#template-name').simulate('change', {
target: { value: 'new name', name: 'name' }, target: { value: 'new name', name: 'name' },
}); });
await act(async () => { await act(async () => {
wrapper.find('button[aria-label="Save"]').simulate('click'); wrapper.find('button[aria-label="Save"]').simulate('click');
wrapper.update(); wrapper.update();
@@ -301,8 +345,9 @@ describe('<JobTemplateEdit />', () => {
const expected = { const expected = {
...mockJobTemplate, ...mockJobTemplate,
project: mockJobTemplate.project,
...updatedTemplateData, ...updatedTemplateData,
inventory: 1,
project: 3,
execution_environment: null, execution_environment: null,
}; };
delete expected.summary_fields; delete expected.summary_fields;
@@ -372,6 +417,7 @@ describe('<JobTemplateEdit />', () => {
}, },
inventory: { inventory: {
id: 2, id: 2,
name: 'Demo Inventory',
organization_id: 1, organization_id: 1,
}, },
credentials: [ credentials: [

View File

@@ -8,14 +8,22 @@ import {
LabelsAPI, LabelsAPI,
ExecutionEnvironmentsAPI, ExecutionEnvironmentsAPI,
UsersAPI, UsersAPI,
InventoriesAPI,
} from '../../../api'; } from '../../../api';
import { import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
} from '../../../../testUtils/enzymeHelpers'; } from '../../../../testUtils/enzymeHelpers';
import WorkflowJobTemplateEdit from './WorkflowJobTemplateEdit'; 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 = { const mockTemplate = {
id: 6, id: 6,
@@ -66,18 +74,40 @@ describe('<WorkflowJobTemplateEdit/>', () => {
], ],
}, },
}); });
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({ ExecutionEnvironmentsAPI.read.mockResolvedValue({
data: { data: {
results: mockExecutionEnvironment, results: mockExecutionEnvironment,
count: 1, count: 1,
}, },
}); });
ExecutionEnvironmentsAPI.readOptions.mockResolvedValue({
data: { actions: { GET: {}, POST: {} } },
});
UsersAPI.readAdminOfOrganizations.mockResolvedValue({ UsersAPI.readAdminOfOrganizations.mockResolvedValue({
data: { count: 1, results: [{ id: 1 }] }, data: { count: 1, results: [{ id: 1, name: 'Default' }] },
}); });
useDebounce.mockImplementation(fn => fn);
await act(async () => { await act(async () => {
history = createMemoryHistory({ history = createMemoryHistory({
initialEntries: ['/templates/workflow_job_template/6/edit'], initialEntries: ['/templates/workflow_job_template/6/edit'],
@@ -120,7 +150,9 @@ describe('<WorkflowJobTemplateEdit/>', () => {
.find('SelectToggle') .find('SelectToggle')
.simulate('click'); .simulate('click');
wrapper.update(); wrapper.update();
wrapper.find('ExecutionEnvironmentLookup').invoke('onChange')(null); wrapper.find('TextInput#execution-environments-input').invoke('onChange')(
''
);
wrapper.find('input#wfjt-description').simulate('change', { wrapper.find('input#wfjt-description').simulate('change', {
target: { value: 'main', name: 'scm_branch' }, target: { value: 'main', name: 'scm_branch' },
}); });
@@ -130,6 +162,7 @@ describe('<WorkflowJobTemplateEdit/>', () => {
}); });
wrapper.update(); wrapper.update();
await waitForElement( await waitForElement(
wrapper, wrapper,
'SelectOption button[aria-label="Label 3"]', 'SelectOption button[aria-label="Label 3"]',

View File

@@ -54,14 +54,12 @@ function JobTemplateForm({
handleCancel, handleCancel,
handleSubmit, handleSubmit,
setFieldValue, setFieldValue,
setFieldTouched,
submitError, submitError,
validateField,
isOverrideDisabledLookup, isOverrideDisabledLookup,
}) { }) {
const [contentError, setContentError] = useState(false); const [contentError, setContentError] = useState(false);
const [inventory, setInventory] = useState(
template?.summary_fields?.inventory
);
const [allowCallbacks, setAllowCallbacks] = useState( const [allowCallbacks, setAllowCallbacks] = useState(
Boolean(template?.host_config_key) Boolean(template?.host_config_key)
); );
@@ -75,15 +73,14 @@ function JobTemplateForm({
name: 'job_type', name: 'job_type',
validate: required(null), validate: required(null),
}); });
const [, inventoryMeta, inventoryHelpers] = useField('inventory'); const [inventoryField, inventoryMeta, inventoryHelpers] = useField(
const [projectField, projectMeta, projectHelpers] = useField({ 'inventory'
name: 'project', );
validate: project => handleProjectValidation(project), const [projectField, projectMeta, projectHelpers] = useField('project');
});
const [scmField, , scmHelpers] = useField('scm_branch'); const [scmField, , scmHelpers] = useField('scm_branch');
const [playbookField, playbookMeta, playbookHelpers] = useField({ const [playbookField, playbookMeta, playbookHelpers] = useField({
name: 'playbook', name: 'playbook',
validate: required(t`Select a value for this field`), validate: required(null),
}); });
const [credentialField, , credentialHelpers] = useField('credentials'); const [credentialField, , credentialHelpers] = useField('credentials');
const [labelsField, , labelsHelpers] = useField('labels'); const [labelsField, , labelsHelpers] = useField('labels');
@@ -109,7 +106,7 @@ function JobTemplateForm({
executionEnvironmentField, executionEnvironmentField,
executionEnvironmentMeta, executionEnvironmentMeta,
executionEnvironmentHelpers, executionEnvironmentHelpers,
] = useField({ name: 'execution_environment' }); ] = useField('execution_environment');
const { const {
request: loadRelatedInstanceGroups, request: loadRelatedInstanceGroups,
@@ -149,24 +146,52 @@ function JobTemplateForm({
}, [enableWebhooks]); }, [enableWebhooks]);
const handleProjectValidation = project => { const handleProjectValidation = project => {
if (!project && projectMeta.touched) { if (!project) {
return t`Select a value for this field`; return t`This field must not be blank`;
} }
if (project?.value?.status === 'never updated') { if (project?.status === 'never updated') {
return t`This project needs to be updated`; return t`This Project needs to be updated`;
} }
return undefined; return undefined;
}; };
const handleProjectUpdate = useCallback( const handleProjectUpdate = useCallback(
value => { value => {
setFieldValue('playbook', '');
setFieldValue('scm_branch', '');
setFieldValue('project', value); 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 = [ const jobTypeOptions = [
{ {
value: '', value: '',
@@ -254,21 +279,19 @@ function JobTemplateForm({
> >
<InventoryLookup <InventoryLookup
fieldId="template-inventory" fieldId="template-inventory"
value={inventory} value={inventoryField.value}
promptId="template-ask-inventory-on-launch" promptId="template-ask-inventory-on-launch"
promptName="ask_inventory_on_launch" promptName="ask_inventory_on_launch"
isPromptableField isPromptableField
tooltip={t`Select the inventory containing the hosts tooltip={t`Select the inventory containing the hosts
you want this job to manage.`} you want this job to manage.`}
onBlur={() => inventoryHelpers.setTouched()} onBlur={() => inventoryHelpers.setTouched()}
onChange={value => { onChange={handleInventoryUpdate}
inventoryHelpers.setValue(value ? value.id : null);
setInventory(value);
}}
required={!askInventoryOnLaunchField.value} required={!askInventoryOnLaunchField.value}
touched={inventoryMeta.touched} touched={inventoryMeta.touched}
error={inventoryMeta.error} error={inventoryMeta.error}
isOverrideDisabled={isOverrideDisabledLookup} isOverrideDisabled={isOverrideDisabledLookup}
validate={handleInventoryValidation}
/> />
</FormGroup> </FormGroup>
@@ -277,14 +300,15 @@ function JobTemplateForm({
onBlur={() => projectHelpers.setTouched()} onBlur={() => projectHelpers.setTouched()}
tooltip={t`Select the project containing the playbook tooltip={t`Select the project containing the playbook
you want this job to execute.`} you want this job to execute.`}
isValid={ isValid={Boolean(
!projectMeta.touched || !projectMeta.error || projectField.value !projectMeta.touched || (!projectMeta.error && projectField.value)
} )}
helperTextInvalid={projectMeta.error} helperTextInvalid={projectMeta.error}
onChange={handleProjectUpdate} onChange={handleProjectUpdate}
required required
autoPopulate={!template?.id} autoPopulate={!template?.id}
isOverrideDisabled={isOverrideDisabledLookup} isOverrideDisabled={isOverrideDisabledLookup}
validate={handleProjectValidation}
/> />
<ExecutionEnvironmentLookup <ExecutionEnvironmentLookup
@@ -294,7 +318,7 @@ function JobTemplateForm({
} }
onBlur={() => executionEnvironmentHelpers.setTouched()} onBlur={() => executionEnvironmentHelpers.setTouched()}
value={executionEnvironmentField.value} value={executionEnvironmentField.value}
onChange={value => executionEnvironmentHelpers.setValue(value)} onChange={handleExecutionEnvironmentUpdate}
popoverContent={t`Select the execution environment for this job template.`} popoverContent={t`Select the execution environment for this job template.`}
tooltip={t`Select a project before editing the execution environment.`} tooltip={t`Select a project before editing the execution environment.`}
globallyAvailable globallyAvailable
@@ -489,6 +513,7 @@ function JobTemplateForm({
onChange={value => instanceGroupsHelpers.setValue(value)} onChange={value => instanceGroupsHelpers.setValue(value)}
tooltip={t`Select the Instance Groups for this Organization tooltip={t`Select the Instance Groups for this Organization
to run on.`} to run on.`}
fieldName="instanceGroups"
/> />
<FieldWithPrompt <FieldWithPrompt
fieldId="template-tags" fieldId="template-tags"
@@ -679,7 +704,7 @@ const FormikApp = withFormik({
const { const {
summary_fields = { summary_fields = {
labels: { results: [] }, labels: { results: [] },
inventory: { organization: null }, inventory: null,
}, },
} = template; } = template;
@@ -705,7 +730,7 @@ const FormikApp = withFormik({
host_config_key: template.host_config_key || '', host_config_key: template.host_config_key || '',
initialInstanceGroups: [], initialInstanceGroups: [],
instanceGroups: [], instanceGroups: [],
inventory: template.inventory || null, inventory: summary_fields?.inventory || null,
job_slice_count: template.job_slice_count || 1, job_slice_count: template.job_slice_count || 1,
job_tags: template.job_tags || '', job_tags: template.job_tags || '',
job_type: template.job_type || 'run', job_type: template.job_type || 'run',
@@ -738,18 +763,6 @@ const FormikApp = withFormik({
setErrors(errors); setErrors(errors);
} }
}, },
validate: values => {
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); })(JobTemplateForm);
export { JobTemplateForm as _JobTemplateForm }; export { JobTemplateForm as _JobTemplateForm };

View File

@@ -213,6 +213,7 @@ function WebhookSubForm({ templateType }) {
isValid={!webhookCredentialMeta.error} isValid={!webhookCredentialMeta.error}
helperTextInvalid={webhookCredentialMeta.error} helperTextInvalid={webhookCredentialMeta.error}
value={webhookCredentialField.value} value={webhookCredentialField.value}
fieldName="webhook_credential"
/> />
)} )}
</FormColumnLayout> </FormColumnLayout>

View File

@@ -69,12 +69,9 @@ describe('<WebhookSubForm />', () => {
.find('TextInputBase[aria-label="workflow job template webhook key"]') .find('TextInputBase[aria-label="workflow job template webhook key"]')
.prop('value') .prop('value')
).toBe('webhook key'); ).toBe('webhook key');
expect( expect(wrapper.find('input#credential-input').prop('value')).toBe(
wrapper 'Github credential'
.find('Chip') );
.find('span')
.text()
).toBe('Github credential');
}); });
test('should make other credential type available', async () => { test('should make other credential type available', async () => {
CredentialsAPI.read.mockResolvedValue({ CredentialsAPI.read.mockResolvedValue({

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import PropTypes, { shape } from 'prop-types'; import PropTypes, { shape } from 'prop-types';
import { useField, useFormikContext, withFormik } from 'formik'; import { useField, useFormikContext, withFormik } from 'formik';
import { import {
Form, Form,
@@ -11,7 +10,6 @@ import {
Title, Title,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { required } from '../../../util/validators'; import { required } from '../../../util/validators';
import FieldWithPrompt from '../../../components/FieldWithPrompt'; import FieldWithPrompt from '../../../components/FieldWithPrompt';
import FormField, { FormSubmitError } from '../../../components/FormField'; import FormField, { FormSubmitError } from '../../../components/FormField';
import { import {
@@ -43,7 +41,7 @@ function WorkflowJobTemplateForm({
submitError, submitError,
isOrgAdmin, isOrgAdmin,
}) { }) {
const { setFieldValue } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const [enableWebhooks, setEnableWebhooks] = useState( const [enableWebhooks, setEnableWebhooks] = useState(
Boolean(template.webhook_service) Boolean(template.webhook_service)
); );
@@ -71,9 +69,7 @@ function WorkflowJobTemplateForm({
executionEnvironmentField, executionEnvironmentField,
executionEnvironmentMeta, executionEnvironmentMeta,
executionEnvironmentHelpers, executionEnvironmentHelpers,
] = useField({ ] = useField('execution_environment');
name: 'execution_environment',
});
useEffect(() => { useEffect(() => {
if (enableWebhooks) { if (enableWebhooks) {
@@ -90,11 +86,28 @@ function WorkflowJobTemplateForm({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [enableWebhooks]); }, [enableWebhooks]);
const onOrganizationChange = useCallback( const handleOrganizationChange = useCallback(
value => { value => {
setFieldValue('organization', 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) { if (hasContentError) {
@@ -122,14 +135,26 @@ function WorkflowJobTemplateForm({
helperTextInvalid={organizationMeta.error} helperTextInvalid={organizationMeta.error}
isValid={!organizationMeta.touched || !organizationMeta.error} isValid={!organizationMeta.touched || !organizationMeta.error}
onBlur={() => organizationHelpers.setTouched()} onBlur={() => organizationHelpers.setTouched()}
onChange={onOrganizationChange} onChange={handleOrganizationChange}
value={organizationField.value} value={organizationField.value}
touched={organizationMeta.touched} touched={organizationMeta.touched}
error={organizationMeta.error} error={organizationMeta.error}
required={isOrgAdmin} required={isOrgAdmin}
autoPopulate={isOrgAdmin} autoPopulate={isOrgAdmin}
validate={
isOrgAdmin ? required(t`Select a value for this field`) : undefined
}
/> />
<> <FormGroup
fieldId="inventory-lookup"
validated={
!(inventoryMeta.touched || askInventoryOnLaunchField.value) ||
!inventoryMeta.error
? 'default'
: 'error'
}
helperTextInvalid={inventoryMeta.error}
>
<InventoryLookup <InventoryLookup
promptId="wfjt-ask-inventory-on-launch" promptId="wfjt-ask-inventory-on-launch"
promptName="ask_inventory_on_launch" promptName="ask_inventory_on_launch"
@@ -138,22 +163,11 @@ function WorkflowJobTemplateForm({
isPromptableField isPromptableField
value={inventoryField.value} value={inventoryField.value}
onBlur={() => inventoryHelpers.setTouched()} onBlur={() => inventoryHelpers.setTouched()}
onChange={value => { onChange={handleInventoryUpdate}
inventoryHelpers.setValue(value);
}}
touched={inventoryMeta.touched} touched={inventoryMeta.touched}
error={inventoryMeta.error} error={inventoryMeta.error}
/> />
{(inventoryMeta.touched || askInventoryOnLaunchField.value) && </FormGroup>
inventoryMeta.error && (
<div
className="pf-c-form__helper-text pf-m-error"
aria-live="polite"
>
{inventoryMeta.error}
</div>
)}
</>
<FieldWithPrompt <FieldWithPrompt
fieldId="wfjt-limit" fieldId="wfjt-limit"
label={t`Limit`} label={t`Limit`}
@@ -199,7 +213,7 @@ function WorkflowJobTemplateForm({
} }
onBlur={() => executionEnvironmentHelpers.setTouched()} onBlur={() => executionEnvironmentHelpers.setTouched()}
value={executionEnvironmentField.value} value={executionEnvironmentField.value}
onChange={value => executionEnvironmentHelpers.setValue(value)} onChange={handleExecutionEnvironmentUpdate}
tooltip={t`Select the default execution environment for this organization to run on.`} tooltip={t`Select the default execution environment for this organization to run on.`}
globallyAvailable globallyAvailable
organizationId={organizationField.value?.id} organizationId={organizationField.value?.id}

View File

@@ -67,6 +67,7 @@ describe('<WorkflowJobTemplateForm/>', () => {
{ name: 'Label 2', id: 2 }, { name: 'Label 2', id: 2 },
{ name: 'Label 3', id: 3 }, { name: 'Label 3', id: 3 },
], ],
count: 3,
}, },
}); });
OrganizationsAPI.read.mockResolvedValue({ OrganizationsAPI.read.mockResolvedValue({
@@ -75,16 +76,20 @@ describe('<WorkflowJobTemplateForm/>', () => {
{ id: 1, name: 'Organization 1' }, { id: 1, name: 'Organization 1' },
{ id: 2, name: 'Organization 2' }, { id: 2, name: 'Organization 2' },
], ],
count: 2,
}, },
}); });
InventoriesAPI.read.mockResolvedValue({ InventoriesAPI.read.mockResolvedValue({
results: [ data: {
{ id: 1, name: 'Foo' }, results: [
{ id: 2, name: 'Bar' }, { id: 1, name: 'Foo' },
], { id: 2, name: 'Bar' },
],
count: 2,
},
}); });
CredentialTypesAPI.read.mockResolvedValue({ CredentialTypesAPI.read.mockResolvedValue({
data: { results: [{ id: 1 }] }, data: { results: [{ id: 1 }], count: 1 },
}); });
InventoriesAPI.readOptions.mockResolvedValue({ InventoriesAPI.readOptions.mockResolvedValue({
data: { actions: { GET: {}, POST: {} } }, data: { actions: { GET: {}, POST: {} } },
@@ -93,13 +98,13 @@ describe('<WorkflowJobTemplateForm/>', () => {
data: { actions: { GET: {}, POST: {} } }, data: { actions: { GET: {}, POST: {} } },
}); });
ExecutionEnvironmentsAPI.read.mockResolvedValue({ ExecutionEnvironmentsAPI.read.mockResolvedValue({
data: { results: [] }, data: { results: [], count: 0 },
}); });
ExecutionEnvironmentsAPI.readOptions.mockResolvedValue({ ExecutionEnvironmentsAPI.readOptions.mockResolvedValue({
data: { actions: { GET: {}, POST: {} } }, data: { actions: { GET: {}, POST: {} } },
}); });
CredentialsAPI.read.mockResolvedValue({ CredentialsAPI.read.mockResolvedValue({
data: { results: [] }, data: { results: [], count: 0 },
}); });
CredentialsAPI.readOptions.mockResolvedValue({ CredentialsAPI.readOptions.mockResolvedValue({
data: { actions: { GET: {}, POST: {} } }, data: { actions: { GET: {}, POST: {} } },

View File

@@ -15,7 +15,7 @@ function UserAdd() {
try { try {
const { const {
data: { id }, data: { id },
} = await OrganizationsAPI.createUser(organization, userValues); } = await OrganizationsAPI.createUser(organization.id, userValues);
history.push(`/users/${id}/details`); history.push(`/users/${id}/details`);
} catch (error) { } catch (error) {
setFormSubmitError(error); setFormSubmitError(error);

View File

@@ -23,7 +23,10 @@ describe('<UserAdd />', () => {
first_name: 'System', first_name: 'System',
last_name: 'Administrator', last_name: 'Administrator',
password: 'password', password: 'password',
organization: 1, organization: {
id: 1,
name: 'Default',
},
is_superuser: true, is_superuser: true,
is_system_auditor: false, is_system_auditor: false,
}; };
@@ -33,7 +36,7 @@ describe('<UserAdd />', () => {
const { organization, ...userData } = updatedUserData; const { organization, ...userData } = updatedUserData;
expect(OrganizationsAPI.createUser.mock.calls).toEqual([ expect(OrganizationsAPI.createUser.mock.calls).toEqual([
[organization, userData], [organization.id, userData],
]); ]);
}); });
@@ -58,7 +61,10 @@ describe('<UserAdd />', () => {
first_name: 'System', first_name: 'System',
last_name: 'Administrator', last_name: 'Administrator',
password: 'password', password: 'password',
organization: 1, organization: {
id: 1,
name: 'Default',
},
is_superuser: true, is_superuser: true,
is_system_auditor: false, is_system_auditor: false,
}; };

View File

@@ -13,6 +13,7 @@ function UserEdit({ user }) {
const handleSubmit = async values => { const handleSubmit = async values => {
setFormSubmitError(null); setFormSubmitError(null);
try { try {
delete values.organization;
await UsersAPI.update(user.id, values); await UsersAPI.update(user.id, values);
history.push(`/users/${user.id}/details`); history.push(`/users/${user.id}/details`);
} catch (error) { } catch (error) {

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -15,8 +15,7 @@ import { required } from '../../../util/validators';
import { FormColumnLayout } from '../../../components/FormLayout'; import { FormColumnLayout } from '../../../components/FormLayout';
function UserFormFields({ user }) { function UserFormFields({ user }) {
const [organization, setOrganization] = useState(null); const { setFieldValue, setFieldTouched } = useFormikContext();
const { setFieldValue } = useFormikContext();
const ldapUser = user.ldap_dn; const ldapUser = user.ldap_dn;
const socialAuthUser = user.auth?.length > 0; const socialAuthUser = user.auth?.length > 0;
@@ -43,21 +42,18 @@ function UserFormFields({ user }) {
}, },
]; ];
const [, organizationMeta, organizationHelpers] = useField({ const [organizationField, organizationMeta, organizationHelpers] = useField(
name: 'organization', 'organization'
validate: !user.id );
? required(t`Select a value for this field`)
: () => undefined,
});
const [userTypeField, userTypeMeta] = useField('user_type'); const [userTypeField, userTypeMeta] = useField('user_type');
const onOrganizationChange = useCallback( const handleOrganizationUpdate = useCallback(
value => { value => {
setFieldValue('organization', value.id); setFieldValue('organization', value);
setOrganization(value); setFieldTouched('organization', true, false);
}, },
[setFieldValue] [setFieldValue, setFieldTouched]
); );
return ( return (
@@ -116,10 +112,11 @@ function UserFormFields({ user }) {
helperTextInvalid={organizationMeta.error} helperTextInvalid={organizationMeta.error}
isValid={!organizationMeta.touched || !organizationMeta.error} isValid={!organizationMeta.touched || !organizationMeta.error}
onBlur={() => organizationHelpers.setTouched()} onBlur={() => organizationHelpers.setTouched()}
onChange={onOrganizationChange} onChange={handleOrganizationUpdate}
value={organization} value={organizationField.value}
required required
autoPopulate={!user?.id} autoPopulate={!user?.id}
validate={required(t`Select a value for this field`)}
/> />
)} )}
<FormGroup <FormGroup
@@ -173,7 +170,7 @@ function UserForm({ user, handleCancel, handleSubmit, submitError }) {
initialValues={{ initialValues={{
first_name: user.first_name || '', first_name: user.first_name || '',
last_name: user.last_name || '', last_name: user.last_name || '',
organization: user.organization || '', organization: null,
email: user.email || '', email: user.email || '',
username: user.username || '', username: user.username || '',
password: '', password: '',

View File

@@ -1,7 +1,6 @@
import React from 'react'; import React, { useCallback } from 'react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Formik, useField } from 'formik'; import { Formik, useField, useFormikContext } from 'formik';
import { Form, FormGroup } from '@patternfly/react-core'; import { Form, FormGroup } from '@patternfly/react-core';
import AnsibleSelect from '../../../components/AnsibleSelect'; import AnsibleSelect from '../../../components/AnsibleSelect';
import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup'; import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup';
@@ -9,19 +8,25 @@ import FormField, { FormSubmitError } from '../../../components/FormField';
import ApplicationLookup from '../../../components/Lookup/ApplicationLookup'; import ApplicationLookup from '../../../components/Lookup/ApplicationLookup';
import Popover from '../../../components/Popover'; import Popover from '../../../components/Popover';
import { required } from '../../../util/validators'; import { required } from '../../../util/validators';
import { FormColumnLayout } from '../../../components/FormLayout'; import { FormColumnLayout } from '../../../components/FormLayout';
function UserTokenFormFields() { function UserTokenFormFields() {
const [applicationField, applicationMeta, applicationHelpers] = useField( const { setFieldValue, setFieldTouched } = useFormikContext();
'application' const [applicationField, applicationMeta] = useField('application');
);
const [scopeField, scopeMeta, scopeHelpers] = useField({ const [scopeField, scopeMeta, scopeHelpers] = useField({
name: 'scope', name: 'scope',
validate: required(t`Please enter a value.`), validate: required(t`Please enter a value.`),
}); });
const handleApplicationUpdate = useCallback(
value => {
setFieldValue('application', value);
setFieldTouched('application', true, false);
},
[setFieldValue, setFieldTouched]
);
return ( return (
<> <>
<FormGroup <FormGroup
@@ -36,9 +41,7 @@ function UserTokenFormFields() {
> >
<ApplicationLookup <ApplicationLookup
value={applicationField.value} value={applicationField.value}
onChange={value => { onChange={handleApplicationUpdate}
applicationHelpers.setValue(value);
}}
label={ label={
<span> <span>
{t`Application`} {t`Application`}
@@ -89,7 +92,6 @@ function UserTokenForm({
handleCancel, handleCancel,
handleSubmit, handleSubmit,
submitError, submitError,
token = {}, token = {},
}) { }) {
return ( return (

View File

@@ -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]);
}