Merge pull request #6654 from AlexSCorey/4962-EnableWebhooksForJT

Adds webhooks to Job template form

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-04-20 20:11:29 +00:00 committed by GitHub
commit e9b254b9d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 562 additions and 3 deletions

View File

@ -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;

View File

@ -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';

View File

@ -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)
);

View File

@ -152,6 +152,10 @@ describe('<JobTemplateAdd />', () => {
project: 2,
playbook: 'Baz',
inventory: 2,
webhook_credential: undefined,
webhook_key: '',
webhook_service: '',
webhook_url: '',
});
});

View File

@ -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([

View File

@ -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 () =>

View File

@ -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 {

View File

@ -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 }) => {

View File

@ -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;

View File

@ -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);

View File

@ -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);
});
});