From 25dd144201e8b0eaa3532a9a169c46bfa9b46c94 Mon Sep 17 00:00:00 2001 From: Elwyn Benson <ebenson@gitlab.com> Date: Mon, 31 Jul 2023 07:57:35 +0000 Subject: [PATCH] Add error state to panels Each panel now deals with its own error handling and displays an internal error state instead of emitting up to parent component. --- .../customizable_dashboard/constants.js | 11 +++- .../customizable_dashboard.vue | 13 ----- .../customizable_dashboard/panels_base.vue | 55 ++++++++++++++++--- .../customizable_dashboard_spec.js | 30 ---------- .../panels_base_spec.js | 31 +++++++++-- locale/gitlab.pot | 14 +++-- 6 files changed, 94 insertions(+), 60 deletions(-) diff --git a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/constants.js b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/constants.js index bfd365de30de..263395339e8c 100644 --- a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/constants.js +++ b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/constants.js @@ -1,4 +1,5 @@ import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; export const GRIDSTACK_MARGIN = 10; export const GRIDSTACK_CSS_HANDLE = '.grid-stack-item-handle'; @@ -6,7 +7,15 @@ export const GRIDSTACK_CELL_HEIGHT = '120px'; export const GRIDSTACK_MIN_ROW = 1; export const I18N_PANEL_EMPTY_STATE_MESSAGE = s__( - 'Analytics|No results match your query or filter', + 'Analytics|No results match your query or filter.', +); +export const I18N_PANEL_ERROR_STATE_MESSAGE = s__('Analytics|Something went wrong.'); +export const I18N_PANEL_ERROR_POPOVER_TITLE = s__('Analytics|Failed to fetch data'); +export const I18N_PANEL_ERROR_POPOVER_MESSAGE = s__( + 'Analytics|Something went wrong while connecting to your data source. See %{linkStart}troubleshooting documentation%{linkEnd}.', +); +export const PANEL_TROUBLESHOOTING_URL = helpPagePath( + '/user/analytics/analytics_dashboards#troubleshooting', ); export const CURSOR_GRABBING_CLASS = 'gl-cursor-grabbing!'; diff --git a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.vue b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.vue index 93c9f0c222ab..5dfcfa3a74f7 100644 --- a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.vue +++ b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.vue @@ -5,8 +5,6 @@ import cloneDeep from 'lodash/cloneDeep'; import { GlButton, GlFormInput, GlForm } from '@gitlab/ui'; import { loadCSSFile } from '~/lib/utils/css_utils'; import { slugify } from '~/lib/utils/text_utility'; -import { createAlert } from '~/alert'; -import { s__, sprintf } from '~/locale'; import UrlSync, { HISTORY_REPLACE_UPDATE_METHOD } from '~/vue_shared/components/url_sync.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { createNewVisualizationPanel } from 'ee/analytics/analytics_dashboards/utils'; @@ -250,16 +248,6 @@ export default { updatedPanel.gridAttributes = this.convertToGridAttributes(item); } }, - handlePanelError(panelTitle, error) { - this.alert = createAlert({ - message: sprintf( - s__('ProductAnalytics|An error occurred while loading the %{panelTitle} panel.'), - { panelTitle }, - ), - error, - captureError: true, - }); - }, setDateRangeFilter({ dateRangeOption, startDate, endDate }) { this.filters = { ...this.filters, @@ -381,7 +369,6 @@ export default { :visualization="panel.visualization" :query-overrides="panel.queryOverrides || undefined" :filters="filters" - @error="handlePanelError(panel.title, $event)" /> </div> </div> diff --git a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/panels_base.vue b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/panels_base.vue index 9caa05d74744..14d2e8d59010 100644 --- a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/panels_base.vue +++ b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/panels_base.vue @@ -1,14 +1,25 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { GlIcon, GlLink, GlLoadingIcon, GlPopover, GlSprintf } from '@gitlab/ui'; import dataSources from 'ee/analytics/analytics_dashboards/data_sources'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { isEmptyPanelData } from 'ee/vue_shared/components/customizable_dashboard/utils'; -import { I18N_PANEL_EMPTY_STATE_MESSAGE } from './constants'; +import { + I18N_PANEL_EMPTY_STATE_MESSAGE, + I18N_PANEL_ERROR_POPOVER_MESSAGE, + I18N_PANEL_ERROR_POPOVER_TITLE, + I18N_PANEL_ERROR_STATE_MESSAGE, + PANEL_TROUBLESHOOTING_URL, +} from './constants'; export default { name: 'AnalyticsDashboardPanel', components: { + GlIcon, + GlLink, GlLoadingIcon, + GlPopover, + GlSprintf, TooltipOnTruncate, LineChart: () => import('ee/analytics/analytics_dashboards/components/visualizations/line_chart.vue'), @@ -52,6 +63,9 @@ export default { showEmptyState() { return !this.error && isEmptyPanelData(this.visualization.type, this.data); }, + showErrorState() { + return Boolean(this.error); + }, }, watch: { visualization: { @@ -80,19 +94,25 @@ export default { }); } catch (error) { this.error = error; - this.$emit('error', error); + Sentry.captureException(error); } finally { this.loading = false; } }, }, I18N_PANEL_EMPTY_STATE_MESSAGE, + I18N_PANEL_ERROR_STATE_MESSAGE, + I18N_PANEL_ERROR_POPOVER_TITLE, + I18N_PANEL_ERROR_POPOVER_MESSAGE, + PANEL_TROUBLESHOOTING_URL, }; </script> <template> <div + ref="panelWrapper" class="grid-stack-item-content gl-shadow-sm gl-rounded-base gl-p-4 gl-display-flex gl-flex-direction-column gl-bg-white" + :class="{ 'gl-border-t-2 gl-border-t-solid gl-border-red-500': showErrorState }" > <tooltip-on-truncate v-if="title" @@ -101,18 +121,20 @@ export default { boundary="viewport" class="gl-pb-3 gl-text-truncate" > + <gl-icon v-if="showErrorState" name="warning" class="gl-text-red-500" /> <strong>{{ title }}</strong> </tooltip-on-truncate> - <div - class="gl-overflow-y-auto gl-h-full" - :class="{ 'gl--flex-center': loading || showEmptyState }" - > + <div class="gl-overflow-y-auto gl-h-full" :class="{ 'gl--flex-center': loading }"> <gl-loading-icon v-if="loading" size="lg" /> - <div v-else-if="showEmptyState" class="gl-text-center gl-text-secondary"> + <div v-else-if="showEmptyState" class="gl-text-secondary"> {{ $options.I18N_PANEL_EMPTY_STATE_MESSAGE }} </div> + <div v-else-if="showErrorState" class="gl-text-secondary"> + {{ $options.I18N_PANEL_ERROR_STATE_MESSAGE }} + </div> + <component :is="visualization.type" v-else-if="!error" @@ -120,5 +142,22 @@ export default { :options="visualization.options" /> </div> + + <gl-popover + v-if="showErrorState" + triggers="hover focus" + :title="$options.I18N_PANEL_ERROR_POPOVER_TITLE" + :show-close-button="false" + placement="top" + :target="$refs.panelWrapper" + > + <gl-sprintf :message="$options.I18N_PANEL_ERROR_POPOVER_MESSAGE"> + <template #link="{ content }"> + <gl-link :href="$options.PANEL_TROUBLESHOOTING_URL" class="gl-font-sm">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </gl-popover> </div> </template> diff --git a/ee/spec/frontend/vue_shared/components/customizable_dashboard/customizable_dashboard_spec.js b/ee/spec/frontend/vue_shared/components/customizable_dashboard/customizable_dashboard_spec.js index 1918aee78f0b..905645cdbd11 100644 --- a/ee/spec/frontend/vue_shared/components/customizable_dashboard/customizable_dashboard_spec.js +++ b/ee/spec/frontend/vue_shared/components/customizable_dashboard/customizable_dashboard_spec.js @@ -15,7 +15,6 @@ import { NEW_DASHBOARD_SLUG, } from 'ee/vue_shared/components/customizable_dashboard/constants'; import { loadCSSFile } from '~/lib/utils/css_utils'; -import { createAlert } from '~/alert'; import waitForPromises from 'helpers/wait_for_promises'; import { filtersToQueryParams, @@ -27,13 +26,6 @@ import { NEW_DASHBOARD } from 'ee/analytics/analytics_dashboards/constants'; import { TEST_VISUALIZATION } from 'ee_jest/analytics/analytics_dashboards/mock_data'; import { dashboard, builtinDashboard, mockDateRangeFilterChangePayload } from './mock_data'; -const mockAlertDismiss = jest.fn(); -jest.mock('~/alert', () => ({ - createAlert: jest.fn().mockImplementation(() => ({ - dismiss: mockAlertDismiss, - })), -})); - jest.mock('gridstack', () => ({ GridStack: { init: jest.fn(() => { @@ -187,18 +179,6 @@ describe('CustomizableDashboard', () => { }, ); - it('calls createAlert when a panel emits an error', () => { - const error = new Error('foo'); - - findPanels().at(0).vm.$emit('error', error); - - expect(createAlert).toHaveBeenCalledWith({ - message: `An error occurred while loading the ${dashboard.panels[0].title} panel.`, - captureError: true, - error, - }); - }); - it('does not show the Edit Button for a custom dashboard', () => { expect(findEditButton().exists()).toBe(false); }); @@ -216,16 +196,6 @@ describe('CustomizableDashboard', () => { beforeEach(() => { createWrapper(); }); - - it('should dismiss the alert', async () => { - findPanels().at(0).vm.$emit('error', new Error()); - - wrapper.destroy(); - - await nextTick(); - - expect(mockAlertDismiss).toHaveBeenCalled(); - }); }); describe('when builtin Dashboard is loaded', () => { diff --git a/ee/spec/frontend/vue_shared/components/customizable_dashboard/panels_base_spec.js b/ee/spec/frontend/vue_shared/components/customizable_dashboard/panels_base_spec.js index 3f46a4609e49..1f0e43a024a7 100644 --- a/ee/spec/frontend/vue_shared/components/customizable_dashboard/panels_base_spec.js +++ b/ee/spec/frontend/vue_shared/components/customizable_dashboard/panels_base_spec.js @@ -1,11 +1,16 @@ -import { GlLoadingIcon } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { GlLoadingIcon, GlPopover } from '@gitlab/ui'; import LineChart from 'ee/analytics/analytics_dashboards/components/visualizations/line_chart.vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import PanelsBase from 'ee/vue_shared/components/customizable_dashboard/panels_base.vue'; import dataSources from 'ee/analytics/analytics_dashboards/data_sources'; import waitForPromises from 'helpers/wait_for_promises'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; -import { I18N_PANEL_EMPTY_STATE_MESSAGE } from 'ee/vue_shared/components/customizable_dashboard/constants'; +import { + I18N_PANEL_EMPTY_STATE_MESSAGE, + I18N_PANEL_ERROR_POPOVER_TITLE, + I18N_PANEL_ERROR_STATE_MESSAGE, +} from 'ee/vue_shared/components/customizable_dashboard/constants'; import { dashboard } from './mock_data'; jest.mock('ee/analytics/analytics_dashboards/data_sources', () => ({ @@ -34,6 +39,7 @@ describe('PanelsBase', () => { const findVisualization = () => wrapper.findComponent(LineChart); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findPanelTitle = () => wrapper.findComponent(TooltipOnTruncate); + const findPanelErrorPopover = () => wrapper.findComponent(GlPopover); describe('default behaviour', () => { beforeEach(() => { @@ -116,13 +122,20 @@ describe('PanelsBase', () => { describe('when there was an error while fetching the data', () => { const mockError = new Error('foo'); + let captureExceptionSpy; beforeEach(() => { jest.spyOn(dataSources.cube_analytics(), 'fetch').mockRejectedValue(mockError); + captureExceptionSpy = jest.spyOn(Sentry, 'captureException'); + createWrapper(); return waitForPromises(); }); + afterEach(() => { + captureExceptionSpy.mockRestore(); + }); + it('should not render the loading icon', () => { expect(findLoadingIcon().exists()).toBe(false); }); @@ -135,8 +148,18 @@ describe('PanelsBase', () => { expect(findVisualization().exists()).toBe(false); }); - it('should emit an error event', () => { - expect(wrapper.emitted('error')[0]).toStrictEqual([mockError]); + it('should render the error state', () => { + expect(wrapper.text()).toContain(I18N_PANEL_ERROR_STATE_MESSAGE); + }); + + it('should render a popover with more information on the error', () => { + const popover = findPanelErrorPopover(); + expect(popover.exists()).toBe(true); + expect(popover.props('title')).toBe(I18N_PANEL_ERROR_POPOVER_TITLE); + }); + + it('should log the error to Sentry', () => { + expect(captureExceptionSpy).toHaveBeenCalledWith(mockError); }); }); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index c1a96a97fe23..880da90ded5d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5351,6 +5351,9 @@ msgstr "" msgid "Analytics|Error while saving visualization." msgstr "" +msgid "Analytics|Failed to fetch data" +msgstr "" + msgid "Analytics|Host" msgstr "" @@ -5369,7 +5372,7 @@ msgstr "" msgid "Analytics|No dashboard matches the specified URL path." msgstr "" -msgid "Analytics|No results match your query or filter" +msgid "Analytics|No results match your query or filter." msgstr "" msgid "Analytics|OS" @@ -5414,6 +5417,12 @@ msgstr "" msgid "Analytics|Single Statistic" msgstr "" +msgid "Analytics|Something went wrong while connecting to your data source. See %{linkStart}troubleshooting documentation%{linkEnd}." +msgstr "" + +msgid "Analytics|Something went wrong." +msgstr "" + msgid "Analytics|To create your own dashboards, first configure a project to store your dashboards." msgstr "" @@ -35165,9 +35174,6 @@ msgstr "" msgid "ProductAnalytics|An error occurred while fetching data. Refresh the page to try again." msgstr "" -msgid "ProductAnalytics|An error occurred while loading the %{panelTitle} panel." -msgstr "" - msgid "ProductAnalytics|Analyze your product with Product Analytics" msgstr "" -- GitLab