diff --git a/awx/ui_next/src/api/models/JobTemplates.js b/awx/ui_next/src/api/models/JobTemplates.js
index 522240c733..0e2eba8079 100644
--- a/awx/ui_next/src/api/models/JobTemplates.js
+++ b/awx/ui_next/src/api/models/JobTemplates.js
@@ -87,6 +87,10 @@ class JobTemplates extends SchedulesMixin(
readWebhookKey(id) {
return this.http.get(`${this.baseUrl}${id}/webhook_key/`);
}
+
+ updateWebhookKey(id) {
+ return this.http.post(`${this.baseUrl}${id}/webhook_key/`);
+ }
}
export default JobTemplates;
diff --git a/awx/ui_next/src/components/Lookup/index.js b/awx/ui_next/src/components/Lookup/index.js
index cde48e2bcd..9321fb08e9 100644
--- a/awx/ui_next/src/components/Lookup/index.js
+++ b/awx/ui_next/src/components/Lookup/index.js
@@ -3,3 +3,4 @@ export { default as InstanceGroupsLookup } from './InstanceGroupsLookup';
export { default as InventoryLookup } from './InventoryLookup';
export { default as ProjectLookup } from './ProjectLookup';
export { default as MultiCredentialsLookup } from './MultiCredentialsLookup';
+export { default as CredentialLookup } from './CredentialLookup';
diff --git a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx
index 201fdb2b3c..0b959320fa 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx
@@ -3,7 +3,7 @@ import { useHistory } from 'react-router-dom';
import { Card, PageSection } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
import JobTemplateForm from '../shared/JobTemplateForm';
-import { JobTemplatesAPI } from '@api';
+import { JobTemplatesAPI, OrganizationsAPI } from '@api';
function JobTemplateAdd() {
const [formSubmitError, setFormSubmitError] = useState(null);
@@ -15,11 +15,13 @@ function JobTemplateAdd() {
instanceGroups,
initialInstanceGroups,
credentials,
+ webhook_credential,
...remainingValues
} = values;
setFormSubmitError(null);
remainingValues.project = remainingValues.project.id;
+ remainingValues.webhook_credential = webhook_credential?.id;
try {
const {
data: { id, type },
@@ -36,6 +38,16 @@ function JobTemplateAdd() {
}
async function submitLabels(templateId, labels = [], orgId) {
+ if (!orgId) {
+ try {
+ const {
+ data: { results },
+ } = await OrganizationsAPI.read();
+ orgId = results[0].id;
+ } catch (err) {
+ throw err;
+ }
+ }
const associationPromises = labels.map(label =>
JobTemplatesAPI.associateLabel(templateId, label, orgId)
);
diff --git a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx
index 504b815d87..dbc55e8580 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx
@@ -152,6 +152,10 @@ describe('', () => {
project: 2,
playbook: 'Baz',
inventory: 2,
+ webhook_credential: undefined,
+ webhook_key: '',
+ webhook_service: '',
+ webhook_url: '',
});
});
diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx
index 5b5c36cbcc..9848f53006 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx
@@ -100,11 +100,13 @@ class JobTemplateEdit extends Component {
instanceGroups,
initialInstanceGroups,
credentials,
+ webhook_credential,
...remainingValues
} = values;
this.setState({ formSubmitError: null });
remainingValues.project = values.project.id;
+ remainingValues.webhook_credential = webhook_credential?.id || null;
try {
await JobTemplatesAPI.update(template.id, remainingValues);
await Promise.all([
diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx
index 8c296b0630..8a8d7131fc 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx
@@ -62,6 +62,12 @@ const mockJobTemplate = {
type: 'job_template',
use_fact_cache: false,
verbosity: '0',
+ webhook_credential: null,
+ webhook_key: 'webhook Key',
+ webhook_service: 'gitlab',
+ related: {
+ webhook_receiver: '/api/v2/workflow_job_templates/57/gitlab/',
+ },
};
const mockRelatedCredentials = {
@@ -245,6 +251,8 @@ describe('', () => {
delete expected.summary_fields;
delete expected.id;
delete expected.type;
+ delete expected.related;
+ expected.webhook_url = `${window.location.origin}${mockJobTemplate.related.webhook_receiver}`;
expect(JobTemplatesAPI.update).toHaveBeenCalledWith(1, expected);
expect(JobTemplatesAPI.disassociateLabel).toHaveBeenCalledTimes(2);
expect(JobTemplatesAPI.associateLabel).toHaveBeenCalledTimes(4);
@@ -308,6 +316,12 @@ describe('', () => {
{ id: 1, kind: 'cloud', name: 'Foo' },
{ id: 2, kind: 'ssh', name: 'Bar' },
],
+ webhook_credential: {
+ id: 7,
+ name: 'webhook credential',
+ kind: 'github_token',
+ credential_type_id: 12,
+ },
},
};
await act(async () =>
diff --git a/awx/ui_next/src/screens/Template/Template.jsx b/awx/ui_next/src/screens/Template/Template.jsx
index c916500685..b95cf64b88 100644
--- a/awx/ui_next/src/screens/Template/Template.jsx
+++ b/awx/ui_next/src/screens/Template/Template.jsx
@@ -45,6 +45,12 @@ function Template({ i18n, me, setBreadcrumb }) {
role_level: 'notification_admin_role',
}),
]);
+ if (data?.related?.webhook_key) {
+ const {
+ data: { webhook_key },
+ } = await JobTemplatesAPI.readWebhookKey(templateId);
+ data.webhook_key = webhook_key;
+ }
setBreadcrumb(data);
return {
diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx
index 37e40bee9a..621b5d9e64 100644
--- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx
+++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx
@@ -40,6 +40,9 @@ import {
import { JobTemplatesAPI, ProjectsAPI } from '@api';
import LabelSelect from './LabelSelect';
import PlaybookSelect from './PlaybookSelect';
+import WebhookSubForm from './WebhooksSubForm';
+
+const { origin } = document.location;
function JobTemplateForm({
template,
@@ -59,6 +62,10 @@ function JobTemplateForm({
Boolean(template?.host_config_key)
);
+ const [enableWebhooks, setEnableWebhooks] = useState(
+ Boolean(template.webhook_service)
+ );
+
const { values: formikValues } = useFormikContext();
const [jobTypeField, jobTypeMeta, jobTypeHelpers] = useField({
name: 'job_type',
@@ -87,6 +94,10 @@ function JobTemplateForm({
);
const [jobTagsField, , jobTagsHelpers] = useField('job_tags');
const [skipTagsField, , skipTagsHelpers] = useField('skip_tags');
+ const webhookService = useField('webhook_service');
+ const webhookUrl = useField('webhook_url');
+ const webhookKey = useField('webhook_key');
+ const webhookCredential = useField('webhook_credential');
const {
request: fetchProject,
@@ -174,16 +185,23 @@ function JobTemplateForm({
];
let callbackUrl;
if (template?.related) {
- const { origin } = document.location;
const path = template.related.callback || `${template.url}callback`;
callbackUrl = `${origin}${path}`;
}
- if (instanceGroupLoading || hasProjectLoading) {
+ if (
+ instanceGroupLoading ||
+ hasProjectLoading
+ // credentialContentLoading
+ ) {
return ;
}
- if (instanceGroupError || projectContentError) {
+ if (
+ instanceGroupError ||
+ projectContentError
+ // credentialContentError
+ ) {
return ;
}
@@ -498,6 +516,39 @@ function JobTemplateForm({
setAllowCallbacks(checked);
}}
/>
+
+ {i18n._(t`Enable Webhook`)}
+
+
+
+ }
+ id="wfjt-enabled-webhooks"
+ isChecked={
+ Boolean(webhookService[0].value) || enableWebhooks
+ }
+ onChange={checked => {
+ setEnableWebhooks(checked);
+ webhookService[2].setValue(
+ !checked ? '' : webhookService[1].initialValue
+ );
+ webhookUrl[2].setValue(
+ !checked ? '' : webhookUrl[1].initialValue
+ );
+ webhookKey[2].setValue(
+ !checked ? '' : webhookKey[1].initialValue
+ );
+ webhookCredential[2].setValue(
+ !checked ? null : webhookCredential[1].initialValue
+ );
+ }}
+ />
+
{allowCallbacks && (
<>
{callbackUrl && (
@@ -616,6 +668,12 @@ const FormikApp = withFormik({
instanceGroups: [],
credentials: summary_fields.credentials || [],
extra_vars: template.extra_vars || '---\n',
+ webhook_service: template.webhook_service || '',
+ webhook_url: template?.related?.webhook_receiver
+ ? `${origin}${template.related.webhook_receiver}`
+ : '',
+ webhook_key: template.webhook_key || '',
+ webhook_credential: template?.summary_fields?.webhook_credential || null,
};
},
handleSubmit: async (values, { props, setErrors }) => {
diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx
index 383bef39f0..7a52aa16e2 100644
--- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx
+++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx
@@ -2,6 +2,8 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils';
+import { Route } from 'react-router-dom';
+import { createMemoryHistory } from 'history';
import JobTemplateForm from './JobTemplateForm';
import { LabelsAPI, JobTemplatesAPI, ProjectsAPI, CredentialsAPI } from '@api';
@@ -34,6 +36,10 @@ describe('', () => {
{ id: 2, kind: 'ssh', name: 'Bar' },
],
},
+ related: { webhook_receiver: '/api/v2/workflow_job_templates/57/gitlab/' },
+ webhook_key: 'webhook key',
+ webhook_service: 'github',
+ webhook_credential: 7,
};
const mockInstanceGroups = [
{
@@ -86,6 +92,9 @@ describe('', () => {
JobTemplatesAPI.readInstanceGroups.mockReturnValue({
data: { results: mockInstanceGroups },
});
+ JobTemplatesAPI.updateWebhookKey.mockReturnValue({
+ data: { webhook_key: 'webhook key' },
+ });
ProjectsAPI.readPlaybooks.mockReturnValue({
data: ['debug.yml'],
});
@@ -209,6 +218,80 @@ describe('', () => {
]);
});
+ test('webhooks and enable concurrent jobs functions properly', async () => {
+ let wrapper;
+ const history = createMemoryHistory({
+ initialEntries: ['/templates/job_template/1/edit'],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+ (
+
+ )}
+ />,
+ {
+ context: {
+ router: {
+ history,
+ route: {
+ location: history.location,
+ match: { params: { id: 1 } },
+ },
+ },
+ },
+ }
+ );
+ });
+ 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('webhook key');
+ await act(() =>
+ wrapper.find('Button[aria-label="Update webhook key"]').prop('onClick')()
+ );
+ expect(JobTemplatesAPI.updateWebhookKey).toBeCalledWith('1');
+ expect(
+ wrapper.find('TextInputBase[aria-label="Webhook URL"]').prop('value')
+ ).toContain('/api/v2/workflow_job_templates/57/gitlab/');
+
+ wrapper.update();
+
+ expect(wrapper.find('FormGroup[name="webhook_service"]').length).toBe(1);
+
+ await act(async () =>
+ wrapper.find('AnsibleSelect#webhook_service').prop('onChange')(
+ {},
+ 'gitlab'
+ )
+ );
+ wrapper.update();
+
+ expect(wrapper.find('AnsibleSelect#webhook_service').prop('value')).toBe(
+ 'gitlab'
+ );
+ });
+
test('should call handleSubmit when Submit button is clicked', async () => {
const handleSubmit = jest.fn();
let wrapper;
diff --git a/awx/ui_next/src/screens/Template/shared/WebhooksSubForm.jsx b/awx/ui_next/src/screens/Template/shared/WebhooksSubForm.jsx
new file mode 100644
index 0000000000..6430fc1f6a
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/shared/WebhooksSubForm.jsx
@@ -0,0 +1,207 @@
+import React, { useEffect, useCallback, useState } from 'react';
+import { SyncAltIcon } from '@patternfly/react-icons';
+import { useParams, useRouteMatch } from 'react-router-dom';
+import { t } from '@lingui/macro';
+import { withI18n } from '@lingui/react';
+import {
+ FormGroup,
+ TextInput,
+ InputGroup,
+ Button,
+} from '@patternfly/react-core';
+import ContentError from '@components/ContentError';
+import ContentLoading from '@components/ContentLoading';
+import useRequest from '@util/useRequest';
+import { useField } from 'formik';
+import { FormColumnLayout } from '@components/FormLayout';
+import { CredentialLookup } from '@components/Lookup';
+import AnsibleSelect from '@components/AnsibleSelect';
+import { FieldTooltip } from '@components/FormField';
+import { JobTemplatesAPI, CredentialTypesAPI } from '@api';
+
+function WebhookSubForm({ i18n, enableWebhooks }) {
+ const [contentError, setContentError] = useState(null);
+ const jtAddMatch = useRouteMatch('/templates/job_template/add');
+ const { id } = useParams();
+
+ const { origin } = document.location;
+
+ const [
+ webhookServiceField,
+ webhookServiceMeta,
+ webhookServiceHelpers,
+ ] = useField('webhook_service');
+
+ const [webhookUrlField, , webhookUrlHelpers] = useField('webhook_url');
+ const [webhookKeyField, webhookKeyMeta, webhookKeyHelpers] = useField(
+ 'webhook_key'
+ );
+ const [
+ webhookCredentialField,
+ webhookCredentialMeta,
+ webhookCredentialHelpers,
+ ] = useField('webhook_credential');
+
+ const {
+ request: loadCredentialType,
+ error,
+ isLoading,
+ result: credTypeId,
+ } = useRequest(
+ useCallback(async () => {
+ let results;
+ if (webhookServiceField.value) {
+ results = await CredentialTypesAPI.read({
+ namespace: `${webhookServiceField.value}_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;
+ }, [webhookServiceField.value])
+ );
+
+ useEffect(() => {
+ loadCredentialType();
+ }, [loadCredentialType]);
+
+ const changeWebhookKey = async () => {
+ try {
+ const {
+ data: { webhook_key: key },
+ } = await JobTemplatesAPI.updateWebhookKey(id);
+ webhookKeyHelpers.setValue(key);
+ } catch (err) {
+ setContentError(err);
+ }
+ };
+
+ 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,
+ },
+ ];
+
+ if (error || contentError) {
+ return ;
+ }
+ if (isLoading) {
+ return ;
+ }
+ return (
+ enableWebhooks && (
+
+
+
+ {
+ webhookServiceHelpers.setValue(val);
+ webhookUrlHelpers.setValue(
+ `${origin}/api/v2/job_templates/${id}/${val}/`
+ );
+ if (val === webhookServiceMeta.initialValue || val === '') {
+ webhookKeyHelpers.setValue(webhookKeyMeta.initialValue);
+ webhookCredentialHelpers.setValue(
+ webhookCredentialMeta.initialValue
+ );
+ } else {
+ webhookKeyHelpers.setValue(
+ i18n
+ ._(t`a new webhook key will be generated on save.`)
+ .toUpperCase()
+ );
+ webhookCredentialHelpers.setValue(null);
+ }
+ }}
+ />
+
+ {!jtAddMatch && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ {credTypeId && (
+ {
+ webhookCredentialHelpers.setValue(value || null);
+ }}
+ isValid={!webhookCredentialMeta.error}
+ helperTextInvalid={webhookCredentialMeta.error}
+ value={webhookCredentialField.value}
+ />
+ )}
+
+ )
+ );
+}
+export default withI18n()(WebhookSubForm);