Add Execution Environments into a few screens

Add EE to the following screens:

* Job Template
* Organization
* Project
* Workflow Job Template

Also, add a new lookup component - ExecutionEnvironmentLoookup.

See: https://github.com/ansible/awx/issues/9189
This commit is contained in:
nixocio 2021-02-09 17:23:47 -05:00 committed by Shane McDonald
parent adf708366a
commit 6e67ae68fd
30 changed files with 558 additions and 24 deletions

View File

@ -0,0 +1,167 @@
import React, { useCallback, useEffect } from 'react';
import { string, func, bool } from 'prop-types';
import { withRouter, useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { FormGroup, Tooltip } from '@patternfly/react-core';
import { ExecutionEnvironmentsAPI } from '../../api';
import { ExecutionEnvironment } from '../../types';
import { getQSConfig, parseQueryString, mergeParams } from '../../util/qs';
import Popover from '../Popover';
import OptionsList from '../OptionsList';
import useRequest from '../../util/useRequest';
import Lookup from './Lookup';
import LookupErrorMessage from './shared/LookupErrorMessage';
const QS_CONFIG = getQSConfig('execution_environments', {
page: 1,
page_size: 5,
order_by: 'name',
});
function ExecutionEnvironmentLookup({
globallyAvailable,
i18n,
isDefaultEnvironment,
isDisabled,
onChange,
organizationId,
popoverContent,
tooltip,
value,
onBlur,
}) {
const location = useLocation();
const {
result: {
executionEnvironments,
count,
relatedSearchableKeys,
searchableKeys,
},
request: fetchExecutionEnvironments,
error,
isLoading,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const globallyAvailableParams = globallyAvailable
? { or__organization__isnull: 'True' }
: {};
const organizationIdParams = organizationId
? { or__organization__id: organizationId }
: {};
const [{ data }, actionsResponse] = await Promise.all([
ExecutionEnvironmentsAPI.read(
mergeParams(params, {
...globallyAvailableParams,
...organizationIdParams,
})
),
ExecutionEnvironmentsAPI.readOptions(),
]);
return {
executionEnvironments: data.results,
count: data.count,
relatedSearchableKeys: (
actionsResponse?.data?.related_search_fields || []
).map(val => val.slice(0, -8)),
searchableKeys: Object.keys(
actionsResponse.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
};
}, [location, globallyAvailable, organizationId]),
{
executionEnvironments: [],
count: 0,
relatedSearchableKeys: [],
searchableKeys: [],
}
);
useEffect(() => {
fetchExecutionEnvironments();
}, [fetchExecutionEnvironments]);
const renderLookup = () => (
<>
<Lookup
id="execution-environments"
header={i18n._(t`Execution Environments`)}
value={value}
onBlur={onBlur}
onChange={onChange}
qsConfig={QS_CONFIG}
isLoading={isLoading}
isDisabled={isDisabled}
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList
value={state.selectedItems}
options={executionEnvironments}
optionCount={count}
searchColumns={[
{
name: i18n._(t`Name`),
key: 'name__icontains',
isDefault: true,
},
]}
sortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys}
multiple={state.multiple}
header={i18n._(t`Execution Environment`)}
name="executionEnvironments"
qsConfig={QS_CONFIG}
readOnly={!canDelete}
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
/>
)}
/>
</>
);
return (
<FormGroup
fieldId="execution-environment-lookup"
label={
isDefaultEnvironment
? i18n._(t`Default Execution Environment`)
: i18n._(t`Execution Environment`)
}
labelIcon={popoverContent && <Popover content={popoverContent} />}
>
{isDisabled ? (
<Tooltip content={tooltip}>{renderLookup()}</Tooltip>
) : (
renderLookup()
)}
<LookupErrorMessage error={error} />
</FormGroup>
);
}
ExecutionEnvironmentLookup.propTypes = {
value: ExecutionEnvironment,
popoverContent: string,
onChange: func.isRequired,
isDefaultEnvironment: bool,
};
ExecutionEnvironmentLookup.defaultProps = {
popoverContent: '',
isDefaultEnvironment: false,
value: null,
};
export default withI18n()(withRouter(ExecutionEnvironmentLookup));

View File

@ -0,0 +1,76 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import ExecutionEnvironmentLookup from './ExecutionEnvironmentLookup';
import { ExecutionEnvironmentsAPI } from '../../api';
jest.mock('../../api');
const mockedExecutionEnvironments = {
count: 1,
results: [
{
id: 2,
name: 'Foo',
image: 'quay.io/ansible/awx-ee',
pull: 'missing',
},
],
};
const executionEnvironment = {
id: 42,
name: 'Bar',
image: 'quay.io/ansible/bar',
pull: 'missing',
};
describe('ExecutionEnvironmentLookup', () => {
let wrapper;
beforeEach(() => {
ExecutionEnvironmentsAPI.read.mockResolvedValue(
mockedExecutionEnvironments
);
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should render successfully', async () => {
ExecutionEnvironmentsAPI.readOptions.mockReturnValue({
data: {
actions: {
GET: {},
POST: {},
},
related_search_fields: [],
},
});
await act(async () => {
wrapper = mountWithContexts(
<ExecutionEnvironmentLookup
value={executionEnvironment}
onChange={() => {}}
/>
);
});
wrapper.update();
expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalledTimes(1);
expect(wrapper.find('ExecutionEnvironmentLookup')).toHaveLength(1);
});
test('should fetch execution environments', async () => {
await act(async () => {
wrapper = mountWithContexts(
<ExecutionEnvironmentLookup
value={executionEnvironment}
onChange={() => {}}
/>
);
});
expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalledTimes(1);
});
});

View File

@ -7,3 +7,4 @@ export { default as CredentialLookup } from './CredentialLookup';
export { default as ApplicationLookup } from './ApplicationLookup';
export { default as HostFilterLookup } from './HostFilterLookup';
export { default as OrganizationLookup } from './OrganizationLookup';
export { default as ExecutionEnvironmentLookup } from './ExecutionEnvironmentLookup';

View File

