summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsoftwarefactory-project-zuul[bot] <33884098+softwarefactory-project-zuul[bot]@users.noreply.github.com>2020-04-20 22:11:29 +0200
committerGitHub <noreply@github.com>2020-04-20 22:11:29 +0200
commite9b254b9d20eee415a85b1c402bda639def01c3c (patch)
treef1ce5a62164f1faf08c0adad5b4cf0b08b8d56ed
parentMerge pull request #6752 from fherbert/job_template_notification (diff)
parentadds test for new webhook component (diff)
downloadawx-e9b254b9d20eee415a85b1c402bda639def01c3c.tar.xz
awx-e9b254b9d20eee415a85b1c402bda639def01c3c.zip
Merge pull request #6654 from AlexSCorey/4962-EnableWebhooksForJT
Adds webhooks to Job template form Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
-rw-r--r--awx/ui_next/src/api/models/JobTemplates.js4
-rw-r--r--awx/ui_next/src/components/Lookup/index.js1
-rw-r--r--awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx14
-rw-r--r--awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx4
-rw-r--r--awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx2
-rw-r--r--awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx14
-rw-r--r--awx/ui_next/src/screens/Template/Template.jsx6
-rw-r--r--awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx38
-rw-r--r--awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx126
-rw-r--r--awx/ui_next/src/screens/Template/shared/WebhookSubForm.jsx232
-rw-r--r--awx/ui_next/src/screens/Template/shared/WebhookSubForm.test.jsx124
11 files changed, 562 insertions, 3 deletions
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('<JobTemplateAdd />', () => {
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('<JobTemplateEdit />', () => {
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('<JobTemplateEdit />', () => {
{ 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..80e4df8539 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.webhook_service && 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..0e96b8995d 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 './WebhookSubForm';
+
+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',
@@ -174,7 +181,6 @@ function JobTemplateForm({
];
let callbackUrl;
if (template?.related) {
- const { origin } = document.location;
const path = template.related.callback || `${template.url}callback`;
callbackUrl = `${origin}${path}`;
}
@@ -498,6 +504,25 @@ function JobTemplateForm({
setAllowCallbacks(checked);
}}
/>
+ <Checkbox
+ aria-label={i18n._(t`Enable Webhook`)}
+ label={
+ <span>
+ {i18n._(t`Enable Webhook`)}
+ &nbsp;
+ <FieldTooltip
+ content={i18n._(
+ t`Enable webhook for this workflow job template.`
+ )}
+ />
+ </span>
+ }
+ id="wfjt-enabled-webhooks"
+ isChecked={enableWebhooks}
+ onChange={checked => {
+ setEnableWebhooks(checked);
+ }}
+ />
<CheckboxField
id="option-concurrent"
name="allow_simultaneous"
@@ -516,6 +541,7 @@ function JobTemplateForm({
</FormCheckboxLayout>
</FormGroup>
</FormFullWidthLayout>
+ <WebhookSubForm enableWebhooks={enableWebhooks} />
{allowCallbacks && (
<>
{callbackUrl && (
@@ -572,7 +598,7 @@ JobTemplateForm.defaultProps = {
};
const FormikApp = withFormik({
- mapPropsToValues({ template = {} }) {
+ mapPropsToValues({ template = {}, i18n }) {
const {
summary_fields = {
labels: { results: [] },
@@ -616,6 +642,14 @@ 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}`
+ : i18n._(t`a new webhook url will be generated on save.`).toUpperCase(),
+ webhook_key:
+ template.webhook_key ||
+ i18n._(t`a new webhook key will be generated on save.`).toUpperCase(),
+ 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..bb2d8be6e2 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('<JobTemplateForm />', () => {
{ 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('<JobTemplateForm />', () => {
JobTemplatesAPI.readInstanceGroups.mockReturnValue({
data: { results: mockInstanceGroups },
});
+ JobTemplatesAPI.updateWebhookKey.mockReturnValue({
+ data: { webhook_key: 'webhook key' },
+ });
ProjectsAPI.readPlaybooks.mockReturnValue({
data: ['debug.yml'],
});
@@ -209,6 +218,123 @@ describe('<JobTemplateForm />', () => {
]);
});
+ 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(
+ <Route
+ path="/templates/job_template/:id/edit"
+ component={() => (
+ <JobTemplateForm
+ template={mockData}
+ handleSubmit={jest.fn()}
+ handleCancel={jest.fn()}
+ />
+ )}
+ />,
+ {
+ 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('webhooks should render properly, without data', async () => {
+ let wrapper;
+ const history = createMemoryHistory({
+ initialEntries: ['/templates/job_template/1/edit'],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <Route
+ path="/templates/job_template/:id/edit"
+ component={() => (
+ <JobTemplateForm
+ template={{
+ ...mockData,
+ webhook_credential: null,
+ webhook_key: '',
+ webhook_service: 'github',
+ related: { webhook_receiver: '' },
+ }}
+ handleSubmit={jest.fn()}
+ handleCancel={jest.fn()}
+ />
+ )}
+ />,
+ {
+ context: {
+ router: {
+ history,
+ route: {
+ location: history.location,
+ match: { params: { id: 1 } },
+ },
+ },
+ },
+ }
+ );
+ });
+ expect(
+ wrapper.find('TextInputBase#template-webhook_key').prop('value')
+ ).toBe('A NEW WEBHOOK KEY WILL BE GENERATED ON SAVE.');
+ expect(
+ wrapper.find('Button[aria-label="Update webhook key"]').prop('isDisabled')
+ ).toBe(true);
+ });
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/WebhookSubForm.jsx b/awx/ui_next/src/screens/Template/shared/WebhookSubForm.jsx
new file mode 100644
index 0000000000..7211990c90
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/shared/WebhookSubForm.jsx
@@ -0,0 +1,232 @@
+import React, { useEffect, useCallback } from 'react';
+import { SyncAltIcon } from '@patternfly/react-icons';
+import { useParams, useLocation } 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 { id, templateType } = useParams();
+ const { pathname } = useLocation();
+
+ const { origin } = document.location;
+
+ const [
+ webhookServiceField,
+ webhookServiceMeta,
+ webhookServiceHelpers,
+ ] = useField('webhook_service');
+
+ const [webhookUrlField, webhookUrlMeta, 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]);
+
+ useEffect(() => {
+ if (enableWebhooks) {
+ webhookServiceHelpers.setValue(webhookServiceMeta.initialValue);
+ webhookUrlHelpers.setValue(webhookUrlMeta.initialValue);
+ webhookKeyHelpers.setValue(webhookKeyMeta.initialValue);
+ webhookCredentialHelpers.setValue(webhookCredentialMeta.initialValue);
+ } else {
+ webhookServiceHelpers.setValue('');
+ webhookUrlHelpers.setValue('');
+ webhookKeyHelpers.setValue('');
+ webhookCredentialHelpers.setValue(null);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [enableWebhooks]);
+
+ const { request: fetchWebhookKey, error: webhookKeyError } = useRequest(
+ useCallback(async () => {
+ const {
+ data: { webhook_key: key },
+ } = await JobTemplatesAPI.updateWebhookKey(id);
+ webhookKeyHelpers.setValue(key);
+ }, [webhookKeyHelpers, id])
+ );
+
+ const changeWebhookKey = async () => {
+ await fetchWebhookKey();
+ };
+ const isUpdateKeyDisabled =
+ pathname.endsWith('/add') ||
+ webhookKeyMeta.initialValue ===
+ 'A NEW WEBHOOK KEY WILL BE GENERATED ON SAVE.';
+ 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 || webhookKeyError) {
+ return <ContentError error={error} />;
+ }
+ if (isLoading) {
+ return <ContentLoading />;
+ }
+ return (
+ enableWebhooks && (
+ <FormColumnLayout>
+ <FormGroup
+ name="webhook_service"
+ fieldId="webhook_service"
+ helperTextInvalid={webhookServiceMeta.error}
+ label={i18n._(t`Webhook Service`)}
+ >
+ <FieldTooltip content={i18n._(t`Select a webhook service.`)} />
+ <AnsibleSelect
+ {...webhookServiceField}
+ id="webhook_service"
+ data={webhookServiceOptions}
+ onChange={(event, val) => {
+ webhookServiceHelpers.setValue(val);
+ webhookUrlHelpers.setValue(
+ pathname.endsWith('/add')
+ ? i18n
+ ._(t`a new webhook url will be generated on save.`)
+ .toUpperCase()
+ : `${origin}/api/v2/${templateType}s/${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);
+ }
+ }}
+ />
+ </FormGroup>
+ <>
+ <FormGroup
+ type="text"
+ fieldId="jt-webhookURL"
+ label={i18n._(t`Webhook URL`)}
+ name="webhook_url"
+ >
+ <FieldTooltip
+ content={i18n._(
+ t`Webhook services can launch jobs with this workflow job template by making a POST request to this URL.`
+ )}
+ />
+ <TextInput
+ id="t-webhookURL"
+ aria-label={i18n._(t`Webhook URL`)}
+ value={webhookUrlField.value}
+ isReadOnly
+ />
+ </FormGroup>
+ <FormGroup
+ label={i18n._(t`Webhook Key`)}
+ fieldId="template-webhook_key"
+ >
+ <FieldTooltip
+ content={i18n._(
+ t`Webhook services can use this as a shared secret.`
+ )}
+ />
+ <InputGroup>
+ <TextInput
+ id="template-webhook_key"
+ isReadOnly
+ aria-label="wfjt-webhook-key"
+ value={webhookKeyField.value}
+ />
+ <Button
+ isDisabled={isUpdateKeyDisabled}
+ variant="tertiary"
+ aria-label={i18n._(t`Update webhook key`)}
+ onClick={changeWebhookKey}
+ >
+ <SyncAltIcon />
+ </Button>
+ </InputGroup>
+ </FormGroup>
+ </>
+
+ {credTypeId && (
+ <CredentialLookup
+ label={i18n._(t`Webhook Credential`)}
+ tooltip={i18n._(
+ t`Optionally select the credential to use to send status updates back to the webhook service.`
+ )}
+ credentialTypeId={credTypeId}
+ onChange={value => {
+ webhookCredentialHelpers.setValue(value || null);
+ }}
+ isValid={!webhookCredentialMeta.error}
+ helperTextInvalid={webhookCredentialMeta.error}
+ value={webhookCredentialField.value}
+ />
+ )}
+ </FormColumnLayout>
+ )
+ );
+}
+export default withI18n()(WebhookSubForm);
diff --git a/awx/ui_next/src/screens/Template/shared/WebhookSubForm.test.jsx b/awx/ui_next/src/screens/Template/shared/WebhookSubForm.test.jsx
new file mode 100644
index 0000000000..e23afb136b
--- /dev/null
+++ b/awx/ui_next/src/screens/Template/shared/WebhookSubForm.test.jsx
@@ -0,0 +1,124 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { Route } from 'react-router-dom';
+import { createMemoryHistory } from 'history';
+
+import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
+import { CredentialsAPI } from '@api';
+import { Formik } from 'formik';
+
+import WebhookSubForm from './WebhookSubForm';
+
+jest.mock('@api');
+
+describe('<WebhooksSubForm />', () => {
+ let wrapper;
+ let history;
+ const initialValues = {
+ webhook_url: '/api/v2/job_templates/51/github/',
+ webhook_credential: { id: 1, name: 'Github credential' },
+ webhook_service: 'github',
+ webhook_key: 'webhook key',
+ };
+ beforeEach(async () => {
+ history = createMemoryHistory({
+ initialEntries: ['templates/job_template/51/edit'],
+ });
+ CredentialsAPI.read.mockResolvedValue({
+ data: { results: [{ id: 12, name: 'Github credential' }] },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <Route path="templates/:templateType/:id/edit">
+ <Formik initialValues={initialValues}>
+ <WebhookSubForm enableWebhooks />
+ </Formik>
+ </Route>,
+ {
+ context: {
+ router: {
+ history,
+ route: {
+ location: { pathname: 'templates/job_template/51/edit' },
+ match: { params: { id: 51, templateType: 'job_template' } },
+ },
+ },
+ },
+ }
+ );
+ });
+ });
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+ test('mounts properly', () => {
+ expect(wrapper.length).toBe(1);
+ });
+ test('should render initial values properly', () => {
+ waitForElement(wrapper, 'Lookup__ChipHolder', el => el.lenth > 0);
+ expect(wrapper.find('AnsibleSelect').prop('value')).toBe('github');
+ expect(
+ wrapper.find('TextInputBase[aria-label="Webhook URL"]').prop('value')
+ ).toContain('/api/v2/job_templates/51/github/');
+ expect(
+ wrapper.find('TextInputBase[aria-label="wfjt-webhook-key"]').prop('value')
+ ).toBe('webhook key');
+ expect(
+ wrapper
+ .find('Chip')
+ .find('span')
+ .text()
+ ).toBe('Github credential');
+ });
+ test('should make other credential type available', async () => {
+ CredentialsAPI.read.mockResolvedValue({
+ data: { results: [{ id: 13, name: 'GitLab credential' }] },
+ });
+ await act(async () =>
+ wrapper.find('AnsibleSelect').prop('onChange')({}, 'gitlab')
+ );
+ expect(CredentialsAPI.read).toHaveBeenCalledWith({
+ namespace: 'gitlab_token',
+ });
+ wrapper.update();
+ expect(
+ wrapper.find('TextInputBase[aria-label="Webhook URL"]').prop('value')
+ ).toContain('/api/v2/job_templates/51/gitlab/');
+ expect(
+ wrapper.find('TextInputBase[aria-label="wfjt-webhook-key"]').prop('value')
+ ).toBe('A NEW WEBHOOK KEY WILL BE GENERATED ON SAVE.');
+ });
+ test('should have disabled button to update webhook key', async () => {
+ let newWrapper;
+ await act(async () => {
+ newWrapper = mountWithContexts(
+ <Route path="templates/:templateType/:id/edit">
+ <Formik
+ initialValues={{
+ ...initialValues,
+ webhook_key: 'A NEW WEBHOOK KEY WILL BE GENERATED ON SAVE.',
+ }}
+ >
+ <WebhookSubForm enableWebhooks />
+ </Formik>
+ </Route>,
+ {
+ context: {
+ router: {
+ history,
+ route: {
+ location: { pathname: 'templates/job_template/51/edit' },
+ match: { params: { id: 51, templateType: 'job_template' } },
+ },
+ },
+ },
+ }
+ );
+ });
+ expect(
+ newWrapper
+ .find("Button[aria-label='Update webhook key']")
+ .prop('isDisabled')
+ ).toBe(true);
+ });
+});