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

View File

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

View File

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

View File

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

View File

@ -39,11 +39,12 @@ function CredentialLookup({
credentialTypeKind,
credentialTypeNamespace,
value,
tooltip,
isDisabled,
autoPopulate,
multiple,
validate,
fieldName,
}) {
const history = useHistory();
const autoPopulateLookup = useAutoPopulateLookup(onChange);
@ -111,6 +112,39 @@ function CredentialLookup({
}
);
const checkCredentialName = useCallback(
async name => {
if (name && name !== '') {
try {
const typeIdParams = credentialTypeId
? { credential_type: credentialTypeId }
: {};
const typeKindParams = credentialTypeKind
? { credential_type__kind: credentialTypeKind }
: {};
const typeNamespaceParams = credentialTypeNamespace
? { credential_type__namespace: credentialTypeNamespace }
: {};
const {
data: { results: nameMatchResults, count: nameMatchCount },
} = await CredentialsAPI.read({
name,
...typeIdParams,
...typeKindParams,
...typeNamespaceParams,
});
onChange(nameMatchCount ? nameMatchResults[0] : null);
} catch {
onChange(null);
}
} else {
onChange(null);
}
},
[onChange, credentialTypeId, credentialTypeKind, credentialTypeNamespace]
);
useEffect(() => {
fetchCredentials();
}, [fetchCredentials]);
@ -132,6 +166,9 @@ function CredentialLookup({
value={value}
onBlur={onBlur}
onChange={onChange}
onDebounce={checkCredentialName}
fieldName={fieldName}
validate={validate}
required={required}
qsConfig={QS_CONFIG}
isDisabled={isDisabled}
@ -212,6 +249,8 @@ CredentialLookup.propTypes = {
value: oneOfType([Credential, arrayOf(Credential)]),
isDisabled: bool,
autoPopulate: bool,
validate: func,
fieldName: string,
};
CredentialLookup.defaultProps = {
@ -225,6 +264,8 @@ CredentialLookup.defaultProps = {
value: null,
isDisabled: false,
autoPopulate: false,
validate: () => undefined,
fieldName: 'credential',
};
export { CredentialLookup as _CredentialLookup };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
/* eslint-disable react/jsx-pascal-case */
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import {
mountWithContexts,
waitForElement,
@ -56,22 +57,25 @@ describe('<Lookup />', () => {
const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }];
await act(async () => {
wrapper = mountWithContexts(
<Lookup
id="test"
multiple
header="Foo Bar"
value={mockSelected}
onChange={onChange}
qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => (
<TestList
id="options-list"
state={state}
dispatch={dispatch}
canDelete={canDelete}
/>
)}
/>
<Formik>
<Lookup
id="test"
multiple
header="Foo Bar"
value={mockSelected}
onChange={onChange}
qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => (
<TestList
id="options-list"
state={state}
dispatch={dispatch}
canDelete={canDelete}
/>
)}
fieldName="foo"
/>
</Formik>
);
});
return wrapper;
@ -137,22 +141,25 @@ describe('<Lookup />', () => {
await act(async () => {
const mockSelected = { name: 'foo', id: 1, url: '/api/v2/item/1' };
wrapper = mountWithContexts(
<Lookup
id="test"
header="Foo Bar"
required
value={mockSelected}
onChange={onChange}
qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => (
<TestList
id="options-list"
state={state}
dispatch={dispatch}
canDelete={canDelete}
/>
)}
/>
<Formik>
<Lookup
id="test"
header="Foo Bar"
required
value={mockSelected}
onChange={onChange}
qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => (
<TestList
id="options-list"
state={state}
dispatch={dispatch}
canDelete={canDelete}
/>
)}
fieldName="foo"
/>
</Formik>
);
});
wrapper.find('button[aria-label="Search"]').simulate('click');
@ -163,23 +170,26 @@ describe('<Lookup />', () => {
test('should be disabled while isLoading is true', async () => {
const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }];
wrapper = mountWithContexts(
<Lookup
id="test"
multiple
header="Foo Bar"
value={mockSelected}
onChange={onChange}
qsConfig={QS_CONFIG}
isLoading
renderOptionsList={({ state, dispatch, canDelete }) => (
<TestList
id="options-list"
state={state}
dispatch={dispatch}
canDelete={canDelete}
/>
)}
/>
<Formik>
<Lookup
id="test"
multiple
header="Foo Bar"
value={mockSelected}
onChange={onChange}
qsConfig={QS_CONFIG}
isLoading
renderOptionsList={({ state, dispatch, canDelete }) => (
<TestList
id="options-list"
state={state}
dispatch={dispatch}
canDelete={canDelete}
/>
)}
fieldName="foo"
/>
</Formik>
);
checkRootElementNotPresent('body div[role="dialog"]');
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 { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types';
import { t } from '@lingui/macro';
import { ToolbarItem, Alert } from '@patternfly/react-core';
import { CredentialsAPI, CredentialTypesAPI } from '../../api';
@ -26,8 +25,14 @@ async function loadCredentials(params, selectedCredentialTypeId) {
return data;
}
function MultiCredentialsLookup(props) {
const { value, onChange, onError, history } = props;
function MultiCredentialsLookup({
value,
onChange,
onError,
history,
fieldName,
validate,
}) {
const [selectedType, setSelectedType] = useState(null);
const isMounted = useIsMounted();
@ -68,9 +73,12 @@ function MultiCredentialsLookup(props) {
if (!selectedType) {
return {
credentials: [],
count: 0,
credentialsCount: 0,
relatedSearchableKeys: [],
searchableKeys: [],
};
}
const params = parseQueryString(QS_CONFIG, history.location.search);
const [{ results, count }, actionsResponse] = await Promise.all([
loadCredentials(params, selectedType.id),
@ -130,6 +138,8 @@ function MultiCredentialsLookup(props) {
id="multiCredential"
header={t`Credentials`}
value={value}
fieldName={fieldName}
validate={validate}
multiple
onChange={onChange}
qsConfig={QS_CONFIG}
@ -240,10 +250,14 @@ MultiCredentialsLookup.propTypes = {
),
onChange: PropTypes.func.isRequired,
onError: PropTypes.func.isRequired,
validate: PropTypes.func,
fieldName: PropTypes.string,
};
MultiCredentialsLookup.defaultProps = {
value: [],
validate: () => undefined,
fieldName: 'credentials',
};
export { MultiCredentialsLookup as _MultiCredentialsLookup };

View File

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

View File

@ -1,7 +1,6 @@
import React, { useCallback, useEffect } from 'react';
import { node, func, bool } from 'prop-types';
import { node, func, bool, string } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { t } from '@lingui/macro';
import { FormGroup } from '@patternfly/react-core';
import { OrganizationsAPI } from '../../api';
@ -21,7 +20,6 @@ const QS_CONFIG = getQSConfig('organizations', {
function OrganizationLookup({
helperTextInvalid,
isValid,
onBlur,
onChange,
@ -31,6 +29,8 @@ function OrganizationLookup({
autoPopulate,
isDisabled,
helperText,
validate,
fieldName,
}) {
const autoPopulateLookup = useAutoPopulateLookup(onChange);
@ -69,6 +69,24 @@ function OrganizationLookup({
}
);
const checkOrganizationName = useCallback(
async name => {
if (name && name !== '') {
try {
const {
data: { results: nameMatchResults, count: nameMatchCount },
} = await OrganizationsAPI.read({ name });
onChange(nameMatchCount ? nameMatchResults[0] : null);
} catch {
onChange(null);
}
} else {
onChange(null);
}
},
[onChange]
);
useEffect(() => {
fetchOrganizations();
}, [fetchOrganizations]);
@ -89,6 +107,9 @@ function OrganizationLookup({
value={value}
onBlur={onBlur}
onChange={onChange}
onDebounce={checkOrganizationName}
fieldName={fieldName}
validate={validate}
qsConfig={QS_CONFIG}
required={required}
sortedColumnKey="name"
@ -144,6 +165,8 @@ OrganizationLookup.propTypes = {
value: Organization,
autoPopulate: bool,
isDisabled: bool,
validate: func,
fieldName: string,
};
OrganizationLookup.defaultProps = {
@ -154,6 +177,8 @@ OrganizationLookup.defaultProps = {
value: null,
autoPopulate: false,
isDisabled: false,
validate: () => undefined,
fieldName: 'organization',
};
export { OrganizationLookup as _OrganizationLookup };

View File

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

View File

@ -1,7 +1,6 @@
import React, { useCallback, useEffect } from 'react';
import { node, string, func, bool } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { t } from '@lingui/macro';
import { FormGroup } from '@patternfly/react-core';
import { ProjectsAPI } from '../../api';
@ -32,6 +31,8 @@ function ProjectLookup({
onBlur,
history,
isOverrideDisabled,
validate,
fieldName,
}) {
const autoPopulateLookup = useAutoPopulateLookup(onChange);
const {
@ -72,6 +73,24 @@ function ProjectLookup({
}
);
const checkProjectName = useCallback(
async name => {
if (name && name !== '') {
try {
const {
data: { results: nameMatchResults, count: nameMatchCount },
} = await ProjectsAPI.read({ name });
onChange(nameMatchCount ? nameMatchResults[0] : null);
} catch {
onChange(null);
}
} else {
onChange(null);
}
},
[onChange]
);
useEffect(() => {
fetchProjects();
}, [fetchProjects]);
@ -92,6 +111,9 @@ function ProjectLookup({
value={value}
onBlur={onBlur}
onChange={onChange}
onDebounce={checkProjectName}
fieldName={fieldName}
validate={validate}
required={required}
isLoading={isLoading}
isDisabled={!canEdit}
@ -164,6 +186,8 @@ ProjectLookup.propTypes = {
tooltip: string,
value: Project,
isOverrideDisabled: bool,
validate: func,
fieldName: string,
};
ProjectLookup.defaultProps = {
@ -175,6 +199,8 @@ ProjectLookup.defaultProps = {
tooltip: '',
value: null,
isOverrideDisabled: false,
validate: () => undefined,
fieldName: 'project',
};
export { ProjectLookup as _ProjectLookup };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,7 +12,11 @@ function HostEdit({ host }) {
const handleSubmit = async values => {
try {
await HostsAPI.update(host.id, values);
const dataToSend = { ...values };
if (dataToSend.inventory) {
dataToSend.inventory = dataToSend.inventory.id;
}
await HostsAPI.update(host.id, dataToSend);
history.push(detailsUrl);
} catch (error) {
setFormError(error);

View File

@ -22,18 +22,19 @@ import CredentialLookup from '../../../components/Lookup/CredentialLookup';
import { VariablesField } from '../../../components/CodeEditor';
function ContainerGroupFormFields({ instanceGroup }) {
const { setFieldValue } = useFormikContext();
const [credentialField, credentialMeta, credentialHelpers] = useField({
name: 'credential',
});
const { setFieldValue, setFieldTouched } = useFormikContext();
const [credentialField, credentialMeta, credentialHelpers] = useField(
'credential'
);
const [overrideField] = useField('override');
const onCredentialChange = useCallback(
const handleCredentialUpdate = useCallback(
value => {
setFieldValue('credential', value);
setFieldTouched('credential', true, false);
},
[setFieldValue]
[setFieldValue, setFieldTouched]
);
return (
@ -52,7 +53,7 @@ function ContainerGroupFormFields({ instanceGroup }) {
helperTextInvalid={credentialMeta.error}
isValid={!credentialMeta.touched || !credentialMeta.error}
onBlur={() => credentialHelpers.setTouched()}
onChange={onCredentialChange}
onChange={handleCredentialUpdate}
value={credentialField.value}
tooltip={t`Credential to authenticate with Kubernetes or OpenShift. Must be of type "Kubernetes/OpenShift API Bearer Token". If left blank, the underlying Pod's service account will be used.`}
autoPopulate={!instanceGroup?.id}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -39,9 +39,7 @@ function OrganizationFormFields({
executionEnvironmentField,
executionEnvironmentMeta,
executionEnvironmentHelpers,
] = useField({
name: 'default_environment',
});
] = useField('default_environment');
const handleCredentialUpdate = useCallback(
value => {
@ -97,6 +95,7 @@ function OrganizationFormFields({
globallyAvailable
organizationId={organizationId}
isDefaultEnvironment
fieldName="default_environment"
/>
<CredentialLookup
credentialTypeNamespace="galaxy_api_token"
@ -107,6 +106,7 @@ function OrganizationFormFields({
onChange={handleCredentialUpdate}
value={galaxyCredentialsField.value}
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,
// normalize falsey credential fields by deleting them.
delete values.credential;
} else {
values.credential = values.credential.id;
}
setFormSubmitError(null);
try {

View File

@ -56,6 +56,7 @@ describe('<ProjectAdd />', () => {
kind: 'scm',
},
],
count: 1,
},
};
@ -68,6 +69,7 @@ describe('<ProjectAdd />', () => {
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,
// normalize falsey credential fields by deleting them.
delete values.credential;
} else {
values.credential = values.credential.id;
}
try {
const {

View File

@ -89,24 +89,21 @@ function ProjectFormFields({
scm_update_cache_timeout: 0,
};
const { setFieldValue } = useFormikContext();
const { setFieldValue, setFieldTouched } = useFormikContext();
const [scmTypeField, scmTypeMeta, scmTypeHelpers] = useField({
name: 'scm_type',
validate: required(t`Set a value for this field`),
});
const [organizationField, organizationMeta, organizationHelpers] = useField({
name: 'organization',
validate: required(t`Select a value for this field`),
});
const [organizationField, organizationMeta, organizationHelpers] = useField(
'organization'
);
const [
executionEnvironmentField,
executionEnvironmentMeta,
executionEnvironmentHelpers,
] = useField({
name: 'default_environment',
});
] = useField('default_environment');
/* Save current scm subform field values to state */
const saveSubFormState = form => {
@ -153,11 +150,20 @@ function ProjectFormFields({
[credentials, setCredentials]
);
const onOrganizationChange = useCallback(
const handleOrganizationUpdate = useCallback(
value => {
setFieldValue('organization', value);
setFieldTouched('organization', true, false);
},
[setFieldValue]
[setFieldValue, setFieldTouched]
);
const handleExecutionEnvironmentUpdate = useCallback(
value => {
setFieldValue('default_environment', value);
setFieldTouched('default_environment', true, false);
},
[setFieldValue, setFieldTouched]
);
return (
@ -180,10 +186,11 @@ function ProjectFormFields({
helperTextInvalid={organizationMeta.error}
isValid={!organizationMeta.touched || !organizationMeta.error}
onBlur={() => organizationHelpers.setTouched()}
onChange={onOrganizationChange}
onChange={handleOrganizationUpdate}
value={organizationField.value}
required
autoPopulate={!project?.id}
validate={required(t`Select a value for this field`)}
/>
<ExecutionEnvironmentLookup
helperTextInvalid={executionEnvironmentMeta.error}
@ -192,13 +199,14 @@ function ProjectFormFields({
}
onBlur={() => executionEnvironmentHelpers.setTouched()}
value={executionEnvironmentField.value}
onChange={value => executionEnvironmentHelpers.setValue(value)}
popoverContent={t`The execution environment that will be used for jobs that use this project. This will be used as fallback when an execution environment has not been explicitly assigned at the job template or workflow level.`}
onChange={handleExecutionEnvironmentUpdate}
tooltip={t`Select an organization before editing the default execution environment.`}
globallyAvailable
isDisabled={!organizationField.value}
organizationId={organizationField.value?.id}
isDefaultEnvironment
fieldName="default_environment"
/>
<FormGroup
fieldId="project-scm-type"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,7 +18,9 @@ class TeamAdd extends React.Component {
async handleSubmit(values) {
const { history } = this.props;
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}`);
} catch (error) {
this.setState({ error });

View File

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

View File

@ -14,7 +14,11 @@ function TeamEdit({ team }) {
const handleSubmit = async values => {
try {
await TeamsAPI.update(team.id, values);
const valuesToSend = { ...values };
if (valuesToSend.organization) {
valuesToSend.organization = valuesToSend.organization.id;
}
await TeamsAPI.update(team.id, valuesToSend);
history.push(`/teams/${team.id}/details`);
} catch (err) {
setError(err);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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