Commit 33fd54f3 authored by Robert Hunt's avatar Robert Hunt Committed by Phil Hughes

Report Widgets Core: Add support for fetch failures

parent 05004557
...@@ -9,10 +9,11 @@ import { ...@@ -9,10 +9,11 @@ import {
GlIntersectionObserver, GlIntersectionObserver,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { once } from 'lodash'; import { once } from 'lodash';
import * as Sentry from '@sentry/browser';
import api from '~/api'; import api from '~/api';
import { sprintf, s__, __ } from '~/locale'; import { sprintf, s__, __ } from '~/locale';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; 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 StatusIcon from './status_icon.vue';
import Actions from './actions.vue'; import Actions from './actions.vue';
...@@ -20,6 +21,7 @@ export const LOADING_STATES = { ...@@ -20,6 +21,7 @@ export const LOADING_STATES = {
collapsedLoading: 'collapsedLoading', collapsedLoading: 'collapsedLoading',
collapsedError: 'collapsedError', collapsedError: 'collapsedError',
expandedLoading: 'expandedLoading', expandedLoading: 'expandedLoading',
expandedError: 'expandedError',
}; };
export default { export default {
...@@ -40,8 +42,8 @@ export default { ...@@ -40,8 +42,8 @@ export default {
data() { data() {
return { return {
loadingState: LOADING_STATES.collapsedLoading, loadingState: LOADING_STATES.collapsedLoading,
collapsedData: null, collapsedData: {},
fullData: null, fullData: [],
isCollapsed: true, isCollapsed: true,
showFade: false, showFade: false,
}; };
...@@ -53,6 +55,9 @@ export default { ...@@ -53,6 +55,9 @@ export default {
widgetLoadingText() { widgetLoadingText() {
return this.$options.i18n?.loading || __('Loading...'); return this.$options.i18n?.loading || __('Loading...');
}, },
widgetErrorText() {
return this.$options.i18n?.error || __('Failed to load');
},
isLoadingSummary() { isLoadingSummary() {
return this.loadingState === LOADING_STATES.collapsedLoading; return this.loadingState === LOADING_STATES.collapsedLoading;
}, },
...@@ -60,11 +65,16 @@ export default { ...@@ -60,11 +65,16 @@ export default {
return this.loadingState === LOADING_STATES.expandedLoading; return this.loadingState === LOADING_STATES.expandedLoading;
}, },
isCollapsible() { isCollapsible() {
if (this.isLoadingSummary) { return !this.isLoadingSummary && this.loadingState !== LOADING_STATES.collapsedError;
return false; },
} hasFullData() {
return this.fullData.length > 0;
return true; },
hasFetchError() {
return (
this.loadingState === LOADING_STATES.collapsedError ||
this.loadingState === LOADING_STATES.expandedError
);
}, },
collapseButtonLabel() { collapseButtonLabel() {
return sprintf( return sprintf(
...@@ -75,6 +85,7 @@ export default { ...@@ -75,6 +85,7 @@ export default {
); );
}, },
statusIconName() { statusIconName() {
if (this.hasFetchError) return EXTENSION_ICONS.error;
if (this.isLoadingSummary) return null; if (this.isLoadingSummary) return null;
return this.statusIcon(this.collapsedData); return this.statusIcon(this.collapsedData);
...@@ -100,7 +111,8 @@ export default { ...@@ -100,7 +111,8 @@ export default {
}) })
.catch((e) => { .catch((e) => {
this.loadingState = LOADING_STATES.collapsedError; this.loadingState = LOADING_STATES.collapsedError;
throw e;
Sentry.captureException(e);
}); });
}, },
methods: { methods: {
...@@ -115,7 +127,7 @@ export default { ...@@ -115,7 +127,7 @@ export default {
this.triggerRedisTracking(); this.triggerRedisTracking();
}, },
loadAllData() { loadAllData() {
if (this.fullData) return; if (this.hasFullData) return;
this.loadingState = LOADING_STATES.expandedLoading; this.loadingState = LOADING_STATES.expandedLoading;
...@@ -125,8 +137,9 @@ export default { ...@@ -125,8 +137,9 @@ export default {
this.fullData = data; this.fullData = data;
}) })
.catch((e) => { .catch((e) => {
this.loadingState = null; this.loadingState = LOADING_STATES.expandedError;
throw e;
Sentry.captureException(e);
}); });
}, },
appear(index) { appear(index) {
...@@ -158,6 +171,7 @@ export default { ...@@ -158,6 +171,7 @@ export default {
> >
<div class="gl-flex-grow-1"> <div class="gl-flex-grow-1">
<template v-if="isLoadingSummary">{{ widgetLoadingText }}</template> <template v-if="isLoadingSummary">{{ widgetLoadingText }}</template>
<template v-else-if="hasFetchError">{{ widgetErrorText }}</template>
<div v-else v-safe-html="summary(collapsedData)"></div> <div v-else v-safe-html="summary(collapsedData)"></div>
</div> </div>
<actions <actions
...@@ -189,7 +203,7 @@ export default { ...@@ -189,7 +203,7 @@ export default {
<gl-loading-icon size="sm" inline /> {{ __('Loading...') }} <gl-loading-icon size="sm" inline /> {{ __('Loading...') }}
</div> </div>
<smart-virtual-list <smart-virtual-list
v-else-if="fullData" v-else-if="hasFullData"
:length="fullData.length" :length="fullData.length"
:remain="20" :remain="20"
:size="32" :size="32"
......
...@@ -54,3 +54,26 @@ import issueExtension from '~/vue_merge_request_widget/extensions/issues'; ...@@ -54,3 +54,26 @@ import issueExtension from '~/vue_merge_request_widget/extensions/issues';
// Register the imported extension // Register the imported extension
registerExtension(issueExtension); 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'),
},
};
```
...@@ -14343,6 +14343,9 @@ msgstr "" ...@@ -14343,6 +14343,9 @@ msgstr ""
msgid "Failed to install." msgid "Failed to install."
msgstr "" msgstr ""
msgid "Failed to load"
msgstr ""
msgid "Failed to load assignees." msgid "Failed to load assignees."
msgstr "" msgstr ""
......
...@@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils'; ...@@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue'; import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import * as Sentry from '@sentry/browser';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { securityReportMergeRequestDownloadPathsQueryResponse } from 'jest/vue_shared/security_reports/mock_data'; import { securityReportMergeRequestDownloadPathsQueryResponse } from 'jest/vue_shared/security_reports/mock_data';
...@@ -19,10 +20,15 @@ import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/consta ...@@ -19,10 +20,15 @@ import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/consta
import eventHub from '~/vue_merge_request_widget/event_hub'; import eventHub from '~/vue_merge_request_widget/event_hub';
import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue'; import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
import { stateKey } from '~/vue_merge_request_widget/stores/state_maps'; 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 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 { faviconDataUrl, overlayDataUrl } from '../lib/utils/mock_data';
import mockData from './mock_data'; import mockData from './mock_data';
import testExtension from './test_extension'; import {
workingExtension,
collapsedDataErrorExtension,
fullDataErrorExtension,
} from './test_extensions';
jest.mock('~/api.js'); jest.mock('~/api.js');
...@@ -892,7 +898,7 @@ describe('MrWidgetOptions', () => { ...@@ -892,7 +898,7 @@ describe('MrWidgetOptions', () => {
describe('mock extension', () => { describe('mock extension', () => {
beforeEach(() => { beforeEach(() => {
registerExtension(testExtension); registerExtension(workingExtension);
createComponent(); createComponent();
}); });
...@@ -914,7 +920,7 @@ describe('MrWidgetOptions', () => { ...@@ -914,7 +920,7 @@ describe('MrWidgetOptions', () => {
.find('[data-testid="widget-extension"] [data-testid="toggle-button"]') .find('[data-testid="widget-extension"] [data-testid="toggle-button"]')
.trigger('click'); .trigger('click');
await Vue.nextTick(); await nextTick();
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith('test_expand_event'); expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith('test_expand_event');
}); });
...@@ -926,7 +932,7 @@ describe('MrWidgetOptions', () => { ...@@ -926,7 +932,7 @@ describe('MrWidgetOptions', () => {
.find('[data-testid="widget-extension"] [data-testid="toggle-button"]') .find('[data-testid="widget-extension"] [data-testid="toggle-button"]')
.trigger('click'); .trigger('click');
await Vue.nextTick(); await nextTick();
expect( expect(
wrapper.find('[data-testid="widget-extension-top-level"]').find(GlDropdown).exists(), wrapper.find('[data-testid="widget-extension-top-level"]').find(GlDropdown).exists(),
...@@ -952,4 +958,50 @@ describe('MrWidgetOptions', () => { ...@@ -952,4 +958,50 @@ describe('MrWidgetOptions', () => {
expect(collapsedSection.find(GlButton).text()).toBe('Full report'); 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'; import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants';
export default { export const workingExtension = {
name: 'WidgetTestExtension', name: 'WidgetTestExtension',
props: ['targetProjectFullPath'], props: ['targetProjectFullPath'],
expandEvent: 'test_expand_event', expandEvent: 'test_expand_event',
...@@ -37,3 +37,63 @@ export default { ...@@ -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