Commit fa158d24 authored by Illya Klymov's avatar Illya Klymov

Merge branch '217745-create-issue-from-alert' into 'master'

Create issue from alert

See merge request gitlab-org/gitlab!32193
parents 805c405a d66489e4
...@@ -20,6 +20,8 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; ...@@ -20,6 +20,8 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { ALERTS_SEVERITY_LABELS } from '../constants'; import { ALERTS_SEVERITY_LABELS } from '../constants';
import updateAlertStatus from '../graphql/mutations/update_alert_status.graphql'; import updateAlertStatus from '../graphql/mutations/update_alert_status.graphql';
import createIssueQuery from '../graphql/mutations/create_issue_from_alert.graphql';
import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
export default { export default {
statuses: { statuses: {
...@@ -60,7 +62,7 @@ export default { ...@@ -60,7 +62,7 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
newIssuePath: { projectIssuesPath: {
type: String, type: String,
required: true, required: true,
}, },
...@@ -85,7 +87,13 @@ export default { ...@@ -85,7 +87,13 @@ export default {
}, },
}, },
data() { data() {
return { alert: null, errored: false, isErrorDismissed: false }; return {
alert: null,
errored: false,
isErrorDismissed: false,
createIssueError: '',
issueCreationInProgress: false,
};
}, },
computed: { computed: {
loading() { loading() {
...@@ -122,6 +130,33 @@ export default { ...@@ -122,6 +130,33 @@ export default {
); );
}); });
}, },
createIssue() {
this.issueCreationInProgress = true;
this.$apollo
.mutate({
mutation: createIssueQuery,
variables: {
iid: this.alert.iid,
projectPath: this.projectPath,
},
})
.then(({ data: { createAlertIssue: { errors, issue } } }) => {
if (errors?.length) {
[this.createIssueError] = errors;
this.issueCreationInProgress = false;
} else if (issue) {
visitUrl(this.issuePath(issue.iid));
}
})
.catch(error => {
this.createIssueError = error;
this.issueCreationInProgress = false;
});
},
issuePath(issueId) {
return joinPaths(this.projectIssuesPath, issueId);
},
}, },
}; };
</script> </script>
...@@ -130,6 +165,14 @@ export default { ...@@ -130,6 +165,14 @@ export default {
<gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError"> <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError">
{{ $options.i18n.errorMsg }} {{ $options.i18n.errorMsg }}
</gl-alert> </gl-alert>
<gl-alert
v-if="createIssueError"
variant="danger"
data-testid="issueCreationError"
@dismiss="createIssueError = null"
>
{{ createIssueError }}
</gl-alert>
<div v-if="loading"><gl-loading-icon size="lg" class="gl-mt-5" /></div> <div v-if="loading"><gl-loading-icon size="lg" class="gl-mt-5" /></div>
<div v-if="alert" class="alert-management-details gl-relative"> <div v-if="alert" class="alert-management-details gl-relative">
<div <div
...@@ -158,17 +201,30 @@ export default { ...@@ -158,17 +201,30 @@ export default {
<template #tool>{{ alert.monitoringTool }}</template> <template #tool>{{ alert.monitoringTool }}</template>
</gl-sprintf> </gl-sprintf>
</div> </div>
<div v-if="glFeatures.alertManagementCreateAlertIssue">
<gl-button
v-if="alert.issueIid"
class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-issue-button"
data-testid="viewIssueBtn"
:href="issuePath(alert.issueIid)"
category="primary"
variant="success"
>
{{ s__('AlertManagement|View issue') }}
</gl-button>
<gl-button <gl-button
v-if="glFeatures.createIssueFromAlertEnabled" v-else
class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-create-issue-button" class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-issue-button"
data-testid="createIssueBtn" data-testid="createIssueBtn"
:href="newIssuePath" :loading="issueCreationInProgress"
category="primary" category="primary"
variant="success" variant="success"
@click="createIssue()"
> >
{{ s__('AlertManagement|Create issue') }} {{ s__('AlertManagement|Create issue') }}
</gl-button> </gl-button>
</div> </div>
</div>
<div <div
v-if="alert" v-if="alert"
class="gl-display-flex gl-justify-content-space-between gl-align-items-center" class="gl-display-flex gl-justify-content-space-between gl-align-items-center"
......
...@@ -8,7 +8,7 @@ Vue.use(VueApollo); ...@@ -8,7 +8,7 @@ Vue.use(VueApollo);
export default selector => { export default selector => {
const domEl = document.querySelector(selector); const domEl = document.querySelector(selector);
const { alertId, projectPath, newIssuePath } = domEl.dataset; const { alertId, projectPath, projectIssuesPath } = domEl.dataset;
const apolloProvider = new VueApollo({ const apolloProvider = new VueApollo({
defaultClient: createDefaultClient( defaultClient: createDefaultClient(
...@@ -39,7 +39,7 @@ export default selector => { ...@@ -39,7 +39,7 @@ export default selector => {
props: { props: {
alertId, alertId,
projectPath, projectPath,
newIssuePath, projectIssuesPath,
}, },
}); });
}, },
......
...@@ -6,4 +6,5 @@ fragment AlertListItem on AlertManagementAlert { ...@@ -6,4 +6,5 @@ fragment AlertListItem on AlertManagementAlert {
startedAt startedAt
endedAt endedAt
eventCount eventCount
issueIid
} }
mutation ($projectPath: ID!, $iid: String!) {
createAlertIssue(input: { iid: $iid, projectPath: $projectPath }) {
errors
issue {
iid
}
}
}
...@@ -35,7 +35,7 @@ ...@@ -35,7 +35,7 @@
} }
@include media-breakpoint-down(xs) { @include media-breakpoint-down(xs) {
.alert-details-create-issue-button { .alert-details-issue-button {
width: 100%; width: 100%;
} }
} }
......
...@@ -4,7 +4,7 @@ class Projects::AlertManagementController < Projects::ApplicationController ...@@ -4,7 +4,7 @@ class Projects::AlertManagementController < Projects::ApplicationController
before_action :authorize_read_alert_management_alert! before_action :authorize_read_alert_management_alert!
before_action do before_action do
push_frontend_feature_flag(:alert_list_status_filtering_enabled) push_frontend_feature_flag(:alert_list_status_filtering_enabled)
push_frontend_feature_flag(:create_issue_from_alert_enabled) push_frontend_feature_flag(:alert_management_create_alert_issue)
push_frontend_feature_flag(:alert_assignee, project) push_frontend_feature_flag(:alert_assignee, project)
end end
......
...@@ -15,7 +15,7 @@ module Projects::AlertManagementHelper ...@@ -15,7 +15,7 @@ module Projects::AlertManagementHelper
{ {
'alert-id' => alert_id, 'alert-id' => alert_id,
'project-path' => project.full_path, 'project-path' => project.full_path,
'new-issue-path' => new_project_issue_path(project) 'project-issues-path' => project_issues_path(project)
} }
end end
end end
...@@ -1847,6 +1847,9 @@ msgstr "" ...@@ -1847,6 +1847,9 @@ msgstr ""
msgid "AlertManagement|Unknown" msgid "AlertManagement|Unknown"
msgstr "" msgstr ""
msgid "AlertManagement|View issue"
msgstr ""
msgid "AlertService|%{linkStart}Learn more%{linkEnd} about configuring this endpoint to receive alerts." msgid "AlertService|%{linkStart}Learn more%{linkEnd} about configuring this endpoint to receive alerts."
msgstr "" msgstr ""
......
...@@ -2,7 +2,9 @@ import { mount, shallowMount } from '@vue/test-utils'; ...@@ -2,7 +2,9 @@ import { mount, shallowMount } from '@vue/test-utils';
import { GlAlert, GlLoadingIcon, GlDropdownItem, GlTable } from '@gitlab/ui'; import { GlAlert, GlLoadingIcon, GlDropdownItem, GlTable } from '@gitlab/ui';
import AlertDetails from '~/alert_management/components/alert_details.vue'; import AlertDetails from '~/alert_management/components/alert_details.vue';
import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.graphql'; import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.graphql';
import createIssueQuery from '~/alert_management/graphql/mutations/create_issue_from_alert.graphql';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { joinPaths } from '~/lib/utils/url_utility';
import mockAlerts from '../mocks/alerts.json'; import mockAlerts from '../mocks/alerts.json';
...@@ -11,13 +13,15 @@ jest.mock('~/flash'); ...@@ -11,13 +13,15 @@ jest.mock('~/flash');
describe('AlertDetails', () => { describe('AlertDetails', () => {
let wrapper; let wrapper;
const newIssuePath = 'root/alerts/-/issues/new'; const projectPath = 'root/alerts';
const projectIssuesPath = 'root/alerts/-/issues';
const findStatusDropdownItem = () => wrapper.find(GlDropdownItem); const findStatusDropdownItem = () => wrapper.find(GlDropdownItem);
const findDetailsTable = () => wrapper.find(GlTable); const findDetailsTable = () => wrapper.find(GlTable);
function mountComponent({ function mountComponent({
data, data,
createIssueFromAlertEnabled = false, alertManagementCreateAlertIssue = false,
loading = false, loading = false,
mountMethod = shallowMount, mountMethod = shallowMount,
stubs = {}, stubs = {},
...@@ -25,14 +29,14 @@ describe('AlertDetails', () => { ...@@ -25,14 +29,14 @@ describe('AlertDetails', () => {
wrapper = mountMethod(AlertDetails, { wrapper = mountMethod(AlertDetails, {
propsData: { propsData: {
alertId: 'alertId', alertId: 'alertId',
projectPath: 'projectPath', projectPath,
newIssuePath, projectIssuesPath,
}, },
data() { data() {
return { alert: { ...mockAlert }, ...data }; return { alert: { ...mockAlert }, ...data };
}, },
provide: { provide: {
glFeatures: { createIssueFromAlertEnabled }, glFeatures: { alertManagementCreateAlertIssue },
}, },
mocks: { mocks: {
$apollo: { $apollo: {
...@@ -49,12 +53,16 @@ describe('AlertDetails', () => { ...@@ -49,12 +53,16 @@ describe('AlertDetails', () => {
} }
afterEach(() => { afterEach(() => {
if (wrapper) {
if (wrapper) { if (wrapper) {
wrapper.destroy(); wrapper.destroy();
} }
}
}); });
const findCreatedIssueBtn = () => wrapper.find('[data-testid="createIssueBtn"]'); const findCreateIssueBtn = () => wrapper.find('[data-testid="createIssueBtn"]');
const findViewIssueBtn = () => wrapper.find('[data-testid="viewIssueBtn"]');
const findIssueCreationAlert = () => wrapper.find('[data-testid="issueCreationError"]');
describe('Alert details', () => { describe('Alert details', () => {
describe('when alert is null', () => { describe('when alert is null', () => {
...@@ -118,17 +126,68 @@ describe('AlertDetails', () => { ...@@ -118,17 +126,68 @@ describe('AlertDetails', () => {
describe('Create issue from alert', () => { describe('Create issue from alert', () => {
describe('createIssueFromAlertEnabled feature flag enabled', () => { describe('createIssueFromAlertEnabled feature flag enabled', () => {
it('should display a button that links to new issue page', () => { it('should display "View issue" button that links the issue page when issue exists', () => {
mountComponent({ createIssueFromAlertEnabled: true }); const issueIid = '3';
expect(findCreatedIssueBtn().exists()).toBe(true); mountComponent({
expect(findCreatedIssueBtn().attributes('href')).toBe(newIssuePath); alertManagementCreateAlertIssue: true,
data: { alert: { ...mockAlert, issueIid } },
});
expect(findViewIssueBtn().exists()).toBe(true);
expect(findViewIssueBtn().attributes('href')).toBe(
joinPaths(projectIssuesPath, issueIid),
);
expect(findCreateIssueBtn().exists()).toBe(false);
});
it('should display "Create issue" button when issue doesn\'t exist yet', () => {
const issueIid = null;
mountComponent({
mountMethod: mount,
alertManagementCreateAlertIssue: true,
data: { alert: { ...mockAlert, issueIid } },
});
expect(findViewIssueBtn().exists()).toBe(false);
expect(findCreateIssueBtn().exists()).toBe(true);
});
it('calls `$apollo.mutate` with `createIssueQuery`', () => {
const issueIid = '10';
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { createAlertIssue: { issue: { iid: issueIid } } } });
findCreateIssueBtn().trigger('click');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: createIssueQuery,
variables: {
iid: mockAlert.iid,
projectPath,
},
});
});
it('shows error alert when issue creation fails ', () => {
const errorMsg = 'Something went wrong';
mountComponent({
mountMethod: mount,
alertManagementCreateAlertIssue: true,
data: { alert: { ...mockAlert, alertIid: 1 } },
});
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg);
findCreateIssueBtn().trigger('click');
setImmediate(() => {
expect(findIssueCreationAlert().text()).toBe(errorMsg);
});
}); });
}); });
describe('createIssueFromAlertEnabled feature flag disabled', () => { describe('createIssueFromAlertEnabled feature flag disabled', () => {
it('should display a button that links to a new issue page', () => { it('should not display a View or Create issue button', () => {
mountComponent({ createIssueFromAlertEnabled: false }); mountComponent({ alertManagementCreateAlertIssue: false });
expect(findCreatedIssueBtn().exists()).toBe(false); expect(findCreateIssueBtn().exists()).toBe(false);
expect(findViewIssueBtn().exists()).toBe(false);
}); });
}); });
}); });
...@@ -223,7 +282,7 @@ describe('AlertDetails', () => { ...@@ -223,7 +282,7 @@ describe('AlertDetails', () => {
variables: { variables: {
iid: 'alertId', iid: 'alertId',
status: 'TRIGGERED', status: 'TRIGGERED',
projectPath: 'projectPath', projectPath,
}, },
}); });
}); });
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
"title": "SyntaxError: Invalid or unexpected token", "title": "SyntaxError: Invalid or unexpected token",
"severity": "CRITICAL", "severity": "CRITICAL",
"eventCount": 7, "eventCount": 7,
"createdAt": "2020-04-17T23:18:14.996Z",
"startedAt": "2020-04-17T23:18:14.996Z", "startedAt": "2020-04-17T23:18:14.996Z",
"endedAt": "2020-04-17T23:18:14.996Z", "endedAt": "2020-04-17T23:18:14.996Z",
"status": "TRIGGERED" "status": "TRIGGERED"
......
...@@ -69,13 +69,13 @@ describe Projects::AlertManagementHelper do ...@@ -69,13 +69,13 @@ describe Projects::AlertManagementHelper do
describe '#alert_management_detail_data' do describe '#alert_management_detail_data' do
let(:alert_id) { 1 } let(:alert_id) { 1 }
let(:new_issue_path) { new_project_issue_path(project) } let(:issues_path) { project_issues_path(project) }
it 'returns detail page configuration' do it 'returns detail page configuration' do
expect(helper.alert_management_detail_data(project, alert_id)).to eq( expect(helper.alert_management_detail_data(project, alert_id)).to eq(
'alert-id' => alert_id, 'alert-id' => alert_id,
'project-path' => project_path, 'project-path' => project_path,
'new-issue-path' => new_issue_path 'project-issues-path' => issues_path
) )
end end
end end
......
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