diff --git a/awx/ui_next/src/api/mixins/InstanceGroups.mixin.js b/awx/ui_next/src/api/mixins/InstanceGroups.mixin.js
index e3b2be1cb5..e6745ac522 100644
--- a/awx/ui_next/src/api/mixins/InstanceGroups.mixin.js
+++ b/awx/ui_next/src/api/mixins/InstanceGroups.mixin.js
@@ -1,10 +1,9 @@
const InstanceGroupsMixin = parent =>
class extends parent {
readInstanceGroups(resourceId, params) {
- return this.http.get(
- `${this.baseUrl}${resourceId}/instance_groups/`,
- params
- );
+ return this.http.get(`${this.baseUrl}${resourceId}/instance_groups/`, {
+ params,
+ });
}
associateInstanceGroup(resourceId, instanceGroupId) {
diff --git a/awx/ui_next/src/api/models/WorkflowJobTemplates.js b/awx/ui_next/src/api/models/WorkflowJobTemplates.js
index 691c444379..a71fe68cbc 100644
--- a/awx/ui_next/src/api/models/WorkflowJobTemplates.js
+++ b/awx/ui_next/src/api/models/WorkflowJobTemplates.js
@@ -6,6 +6,32 @@ class WorkflowJobTemplates extends Base {
this.baseUrl = '/api/v2/workflow_job_templates/';
}
+ readWebhookKey(id) {
+ return this.http.get(`${this.baseUrl}${id}/webhook_key/`);
+ }
+
+ updateWebhookKey(id) {
+ return this.http.post(`${this.baseUrl}${id}/webhook_key/`);
+ }
+
+ associateLabel(id, label, orgId) {
+ return this.http.post(`${this.baseUrl}${id}/labels/`, {
+ name: label.name,
+ organization: orgId,
+ });
+ }
+
+ createNode(id, data) {
+ return this.http.post(`${this.baseUrl}${id}/workflow_nodes/`, data);
+ }
+
+ disassociateLabel(id, label) {
+ return this.http.post(`${this.baseUrl}${id}/labels/`, {
+ id: label.id,
+ disassociate: true,
+ });
+ }
+
launch(id, data) {
return this.http.post(`${this.baseUrl}${id}/launch/`, data);
}
@@ -20,16 +46,10 @@ class WorkflowJobTemplates extends Base {
});
}
- readWebhookKey(id) {
- return this.http.get(`${this.baseUrl}${id}/webhook_key/`);
- }
-
- createNode(id, data) {
- return this.http.post(`${this.baseUrl}${id}/workflow_nodes/`, data);
- }
-
readScheduleList(id, params) {
- return this.http.get(`${this.baseUrl}${id}/schedules/`, { params });
+ return this.http.get(`${this.baseUrl}${id}/schedules/`, {
+ params,
+ });
}
}
diff --git a/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx b/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx
index d27b85d328..c8049328a8 100644
--- a/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx
+++ b/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx
@@ -5,7 +5,7 @@ import { t } from '@lingui/macro';
import { useField } from 'formik';
import styled from 'styled-components';
import { Split, SplitItem } from '@patternfly/react-core';
-import { CheckboxField } from '@components/FormField';
+import { CheckboxField, FieldTooltip } from '@components/FormField';
import MultiButtonToggle from '@components/MultiButtonToggle';
import { yamlToJson, jsonToYaml, isJson } from '@util/yaml';
import CodeMirrorInput from './CodeMirrorInput';
@@ -20,7 +20,15 @@ const StyledCheckboxField = styled(CheckboxField)`
--pf-c-check__label--FontSize: var(--pf-c-form__label--FontSize);
`;
-function VariablesField({ i18n, id, name, label, readOnly, promptId }) {
+function VariablesField({
+ i18n,
+ id,
+ name,
+ label,
+ readOnly,
+ promptId,
+ tooltip,
+}) {
const [field, meta, helpers] = useField(name);
const [mode, setMode] = useState(isJson(field.value) ? JSON_MODE : YAML_MODE);
@@ -32,6 +40,7 @@ function VariablesField({ i18n, id, name, label, readOnly, promptId }) {
+ {tooltip && }
{
expect(field.prop('hasErrors')).toEqual(true);
expect(wrapper.find('.pf-m-error')).toHaveLength(1);
});
+ it('should render tooltip', () => {
+ const value = '---\n';
+ const wrapper = mount(
+
+ {() => (
+
+ )}
+
+ );
+ expect(wrapper.find('Tooltip').length).toBe(1);
+ });
it('should submit value through Formik', async () => {
const value = '---\nfoo: bar\n';
diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx
index bd3dbe3a5c..37f8a2e3bb 100644
--- a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx
+++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx
@@ -6,6 +6,7 @@ import { t } from '@lingui/macro';
import { CredentialsAPI } from '@api';
import { Credential } from '@types';
import { getQSConfig, parseQueryString, mergeParams } from '@util/qs';
+import { FieldTooltip } from '@components/FormField';
import { FormGroup } from '@patternfly/react-core';
import Lookup from '@components/Lookup';
import OptionsList from './shared/OptionsList';
@@ -28,6 +29,7 @@ function CredentialLookup({
value,
history,
i18n,
+ tooltip,
}) {
const [credentials, setCredentials] = useState([]);
const [count, setCount] = useState(0);
@@ -60,6 +62,7 @@ function CredentialLookup({
label={label}
helperTextInvalid={helperTextInvalid}
>
+ {tooltip && }
dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
/>
diff --git a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx
index 69a55550f1..5863abe8ea 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useHistory } from 'react-router-dom';
-import { Card } from '@patternfly/react-core';
+import { Card, PageSection } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
import JobTemplateForm from '../shared/JobTemplateForm';
import { JobTemplatesAPI } from '@api';
@@ -61,15 +61,17 @@ function JobTemplateAdd() {
}
return (
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/awx/ui_next/src/screens/Template/Template.jsx b/awx/ui_next/src/screens/Template/Template.jsx
index 84839c1a3f..61f63e70e4 100644
--- a/awx/ui_next/src/screens/Template/Template.jsx
+++ b/awx/ui_next/src/screens/Template/Template.jsx
@@ -164,87 +164,91 @@ class Template extends Component {
}
return (
-
- {cardHeader}
-
-
- {template && (
- (
-
- )}
+
+
+ {cardHeader}
+
+
- )}
- {template && (
+ {template && (
+ (
+
+ )}
+ />
+ )}
+ {template && (
+ }
+ />
+ )}
+ {template && (
+ (
+
+ )}
+ />
+ )}
+ {canSeeNotificationsTab && (
+ (
+
+ )}
+ />
+ )}
+ {template?.id && (
+
+
+
+ )}
+ {template && (
+ (
+
+ )}
+ />
+ )}
}
+ key="not-found"
+ path="*"
+ render={() =>
+ !hasContentLoading && (
+
+ {match.params.id && (
+
+ {i18n._(`View Template Details`)}
+
+ )}
+
+ )
+ }
/>
- )}
- {template && (
- (
-
- )}
- />
- )}
- {canSeeNotificationsTab && (
- (
-
- )}
- />
- )}
- {template?.id && (
-
-
-
- )}
- {template && (
- }
- />
- )}
-
- !hasContentLoading && (
-
- {match.params.id && (
-
- {i18n._(`View Template Details`)}
-
- )}
-
- )
- }
- />
-
-
+
+
+
);
}
}
diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx
index 717b1b5f89..913fcfb648 100644
--- a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx
+++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx
@@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
-import { Card } from '@patternfly/react-core';
+import { Card, PageSection } from '@patternfly/react-core';
import {
JobTemplatesAPI,
@@ -141,7 +141,7 @@ function TemplateList({ i18n }) {
);
return (
- <>
+
- >
+
);
}
diff --git a/awx/ui_next/src/screens/Template/Templates.jsx b/awx/ui_next/src/screens/Template/Templates.jsx
index c8eb2f243e..f6572a54ab 100644
--- a/awx/ui_next/src/screens/Template/Templates.jsx
+++ b/awx/ui_next/src/screens/Template/Templates.jsx
@@ -2,7 +2,6 @@ import React, { Component } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Route, withRouter, Switch } from 'react-router-dom';
-import { PageSection } from '@patternfly/react-core';
import { Config } from '@contexts/Config';
import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs';
@@ -10,6 +9,7 @@ import { TemplateList } from './TemplateList';
import Template from './Template';
import WorkflowJobTemplate from './WorkflowJobTemplate';
import JobTemplateAdd from './JobTemplateAdd';
+import WorkflowJobTemplateAdd from './WorkflowJobTemplateAdd';
class Templates extends Component {
constructor(props) {
@@ -20,6 +20,9 @@ class Templates extends Component {
breadcrumbConfig: {
'/templates': i18n._(t`Templates`),
'/templates/job_template/add': i18n._(t`Create New Job Template`),
+ '/templates/workflow_job_template/add': i18n._(
+ t`Create New Workflow Template`
+ ),
},
};
}
@@ -32,6 +35,9 @@ class Templates extends Component {
const breadcrumbConfig = {
'/templates': i18n._(t`Templates`),
'/templates/job_template/add': i18n._(t`Create New Job Template`),
+ '/templates/workflow_job_template/add': i18n._(
+ t`Create New Workflow Template`
+ ),
[`/templates/${template.type}/${template.id}`]: `${template.name}`,
[`/templates/${template.type}/${template.id}/details`]: i18n._(
t`Details`
@@ -56,47 +62,48 @@ class Templates extends Component {
return (
<>
-
-
- }
- />
- (
-
- {({ me }) => (
-
- )}
-
- )}
- />
- (
-
- {({ me }) => (
-
- )}
-
- )}
- />
- } />
-
-
+
+ }
+ />
+ }
+ />
+ (
+
+ {({ me }) => (
+
+ )}
+
+ )}
+ />
+ (
+
+ {({ me }) => (
+
+ )}
+
+ )}
+ />
+ } />
+
>
);
}
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx
index bdb9ca8ad6..6c63e16ab0 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx
@@ -11,8 +11,10 @@ import FullPage from '@components/FullPage';
import JobList from '@components/JobList';
import RoutedTabs from '@components/RoutedTabs';
import ScheduleList from '@components/ScheduleList';
+import ContentLoading from '@components/ContentLoading';
import { WorkflowJobTemplatesAPI, CredentialsAPI } from '@api';
import WorkflowJobTemplateDetail from './WorkflowJobTemplateDetail';
+import WorkflowJobTemplateEdit from './WorkflowJobTemplateEdit';
import { Visualizer } from './WorkflowJobTemplateVisualizer';
class WorkflowJobTemplate extends Component {
@@ -23,6 +25,7 @@ class WorkflowJobTemplate extends Component {
contentError: null,
hasContentLoading: true,
template: null,
+ webhook_key: null,
};
this.loadTemplate = this.loadTemplate.bind(this);
this.loadSchedules = this.loadSchedules.bind(this);
@@ -50,19 +53,20 @@ class WorkflowJobTemplate extends Component {
const {
data: { webhook_key },
} = await WorkflowJobTemplatesAPI.readWebhookKey(id);
- this.setState({ webHookKey: webhook_key });
+ this.setState({ webhook_key });
}
if (data?.summary_fields?.webhook_credential) {
const {
data: {
- summary_fields: { credential_type: name },
+ summary_fields: {
+ credential_type: { name },
+ },
},
} = await CredentialsAPI.readDetail(
data.summary_fields.webhook_credential.id
);
data.summary_fields.webhook_credential.kind = name;
}
-
this.setState({ template: data });
setBreadcrumb(data);
} catch (err) {
@@ -83,7 +87,7 @@ class WorkflowJobTemplate extends Component {
contentError,
hasContentLoading,
template,
- webHookKey,
+ webhook_key,
} = this.state;
const tabsArray = [
@@ -115,7 +119,9 @@ class WorkflowJobTemplate extends Component {
if (location.pathname.endsWith('edit')) {
cardHeader = null;
}
-
+ if (hasContentLoading) {
+ return ;
+ }
if (!hasContentLoading && contentError) {
return (
@@ -134,73 +140,89 @@ class WorkflowJobTemplate extends Component {
}
return (
-
- {cardHeader}
-
-
- {template && (
- (
-
- )}
+
+
+ {cardHeader}
+
+
- )}
- {template && (
- (
-
-
-
-
-
- )}
- />
- )}
- {template?.id && (
-
- (
+
+ )}
/>
-
- )}
- {template && (
+ )}
+ {template && (
+ (
+
+ )}
+ />
+ )}
+ {template && (
+ (
+
+
+
+
+
+ )}
+ />
+ )}
+ {template?.id && (
+
+
+
+ )}
+ {template.id && (
+ (
+
+ )}
+ />
+ )}
}
+ key="not-found"
+ path="*"
+ render={() =>
+ !hasContentLoading && (
+
+ {match.params.id && (
+
+ {i18n._(`View Template Details`)}
+
+ )}
+
+ )
+ }
/>
- )}
-
- !hasContentLoading && (
-
- {match.params.id && (
-
- {i18n._(`View Template Details`)}
-
- )}
-
- )
- }
- />
-
-
+
+
+
);
}
}
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/WorkflowJobTemplateAdd.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/WorkflowJobTemplateAdd.jsx
new file mode 100644
index 0000000000..c1efe45572
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/WorkflowJobTemplateAdd.jsx
@@ -0,0 +1,63 @@
+import React, { useState } from 'react';
+import { useHistory } from 'react-router-dom';
+
+import { Card, PageSection } from '@patternfly/react-core';
+import { CardBody } from '@components/Card';
+
+import { WorkflowJobTemplatesAPI, OrganizationsAPI } from '@api';
+import WorkflowJobTemplateForm from '../shared/WorkflowJobTemplateForm';
+
+function WorkflowJobTemplateAdd() {
+ const history = useHistory();
+ const [formSubmitError, setFormSubmitError] = useState(null);
+
+ const handleSubmit = async values => {
+ const { labels, organizationId, ...remainingValues } = values;
+ try {
+ const {
+ data: { id },
+ } = await WorkflowJobTemplatesAPI.create(remainingValues);
+ await Promise.all(await submitLabels(id, labels, organizationId));
+ history.push(`/templates/workflow_job_template/${id}/details`);
+ } catch (err) {
+ setFormSubmitError(err);
+ }
+ };
+
+ const submitLabels = async (templateId, labels = [], organizationId) => {
+ if (!organizationId) {
+ try {
+ const {
+ data: { results },
+ } = await OrganizationsAPI.read();
+ organizationId = results[0].id;
+ } catch (err) {
+ throw err;
+ }
+ }
+ const associatePromises = labels.map(label =>
+ WorkflowJobTemplatesAPI.associateLabel(templateId, label, organizationId)
+ );
+ return [...associatePromises];
+ };
+
+ const handleCancel = () => {
+ history.push(`/templates`);
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export default WorkflowJobTemplateAdd;
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/WorkflowJobTemplateAdd.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/WorkflowJobTemplateAdd.test.jsx
new file mode 100644
index 0000000000..7a4c11831c
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/WorkflowJobTemplateAdd.test.jsx
@@ -0,0 +1,130 @@
+import React from 'react';
+import { Route } from 'react-router-dom';
+import { act } from 'react-dom/test-utils';
+import { WorkflowJobTemplatesAPI, OrganizationsAPI, LabelsAPI } from '@api';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { createMemoryHistory } from 'history';
+
+import WorkflowJobTemplateAdd from './WorkflowJobTemplateAdd';
+
+jest.mock('@api/models/WorkflowJobTemplates');
+jest.mock('@api/models/Organizations');
+jest.mock('@api/models/Labels');
+jest.mock('@api/models/Inventories');
+
+describe('', () => {
+ let wrapper;
+ let history;
+ beforeEach(async () => {
+ WorkflowJobTemplatesAPI.create.mockResolvedValue({ data: { id: 1 } });
+ OrganizationsAPI.read.mockResolvedValue({ results: [{ id: 1 }] });
+ LabelsAPI.read.mockResolvedValue({
+ data: {
+ results: [
+ { name: 'Label 1', id: 1 },
+ { name: 'Label 2', id: 2 },
+ { name: 'Label 3', id: 3 },
+ ],
+ },
+ });
+
+ await act(async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/templates/workflow_job_template/add'],
+ });
+ await act(async () => {
+ wrapper = await mountWithContexts(
+ }
+ />,
+ {
+ context: {
+ router: {
+ history,
+ route: {
+ location: history.location,
+ },
+ },
+ },
+ }
+ );
+ });
+ });
+ });
+ afterEach(async () => {
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ test('initially renders successfully', async () => {
+ expect(wrapper.length).toBe(1);
+ });
+
+ test('calls workflowJobTemplatesAPI with correct information on submit', async () => {
+ await act(async () => {
+ await wrapper.find('WorkflowJobTemplateForm').invoke('handleSubmit')({
+ name: 'Alex',
+ labels: [{ name: 'Foo', id: 1 }, { name: 'bar', id: 2 }],
+ organizationId: 1,
+ });
+ });
+ expect(WorkflowJobTemplatesAPI.create).toHaveBeenCalledWith({
+ name: 'Alex',
+ });
+ expect(WorkflowJobTemplatesAPI.associateLabel).toHaveBeenCalledTimes(2);
+ });
+
+ test('handleCancel navigates the user to the /templates', async () => {
+ await act(async () => {
+ await wrapper.find('WorkflowJobTemplateForm').invoke('handleCancel')();
+ });
+ expect(history.location.pathname).toBe('/templates');
+ });
+
+ test('throwing error renders FormSubmitError component', async () => {
+ const error = {
+ response: {
+ config: {
+ method: 'post',
+ url: '/api/v2/workflow_job_templates/',
+ },
+ data: { detail: 'An error occurred' },
+ },
+ };
+
+ WorkflowJobTemplatesAPI.create.mockRejectedValue(error);
+ await act(async () => {
+ wrapper.find('WorkflowJobTemplateForm').invoke('handleSubmit')({
+ name: 'Foo',
+ });
+ });
+ expect(WorkflowJobTemplatesAPI.create).toHaveBeenCalled();
+ wrapper.update();
+ expect(wrapper.find('WorkflowJobTemplateForm').prop('submitError')).toEqual(
+ error
+ );
+ });
+
+ test('throwing error prevents navigation away from form', async () => {
+ OrganizationsAPI.read.mockRejectedValue({
+ response: {
+ config: {
+ method: 'get',
+ url: '/api/v2/organizations/',
+ },
+ data: 'An error occurred',
+ },
+ });
+ WorkflowJobTemplatesAPI.update.mockResolvedValue();
+
+ await act(async () => {
+ await wrapper.find('Button[aria-label="Save"]').simulate('click');
+ });
+ expect(wrapper.find('WorkflowJobTemplateForm').length).toBe(1);
+ expect(OrganizationsAPI.read).toBeCalled();
+ expect(history.location.pathname).toBe(
+ '/templates/workflow_job_template/add'
+ );
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/index.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/index.js
new file mode 100644
index 0000000000..51111ce0fb
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/index.js
@@ -0,0 +1 @@
+export { default } from './WorkflowJobTemplateAdd';
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx
index f97ed439fe..d528013def 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx
@@ -25,7 +25,7 @@ import LaunchButton from '@components/LaunchButton';
import Sparkline from '@components/Sparkline';
import { toTitleCase } from '@util/strings';
-function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
+function WorkflowJobTemplateDetail({ template, i18n, webhook_key }) {
const {
id,
ask_inventory_on_launch,
@@ -39,12 +39,15 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
related,
webhook_credential,
} = template;
+
const urlOrigin = window.location.origin;
const history = useHistory();
+
const [deletionError, setDeletionError] = useState(null);
const [hasContentLoading, setHasContentLoading] = useState(false);
+
const renderOptionsField =
- template.allow_simultaneous || template.webhook_servicee;
+ template.allow_simultaneous || template.webhook_service;
const renderOptions = (
@@ -55,7 +58,7 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
)}
{template.webhook_service && (
- {i18n._(t`- Webhooks`)}
+ {i18n._(t`- Enable Webhook`)}
)}
@@ -75,6 +78,7 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
}
setHasContentLoading(false);
};
+
const inventoryValue = (kind, inventoryId) => {
const inventorykind = kind === 'smart' ? 'smart_inventory' : 'inventory';
@@ -91,6 +95,7 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
);
};
+
const canLaunch = summary_fields?.user_capabilities?.start;
const recentPlaybookJobs = summary_fields.recent_jobs.map(job => ({
...job,
@@ -143,7 +148,7 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
value={`${urlOrigin}${template.related.webhook_receiver}`}
/>
)}
-
+
{webhook_credential && (
', () => {
},
webhook_service: 'Github',
};
+
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/templates/workflow_job_template/1/details'],
@@ -51,7 +52,7 @@ describe('', () => {
component={() => (
{}}
/>
@@ -75,12 +76,15 @@ describe('', () => {
);
});
});
+
afterEach(() => {
wrapper.unmount();
});
+
test('renders successfully', () => {
expect(wrapper.find(WorkflowJobTemplateDetail).length).toBe(1);
});
+
test('expect detail fields to render properly', () => {
const renderedValues = [
{
@@ -147,6 +151,7 @@ describe('', () => {
renderedValues.map(value => assertValue(value));
});
+
test('link out resource have the correct url', () => {
const inventory = wrapper.find('Detail[label="Inventory"]').find('Link');
const organization = wrapper
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.jsx
new file mode 100644
index 0000000000..b958ff9ff4
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.jsx
@@ -0,0 +1,69 @@
+import React, { useState } from 'react';
+import { useHistory } from 'react-router-dom';
+
+import { CardBody } from '@components/Card';
+import { getAddedAndRemoved } from '@util/lists';
+import { WorkflowJobTemplatesAPI, OrganizationsAPI } from '@api';
+import { WorkflowJobTemplateForm } from '../shared';
+
+function WorkflowJobTemplateEdit({ template, webhook_key }) {
+ const history = useHistory();
+ const [formSubmitError, setFormSubmitError] = useState(null);
+
+ const handleSubmit = async values => {
+ const { labels, ...remainingValues } = values;
+ try {
+ await Promise.all(
+ await submitLabels(labels, values.organization, template.organization)
+ );
+ await WorkflowJobTemplatesAPI.update(template.id, remainingValues);
+ history.push(`/templates/workflow_job_template/${template.id}/details`);
+ } catch (err) {
+ setFormSubmitError(err);
+ }
+ };
+
+ const submitLabels = async (labels = [], formOrgId, templateOrgId) => {
+ const { added, removed } = getAddedAndRemoved(
+ template.summary_fields.labels.results,
+ labels
+ );
+ let orgId = formOrgId || templateOrgId;
+ if (!orgId) {
+ try {
+ const {
+ data: { results },
+ } = await OrganizationsAPI.read();
+ orgId = results[0].id;
+ } catch (err) {
+ throw err;
+ }
+ }
+
+ const disassociationPromises = await removed.map(label =>
+ WorkflowJobTemplatesAPI.disassociateLabel(template.id, label)
+ );
+ const associationPromises = await added.map(label =>
+ WorkflowJobTemplatesAPI.associateLabel(template.id, label, orgId)
+ );
+ const results = [...disassociationPromises, ...associationPromises];
+ return results;
+ };
+
+ const handleCancel = () => {
+ history.push(`/templates/workflow_job_template/${template.id}/details`);
+ };
+
+ return (
+
+
+
+ );
+}
+export default WorkflowJobTemplateEdit;
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.test.jsx
new file mode 100644
index 0000000000..49475d71f8
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.test.jsx
@@ -0,0 +1,188 @@
+import React from 'react';
+import { Route } from 'react-router-dom';
+import { act } from 'react-dom/test-utils';
+import { WorkflowJobTemplatesAPI, OrganizationsAPI, LabelsAPI } from '@api';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import { createMemoryHistory } from 'history';
+import WorkflowJobTemplateEdit from './WorkflowJobTemplateEdit';
+
+jest.mock('@api/models/WorkflowJobTemplates');
+jest.mock('@api/models/Labels');
+jest.mock('@api/models/Organizations');
+jest.mock('@api/models/Inventories');
+
+const mockTemplate = {
+ id: 6,
+ name: 'Foo',
+ description: 'Foo description',
+ summary_fields: {
+ inventory: { id: 1, name: 'Inventory 1' },
+ organization: { id: 1, name: 'Organization 1' },
+ labels: {
+ results: [{ name: 'Label 1', id: 1 }, { name: 'Label 2', id: 2 }],
+ },
+ },
+ scm_branch: 'devel',
+ limit: '5000',
+ variables: '---',
+};
+describe('', () => {
+ let wrapper;
+ let history;
+ beforeEach(async () => {
+ LabelsAPI.read.mockResolvedValue({
+ data: {
+ results: [{ name: 'Label 1', id: 1 }, { name: 'Label 2', id: 2 }],
+ },
+ });
+ OrganizationsAPI.read.mockResolvedValue({ results: [{ id: 1 }] });
+
+ await act(async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/templates/workflow_job_template/6/edit'],
+ });
+ wrapper = mountWithContexts(
+ }
+ />,
+ {
+ context: {
+ router: {
+ history,
+ route: {
+ location: history.location,
+ match: { params: { id: 6 } },
+ },
+ },
+ },
+ }
+ );
+ });
+ });
+
+ afterEach(async () => {
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ test('renders successfully', () => {
+ expect(wrapper.find('WorkflowJobTemplateEdit').length).toBe(1);
+ });
+
+ test('api is called to properly to save the updated template.', async () => {
+ await act(async () => {
+ await wrapper.find('WorkflowJobTemplateForm').invoke('handleSubmit')({
+ id: 6,
+ name: 'Alex',
+ description: 'Apollo and Athena',
+ inventory: 1,
+ organization: 1,
+ labels: [{ name: 'Label 2', id: 2 }, { name: 'Generated Label' }],
+ scm_branch: 'master',
+ limit: '5000',
+ variables: '---',
+ });
+ });
+
+ expect(WorkflowJobTemplatesAPI.update).toHaveBeenCalledWith(6, {
+ id: 6,
+ name: 'Alex',
+ description: 'Apollo and Athena',
+ inventory: 1,
+ organization: 1,
+ scm_branch: 'master',
+ limit: '5000',
+ variables: '---',
+ });
+ wrapper.update();
+ await expect(WorkflowJobTemplatesAPI.disassociateLabel).toBeCalledWith(6, {
+ name: 'Label 1',
+ id: 1,
+ });
+ wrapper.update();
+ await expect(WorkflowJobTemplatesAPI.associateLabel).toBeCalledTimes(1);
+ });
+
+ test('handleCancel navigates the user to the /templates', () => {
+ act(() => {
+ wrapper.find('WorkflowJobTemplateForm').invoke('handleCancel')();
+ });
+ expect(history.location.pathname).toBe(
+ '/templates/workflow_job_template/6/details'
+ );
+ });
+
+ test('throwing error renders FormSubmitError component', async () => {
+ const error = {
+ response: {
+ config: {
+ method: 'patch',
+ url: '/api/v2/workflow_job_templates/',
+ },
+ data: { detail: 'An error occurred' },
+ },
+ };
+ WorkflowJobTemplatesAPI.update.mockRejectedValue(error);
+ await act(async () => {
+ wrapper.find('Button[aria-label="Save"]').simulate('click');
+ });
+ expect(WorkflowJobTemplatesAPI.update).toHaveBeenCalled();
+ wrapper.update();
+ expect(wrapper.find('WorkflowJobTemplateForm').prop('submitError')).toEqual(
+ error
+ );
+ });
+
+ test('throwing error prevents form submission', async () => {
+ const templateWithoutOrg = {
+ id: 6,
+ name: 'Foo',
+ description: 'Foo description',
+ summary_fields: {
+ inventory: { id: 1, name: 'Inventory 1' },
+ labels: {
+ results: [{ name: 'Label 1', id: 1 }, { name: 'Label 2', id: 2 }],
+ },
+ },
+ scm_branch: 'devel',
+ limit: '5000',
+ variables: '---',
+ };
+
+ let newWrapper;
+ await act(async () => {
+ newWrapper = await mountWithContexts(
+ ,
+ {
+ context: {
+ router: {
+ history,
+ },
+ },
+ }
+ );
+ });
+ OrganizationsAPI.read.mockRejectedValue({
+ response: {
+ config: {
+ method: 'get',
+ url: '/api/v2/organizations/',
+ },
+ data: { detail: 'An error occurred' },
+ },
+ });
+
+ WorkflowJobTemplatesAPI.update.mockResolvedValue();
+
+ await act(async () => {
+ await newWrapper.find('Button[aria-label="Save"]').simulate('click');
+ });
+ expect(newWrapper.find('WorkflowJobTemplateForm').length).toBe(1);
+ expect(OrganizationsAPI.read).toBeCalled();
+ expect(WorkflowJobTemplatesAPI.update).not.toBeCalled();
+ expect(history.location.pathname).toBe(
+ '/templates/workflow_job_template/6/edit'
+ );
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/index.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/index.js
new file mode 100644
index 0000000000..bd84799fb0
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateEdit/index.js
@@ -0,0 +1 @@
+export { default } from './WorkflowJobTemplateEdit';
diff --git a/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx
new file mode 100644
index 0000000000..770f665982
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx
@@ -0,0 +1,509 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { t } from '@lingui/macro';
+import { useRouteMatch, useParams } from 'react-router-dom';
+
+import { func, shape } from 'prop-types';
+
+import { withI18n } from '@lingui/react';
+import { Formik, Field } from 'formik';
+import {
+ Form,
+ FormGroup,
+ InputGroup,
+ Button,
+ TextInput,
+ Checkbox,
+} from '@patternfly/react-core';
+import { required } from '@util/validators';
+import { SyncAltIcon } from '@patternfly/react-icons';
+
+import AnsibleSelect from '@components/AnsibleSelect';
+import { WorkflowJobTemplatesAPI, CredentialTypesAPI } from '@api';
+
+import useRequest from '@util/useRequest';
+import FormField, {
+ FieldTooltip,
+ FormSubmitError,
+} from '@components/FormField';
+import {
+ FormColumnLayout,
+ FormFullWidthLayout,
+ FormCheckboxLayout,
+} from '@components/FormLayout';
+import ContentLoading from '@components/ContentLoading';
+import OrganizationLookup from '@components/Lookup/OrganizationLookup';
+import CredentialLookup from '@components/Lookup/CredentialLookup';
+import { InventoryLookup } from '@components/Lookup';
+import { VariablesField } from '@components/CodeMirrorInput';
+import FormActionGroup from '@components/FormActionGroup';
+import ContentError from '@components/ContentError';
+import CheckboxField from '@components/FormField/CheckboxField';
+import LabelSelect from './LabelSelect';
+
+function WorkflowJobTemplateForm({
+ handleSubmit,
+ handleCancel,
+ i18n,
+ template = {},
+ webhook_key,
+ submitError,
+}) {
+ const urlOrigin = window.location.origin;
+ const { id } = useParams();
+ const wfjtAddMatch = useRouteMatch('/templates/workflow_job_template/add');
+
+ const [hasContentError, setContentError] = useState(null);
+ const [webhook_url, setWebhookUrl] = useState(
+ template?.related?.webhook_receiver
+ ? `${urlOrigin}${template.related.webhook_receiver}`
+ : ''
+ );
+ const [inventory, setInventory] = useState(
+ template?.summary_fields?.inventory || null
+ );
+ const [organization, setOrganization] = useState(
+ template?.summary_fields?.organization || null
+ );
+ const [webhookCredential, setWebhookCredential] = useState(
+ template?.summary_fields?.webhook_credential || null
+ );
+ const [webhookKey, setWebHookKey] = useState(webhook_key);
+ const [webhookService, setWebHookService] = useState(
+ template.webhook_service || ''
+ );
+ const [hasWebhooks, setHasWebhooks] = useState(Boolean(webhookService));
+
+ const webhookServiceOptions = [
+ {
+ value: '',
+ key: '',
+ label: i18n._(t`Choose a Webhook Service`),
+ isDisabled: true,
+ },
+ {
+ value: 'github',
+ key: 'github',
+ label: i18n._(t`GitHub`),
+ isDisabled: false,
+ },
+ {
+ value: 'gitlab',
+ key: 'gitlab',
+ label: i18n._(t`GitLab`),
+ isDisabled: false,
+ },
+ ];
+ const {
+ request: loadCredentialType,
+ error: contentError,
+ contentLoading,
+ result: credTypeId,
+ } = useRequest(
+ useCallback(async () => {
+ let results;
+ if (webhookService) {
+ results = await CredentialTypesAPI.read({
+ namespace: `${webhookService}_token`,
+ });
+ // TODO: Consider how to handle the situation where the results returns
+ // and empty array, or any of the other values is undefined or null (data, results, id)
+ }
+ return results?.data?.results[0]?.id;
+ }, [webhookService])
+ );
+
+ useEffect(() => {
+ loadCredentialType();
+ }, [loadCredentialType]);
+
+ // TODO: Convert this function below to useRequest. Might want to create a new
+ // webhookkey component that handles all of that api calls. Will also need
+ // to move this api call out of WorkflowJobTemplate.jsx and add it to workflowJobTemplateDetai.jsx
+ const changeWebhookKey = async () => {
+ try {
+ const {
+ data: { webhook_key: key },
+ } = await WorkflowJobTemplatesAPI.updateWebhookKey(id);
+ setWebHookKey(key);
+ } catch (err) {
+ setContentError(err);
+ }
+ };
+
+ let initialWebhookKey = webhook_key;
+ const initialWebhookCredential = template?.summary_fields?.webhook_credential;
+
+ const storeWebhookValues = (form, webhookServiceValue) => {
+ if (
+ webhookServiceValue === form.initialValues.webhook_service ||
+ webhookServiceValue === ''
+ ) {
+ form.setFieldValue(
+ 'webhook_credential',
+ form.initialValues.webhook_credential
+ );
+ setWebhookCredential(initialWebhookCredential);
+
+ setWebhookUrl(
+ template?.related?.webhook_receiver
+ ? `${urlOrigin}${template.related.webhook_receiver}`
+ : ''
+ );
+ form.setFieldValue('webhook_service', form.initialValues.webhook_service);
+ setWebHookService(form.initialValues.webhook_service);
+
+ setWebHookKey(initialWebhookKey);
+ } else {
+ form.setFieldValue('webhook_credential', null);
+ setWebhookCredential(null);
+
+ setWebhookUrl(
+ `${urlOrigin}/api/v2/workflow_job_templates/${template.id}/${webhookServiceValue}/`
+ );
+
+ setWebHookKey(
+ i18n._(t`a new webhook key will be generated on save.`).toUpperCase()
+ );
+ }
+ };
+
+ const handleWebhookEnablement = (
+ form,
+ enabledWebhooks,
+ webhookServiceValue
+ ) => {
+ if (!enabledWebhooks) {
+ initialWebhookKey = webhookKey;
+ form.setFieldValue('webhook_credential', null);
+ form.setFieldValue('webhook_service', '');
+ setWebhookUrl('');
+ setWebHookService('');
+ setWebHookKey('');
+ } else {
+ storeWebhookValues(form, webhookServiceValue);
+ }
+ };
+
+ if (hasContentError || contentError) {
+ return ;
+ }
+
+ if (contentLoading) {
+ return ;
+ }
+
+ return (
+ {
+ if (values.webhook_service === '') {
+ values.webhook_credential = '';
+ }
+ return handleSubmit(values);
+ }}
+ initialValues={{
+ name: template.name || '',
+ description: template.description || '',
+ inventory: template?.summary_fields?.inventory?.id || null,
+ organization: template?.summary_fields?.organization?.id || null,
+ labels: template.summary_fields?.labels?.results || [],
+ extra_vars: template.extra_vars || '---',
+ limit: template.limit || '',
+ scm_branch: template.scm_branch || '',
+ allow_simultaneous: template.allow_simultaneous || false,
+ webhook_credential:
+ template?.summary_fields?.webhook_credential?.id || null,
+ webhook_service: template.webhook_service || '',
+ ask_limit_on_launch: template.ask_limit_on_launch || false,
+ ask_inventory_on_launch: template.ask_inventory_on_launch || false,
+ ask_variables_on_launch: template.ask_variables_on_launch || false,
+ ask_scm_branch_on_launch: template.ask_scm_branch_on_launch || false,
+ }}
+ >
+ {formik => (
+
+ )}
+
+ );
+}
+
+WorkflowJobTemplateForm.propTypes = {
+ handleSubmit: func.isRequired,
+ handleCancel: func.isRequired,
+ submitError: shape({}),
+};
+
+WorkflowJobTemplateForm.defaultProps = {
+ submitError: null,
+};
+
+export default withI18n()(WorkflowJobTemplateForm);
diff --git a/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.test.jsx b/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.test.jsx
new file mode 100644
index 0000000000..904ac78204
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.test.jsx
@@ -0,0 +1,229 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { Route } from 'react-router-dom';
+import { createMemoryHistory } from 'history';
+import { sleep } from '@testUtils/testUtils';
+
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import WorkflowJobTemplateForm from './WorkflowJobTemplateForm';
+import {
+ WorkflowJobTemplatesAPI,
+ LabelsAPI,
+ OrganizationsAPI,
+ InventoriesAPI,
+} from '@api';
+
+jest.mock('@api/models/WorkflowJobTemplates');
+jest.mock('@api/models/Labels');
+jest.mock('@api/models/Organizations');
+jest.mock('@api/models/Inventories');
+
+describe('', () => {
+ let wrapper;
+ let history;
+ const handleSubmit = jest.fn();
+ const handleCancel = jest.fn();
+ const mockTemplate = {
+ id: 6,
+ name: 'Foo',
+ description: 'Foo description',
+ summary_fields: {
+ inventory: { id: 1, name: 'Inventory 1' },
+ organization: { id: 1, name: 'Organization 1' },
+ labels: {
+ results: [{ name: 'Label 1', id: 1 }, { name: 'Label 2', id: 2 }],
+ },
+ },
+ scm_branch: 'devel',
+ limit: '5000',
+ variables: '---',
+ related: {
+ webhook_receiver: '/api/v2/workflow_job_templates/57/gitlab/',
+ },
+ };
+
+ beforeEach(async () => {
+ WorkflowJobTemplatesAPI.updateWebhookKey.mockResolvedValue({
+ data: { webhook_key: 'sdafdghjkl2345678ionbvcxz' },
+ });
+ LabelsAPI.read.mockResolvedValue({
+ data: {
+ results: [
+ { name: 'Label 1', id: 1 },
+ { name: 'Label 2', id: 2 },
+ { name: 'Label 3', id: 3 },
+ ],
+ },
+ });
+ OrganizationsAPI.read.mockResolvedValue({
+ results: [{ id: 1 }, { id: 2 }],
+ });
+ InventoriesAPI.read.mockResolvedValue({
+ results: [{ id: 1, name: 'Foo' }, { id: 2, name: 'Bar' }],
+ });
+
+ history = createMemoryHistory({
+ initialEntries: ['/templates/workflow_job_template/6/edit'],
+ });
+ await act(async () => {
+ wrapper = await mountWithContexts(
+ (
+
+ )}
+ />,
+ {
+ context: {
+ router: {
+ history,
+ route: {
+ location: history.location,
+ match: { params: { id: 6 } },
+ },
+ },
+ },
+ }
+ );
+ });
+ });
+
+ afterEach(() => {
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ test('renders successfully', () => {
+ expect(wrapper.length).toBe(1);
+ });
+
+ test('all the fields render successfully', () => {
+ const fields = [
+ 'FormField[name="name"]',
+ 'FormField[name="description"]',
+ 'Field[name="organization"]',
+ 'Field[name="inventory"]',
+ 'FormField[name="limit"]',
+ 'FormField[name="scm_branch"]',
+ 'Field[name="labels"]',
+ 'VariablesField',
+ ];
+ const assertField = field => {
+ expect(wrapper.find(`${field}`).length).toBe(1);
+ };
+ fields.map((field, index) => assertField(field, index));
+ });
+
+ test('changing inputs should update values', async () => {
+ const inputsToChange = [
+ {
+ element: 'wfjt-name',
+ value: { value: 'new foo', name: 'name' },
+ },
+ {
+ element: 'wfjt-description',
+ value: { value: 'new bar', name: 'description' },
+ },
+ { element: 'wfjt-limit', value: { value: 1234567890, name: 'limit' } },
+ {
+ element: 'wfjt-scm_branch',
+ value: { value: 'new branch', name: 'scm_branch' },
+ },
+ ];
+ const changeInputs = async ({ element, value }) => {
+ wrapper.find(`input#${element}`).simulate('change', {
+ target: { value: `${value.value}`, name: `${value.name}` },
+ });
+ };
+
+ await act(async () => {
+ inputsToChange.map(input => changeInputs(input));
+
+ wrapper.find('LabelSelect').invoke('onChange')([
+ { name: 'Label 3', id: 3 },
+ { name: 'Label 1', id: 1 },
+ { name: 'Label 2', id: 2 },
+ ]);
+ wrapper.find('InventoryLookup').invoke('onChange')({
+ id: 3,
+ name: 'inventory',
+ });
+ wrapper.find('OrganizationLookup').invoke('onChange')({
+ id: 3,
+ name: 'organization',
+ });
+ });
+ wrapper.update();
+
+ const assertChanges = ({ element, value }) => {
+ expect(wrapper.find(`input#${element}`).prop('value')).toEqual(
+ `${value.value}`
+ );
+ };
+
+ inputsToChange.map(input => assertChanges(input));
+ });
+
+ test('webhooks and enable concurrent jobs functions properly', async () => {
+ act(() => {
+ wrapper.find('Checkbox[aria-label="Enable Webhook"]').invoke('onChange')(
+ true,
+ {
+ currentTarget: { value: true, type: 'change', checked: true },
+ }
+ );
+ });
+ wrapper.update();
+ expect(
+ wrapper.find('Checkbox[aria-label="Enable Webhook"]').prop('isChecked')
+ ).toBe(true);
+
+ expect(
+ wrapper.find('input[aria-label="wfjt-webhook-key"]').prop('readOnly')
+ ).toBe(true);
+ expect(
+ wrapper.find('input[aria-label="wfjt-webhook-key"]').prop('value')
+ ).toBe('sdfghjklmnbvcdsew435678iokjhgfd');
+ await act(() =>
+ wrapper
+ .find('FormGroup[name="webhook_key"]')
+ .find('Button[variant="tertiary"]')
+ .prop('onClick')()
+ );
+ expect(WorkflowJobTemplatesAPI.updateWebhookKey).toBeCalledWith('6');
+ expect(
+ wrapper.find('TextInputBase[aria-label="Webhook URL"]').prop('value')
+ ).toContain('/api/v2/workflow_job_templates/57/gitlab/');
+
+ wrapper.update();
+
+ expect(wrapper.find('Field[name="webhook_service"]').length).toBe(1);
+
+ act(() => wrapper.find('AnsibleSelect').prop('onChange')({}, 'gitlab'));
+ wrapper.update();
+
+ expect(wrapper.find('AnsibleSelect').prop('value')).toBe('gitlab');
+ });
+
+ test('handleSubmit is called on submit button click', async () => {
+ act(() => {
+ wrapper.find('Formik').prop('onSubmit')({});
+ });
+ wrapper.update();
+ sleep(0);
+ expect(handleSubmit).toBeCalled();
+ });
+
+ test('handleCancel is called on cancel button click', async () => {
+ act(() => {
+ wrapper.find('button[aria-label="Cancel"]').simulate('click');
+ });
+
+ expect(handleCancel).toBeCalled();
+ });
+});
diff --git a/awx/ui_next/src/screens/Template/shared/index.js b/awx/ui_next/src/screens/Template/shared/index.js
index 4e4c871715..a446c029c5 100644
--- a/awx/ui_next/src/screens/Template/shared/index.js
+++ b/awx/ui_next/src/screens/Template/shared/index.js
@@ -1 +1,2 @@
-export { default } from './JobTemplateForm';
+export { default as JobTemplateForm } from './JobTemplateForm';
+export { default as WorkflowJobTemplateForm } from './WorkflowJobTemplateForm';