diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index ca687cd1daa333df82bc519cf7a352588091a3c7..a5ba2ddea386bdf4880db13543ff3d83f1ed09b6 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -36,6 +36,7 @@ import { WIDGET_TYPE_WEIGHT, WIDGET_TYPE_DEVELOPMENT, STATE_OPEN, + WIDGET_TYPE_ERROR_TRACKING, WIDGET_TYPE_ITERATION, WIDGET_TYPE_MILESTONE, WORK_ITEM_TYPE_VALUE_INCIDENT, @@ -73,6 +74,7 @@ import WorkItemDescription from './work_item_description.vue'; import WorkItemNotes from './work_item_notes.vue'; import WorkItemAwardEmoji from './work_item_award_emoji.vue'; import WorkItemRelationships from './work_item_relationships/work_item_relationships.vue'; +import WorkItemErrorTracking from './work_item_error_tracking.vue'; import WorkItemStickyHeader from './work_item_sticky_header.vue'; import WorkItemAncestors from './work_item_ancestors/work_item_ancestors.vue'; import WorkItemTitle from './work_item_title.vue'; @@ -115,6 +117,7 @@ export default { WorkItemTree, WorkItemNotes, WorkItemRelationships, + WorkItemErrorTracking, WorkItemStickyHeader, WorkItemAncestors, WorkItemTitle, @@ -398,6 +401,9 @@ export default { workItemAwardEmoji() { return this.findWidget(WIDGET_TYPE_AWARD_EMOJI); }, + workItemErrorTrackingIdentifier() { + return this.findWidget(WIDGET_TYPE_ERROR_TRACKING)?.identifier; + }, workItemHierarchy() { return this.findWidget(WIDGET_TYPE_HIERARCHY); }, @@ -1036,6 +1042,12 @@ export default { /> </aside> + <work-item-error-tracking + v-if="workItemErrorTrackingIdentifier" + :full-path="workItemFullPath" + :identifier="workItemErrorTrackingIdentifier" + /> + <design-widget v-if="hasDesignWidget" :class="{ 'gl-mt-0': isDrawer }" diff --git a/app/assets/javascripts/work_items/components/work_item_error_tracking.vue b/app/assets/javascripts/work_items/components/work_item_error_tracking.vue new file mode 100644 index 0000000000000000000000000000000000000000..0f6e6b4aab0bafdcd33f3a10d72f1a210189e80f --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_error_tracking.vue @@ -0,0 +1,85 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import Stacktrace from '~/error_tracking/components/stacktrace.vue'; +import service from '~/error_tracking/services'; +import Poll from '~/lib/utils/poll'; +import { __ } from '~/locale'; + +export default { + components: { + GlLoadingIcon, + Stacktrace, + }, + props: { + fullPath: { + type: String, + required: true, + }, + identifier: { + type: String, + required: true, + }, + }, + data() { + return { + loading: false, + stackTraceData: {}, + }; + }, + computed: { + stackTraceEntries() { + return this.stackTraceData.stack_trace_entries?.toReversed() ?? []; + }, + stackTracePath() { + return `/${this.fullPath}/-/error_tracking/${this.identifier}/stack_trace.json`; + }, + }, + mounted() { + this.startPolling(this.stackTracePath); + }, + beforeDestroy() { + this.stackTracePoll?.stop(); + }, + methods: { + startPolling(endpoint) { + this.loading = true; + + this.stackTracePoll = new Poll({ + resource: service, + method: 'getSentryData', + data: { endpoint }, + successCallback: ({ data }) => { + if (!data) { + return; + } + + this.stackTraceData = data.error; + this.stackTracePoll.stop(); + this.loading = false; + }, + errorCallback: () => { + createAlert({ message: __('Failed to load stacktrace.') }); + this.loading = false; + }, + }); + + this.stackTracePoll.makeRequest(); + }, + }, +}; +</script> + +<template> + <div> + <div :class="{ 'gl-border-b-0': loading }" class="card card-slim gl-mb-0 gl-mt-5"> + <div class="card-header gl-border-b-0"> + <h2 class="card-title gl-my-2 gl-text-base">{{ __('Stack trace') }}</h2> + </div> + </div> + <div v-if="loading" class="card gl-mb-0"> + <gl-loading-icon class="gl-my-3" /> + </div> + <stacktrace v-else :entries="stackTraceEntries" /> + </div> +</template> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index a2eb0f1037154b6c0dfcbfd91a7db67bf3ee300c..f4255697ed2e68e23fe8f072f7994f01b06b49a2 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -11,6 +11,7 @@ export const TRACKING_CATEGORY_SHOW = 'workItems:show'; export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES'; export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION'; +export const WIDGET_TYPE_ERROR_TRACKING = 'ERROR_TRACKING'; export const WIDGET_TYPE_AWARD_EMOJI = 'AWARD_EMOJI'; export const WIDGET_TYPE_NOTIFICATIONS = 'NOTIFICATIONS'; export const WIDGET_TYPE_CURRENT_USER_TODOS = 'CURRENT_USER_TODOS'; diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql index 523947fee0f17930a8a1daa74ae1c3136003e1ab..e38550963b8b3a756ffa8cb2ec650f1c429be3df 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -111,4 +111,7 @@ fragment WorkItemWidgets on WorkItemWidget { } } } + ... on WorkItemWidgetErrorTracking { + identifier + } } diff --git a/ee/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/ee/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql index 153dc31a9674e99e55853d1de2bc43e1bf0232d2..3cc637c3d65f4031b735dee6631c4e1a9e604e6c 100644 --- a/ee/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/ee/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -162,4 +162,7 @@ fragment WorkItemWidgets on WorkItemWidget { } } } + ... on WorkItemWidgetErrorTracking { + identifier + } } diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js index 8303020d7d121b1d41156cce803b943f6d03adfe..bfcd3681aaa5590bfe07984a90c9c8fa7610402c 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -13,6 +13,7 @@ import WorkItemAncestors from '~/work_items/components/work_item_ancestors/work_ import WorkItemDescription from '~/work_items/components/work_item_description.vue'; import WorkItemCreatedUpdated from '~/work_items/components/work_item_created_updated.vue'; import WorkItemAttributesWrapper from '~/work_items/components/work_item_attributes_wrapper.vue'; +import WorkItemErrorTracking from '~/work_items/components/work_item_error_tracking.vue'; import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue'; import WorkItemRelationships from '~/work_items/components/work_item_relationships/work_item_relationships.vue'; import WorkItemNotes from '~/work_items/components/work_item_notes.vue'; @@ -116,6 +117,7 @@ describe('WorkItemDetail component', () => { const findAncestors = () => wrapper.findComponent(WorkItemAncestors); const findCloseButton = () => wrapper.findByTestId('work-item-close'); const findWorkItemType = () => wrapper.findByTestId('work-item-type'); + const findErrorTrackingWidget = () => wrapper.findComponent(WorkItemErrorTracking); const findHierarchyTree = () => wrapper.findComponent(WorkItemTree); const findWorkItemRelationships = () => wrapper.findComponent(WorkItemRelationships); const findNotesWidget = () => wrapper.findComponent(WorkItemNotes); @@ -279,10 +281,7 @@ describe('WorkItemDetail component', () => { expect(workItemUpdatedSubscriptionHandler).toHaveBeenCalledWith({ id }); }); - it('fetches allowed children types for current work item', async () => { - createComponent(); - await waitForPromises(); - + it('fetches allowed children types for current work item', () => { expect(allowedChildrenTypesHandler).toHaveBeenCalled(); }); @@ -293,6 +292,13 @@ describe('WorkItemDetail component', () => { expect(findHierarchyTree().props('parentMilestone')).toEqual(milestone); }); + + it('renders error tracking widget', () => { + expect(findErrorTrackingWidget().props()).toEqual({ + fullPath: 'group/project', + identifier: '1', + }); + }); }); describe('close button', () => { diff --git a/spec/frontend/work_items/components/work_item_error_tracking_spec.js b/spec/frontend/work_items/components/work_item_error_tracking_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..f90bdc4d11be7ce3cb4de225508f1b556cadf1db --- /dev/null +++ b/spec/frontend/work_items/components/work_item_error_tracking_spec.js @@ -0,0 +1,73 @@ +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import Stacktrace from '~/error_tracking/components/stacktrace.vue'; +import WorkItemErrorTracking from '~/work_items/components/work_item_error_tracking.vue'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; + +jest.mock('~/alert'); + +describe('WorkItemErrorTracking component', () => { + let axiosMock; + let wrapper; + + const successResponse = { + error: { + stack_trace_entries: [{ id: 1 }, { id: 2 }], + }, + }; + + const findStacktrace = () => wrapper.findComponent(Stacktrace); + + const createComponent = () => { + wrapper = shallowMount(WorkItemErrorTracking, { + propsData: { + fullPath: 'group/project', + identifier: '12345', + }, + }); + }; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + }); + + afterEach(() => { + axiosMock.restore(); + }); + + it('renders h2 heading', () => { + createComponent(); + + expect(wrapper.find('h2').text()).toBe('Stack trace'); + }); + + it('makes call to stack trace endpoint', async () => { + createComponent(); + await waitForPromises(); + + expect(axiosMock.history.get[0].url).toBe( + '/group/project/-/error_tracking/12345/stack_trace.json', + ); + }); + + it('renders Stacktrace component when we get data', async () => { + axiosMock.onGet().reply(HTTP_STATUS_OK, successResponse); + createComponent(); + await waitForPromises(); + + expect(findStacktrace().props('entries')).toEqual( + successResponse.error.stack_trace_entries.toReversed(), + ); + }); + + it('renders alert when we fail to get data', async () => { + axiosMock.onGet().reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + createComponent(); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ message: 'Failed to load stacktrace.' }); + }); +}); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index aed69a0252e6137fabb0c095fd8dbe65a667681b..d99e02c4aeca5796fa7c331788114fd947f5381e 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -1345,6 +1345,7 @@ export const workItemResponseFactory = ({ discussionLocked = false, canInviteMembers = false, labelsWidgetPresent = true, + errorTrackingWidgetPresent = true, hierarchyWidgetPresent = true, linkedItemsWidgetPresent = true, crmContactsWidgetPresent = true, @@ -1589,6 +1590,13 @@ export const workItemResponseFactory = ({ }, } : { type: 'MOCK TYPE' }, + errorTrackingWidgetPresent + ? { + __typename: 'WorkItemWidgetErrorTracking', + type: 'ERROR_TRACKING', + identifier: '1', + } + : { type: 'MOCK TYPE' }, hierarchyWidgetPresent ? { __typename: 'WorkItemWidgetHierarchy', diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js index 21d1cc53fc315d56ccb3816ddde39978c0a0317a..f1bf75e1961151c01ba01af47e4f3e35d72acf0a 100644 --- a/spec/frontend/work_items/router_spec.js +++ b/spec/frontend/work_items/router_spec.js @@ -86,6 +86,7 @@ describe('Work items router', () => { WorkItemCreateBranchMergeRequestModal: true, WorkItemDevelopment: true, WorkItemChangeTypeModal: true, + WorkItemErrorTracking: true, }, }); };