@ -13,7 +13,10 @@ function OrganizationAdd() {
const handleSubmit = async (values, groupsToAssociate) => {
try {
const { data: response } = await OrganizationsAPI.create(values);
const { data: response } = await OrganizationsAPI.create({
...values,
default_environment: values.default_environment?.id,
});
await Promise.all(
groupsToAssociate
.map(id => OrganizationsAPI.associateInstanceGroup(response.id, id))

View File

@ -17,13 +17,18 @@ describe('<OrganizationAdd />', () => {
description: 'new description',
custom_virtualenv: 'Buzz',
galaxy_credentials: [],
default_environment: { id: 1, name: 'Foo' },
};
OrganizationsAPI.create.mockResolvedValueOnce({ data: {} });
await act(async () => {
const wrapper = mountWithContexts(<OrganizationAdd />);
wrapper.find('OrganizationForm').prop('onSubmit')(updatedOrgData, []);
});
expect(OrganizationsAPI.create).toHaveBeenCalledWith(updatedOrgData);
expect(OrganizationsAPI.create).toHaveBeenCalledWith({
...updatedOrgData,
default_environment: 1,
});
expect(OrganizationsAPI.create).toHaveBeenCalledTimes(1);
});
test('should navigate to organizations list when cancel is clicked', async () => {

View File

@ -94,6 +94,12 @@ function OrganizationDetail({ i18n, organization }) {
label={i18n._(t`Ansible Environment`)}
value={custom_virtualenv}
/>
{summary_fields?.default_environment?.name && (
<Detail
label={i18n._(t`Default Execution Environment`)}
value={summary_fields.default_environment.name}
/>
)}
<UserDateDetail
label={i18n._(t`Created`)}
date={created}

View File

@ -13,6 +13,7 @@ jest.mock('../../../api');
describe('<OrganizationDetail />', () => {
const mockOrganization = {
id: 12,
name: 'Foo',
description: 'Bar',
custom_virtualenv: 'Fizz',
@ -24,7 +25,14 @@ describe('<OrganizationDetail />', () => {
edit: true,
delete: true,
},
default_environment: {
id: 1,
name: 'Default EE',
description: '',
image: 'quay.io/ansible/awx-ee',
},
},
default_environment: 1,
};
const mockInstanceGroups = {
data: {
@ -43,7 +51,7 @@ describe('<OrganizationDetail />', () => {
jest.clearAllMocks();
});
test('initially renders succesfully', async () => {
test('initially renders successfully', async () => {
await act(async () => {
mountWithContexts(<OrganizationDetail organization={mockOrganization} />);
});
@ -86,6 +94,7 @@ describe('<OrganizationDetail />', () => {
{ label: 'Created', value: '7/7/2015, 5:21:26 PM' },
{ label: 'Last Modified', value: '8/11/2019, 7:47:37 PM' },
{ label: 'Max Hosts', value: '0' },
{ label: 'Default Execution Environment', value: 'Default EE' },
];
for (let i = 0; i < testParams.length; i++) {
const { label, value } = testParams[i];

View File

@ -28,7 +28,10 @@ function OrganizationEdit({ organization }) {
const addedCredentialIds = addedCredentials.map(({ id }) => id);
const removedCredentialIds = removedCredentials.map(({ id }) => id);
await OrganizationsAPI.update(organization.id, values);
await OrganizationsAPI.update(organization.id, {
...values,
default_environment: values.default_environment?.id || null,
});
await Promise.all(
groupsToAssociate
.map(id =>

View File

@ -19,6 +19,13 @@ describe('<OrganizationEdit />', () => {
related: {
instance_groups: '/api/v2/organizations/1/instance_groups',
},
default_environment: 1,
summary_fields: {
default_environment: {
id: 1,
name: 'Baz',
},
},
};
test('onSubmit should call api update', async () => {
@ -31,6 +38,7 @@ describe('<OrganizationEdit />', () => {
name: 'new name',
description: 'new description',
custom_virtualenv: 'Buzz',
default_environment: null,
};
wrapper.find('OrganizationForm').prop('onSubmit')(updatedOrgData, [], []);

View File

@ -12,16 +12,21 @@ import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading';
import FormField, { FormSubmitError } from '../../../components/FormField';
import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup';
import { InstanceGroupsLookup } from '../../../components/Lookup';
import {
InstanceGroupsLookup,
ExecutionEnvironmentLookup,
} from '../../../components/Lookup';
import { getAddedAndRemoved } from '../../../util/lists';
import { required, minMaxValue } from '../../../util/validators';
import { FormColumnLayout } from '../../../components/FormLayout';
import CredentialLookup from '../../../components/Lookup/CredentialLookup';
function OrganizationFormFields({ i18n, instanceGroups, setInstanceGroups }) {
const { license_info = {}, me = {} } = useConfig();
const { custom_virtualenvs } = useContext(ConfigContext);
const { setFieldValue } = useFormikContext();
const [venvField] = useField('custom_virtualenv');
const { license_info = {}, me = {} } = useConfig();
const [
galaxyCredentialsField,
@ -29,12 +34,19 @@ function OrganizationFormFields({ i18n, instanceGroups, setInstanceGroups }) {
galaxyCredentialsHelpers,
] = useField('galaxy_credentials');
const [
executionEnvironmentField,
executionEnvironmentMeta,
executionEnvironmentHelpers,
] = useField({
name: 'default_environment',
});
const defaultVenv = {
label: i18n._(t`Use Default Ansible Environment`),
value: '/var/lib/awx/venv/ansible/',
key: 'default',
};
const { custom_virtualenvs } = useContext(ConfigContext);
const handleCredentialUpdate = useCallback(
value => {
@ -100,6 +112,20 @@ function OrganizationFormFields({ i18n, instanceGroups, setInstanceGroups }) {
t`Select the Instance Groups for this Organization to run on.`
)}
/>
<ExecutionEnvironmentLookup
helperTextInvalid={executionEnvironmentMeta.error}
isValid={
!executionEnvironmentMeta.touched || !executionEnvironmentMeta.error
}
onBlur={() => executionEnvironmentHelpers.setTouched()}
value={executionEnvironmentField.value}
onChange={value => executionEnvironmentHelpers.setValue(value)}
popoverContent={i18n._(
t`Select the default execution environment for this organization.`
)}
globallyAvailable
isDefaultEnvironment
/>
<CredentialLookup
credentialTypeNamespace="galaxy_api_token"
label={i18n._(t`Galaxy Credentials`)}
@ -185,6 +211,8 @@ function OrganizationForm({
custom_virtualenv: organization.custom_virtualenv || '',
max_hosts: organization.max_hosts || '0',
galaxy_credentials: organization.galaxy_credentials || [],
default_environment:
organization.summary_fields?.default_environment || '',
}}
onSubmit={handleSubmit}
>
@ -221,6 +249,7 @@ OrganizationForm.defaultProps = {
description: '',
max_hosts: '0',
custom_virtualenv: '',
default_environment: '',
},
submitError: null,
};

View File

@ -4,7 +4,7 @@ import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { OrganizationsAPI } from '../../../api';
import { OrganizationsAPI, ExecutionEnvironmentsAPI } from '../../../api';
import OrganizationForm from './OrganizationForm';
@ -32,6 +32,8 @@ describe('<OrganizationForm />', () => {
{ name: 'Two', id: 2 },
];
const mockExecutionEnvironment = [{ name: 'EE' }];
afterEach(() => {
jest.clearAllMocks();
});
@ -132,6 +134,11 @@ describe('<OrganizationForm />', () => {
results: mockInstanceGroups,
},
});
ExecutionEnvironmentsAPI.read.mockReturnValue({
data: {
results: mockExecutionEnvironment,
},
});
let wrapper;
const onSubmit = jest.fn();
await act(async () => {
@ -155,10 +162,15 @@ describe('<OrganizationForm />', () => {
wrapper.find('input#org-max_hosts').simulate('change', {
target: { value: 134, name: 'max_hosts' },
});
wrapper.find('ExecutionEnvironmentLookup').invoke('onChange')({
id: 1,
name: 'Test EE',
});
});
await act(async () => {
wrapper.find('button[aria-label="Save"]').simulate('click');
});
wrapper.update();
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0]).toEqual({
name: 'new foo',
@ -166,6 +178,7 @@ describe('<OrganizationForm />', () => {
galaxy_credentials: [],
custom_virtualenv: 'Fizz',
max_hosts: 134,
default_environment: { id: 1, name: 'Test EE' },
});
});
@ -209,12 +222,16 @@ describe('<OrganizationForm />', () => {
results: mockInstanceGroups,
},
});
ExecutionEnvironmentsAPI.read.mockReturnValue({
data: { results: mockExecutionEnvironment },
});
const mockDataForm = {
name: 'Foo',
description: 'Bar',
galaxy_credentials: [],
max_hosts: 1,
custom_virtualenv: 'Fizz',
default_environment: '',
};
const onSubmit = jest.fn();
OrganizationsAPI.update.mockResolvedValue(1, mockDataForm);
@ -320,6 +337,7 @@ describe('<OrganizationForm />', () => {
galaxy_credentials: [],
max_hosts: 0,
custom_virtualenv: 'Fizz',
default_environment: '',
},
[],
[]

View File

@ -27,6 +27,7 @@ function ProjectAdd() {
} = await ProjectsAPI.create({
...values,
organization: values.organization.id,
default_environment: values.default_environment?.id,
});
history.push(`/projects/${id}/details`);
} catch (error) {

View File

@ -20,11 +20,12 @@ describe('<ProjectAdd />', () => {
scm_clean: true,
credential: 100,
local_path: '',
organization: 2,
organization: { id: 2, name: 'Bar' },
scm_update_on_launch: true,
scm_update_cache_timeout: 3,
allow_override: false,
custom_virtualenv: '/var/lib/awx/venv/custom-env',
default_environment: { id: 1, name: 'Foo' },
};
const projectOptionsResolve = {
@ -102,6 +103,11 @@ describe('<ProjectAdd />', () => {
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
wrapper.find('ProjectForm').invoke('handleSubmit')(projectData);
expect(ProjectsAPI.create).toHaveBeenCalledTimes(1);
expect(ProjectsAPI.create).toHaveBeenCalledWith({
...projectData,
organization: 2,
default_environment: 1,
});
});
test('handleSubmit should throw an error', async () => {

View File

@ -124,10 +124,18 @@ function ProjectDetail({ project, i18n }) {
label={i18n._(t`Cache Timeout`)}
value={`${scm_update_cache_timeout} ${i18n._(t`Seconds`)}`}
/>
<Detail
label={i18n._(t`Ansible Environment`)}
value={custom_virtualenv}
/>
{summary_fields?.default_environment?.name && (
<Detail
label={i18n._(t`Execution Environment`)}
value={summary_fields.default_environment.name}
/>
)}
<Config>
{({ project_base_dir }) => (
<Detail
@ -137,6 +145,7 @@ function ProjectDetail({ project, i18n }) {
)}
</Config>
<Detail label={i18n._(t`Playbook Directory`)} value={local_path} />
<UserDateDetail
label={i18n._(t`Created`)}
date={created}

View File

@ -25,6 +25,11 @@ describe('<ProjectDetail />', () => {
id: 10,
name: 'Foo',
},
default_environment: {
id: 12,
name: 'Bar',
image: 'quay.io/ansible/awx-ee',
},
credential: {
id: 1000,
name: 'qux',
@ -67,9 +72,10 @@ describe('<ProjectDetail />', () => {
scm_update_cache_timeout: 5,
allow_override: true,
custom_virtualenv: '/custom-venv',
default_environment: 1,
};
test('initially renders succesfully', () => {
test('initially renders successfully', () => {
mountWithContexts(<ProjectDetail project={mockProject} />);
});
@ -95,6 +101,10 @@ describe('<ProjectDetail />', () => {
`${mockProject.scm_update_cache_timeout} Seconds`
);
assertDetail('Ansible Environment', mockProject.custom_virtualenv);
assertDetail(
'Execution Environment',
mockProject.summary_fields.default_environment.name
);
const dateDetails = wrapper.find('UserDateDetail');
expect(dateDetails).toHaveLength(2);
expect(dateDetails.at(0).prop('label')).toEqual('Created');

View File

@ -26,6 +26,7 @@ function ProjectEdit({ project }) {
} = await ProjectsAPI.update(project.id, {
...values,
organization: values.organization.id,
default_environment: values.default_environment?.id || null,
});
history.push(`/projects/${id}/details`);
} catch (error) {

View File

@ -12,6 +12,7 @@ import ContentLoading from '../../../components/ContentLoading';
import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup';
import FormField, { FormSubmitError } from '../../../components/FormField';
import OrganizationLookup from '../../../components/Lookup/OrganizationLookup';
import ExecutionEnvironmentLookup from '../../../components/Lookup/ExecutionEnvironmentLookup';
import { CredentialTypesAPI, ProjectsAPI } from '../../../api';
import { required } from '../../../util/validators';
import {
@ -101,6 +102,14 @@ function ProjectFormFields({
validate: required(i18n._(t`Select a value for this field`), i18n),
});
const [
executionEnvironmentField,
executionEnvironmentMeta,
executionEnvironmentHelpers,
] = useField({
name: 'default_environment',
});
/* Save current scm subform field values to state */
const saveSubFormState = form => {
const currentScmFormFields = { ...scmFormFields };
@ -178,6 +187,25 @@ function ProjectFormFields({
required
autoPopulate={!project?.id}
/>
<ExecutionEnvironmentLookup
helperTextInvalid={executionEnvironmentMeta.error}
isValid={
!executionEnvironmentMeta.touched || !executionEnvironmentMeta.error
}
onBlur={() => executionEnvironmentHelpers.setTouched()}
value={executionEnvironmentField.value}
onChange={value => executionEnvironmentHelpers.setValue(value)}
popoverContent={i18n._(
t`Select the default execution environment for this project.`
)}
tooltip={i18n._(
t`Select an organization before editing the default execution environment.`
)}
globallyAvailable
isDisabled={!organizationField.value}
organizationId={organizationField.value?.id}
isDefaultEnvironment
/>
<FormGroup
fieldId="project-scm-type"
helperTextInvalid={scmTypeMeta.error}
@ -387,6 +415,8 @@ function ProjectForm({ i18n, project, submitError, ...props }) {
scm_update_cache_timeout: project.scm_update_cache_timeout || 0,
scm_update_on_launch: project.scm_update_on_launch || false,
scm_url: project.scm_url || '',
default_environment:
project.summary_fields?.default_environment || null,
}}
onSubmit={handleSubmit}
>

View File

@ -27,7 +27,10 @@ function JobTemplateAdd() {
try {
const {
data: { id, type },
} = await JobTemplatesAPI.create(remainingValues);
} = await JobTemplatesAPI.create({
...remainingValues,
execution_environment: values.execution_environment?.id,
});
await Promise.all([
submitLabels(id, labels, values.project.summary_fields.organization.id),
submitInstanceGroups(id, instanceGroups),

View File

@ -58,6 +58,7 @@ const jobTemplateData = {
timeout: 0,
use_fact_cache: false,
verbosity: '0',
execution_environment: { id: 1, name: 'Foo' },
};
describe('<JobTemplateAdd />', () => {
@ -77,6 +78,12 @@ describe('<JobTemplateAdd />', () => {
beforeEach(() => {
LabelsAPI.read.mockResolvedValue({ data: { results: [] } });
ProjectsAPI.readDetail.mockReturnValue({
name: 'foo',
id: 1,
allow_override: true,
organization: 1,
});
});
afterEach(() => {
@ -126,12 +133,13 @@ describe('<JobTemplateAdd />', () => {
...jobTemplateData,
},
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<JobTemplateAdd />);
});
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
act(() => {
await act(() => {
wrapper.find('input#template-name').simulate('change', {
target: { value: 'Bar', name: 'name' },
});
@ -144,6 +152,10 @@ describe('<JobTemplateAdd />', () => {
name: 'project',
summary_fields: { organization: { id: 1, name: 'Org Foo' } },
});
wrapper.find('ExecutionEnvironmentLookup').invoke('onChange')({
id: 1,
name: 'Foo',
});
wrapper.update();
wrapper.find('Select#template-playbook').prop('onToggle')();
wrapper.update();
@ -170,6 +182,7 @@ describe('<JobTemplateAdd />', () => {
inventory: 2,
webhook_credential: undefined,
webhook_service: '',
execution_environment: 1,
});
});
@ -190,7 +203,7 @@ describe('<JobTemplateAdd />', () => {
});
});
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
act(() => {
await act(async () => {
wrapper.find('input#template-name').simulate('change', {
target: { value: 'Foo', name: 'name' },
});

View File

@ -206,6 +206,12 @@ function JobTemplateDetail({ i18n, template }) {
) : (
<DeletedDetail label={i18n._(t`Project`)} />
)}
{summary_fields?.execution_environment && (
<Detail
label={i18n._(t`Execution Environment`)}
value={summary_fields.execution_environment.name}
/>
)}
<Detail
label={i18n._(t`Source Control Branch`)}
value={template.scm_branch}

View File

@ -142,6 +142,7 @@ describe('<JobTemplateDetail />', () => {
el => el.length === 0
);
});
test('webhook fields should render properly', () => {
expect(wrapper.find('Detail[label="Webhook Service"]').length).toBe(1);
expect(wrapper.find('Detail[label="Webhook Service"]').prop('value')).toBe(
@ -154,4 +155,13 @@ describe('<JobTemplateDetail />', () => {
expect(wrapper.find('Detail[label="Webhook Key"]').length).toBe(1);
expect(wrapper.find('Detail[label="Webhook Credential"]').length).toBe(1);
});
test('execution environment field should render properly', () => {
expect(wrapper.find('Detail[label="Execution Environment"]').length).toBe(
1
);
expect(
wrapper.find('Detail[label="Execution Environment"]').prop('value')
).toBe('Default EE');
});
});

View File

@ -57,7 +57,10 @@ function JobTemplateEdit({ template }) {
remainingValues.project = values.project.id;
remainingValues.webhook_credential = webhook_credential?.id || null;
try {
await JobTemplatesAPI.update(template.id, remainingValues);
await JobTemplatesAPI.update(template.id, {
...remainingValues,
execution_environment: values.execution_environment?.id,
});
await Promise.all([
submitLabels(labels, template?.organization),
submitInstanceGroups(instanceGroups, initialInstanceGroups),

View File

@ -28,7 +28,10 @@ function WorkflowJobTemplateAdd() {
try {
const {
data: { id },
} = await WorkflowJobTemplatesAPI.create(templatePayload);
} = await WorkflowJobTemplatesAPI.create({
...templatePayload,
execution_environment: values.execution_environment?.id,
});
await Promise.all(await submitLabels(id, labels, organizationId));
history.push(`/templates/workflow_job_template/${id}/visualizer`);
} catch (err) {

View File

@ -125,6 +125,12 @@ function WorkflowJobTemplateDetail({ template, i18n }) {
}
/>
)}
{summary_fields?.execution_environment && (
<Detail
label={i18n._(t`Execution Environment`)}
value={summary_fields.execution_environment.name}
/>
)}
<Detail label={i18n._(t`Job Type`)} value={toTitleCase(type)} />
{summary_fields.inventory && (
<Detail

View File

@ -22,6 +22,12 @@ describe('<WorkflowJobTemplateDetail/>', () => {
created_by: { id: 1, username: 'Athena' },
modified_by: { id: 1, username: 'Apollo' },
organization: { id: 1, name: 'Org' },
execution_environment: {
id: 4,
name: 'Demo EE',
description: '',
image: 'quay.io/ansible/awx-ee',
},
inventory: { kind: 'Foo', id: 1, name: 'Bar' },
labels: {
results: [
@ -40,6 +46,7 @@ describe('<WorkflowJobTemplateDetail/>', () => {
},
webhook_service: 'Github',
webhook_key: 'Foo webhook key',
execution_environment: 4,
};
beforeEach(async () => {
@ -127,6 +134,11 @@ describe('<WorkflowJobTemplateDetail/>', () => {
prop: 'value',
value: 'Workflow Job Template',
},
{
element: 'Detail[label="Execution Environment"]',
prop: 'value',
value: 'Demo EE',
},
];
const organization = wrapper

View File

@ -20,7 +20,7 @@ function WorkflowJobTemplateEdit({ template }) {
...templatePayload
} = values;
templatePayload.inventory = inventory?.id || null;
templatePayload.organization = organization?.id;
templatePayload.organization = organization?.id || null;
templatePayload.webhook_credential = webhook_credential?.id || null;
const formOrgId =
@ -29,7 +29,10 @@ function WorkflowJobTemplateEdit({ template }) {
await Promise.all(
await submitLabels(labels, formOrgId, template.organization)
);
await WorkflowJobTemplatesAPI.update(template.id, templatePayload);
await WorkflowJobTemplatesAPI.update(template.id, {
...templatePayload,
execution_environment: values.execution_environment?.id,
});
history.push(`/templates/workflow_job_template/${template.id}/details`);
} catch (err) {
setFormSubmitError(err);

View File

@ -37,9 +37,10 @@ import {
InstanceGroupsLookup,
ProjectLookup,
MultiCredentialsLookup,
ExecutionEnvironmentLookup,
} from '../../../components/Lookup';
import Popover from '../../../components/Popover';
import { JobTemplatesAPI } from '../../../api';
import { JobTemplatesAPI, ProjectsAPI } from '../../../api';
import LabelSelect from './LabelSelect';
import PlaybookSelect from './PlaybookSelect';
import WebhookSubForm from './WebhookSubForm';
@ -101,10 +102,40 @@ function JobTemplateForm({
'webhook_credential'
);
const [
executionEnvironmentField,
executionEnvironmentMeta,
executionEnvironmentHelpers,
] = useField({ name: 'execution_environment' });
const projectId = projectField.value?.id;
const {
request: fetchProject,
error: fetchProjectError,
isLoading: fetchProjectLoading,
result: projectData,
} = useRequest(
useCallback(async () => {
if (!projectId) {
return {};
}
const { data } = await ProjectsAPI.readDetail(projectId);
return data;
}, [projectId]),
{
projectData: null,
}
);
useEffect(() => {
fetchProject();
}, [fetchProject]);
const {
request: loadRelatedInstanceGroups,
error: instanceGroupError,
contentLoading: instanceGroupLoading,
isLoading: instanceGroupLoading,
} = useRequest(
useCallback(async () => {
if (!template?.id) {
@ -182,12 +213,16 @@ function JobTemplateForm({
callbackUrl = `${origin}${path}`;
}
if (instanceGroupLoading) {
if (instanceGroupLoading || fetchProjectLoading) {
return <ContentLoading />;
}
if (contentError || instanceGroupError) {
return <ContentError error={contentError || instanceGroupError} />;
if (contentError || instanceGroupError || fetchProjectError) {
return (
<ContentError
error={contentError || instanceGroupError || fetchProjectError}
/>
);
}
return (
@ -258,6 +293,7 @@ function JobTemplateForm({
isOverrideDisabled={isOverrideDisabledLookup}
/>
</FormGroup>
<ProjectLookup
value={projectField.value}
onBlur={() => projectHelpers.setTouched()}
@ -270,6 +306,26 @@ function JobTemplateForm({
autoPopulate={!template?.id}
isOverrideDisabled={isOverrideDisabledLookup}
/>
<ExecutionEnvironmentLookup
helperTextInvalid={executionEnvironmentMeta.error}
isValid={
!executionEnvironmentMeta.touched || !executionEnvironmentMeta.error
}
onBlur={() => executionEnvironmentHelpers.setTouched()}
value={executionEnvironmentField.value}
onChange={value => executionEnvironmentHelpers.setValue(value)}
popoverContent={i18n._(
t`Select the execution environment for this job template.`
)}
tooltip={i18n._(
t`Select a project before editing the execution environment.`
)}
globallyAvailable
isDisabled={!projectField.value}
organizationId={projectData?.organization}
/>
{projectField.value?.allow_override && (
<FieldWithPrompt
fieldId="template-scm-branch"
@ -703,6 +759,8 @@ const FormikApp = withFormik({
template.webhook_key ||
i18n._(t`a new webhook key will be generated on save.`).toUpperCase(),
webhook_credential: template?.summary_fields?.webhook_credential || null,
execution_environment:
template.summary_fields?.execution_environment || '',
};
},
handleSubmit: async (values, { props, setErrors }) => {

View File

@ -22,7 +22,10 @@ import {
SubFormLayout,
} from '../../../components/FormLayout';
import OrganizationLookup from '../../../components/Lookup/OrganizationLookup';
import { InventoryLookup } from '../../../components/Lookup';
import {
InventoryLookup,
ExecutionEnvironmentLookup,
} from '../../../components/Lookup';
import { VariablesField } from '../../../components/CodeMirrorInput';
import FormActionGroup from '../../../components/FormActionGroup';
import ContentError from '../../../components/ContentError';
@ -63,6 +66,14 @@ function WorkflowJobTemplateForm({
'webhook_credential'
);
const [
executionEnvironmentField,
executionEnvironmentMeta,
executionEnvironmentHelpers,
] = useField({
name: 'execution_environment',
});
useEffect(() => {
if (enableWebhooks) {
webhookServiceHelpers.setValue(webhookServiceMeta.initialValue);
@ -178,6 +189,20 @@ function WorkflowJobTemplateForm({
}}
/>
</FieldWithPrompt>
<ExecutionEnvironmentLookup
helperTextInvalid={executionEnvironmentMeta.error}
isValid={
!executionEnvironmentMeta.touched || !executionEnvironmentMeta.error
}
onBlur={() => executionEnvironmentHelpers.setTouched()}
value={executionEnvironmentField.value}
onChange={value => executionEnvironmentHelpers.setValue(value)}
tooltip={i18n._(
t`Select the default execution environment for this organization to run on.`
)}
globallyAvailable
organizationId={organizationField.value?.id}
/>
</FormColumnLayout>
<FormFullWidthLayout>
<FormGroup
@ -296,6 +321,8 @@ const FormikApp = withFormik({
? `${urlOrigin}${template.related.webhook_receiver}`
: '',
webhook_key: template.webhook_key || '',
execution_environment:
template.summary_fields?.execution_environment || '',
};
},
handleSubmit: async (values, { props, setErrors }) => {

View File

@ -133,6 +133,12 @@
"id": "1",
"name": "Webhook Credential"
},
"execution_environment": {
"id": 1,
"name": "Default EE",
"description": "",
"image": "quay.io/ansible/awx-ee"
}
},
"created": "2019-09-30T16:18:34.564820Z",
@ -177,5 +183,6 @@
"job_slice_count": 1,
"webhook_credential": 1,
"webhook_key": "asertdyuhjkhgfd234567kjgfds",
"webhook_service": "github"
"webhook_service": "github",
"execution_environment": 1
}

View File

@ -416,4 +416,5 @@ export const ExecutionEnvironment = shape({
url: string,
summary_fields: shape({}),
description: string,
pull: string,
});