Commit 811ec8fa authored by Martin Wortschack's avatar Martin Wortschack

Merge branch 'lm-create-issues-from-sentry-details-page' into 'master'

Create issues from sentry details page

See merge request gitlab-org/gitlab!20666
parents 2059e64c 23559ac3
<script> <script>
import { mapActions, mapGetters, mapState } from 'vuex'; import { mapActions, mapGetters, mapState } from 'vuex';
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import { __, sprintf } from '~/locale'; import { GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
import { GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui'; import { __, sprintf, n__ } from '~/locale';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import Stacktrace from './stacktrace.vue'; import Stacktrace from './stacktrace.vue';
...@@ -12,7 +13,8 @@ import { trackClickErrorLinkToSentryOptions } from '../utils'; ...@@ -12,7 +13,8 @@ import { trackClickErrorLinkToSentryOptions } from '../utils';
export default { export default {
components: { components: {
GlButton, LoadingButton,
GlFormInput,
GlLink, GlLink,
GlLoadingIcon, GlLoadingIcon,
TooltipOnTruncate, TooltipOnTruncate,
...@@ -32,10 +34,19 @@ export default { ...@@ -32,10 +34,19 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
issueProjectPath: { projectIssuesPath: {
type: String, type: String,
required: true, required: true,
}, },
csrfToken: {
type: String,
required: true,
},
},
data() {
return {
issueCreationInProgress: false,
};
}, },
computed: { computed: {
...mapState('details', ['error', 'loading', 'loadingStacktrace', 'stacktraceData']), ...mapState('details', ['error', 'loading', 'loadingStacktrace', 'stacktraceData']),
...@@ -62,33 +73,26 @@ export default { ...@@ -62,33 +73,26 @@ export default {
showStacktrace() { showStacktrace() {
return Boolean(!this.loadingStacktrace && this.stacktrace && this.stacktrace.length); return Boolean(!this.loadingStacktrace && this.stacktrace && this.stacktrace.length);
}, },
errorTitle() { issueTitle() {
return `${this.error.title}`; return this.error.title;
},
errorUrl() {
return sprintf(__('Sentry event: %{external_url}'), {
external_url: this.error.external_url,
});
},
errorFirstSeen() {
return sprintf(__('First seen: %{first_seen}'), { first_seen: this.error.first_seen });
},
errorLastSeen() {
return sprintf(__('Last seen: %{last_seen}'), { last_seen: this.error.last_seen });
},
errorCount() {
return sprintf(__('Events: %{count}'), { count: this.error.count });
},
errorUserCount() {
return sprintf(__('Users: %{user_count}'), { user_count: this.error.user_count });
},
issueLink() {
return `${this.issueProjectPath}?issue[title]=${encodeURIComponent(
this.errorTitle,
)}&issue[description]=${encodeURIComponent(this.issueDescription)}`;
}, },
issueDescription() { issueDescription() {
return `${this.errorUrl}${this.errorFirstSeen}${this.errorLastSeen}${this.errorCount}${this.errorUserCount}`; return sprintf(
__(
'%{description}- Sentry event: %{errorUrl}- First seen: %{firstSeen}- Last seen: %{lastSeen} %{countLabel}: %{count}%{userCountLabel}: %{userCount}',
),
{
description: '# Error Details:\n',
errorUrl: `${this.error.external_url}\n`,
firstSeen: `\n${this.error.first_seen}\n`,
lastSeen: `${this.error.last_seen}\n`,
countLabel: n__('- Event', '- Events', this.error.count),
count: `${this.error.count}\n`,
userCountLabel: n__('- User', '- Users', this.error.user_count),
userCount: `${this.error.user_count}\n`,
},
false,
);
}, },
}, },
mounted() { mounted() {
...@@ -98,6 +102,10 @@ export default { ...@@ -98,6 +102,10 @@ export default {
methods: { methods: {
...mapActions('details', ['startPollingDetails', 'startPollingStacktrace']), ...mapActions('details', ['startPollingDetails', 'startPollingStacktrace']),
trackClickErrorLinkToSentryOptions, trackClickErrorLinkToSentryOptions,
createIssue() {
this.issueCreationInProgress = true;
this.$refs.sentryIssueForm.submit();
},
formatDate(date) { formatDate(date) {
return `${this.timeFormated(date)} (${dateFormat(date, 'UTC:yyyy-mm-dd h:MM:ssTT Z')})`; return `${this.timeFormated(date)} (${dateFormat(date, 'UTC:yyyy-mm-dd h:MM:ssTT Z')})`;
}, },
...@@ -114,9 +122,17 @@ export default { ...@@ -114,9 +122,17 @@ export default {
<div v-else-if="showDetails" class="error-details"> <div v-else-if="showDetails" class="error-details">
<div class="top-area align-items-center justify-content-between py-3"> <div class="top-area align-items-center justify-content-between py-3">
<span v-if="!loadingStacktrace && stacktrace" v-html="reported"></span> <span v-if="!loadingStacktrace && stacktrace" v-html="reported"></span>
<gl-button variant="success" :href="issueLink"> <form ref="sentryIssueForm" :action="projectIssuesPath" method="POST">
{{ __('Create issue') }} <gl-form-input class="hidden" name="issue[title]" :value="issueTitle" />
</gl-button> <input name="issue[description]" :value="issueDescription" type="hidden" />
<gl-form-input :value="csrfToken" class="hidden" name="authenticity_token" />
<loading-button
class="btn-success"
:label="__('Create issue')"
:loading="issueCreationInProgress"
@click="createIssue"
/>
</form>
</div> </div>
<div> <div>
<tooltip-on-truncate :title="error.title" truncate-target="child" placement="top"> <tooltip-on-truncate :title="error.title" truncate-target="child" placement="top">
......
import Vue from 'vue'; import Vue from 'vue';
import store from './store'; import store from './store';
import ErrorDetails from './components/error_details.vue'; import ErrorDetails from './components/error_details.vue';
import csrf from '~/lib/utils/csrf';
export default () => { export default () => {
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
...@@ -12,13 +13,14 @@ export default () => { ...@@ -12,13 +13,14 @@ export default () => {
store, store,
render(createElement) { render(createElement) {
const domEl = document.querySelector(this.$options.el); const domEl = document.querySelector(this.$options.el);
const { issueDetailsPath, issueStackTracePath, issueProjectPath } = domEl.dataset; const { issueDetailsPath, issueStackTracePath, projectIssuesPath } = domEl.dataset;
return createElement('error-details', { return createElement('error-details', {
props: { props: {
issueDetailsPath, issueDetailsPath,
issueStackTracePath, issueStackTracePath,
issueProjectPath, projectIssuesPath,
csrfToken: csrf.token,
}, },
}); });
}, },
......
...@@ -18,7 +18,7 @@ module Projects::ErrorTrackingHelper ...@@ -18,7 +18,7 @@ module Projects::ErrorTrackingHelper
opts = [project, issue_id, { format: :json }] opts = [project, issue_id, { format: :json }]
{ {
'issue-project-path' => new_project_issue_path(project), 'project-issues-path' => project_issues_path(project),
'issue-details-path' => details_project_error_tracking_index_path(*opts), 'issue-details-path' => details_project_error_tracking_index_path(*opts),
'issue-stack-trace-path' => stack_trace_project_error_tracking_index_path(*opts) 'issue-stack-trace-path' => stack_trace_project_error_tracking_index_path(*opts)
} }
......
---
title: Adds ability to create issues from sentry details page
merge_request: 20666
author:
type: added
...@@ -234,6 +234,9 @@ msgstr[1] "" ...@@ -234,6 +234,9 @@ msgstr[1] ""
msgid "%{count} related %{pluralized_subject}: %{links}" msgid "%{count} related %{pluralized_subject}: %{links}"
msgstr "" msgstr ""
msgid "%{description}- Sentry event: %{errorUrl}- First seen: %{firstSeen}- Last seen: %{lastSeen} %{countLabel}: %{count}%{userCountLabel}: %{userCount}"
msgstr ""
msgid "%{duration}ms" msgid "%{duration}ms"
msgstr "" msgstr ""
...@@ -474,12 +477,22 @@ msgstr "" ...@@ -474,12 +477,22 @@ msgstr ""
msgid ", or " msgid ", or "
msgstr "" msgstr ""
msgid "- Event"
msgid_plural "- Events"
msgstr[0] ""
msgstr[1] ""
msgid "- Runner is active and can process any new jobs" msgid "- Runner is active and can process any new jobs"
msgstr "" msgstr ""
msgid "- Runner is paused and will not receive any new jobs" msgid "- Runner is paused and will not receive any new jobs"
msgstr "" msgstr ""
msgid "- User"
msgid_plural "- Users"
msgstr[0] ""
msgstr[1] ""
msgid "- show less" msgid "- show less"
msgstr "" msgstr ""
...@@ -6983,9 +6996,6 @@ msgstr "" ...@@ -6983,9 +6996,6 @@ msgstr ""
msgid "Events" msgid "Events"
msgstr "" msgstr ""
msgid "Events: %{count}"
msgstr ""
msgid "Every %{action} attempt has failed: %{job_error_message}. Please try again." msgid "Every %{action} attempt has failed: %{job_error_message}. Please try again."
msgstr "" msgstr ""
...@@ -7642,9 +7652,6 @@ msgstr "" ...@@ -7642,9 +7652,6 @@ msgstr ""
msgid "First seen" msgid "First seen"
msgstr "" msgstr ""
msgid "First seen: %{first_seen}"
msgstr ""
msgid "Fixed date" msgid "Fixed date"
msgstr "" msgstr ""
...@@ -10108,9 +10115,6 @@ msgstr "" ...@@ -10108,9 +10115,6 @@ msgstr ""
msgid "Last seen" msgid "Last seen"
msgstr "" msgstr ""
msgid "Last seen: %{last_seen}"
msgstr ""
msgid "Last successful update" msgid "Last successful update"
msgstr "" msgstr ""
...@@ -15637,9 +15641,6 @@ msgstr "" ...@@ -15637,9 +15641,6 @@ msgstr ""
msgid "Sentry event" msgid "Sentry event"
msgstr "" msgstr ""
msgid "Sentry event: %{external_url}"
msgstr ""
msgid "Sep" msgid "Sep"
msgstr "" msgstr ""
...@@ -19362,9 +19363,6 @@ msgstr "" ...@@ -19362,9 +19363,6 @@ msgstr ""
msgid "Users with a Guest role or those who don't belong to any projects or groups don't count towards seats in use." msgid "Users with a Guest role or those who don't belong to any projects or groups don't count towards seats in use."
msgstr "" msgstr ""
msgid "Users: %{user_count}"
msgstr ""
msgid "UsersSelect|%{name} + %{length} more" msgid "UsersSelect|%{name} + %{length} more"
msgstr "" msgstr ""
......
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { GlButton, GlLoadingIcon, GlLink } from '@gitlab/ui'; import { GlLoadingIcon, GlLink } from '@gitlab/ui';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import Stacktrace from '~/error_tracking/components/stacktrace.vue'; import Stacktrace from '~/error_tracking/components/stacktrace.vue';
import ErrorDetails from '~/error_tracking/components/error_details.vue'; import ErrorDetails from '~/error_tracking/components/error_details.vue';
...@@ -15,12 +16,14 @@ describe('ErrorDetails', () => { ...@@ -15,12 +16,14 @@ describe('ErrorDetails', () => {
function mountComponent() { function mountComponent() {
wrapper = shallowMount(ErrorDetails, { wrapper = shallowMount(ErrorDetails, {
stubs: { LoadingButton },
localVue, localVue,
store, store,
propsData: { propsData: {
issueDetailsPath: '/123/details', issueDetailsPath: '/123/details',
issueStackTracePath: '/stacktrace', issueStackTracePath: '/stacktrace',
issueProjectPath: '/test-project/issues/new', projectIssuesPath: '/test-project/issues/',
csrfToken: 'fakeToken',
}, },
}); });
} }
...@@ -83,36 +86,6 @@ describe('ErrorDetails', () => { ...@@ -83,36 +86,6 @@ describe('ErrorDetails', () => {
expect(wrapper.find(Stacktrace).exists()).toBe(false); expect(wrapper.find(Stacktrace).exists()).toBe(false);
}); });
it('should allow an issue to be created with title and description', () => {
store.state.details.loading = false;
store.state.details.error = {
id: 1,
title: 'Issue title',
external_url: 'http://sentry.gitlab.net/gitlab',
first_seen: '2017-05-26T13:32:48Z',
last_seen: '2018-05-26T13:32:48Z',
count: 12,
user_count: 2,
};
mountComponent();
const button = wrapper.find(GlButton);
const title = 'Issue title';
const url = 'Sentry event: http://sentry.gitlab.net/gitlab';
const firstSeen = 'First seen: 2017-05-26T13:32:48Z';
const lastSeen = 'Last seen: 2018-05-26T13:32:48Z';
const count = 'Events: 12';
const userCount = 'Users: 2';
const issueDescription = `${url}${firstSeen}${lastSeen}${count}${userCount}`;
const issueLink = `/test-project/issues/new?issue[title]=${encodeURIComponent(
title,
)}&issue[description]=${encodeURIComponent(issueDescription)}`;
expect(button.exists()).toBe(true);
expect(button.attributes().href).toBe(issueLink);
});
describe('Stacktrace', () => { describe('Stacktrace', () => {
it('should show stacktrace', () => { it('should show stacktrace', () => {
store.state.details.loading = false; store.state.details.loading = false;
...@@ -132,5 +105,38 @@ describe('ErrorDetails', () => { ...@@ -132,5 +105,38 @@ describe('ErrorDetails', () => {
expect(wrapper.find(Stacktrace).exists()).toBe(false); expect(wrapper.find(Stacktrace).exists()).toBe(false);
}); });
}); });
describe('When a user clicks the create issue button', () => {
beforeEach(() => {
store.state.details.loading = false;
store.state.details.error = {
id: 1,
title: 'Issue title',
external_url: 'http://sentry.gitlab.net/gitlab',
first_seen: '2017-05-26T13:32:48Z',
last_seen: '2018-05-26T13:32:48Z',
count: 12,
user_count: 2,
};
mountComponent();
});
it('should set the form values with title and description', () => {
const csrfTokenInput = wrapper.find('glforminput-stub[name="authenticity_token"]');
const issueTitleInput = wrapper.find('glforminput-stub[name="issue[title]"]');
const issueDescriptionInput = wrapper.find('input[name="issue[description]"]');
expect(csrfTokenInput.attributes('value')).toBe('fakeToken');
expect(issueTitleInput.attributes('value')).toContain(wrapper.vm.issueTitle);
expect(issueDescriptionInput.attributes('value')).toContain(wrapper.vm.issueDescription);
});
it('should submit the form', () => {
window.HTMLFormElement.prototype.submit = () => {};
const submitSpy = jest.spyOn(wrapper.vm.$refs.sentryIssueForm, 'submit');
wrapper.find('button').trigger('click');
expect(submitSpy).toHaveBeenCalled();
submitSpy.mockRestore();
});
});
}); });
}); });
...@@ -81,7 +81,7 @@ describe Projects::ErrorTrackingHelper do ...@@ -81,7 +81,7 @@ describe Projects::ErrorTrackingHelper do
let(:route_params) { [project.owner, project, issue_id, { format: :json }] } let(:route_params) { [project.owner, project, issue_id, { format: :json }] }
let(:details_path) { details_namespace_project_error_tracking_index_path(*route_params) } let(:details_path) { details_namespace_project_error_tracking_index_path(*route_params) }
let(:stack_trace_path) { stack_trace_namespace_project_error_tracking_index_path(*route_params) } let(:stack_trace_path) { stack_trace_namespace_project_error_tracking_index_path(*route_params) }
let(:issue_project_path) { new_project_issue_path(project) } let(:issues_path) { project_issues_path(project) }
let(:result) { helper.error_details_data(project, issue_id) } let(:result) { helper.error_details_data(project, issue_id) }
...@@ -93,8 +93,8 @@ describe Projects::ErrorTrackingHelper do ...@@ -93,8 +93,8 @@ describe Projects::ErrorTrackingHelper do
expect(result['issue-stack-trace-path']).to eq stack_trace_path expect(result['issue-stack-trace-path']).to eq stack_trace_path
end end
it 'returns the correct path for creating a new issue' do it 'creates an issue and redirects to issue show page' do
expect(result['issue-project-path']).to eq issue_project_path expect(result['project-issues-path']).to eq issues_path
end end
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