Commit 9e69466a authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch '119018-PD-integration-settings' into 'master'

Add PagerDuty integration settings form

See merge request gitlab-org/gitlab!36719
parents f0aa0034 4de06db6
......@@ -9,15 +9,11 @@ import {
GlNewDropdown,
GlNewDropdownItem,
} from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import createFlash from '~/flash';
import {
I18N_ALERT_SETTINGS_FORM,
NO_ISSUE_TEMPLATE_SELECTED,
TAKING_INCIDENT_ACTION_DOCS_LINK,
ISSUE_TEMPLATES_DOCS_LINK,
ERROR_MSG,
} from '../constants';
export default {
......@@ -31,7 +27,7 @@ export default {
GlNewDropdown,
GlNewDropdownItem,
},
inject: ['alertSettings', 'operationsSettingsEndpoint'],
inject: ['service', 'alertSettings'],
data() {
return {
templates: [NO_ISSUE_TEMPLATE_SELECTED, ...this.alertSettings.templates],
......@@ -65,23 +61,10 @@ export default {
},
updateAlertsIntegrationSettings() {
this.loading = true;
return axios
.patch(this.operationsSettingsEndpoint, {
project: {
incident_management_setting_attributes: this.formData,
},
})
.then(() => {
refreshCurrentPage();
})
.catch(({ response }) => {
const message = response?.data?.message || '';
createFlash(`${ERROR_MSG} ${message}`, 'alert');
})
.finally(() => {
this.loading = false;
});
this.service.updateSettings(this.formData).catch(() => {
this.loading = false;
});
},
},
};
......
<script>
import { GlButton, GlTabs, GlTab } from '@gitlab/ui';
import AlertsSettingsForm from './alerts_form.vue';
import PagerDutySettingsForm from './pagerduty_form.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { INTEGRATION_TABS_CONFIG, I18N_INTEGRATION_TABS } from '../constants';
export default {
......@@ -9,9 +11,19 @@ export default {
GlTabs,
GlTab,
AlertsSettingsForm,
PagerDutySettingsForm,
},
mixins: [glFeatureFlagMixin()],
tabs: INTEGRATION_TABS_CONFIG,
i18n: I18N_INTEGRATION_TABS,
methods: {
isFeatureFlagEnabled(tab) {
if (tab.featureFlag) {
return this.glFeatures[tab.featureFlag];
}
return true;
},
},
};
</script>
......@@ -37,7 +49,7 @@ export default {
<gl-tabs>
<gl-tab
v-for="(tab, index) in $options.tabs"
v-if="tab.active"
v-if="tab.active && isFeatureFlagEnabled(tab)"
:key="`${tab.title}_${index}`"
:title="tab.title"
>
......
<script>
import {
GlAlert,
GlButton,
GlSprintf,
GlLink,
GlIcon,
GlFormGroup,
GlFormInputGroup,
GlToggle,
GlModal,
GlModalDirective,
} from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { I18N_PAGERDUTY_SETTINGS_FORM, CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK } from '../constants';
import { isEqual } from 'lodash';
export default {
components: {
GlAlert,
GlButton,
GlSprintf,
GlLink,
GlIcon,
GlFormGroup,
GlFormInputGroup,
GlToggle,
GlModal,
ClipboardButton,
},
directives: {
'gl-modal': GlModalDirective,
},
inject: ['service', 'pagerDutySettings'],
data() {
return {
active: this.pagerDutySettings.active,
webhookUrl: this.pagerDutySettings.webhookUrl,
loading: false,
resettingWebhook: false,
webhookUpdateFailed: false,
showAlert: false,
};
},
i18n: I18N_PAGERDUTY_SETTINGS_FORM,
CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK,
computed: {
formData() {
return {
pagerduty_active: this.active,
};
},
isFormUpdated() {
return isEqual(this.pagerDutySettings, {
active: this.active,
webhookUrl: this.webhookUrl,
});
},
isSaveDisabled() {
return this.isFormUpdated || this.loading || this.resettingWebhook;
},
webhookUpdateAlertMsg() {
return this.webhookUpdateFailed
? this.$options.i18n.webhookUrl.updateErrMsg
: this.$options.i18n.webhookUrl.updateSuccessMsg;
},
webhookUpdateAlertVariant() {
return this.webhookUpdateFailed ? 'danger' : 'success';
},
},
methods: {
updatePagerDutyIntegrationSettings() {
this.loading = true;
this.service.updateSettings(this.formData).catch(() => {
this.loading = false;
});
},
resetWebhookUrl() {
this.resettingWebhook = true;
this.service
.resetWebhookUrl()
.then(({ data: { pagerduty_webhook_url: url } }) => {
this.webhookUrl = url;
this.showAlert = true;
this.webhookUpdateFailed = false;
})
.catch(() => {
this.showAlert = true;
this.webhookUpdateFailed = true;
})
.finally(() => {
this.resettingWebhook = false;
});
},
},
};
</script>
<template>
<div>
<gl-alert
v-if="showAlert"
class="gl-mb-3"
:variant="webhookUpdateAlertVariant"
@dismiss="showAlert = false"
>
{{ webhookUpdateAlertMsg }}
</gl-alert>
<p>{{ $options.i18n.introText }}</p>
<form ref="settingsForm" @submit.prevent="updatePagerDutyIntegrationSettings">
<gl-form-group class="col-8 col-md-9 gl-p-0">
<gl-toggle
id="active"
v-model="active"
:is-loading="loading"
:label="$options.i18n.activeToggle.label"
/>
</gl-form-group>
<gl-form-group
class="col-8 col-md-9 gl-p-0"
:label="$options.i18n.webhookUrl.label"
label-for="url"
label-class="label-bold"
>
<gl-form-input-group id="url" data-testid="webhook-url" readonly :value="webhookUrl">
<template #append>
<clipboard-button
:text="webhookUrl"
:title="$options.i18n.webhookUrl.copyToClipboard"
/>
</template>
</gl-form-input-group>
<div class="gl-text-gray-400 gl-pt-2">
<gl-sprintf :message="$options.i18n.webhookUrl.helpText">
<template #docsLink>
<gl-link
:href="$options.CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK"
target="_blank"
class="gl-display-inline-flex"
>
<span>{{ $options.i18n.webhookUrl.helpDocsLink }}</span>
<gl-icon name="external-link" />
</gl-link>
</template>
</gl-sprintf>
</div>
<gl-button
v-gl-modal.resetWebhookModal
class="gl-mt-3"
:disabled="loading"
:loading="resettingWebhook"
data-testid="webhook-reset-btn"
>
{{ $options.i18n.webhookUrl.resetWebhookUrl }}
</gl-button>
<gl-modal
modal-id="resetWebhookModal"
:title="$options.i18n.webhookUrl.resetWebhookUrl"
:ok-title="$options.i18n.webhookUrl.resetWebhookUrl"
ok-variant="danger"
@ok="resetWebhookUrl"
>
{{ $options.i18n.webhookUrl.restKeyInfo }}
</gl-modal>
</gl-form-group>
<gl-button
ref="submitBtn"
:disabled="isSaveDisabled"
variant="success"
type="submit"
class="js-no-auto-disable"
>
{{ $options.i18n.saveBtnLabel }}
</gl-button>
</form>
</div>
</template>
import { __, s__ } from '~/locale';
/* Integration tabs constants */
export const INTEGRATION_TABS_CONFIG = [
{
title: s__('IncidentSettings|Alert integration'),
......@@ -8,8 +9,9 @@ export const INTEGRATION_TABS_CONFIG = [
},
{
title: s__('IncidentSettings|PagerDuty integration'),
component: '',
active: false,
component: 'PagerDutySettingsForm',
active: true,
featureFlag: 'pagerdutyWebhook',
},
{
title: s__('IncidentSettings|Grafana integration'),
......@@ -21,12 +23,13 @@ export const INTEGRATION_TABS_CONFIG = [
export const I18N_INTEGRATION_TABS = {
headerText: s__('IncidentSettings|Incidents'),
expandBtnLabel: __('Expand'),
saveBtnLabel: __('Save changes'),
subHeaderText: s__(
'IncidentSettings|Set up integrations with external tools to help better manage incidents.',
),
};
/* Alerts integration settings constants */
export const I18N_ALERT_SETTINGS_FORM = {
saveBtnLabel: __('Save changes'),
introText: __('Action to take when receiving an alert. %{docsLink}'),
......@@ -48,4 +51,33 @@ export const TAKING_INCIDENT_ACTION_DOCS_LINK =
export const ISSUE_TEMPLATES_DOCS_LINK =
'/help/user/project/description_templates#creating-issue-templates';
/* PagerDuty integration settings constants */
export const I18N_PAGERDUTY_SETTINGS_FORM = {
introText: s__(
'PagerDutySettings|Setting up a webhook with PagerDuty will automatically create a GitLab issue for each PagerDuty incident.',
),
activeToggle: {
label: s__('PagerDutySettings|Active'),
},
webhookUrl: {
label: s__('PagerDutySettings|Webhook URL'),
helpText: s__(
'PagerDutySettings|Create a GitLab issue for each PagerDuty incident by %{docsLink}',
),
helpDocsLink: s__('PagerDutySettings|configuring a webhook in PagerDuty'),
resetWebhookUrl: s__('PagerDutySettings|Reset webhook URL'),
copyToClipboard: __('Copy'),
updateErrMsg: s__('PagerDutySettings|Failed to update Webhook URL'),
updateSuccessMsg: s__('PagerDutySettings|Webhook URL update was successful'),
restKeyInfo: s__(
"PagerDutySettings|Resetting the webhook URL for this project will require updating this integration's settings in PagerDuty.",
),
},
saveBtnLabel: __('Save changes'),
};
export const CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK = 'https://support.pagerduty.com/docs/webhooks';
/* common constants */
export const ERROR_MSG = __('There was an error saving your changes.');
import axios from '~/lib/utils/axios_utils';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import createFlash from '~/flash';
import { ERROR_MSG } from './constants';
export default class IncidentsSettingsService {
constructor(settingsEndpoint, webhookUpdateEndpoint) {
this.settingsEndpoint = settingsEndpoint;
this.webhookUpdateEndpoint = webhookUpdateEndpoint;
}
updateSettings(data) {
return axios
.patch(this.settingsEndpoint, {
project: {
incident_management_setting_attributes: data,
},
})
.then(() => {
refreshCurrentPage();
})
.catch(({ response }) => {
const message = response?.data?.message || '';
createFlash(`${ERROR_MSG} ${message}`, 'alert');
});
}
resetWebhookUrl() {
return axios.post(this.webhookUpdateEndpoint);
}
}
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import SettingsTabs from './components/incidents_settings_tabs.vue';
import IncidentsSettingsService from './incidents_settings_service';
export default () => {
const el = document.querySelector('.js-incidents-settings');
......@@ -10,19 +11,33 @@ export default () => {
}
const {
dataset: { operationsSettingsEndpoint, templates, createIssue, issueTemplateKey, sendEmail },
dataset: {
operationsSettingsEndpoint,
templates,
createIssue,
issueTemplateKey,
sendEmail,
pagerdutyActive,
pagerdutyWebhookUrl,
pagerdutyResetKeyPath,
},
} = el;
const service = new IncidentsSettingsService(operationsSettingsEndpoint, pagerdutyResetKeyPath);
return new Vue({
el,
provide: {
operationsSettingsEndpoint,
service,
alertSettings: {
templates: JSON.parse(templates),
createIssue: parseBoolean(createIssue),
issueTemplateKey,
sendEmail: parseBoolean(sendEmail),
},
pagerDutySettings: {
active: parseBoolean(pagerdutyActive),
webhookUrl: pagerdutyWebhookUrl,
},
},
render(createElement) {
return createElement(SettingsTabs);
......
......@@ -16690,6 +16690,33 @@ msgstr ""
msgid "Page was successfully deleted"
msgstr ""
msgid "PagerDutySettings|Active"
msgstr ""
msgid "PagerDutySettings|Create a GitLab issue for each PagerDuty incident by %{docsLink}"
msgstr ""
msgid "PagerDutySettings|Failed to update Webhook URL"
msgstr ""
msgid "PagerDutySettings|Reset webhook URL"
msgstr ""
msgid "PagerDutySettings|Resetting the webhook URL for this project will require updating this integration's settings in PagerDuty."
msgstr ""
msgid "PagerDutySettings|Setting up a webhook with PagerDuty will automatically create a GitLab issue for each PagerDuty incident."
msgstr ""
msgid "PagerDutySettings|Webhook URL"
msgstr ""
msgid "PagerDutySettings|Webhook URL update was successful"
msgstr ""
msgid "PagerDutySettings|configuring a webhook in PagerDuty"
msgstr ""
msgid "Pages"
msgstr ""
......
......@@ -48,7 +48,14 @@ exports[`IncidentsSettingTabs should render the component 1`] = `
data-testid="AlertsSettingsForm-tab"
/>
</gl-tab-stub>
<!---->
<gl-tab-stub
title="PagerDuty integration"
>
<pagerdutysettingsform-stub
class="gl-pt-3"
data-testid="PagerDutySettingsForm-tab"
/>
</gl-tab-stub>
<!---->
</gl-tabs-stub>
</div>
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Alert integration settings form should match the default snapshot 1`] = `
<div>
<!---->
<p>
Setting up a webhook with PagerDuty will automatically create a GitLab issue for each PagerDuty incident.
</p>
<form>
<gl-form-group-stub
class="col-8 col-md-9 gl-p-0"
>
<gl-toggle-stub
id="active"
label="Active"
labelposition="top"
value="true"
/>
</gl-form-group-stub>
<gl-form-group-stub
class="col-8 col-md-9 gl-p-0"
label="Webhook URL"
label-class="label-bold"
label-for="url"
>
<gl-form-input-group-stub
data-testid="webhook-url"
id="url"
predefinedoptions="[object Object]"
readonly=""
value="pagerduty.webhook.com"
/>
<div
class="gl-text-gray-400 gl-pt-2"
>
<gl-sprintf-stub
message="Create a GitLab issue for each PagerDuty incident by %{docsLink}"
/>
</div>
<gl-button-stub
category="tertiary"
class="gl-mt-3"
data-testid="webhook-reset-btn"
icon=""
role="button"
size="medium"
tabindex="0"
variant="default"
>
Reset webhook URL
</gl-button-stub>
<gl-modal-stub
modalclass=""
modalid="resetWebhookModal"
ok-title="Reset webhook URL"
ok-variant="danger"
size="md"
title="Reset webhook URL"
titletag="h4"
>
Resetting the webhook URL for this project will require updating this integration's settings in PagerDuty.
</gl-modal-stub>
</gl-form-group-stub>
<gl-button-stub
category="tertiary"
class="js-no-auto-disable"
icon=""
size="medium"
type="submit"
variant="success"
>
Save changes
</gl-button-stub>
</form>
</div>
`;
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import AlertsSettingsForm from '~/incidents_settings/components/alerts_form.vue';
import { ERROR_MSG } from '~/incidents_settings/constants';
import createFlash from '~/flash';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import waitForPromises from 'helpers/wait_for_promises';
jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility');
describe('Alert integration settings form', () => {
let wrapper;
const service = { updateSettings: jest.fn().mockResolvedValue() };
const findForm = () => wrapper.find({ ref: 'settingsForm' });
beforeEach(() => {
wrapper = shallowMount(AlertsSettingsForm, {
provide: {
operationsSettingsEndpoint: 'operations/endpoint',
service,
alertSettings: {
issueTemplateKey: 'selecte_tmpl',
createIssue: true,
......@@ -32,6 +24,7 @@ describe('Alert integration settings form', () => {
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
......@@ -42,30 +35,15 @@ describe('Alert integration settings form', () => {
});
describe('form', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
it('should refresh the page on successful submit', () => {
mock.onPatch().reply(200);
findForm().trigger('submit');
return waitForPromises().then(() => {
expect(refreshCurrentPage).toHaveBeenCalled();
});
});
it('should display a flah message on unsuccessful submit', () => {
mock.onPatch().reply(400);
it('should call service `updateSettings` on submit', () => {
findForm().trigger('submit');
return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalledWith(expect.stringContaining(ERROR_MSG), 'alert');
});
expect(service.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
create_issue: wrapper.vm.createIssueEnabled,
issue_template_key: wrapper.vm.issueTemplate,
send_email: wrapper.vm.sendEmailEnabled,
}),
);
});
});
});
import axios from '~/lib/utils/axios_utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import httpStatusCodes from '~/lib/utils/http_status';
import IncidentsSettingsService from '~/incidents_settings/incidents_settings_service';
import { ERROR_MSG } from '~/incidents_settings/constants';
import createFlash from '~/flash';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility');
describe('IncidentsSettingsService', () => {
const settingsEndpoint = 'operations/settings';
const webhookUpdateEndpoint = 'webhook/update';
let mock;
let service;
beforeEach(() => {
mock = new AxiosMockAdapter(axios);
service = new IncidentsSettingsService(settingsEndpoint, webhookUpdateEndpoint);
});
afterEach(() => {
mock.restore();
});
describe('updateSettings', () => {
it('should refresh the page on successful update', () => {
mock.onPatch().reply(httpStatusCodes.OK);
return service.updateSettings({}).then(() => {
expect(refreshCurrentPage).toHaveBeenCalled();
});
});
it('should display a flash message on update error', () => {
mock.onPatch().reply(httpStatusCodes.BAD_REQUEST);
return service.updateSettings({}).then(() => {
expect(createFlash).toHaveBeenCalledWith(expect.stringContaining(ERROR_MSG), 'alert');
});
});
});
describe('resetWebhookUrl', () => {
it('should make a call for webhook update', () => {
jest.spyOn(axios, 'post');
mock.onPost().reply(httpStatusCodes.OK);
return service.resetWebhookUrl().then(() => {
expect(axios.post).toHaveBeenCalledWith(webhookUpdateEndpoint);
});
});
});
});
......@@ -6,7 +6,9 @@ describe('IncidentsSettingTabs', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(IncidentsSettingTabs);
wrapper = shallowMount(IncidentsSettingTabs, {
provide: { glFeatures: { pagerdutyWebhook: true } },
});
});
afterEach(() => {
......
import { shallowMount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import PagerDutySettingsForm from '~/incidents_settings/components/pagerduty_form.vue';
import { GlAlert, GlModal } from '@gitlab/ui';
describe('Alert integration settings form', () => {
let wrapper;
const resetWebhookUrl = jest.fn();
const service = { updateSettings: jest.fn().mockResolvedValue(), resetWebhookUrl };
const findForm = () => wrapper.find({ ref: 'settingsForm' });
const findWebhookInput = () => wrapper.find('[data-testid="webhook-url"]');
const findModal = () => wrapper.find(GlModal);
const findAlert = () => wrapper.find(GlAlert);
beforeEach(() => {
wrapper = shallowMount(PagerDutySettingsForm, {
provide: {
service,
pagerDutySettings: {
active: true,
webhookUrl: 'pagerduty.webhook.com',
webhookUpdateEndpoint: 'webhook/update',
},
},
});
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
it('should match the default snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('should call service `updateSettings` on form submit', () => {
findForm().trigger('submit');
expect(service.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({ pagerduty_active: wrapper.vm.active }),
);
});
describe('Webhook reset', () => {
it('should make a call for webhook reset and reset form values', async () => {
const newWebhookUrl = 'new.webhook.url?token=token';
resetWebhookUrl.mockResolvedValueOnce({
data: { pagerduty_webhook_url: newWebhookUrl },
});
findModal().vm.$emit('ok');
await waitForPromises();
expect(resetWebhookUrl).toHaveBeenCalled();
expect(findWebhookInput().attributes('value')).toBe(newWebhookUrl);
expect(findAlert().attributes('variant')).toBe('success');
});
it('should show error message and NOT reset webhook url', async () => {
resetWebhookUrl.mockRejectedValueOnce();
findModal().vm.$emit('ok');
await waitForPromises();
expect(findAlert().attributes('variant')).toBe('danger');
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment