Commit ebcef7e1 authored by Phil Hughes's avatar Phil Hughes

Merge branch '345496-report-widgets-core-add-support-for-fetch-failures' into 'master'

Report Widgets Core: Add support for fetch failures

See merge request gitlab-org/gitlab!74518
parents 768263f3 33fd54f3
......@@ -9,10 +9,11 @@ import {
GlIntersectionObserver,
} from '@gitlab/ui';
import { once } from 'lodash';
import * as Sentry from '@sentry/browser';
import api from '~/api';
import { sprintf, s__, __ } from '~/locale';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
import { EXTENSION_ICON_CLASS } from '../../constants';
import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants';
import StatusIcon from './status_icon.vue';
import Actions from './actions.vue';
......@@ -20,6 +21,7 @@ export const LOADING_STATES = {
collapsedLoading: 'collapsedLoading',
collapsedError: 'collapsedError',
expandedLoading: 'expandedLoading',
expandedError: 'expandedError',
};
export default {
......@@ -40,8 +42,8 @@ export default {
data() {
return {
loadingState: LOADING_STATES.collapsedLoading,
collapsedData: null,
fullData: null,
collapsedData: {},
fullData: [],
isCollapsed: true,
showFade: false,
};
......@@ -53,6 +55,9 @@ export default {
widgetLoadingText() {
return this.$options.i18n?.loading || __('Loading...');
},
widgetErrorText() {
return this.$options.i18n?.error || __('Failed to load');
},
isLoadingSummary() {
return this.loadingState === LOADING_STATES.collapsedLoading;
},
......@@ -60,11 +65,16 @@ export default {
return this.loadingState === LOADING_STATES.expandedLoading;
},
isCollapsible() {
if (this.isLoadingSummary) {
return false;
}
return true;
return !this.isLoadingSummary && this.loadingState !== LOADING_STATES.collapsedError;
},
hasFullData() {
return this.fullData.length > 0;
},
hasFetchError() {
return (
this.loadingState === LOADING_STATES.collapsedError ||
this.loadingState === LOADING_STATES.expandedError
);
},
collapseButtonLabel() {
return sprintf(
......@@ -75,6 +85,7 @@ export default {
);
},
statusIconName() {
if (this.hasFetchError) return EXTENSION_ICONS.error;
if (this.isLoadingSummary) return null;
return this.statusIcon(this.collapsedData);
......@@ -100,7 +111,8 @@ export default {
})
.catch((e) => {
this.loadingState = LOADING_STATES.collapsedError;
throw e;
Sentry.captureException(e);
});
},
methods: {
......@@ -115,7 +127,7 @@ export default {
this.triggerRedisTracking();
},
loadAllData() {
if (this.fullData) return;
if (this.hasFullData) return;
this.loadingState = LOADING_STATES.expandedLoading;
......@@ -125,8 +137,9 @@ export default {
this.fullData = data;
})
.catch((e) => {
this.loadingState = null;
throw e;
this.loadingState = LOADING_STATES.expandedError;
Sentry.captureException(e);
});
},
appear(index) {
......@@ -158,6 +171,7 @@ export default {
>
<div class="gl-flex-grow-1">
<template v-if="isLoadingSummary">{{ widgetLoadingText }}</template>
<template v-else-if="hasFetchError">{{ widgetErrorText }}</template>
<div v-else v-safe-html="summary(collapsedData)"></div>
</div>
<actions
......@@ -189,7 +203,7 @@ export default {
<gl-loading-icon size="sm" inline /> {{ __('Loading...') }}
</div>
<smart-virtual-list
v-else-if="fullData"
v-else-if="hasFullData"
:length="fullData.length"
:remain="20"
:size="32"
......
......@@ -54,3 +54,26 @@ import issueExtension from '~/vue_merge_request_widget/extensions/issues';
// Register the imported extension
registerExtension(issueExtension);
```
## Fetching errors
If `fetchCollapsedData()` or `fetchFullData()` methods throw an error:
- The loading state of the extension is updated to `LOADING_STATES.collapsedError` and `LOADING_STATES.expandedError`
respectively.
- The extensions header displays an error icon and updates the text to be either:
- The text defined in `$options.i18n.error`.
- "Failed to load" if `$options.i18n.error` is not defined.
- The error is sent to Sentry to log that it occurred.
To customise the error text, you need to add it to the `i18n` object in your extension:
```javascript
export default {
//...
i18n: {
//...
error: __('Your error text'),
},
};
```
......@@ -14351,6 +14351,9 @@ msgstr ""
msgid "Failed to install."
msgstr ""
msgid "Failed to load"
msgstr ""
msgid "Failed to load assignees."
msgstr ""
......
......@@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import * as Sentry from '@sentry/browser';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { securityReportMergeRequestDownloadPathsQueryResponse } from 'jest/vue_shared/security_reports/mock_data';
......@@ -19,10 +20,15 @@ import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/consta
import eventHub from '~/vue_merge_request_widget/event_hub';
import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
import { stateKey } from '~/vue_merge_request_widget/stores/state_maps';
import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue';
import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql';
import { faviconDataUrl, overlayDataUrl } from '../lib/utils/mock_data';
import mockData from './mock_data';
import testExtension from './test_extension';
import {
workingExtension,
collapsedDataErrorExtension,
fullDataErrorExtension,
} from './test_extensions';
jest.mock('~/api.js');
......@@ -892,7 +898,7 @@ describe('MrWidgetOptions', () => {
describe('mock extension', () => {
beforeEach(() => {
registerExtension(testExtension);
registerExtension(workingExtension);
createComponent();
});
......@@ -914,7 +920,7 @@ describe('MrWidgetOptions', () => {
.find('[data-testid="widget-extension"] [data-testid="toggle-button"]')
.trigger('click');
await Vue.nextTick();
await nextTick();
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith('test_expand_event');
});
......@@ -926,7 +932,7 @@ describe('MrWidgetOptions', () => {
.find('[data-testid="widget-extension"] [data-testid="toggle-button"]')
.trigger('click');
await Vue.nextTick();
await nextTick();
expect(
wrapper.find('[data-testid="widget-extension-top-level"]').find(GlDropdown).exists(),
......@@ -952,4 +958,50 @@ describe('MrWidgetOptions', () => {
expect(collapsedSection.find(GlButton).text()).toBe('Full report');
});
});
describe('mock extension errors', () => {
let captureException;
const itHandlesTheException = () => {
expect(captureException).toHaveBeenCalledTimes(1);
expect(captureException).toHaveBeenCalledWith(new Error('Fetch error'));
expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('error');
};
beforeEach(() => {
captureException = jest.spyOn(Sentry, 'captureException');
});
afterEach(() => {
registeredExtensions.extensions = [];
captureException = null;
});
it('handles collapsed data fetch errors', async () => {
registerExtension(collapsedDataErrorExtension);
createComponent();
await waitForPromises();
expect(
wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]').exists(),
).toBe(false);
itHandlesTheException();
});
it('handles full data fetch errors', async () => {
registerExtension(fullDataErrorExtension);
createComponent();
await waitForPromises();
expect(wrapper.findComponent(StatusIcon).props('iconName')).not.toBe('error');
wrapper
.find('[data-testid="widget-extension"] [data-testid="toggle-button"]')
.trigger('click');
await nextTick();
await waitForPromises();
itHandlesTheException();
});
});
});
import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants';
export default {
export const workingExtension = {
name: 'WidgetTestExtension',
props: ['targetProjectFullPath'],
expandEvent: 'test_expand_event',
......@@ -37,3 +37,63 @@ export default {
},
},
};
export const collapsedDataErrorExtension = {
name: 'WidgetTestCollapsedErrorExtension',
props: ['targetProjectFullPath'],
expandEvent: 'test_expand_event',
computed: {
summary({ count, targetProjectFullPath }) {
return `Test extension summary count: ${count} & ${targetProjectFullPath}`;
},
statusIcon({ count }) {
return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success;
},
},
methods: {
fetchCollapsedData() {
return Promise.reject(new Error('Fetch error'));
},
fetchFullData() {
return Promise.resolve([
{
id: 1,
text: 'Hello world',
icon: {
name: EXTENSION_ICONS.failed,
},
badge: {
text: 'Closed',
},
link: {
href: 'https://gitlab.com',
text: 'GitLab.com',
},
actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }],
},
]);
},
},
};
export const fullDataErrorExtension = {
name: 'WidgetTestCollapsedErrorExtension',
props: ['targetProjectFullPath'],
expandEvent: 'test_expand_event',
computed: {
summary({ count, targetProjectFullPath }) {
return `Test extension summary count: ${count} & ${targetProjectFullPath}`;
},
statusIcon({ count }) {
return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success;
},
},
methods: {
fetchCollapsedData({ targetProjectFullPath }) {
return Promise.resolve({ targetProjectFullPath, count: 1 });
},
fetchFullData() {
return Promise.reject(new Error('Fetch error'));
},
},
};
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