Add organization as part of creating/editing an execution environments

Add organization as part of creating/editing an execution environments

If one is a `system admin` the Organization is an optional field. Not
providing an Organization makes the execution environment globally
available.

If one is a `org admin` the Organization is a required field.

See: https://github.com/ansible/awx/issues/7887
This commit is contained in:
nixocio
2020-10-02 17:27:12 -04:00
committed by Shane McDonald
parent ecaa66c13b
commit 9786dc08d3
7 changed files with 118 additions and 30 deletions

View File

@@ -30,6 +30,7 @@ function OrganizationLookup({
history, history,
autoPopulate, autoPopulate,
isDisabled, isDisabled,
helperText,
}) { }) {
const autoPopulateLookup = useAutoPopulateLookup(onChange); const autoPopulateLookup = useAutoPopulateLookup(onChange);
@@ -79,6 +80,7 @@ function OrganizationLookup({
isRequired={required} isRequired={required}
validated={isValid ? 'default' : 'error'} validated={isValid ? 'default' : 'error'}
label={i18n._(t`Organization`)} label={i18n._(t`Organization`)}
helperText={helperText}
> >
<Lookup <Lookup
isDisabled={isDisabled} isDisabled={isDisabled}

View File

@@ -2,9 +2,10 @@ import React, { useState } from 'react';
import { Card, PageSection } from '@patternfly/react-core'; import { Card, PageSection } from '@patternfly/react-core';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import ExecutionEnvironmentForm from '../shared/ExecutionEnvironmentForm';
import { CardBody } from '../../../components/Card';
import { ExecutionEnvironmentsAPI } from '../../../api'; import { ExecutionEnvironmentsAPI } from '../../../api';
import { Config } from '../../../contexts/Config';
import { CardBody } from '../../../components/Card';
import ExecutionEnvironmentForm from '../shared/ExecutionEnvironmentForm';
function ExecutionEnvironmentAdd() { function ExecutionEnvironmentAdd() {
const history = useHistory(); const history = useHistory();
@@ -14,7 +15,8 @@ function ExecutionEnvironmentAdd() {
try { try {
const { data: response } = await ExecutionEnvironmentsAPI.create({ const { data: response } = await ExecutionEnvironmentsAPI.create({
...values, ...values,
credential: values?.credential?.id, credential: values.credential?.id,
organization: values.organization?.id,
}); });
history.push(`/execution_environments/${response.id}/details`); history.push(`/execution_environments/${response.id}/details`);
} catch (error) { } catch (error) {
@@ -29,11 +31,16 @@ function ExecutionEnvironmentAdd() {
<PageSection> <PageSection>
<Card> <Card>
<CardBody> <CardBody>
<ExecutionEnvironmentForm <Config>
onSubmit={handleSubmit} {({ me }) => (
submitError={submitError} <ExecutionEnvironmentForm
onCancel={handleCancel} onSubmit={handleSubmit}
/> submitError={submitError}
onCancel={handleCancel}
me={me || {}}
/>
)}
</Config>
</CardBody> </CardBody>
</Card> </Card>
</PageSection> </PageSection>

View File

@@ -8,6 +8,11 @@ import ExecutionEnvironmentAdd from './ExecutionEnvironmentAdd';
jest.mock('../../../api'); jest.mock('../../../api');
const mockMe = {
is_superuser: true,
is_system_auditor: false,
};
const executionEnvironmentData = { const executionEnvironmentData = {
credential: 4, credential: 4,
description: 'A simple EE', description: 'A simple EE',
@@ -29,7 +34,7 @@ describe('<ExecutionEnvironmentAdd/>', () => {
initialEntries: ['/execution_environments'], initialEntries: ['/execution_environments'],
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<ExecutionEnvironmentAdd />, { wrapper = mountWithContexts(<ExecutionEnvironmentAdd me={mockMe} />, {
context: { router: { history } }, context: { router: { history } },
}); });
}); });

View File

@@ -4,6 +4,7 @@ import { useHistory } from 'react-router-dom';
import { CardBody } from '../../../components/Card'; import { CardBody } from '../../../components/Card';
import { ExecutionEnvironmentsAPI } from '../../../api'; import { ExecutionEnvironmentsAPI } from '../../../api';
import ExecutionEnvironmentForm from '../shared/ExecutionEnvironmentForm'; import ExecutionEnvironmentForm from '../shared/ExecutionEnvironmentForm';
import { Config } from '../../../contexts/Config';
function ExecutionEnvironmentEdit({ executionEnvironment }) { function ExecutionEnvironmentEdit({ executionEnvironment }) {
const history = useHistory(); const history = useHistory();
@@ -15,6 +16,7 @@ function ExecutionEnvironmentEdit({ executionEnvironment }) {
await ExecutionEnvironmentsAPI.update(executionEnvironment.id, { await ExecutionEnvironmentsAPI.update(executionEnvironment.id, {
...values, ...values,
credential: values.credential ? values.credential.id : null, credential: values.credential ? values.credential.id : null,
organization: values.organization ? values.organization.id : null,
}); });
history.push(detailsUrl); history.push(detailsUrl);
} catch (error) { } catch (error) {
@@ -27,12 +29,17 @@ function ExecutionEnvironmentEdit({ executionEnvironment }) {
}; };
return ( return (
<CardBody> <CardBody>
<ExecutionEnvironmentForm <Config>
executionEnvironment={executionEnvironment} {({ me }) => (
onSubmit={handleSubmit} <ExecutionEnvironmentForm
submitError={submitError} executionEnvironment={executionEnvironment}
onCancel={handleCancel} onSubmit={handleSubmit}
/> submitError={submitError}
onCancel={handleCancel}
me={me || {}}
/>
)}
</Config>
</CardBody> </CardBody>
); );
} }

View File

@@ -9,6 +9,11 @@ import ExecutionEnvironmentEdit from './ExecutionEnvironmentEdit';
jest.mock('../../../api'); jest.mock('../../../api');
const mockMe = {
is_superuser: true,
is_system_auditor: false,
};
const executionEnvironmentData = { const executionEnvironmentData = {
id: 42, id: 42,
credential: { id: 4 }, credential: { id: 4 },
@@ -31,6 +36,7 @@ describe('<ExecutionEnvironmentEdit/>', () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<ExecutionEnvironmentEdit <ExecutionEnvironmentEdit
executionEnvironment={executionEnvironmentData} executionEnvironment={executionEnvironmentData}
me={mockMe}
/>, />,
{ {
context: { router: { history } }, context: { router: { history } },
@@ -53,6 +59,7 @@ describe('<ExecutionEnvironmentEdit/>', () => {
expect(ExecutionEnvironmentsAPI.update).toHaveBeenCalledWith(42, { expect(ExecutionEnvironmentsAPI.update).toHaveBeenCalledWith(42, {
...updateExecutionEnvironmentData, ...updateExecutionEnvironmentData,
credential: null, credential: null,
organization: null,
}); });
}); });

View File

@@ -1,18 +1,42 @@
import React from 'react'; import React, { useCallback } from 'react';
import { func, shape } from 'prop-types'; import { func, shape } from 'prop-types';
import { Formik, useField } from 'formik'; import { Formik, useField, useFormikContext } from 'formik';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Form } from '@patternfly/react-core'; import { Form } from '@patternfly/react-core';
import FormField, { FormSubmitError } from '../../../components/FormField';
import FormActionGroup from '../../../components/FormActionGroup';
import CredentialLookup from '../../../components/Lookup/CredentialLookup';
import { url } from '../../../util/validators';
import { FormColumnLayout } from '../../../components/FormLayout';
function ExecutionEnvironmentFormFields({ i18n }) { import CredentialLookup from '../../../components/Lookup/CredentialLookup';
const [credentialField, , credentialHelpers] = useField('credential'); import FormActionGroup from '../../../components/FormActionGroup';
import FormField, { FormSubmitError } from '../../../components/FormField';
import { FormColumnLayout } from '../../../components/FormLayout';
import { OrganizationLookup } from '../../../components/Lookup';
import { required, url } from '../../../util/validators';
function ExecutionEnvironmentFormFields({ i18n, me, executionEnvironment }) {
const [credentialField] = useField('credential');
const [organizationField, organizationMeta, organizationHelpers] = useField({
name: 'organization',
validate:
!me?.is_superuser &&
required(i18n._(t`Select a value for this field`), i18n),
});
const { setFieldValue } = useFormikContext();
const onCredentialChange = useCallback(
value => {
setFieldValue('credential', value);
},
[setFieldValue]
);
const onOrganizationChange = useCallback(
value => {
setFieldValue('organization', value);
},
[setFieldValue]
);
return ( return (
<> <>
<FormField <FormField
@@ -32,9 +56,26 @@ function ExecutionEnvironmentFormFields({ i18n }) {
name="description" name="description"
type="text" type="text"
/> />
<OrganizationLookup
helperTextInvalid={organizationMeta.error}
isValid={!organizationMeta.touched || !organizationMeta.error}
onBlur={() => organizationHelpers.setTouched()}
onChange={onOrganizationChange}
value={organizationField.value}
required={!me.is_superuser}
helperText={
me?.is_superuser
? i18n._(
t`Leave this field blank to make the execution environment globally available.`
)
: null
}
autoPopulate={!me?.is_superuser ? !executionEnvironment?.id : null}
/>
<CredentialLookup <CredentialLookup
label={i18n._(t`Registry credential`)} label={i18n._(t`Registry credential`)}
onChange={value => credentialHelpers.setValue(value)} onChange={onCredentialChange}
value={credentialField.value} value={credentialField.value}
/> />
</> </>
@@ -46,19 +87,21 @@ function ExecutionEnvironmentForm({
onSubmit, onSubmit,
onCancel, onCancel,
submitError, submitError,
me,
...rest ...rest
}) { }) {
const initialValues = { const initialValues = {
image: executionEnvironment.image || '', image: executionEnvironment.image || '',
description: executionEnvironment.description || '', description: executionEnvironment.description || '',
credential: executionEnvironment?.summary_fields?.credential || null, credential: executionEnvironment.summary_fields?.credential || null,
organization: executionEnvironment.summary_fields?.organization || null,
}; };
return ( return (
<Formik initialValues={initialValues} onSubmit={values => onSubmit(values)}> <Formik initialValues={initialValues} onSubmit={values => onSubmit(values)}>
{formik => ( {formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}> <Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout> <FormColumnLayout>
<ExecutionEnvironmentFormFields {...rest} /> <ExecutionEnvironmentFormFields me={me} {...rest} />
{submitError && <FormSubmitError error={submitError} />} {submitError && <FormSubmitError error={submitError} />}
<FormActionGroup <FormActionGroup
onCancel={onCancel} onCancel={onCancel}

View File

@@ -6,6 +6,11 @@ import ExecutionEnvironmentForm from './ExecutionEnvironmentForm';
jest.mock('../../../api'); jest.mock('../../../api');
const mockMe = {
is_superuser: true,
is_super_auditor: false,
};
const executionEnvironment = { const executionEnvironment = {
id: 16, id: 16,
type: 'execution_environment', type: 'execution_environment',
@@ -47,6 +52,7 @@ describe('<ExecutionEnvironmentForm/>', () => {
onCancel={onCancel} onCancel={onCancel}
onSubmit={onSubmit} onSubmit={onSubmit}
executionEnvironment={executionEnvironment} executionEnvironment={executionEnvironment}
me={mockMe}
/> />
); );
}); });
@@ -75,8 +81,8 @@ describe('<ExecutionEnvironmentForm/>', () => {
expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit).toHaveBeenCalledTimes(1);
}); });
test('should update form values', () => { test('should update form values', async () => {
act(() => { await act(async () => {
wrapper.find('input#execution-environment-image').simulate('change', { wrapper.find('input#execution-environment-image').simulate('change', {
target: { target: {
value: 'https://registry.com/image/container2', value: 'https://registry.com/image/container2',
@@ -93,8 +99,19 @@ describe('<ExecutionEnvironmentForm/>', () => {
id: 99, id: 99,
name: 'credential', name: 'credential',
}); });
wrapper.find('OrganizationLookup').invoke('onBlur')();
wrapper.find('OrganizationLookup').invoke('onChange')({
id: 3,
name: 'organization',
});
}); });
wrapper.update(); wrapper.update();
expect(wrapper.find('OrganizationLookup').prop('value')).toEqual({
id: 3,
name: 'organization',
});
expect( expect(
wrapper.find('input#execution-environment-image').prop('value') wrapper.find('input#execution-environment-image').prop('value')
).toEqual('https://registry.com/image/container2'); ).toEqual('https://registry.com/image/container2');