Skip to content
Snippets Groups Projects
Commit 956003d5 authored by Elwyn Benson's avatar Elwyn Benson :red_circle: Committed by Robert Hunt
Browse files

Add empty state for customizable dashboard panels

This will help make it clear there is no data instead of just showing
a blank / empty panel.

Changelog: changed
MR: !115266
EE: true
parent 8a56e084
No related branches found
No related tags found
3 merge requests!118700Remove refactor_vulnerability_filters feature flag,!116602Draft: Resolve "Remove the possibility to set redis_slot in known_events",!115266Add empty state to panels
...@@ -44,10 +44,10 @@ const convertToSingleValue = (resultSet, query) => { ...@@ -44,10 +44,10 @@ const convertToSingleValue = (resultSet, query) => {
const [row] = resultSet.rawData(); const [row] = resultSet.rawData();
if (!row) { if (!row) {
return undefined; return 0;
} }
return row[measure ?? DEFAULT_COUNT_KEY] ?? Object.values(row)[0] ?? undefined; return row[measure ?? DEFAULT_COUNT_KEY] ?? Object.values(row)[0] ?? 0;
}; };
const buildDateRangeFilter = (query, queryOverrides, { startDate, endDate }) => { const buildDateRangeFilter = (query, queryOverrides, { startDate, endDate }) => {
......
import { s__ } from '~/locale';
export const GRIDSTACK_MARGIN = 10; export const GRIDSTACK_MARGIN = 10;
export const GRIDSTACK_CSS_HANDLE = '.grid-stack-item-handle'; export const GRIDSTACK_CSS_HANDLE = '.grid-stack-item-handle';
export const GRIDSTACK_CELL_HEIGHT = '120px'; export const GRIDSTACK_CELL_HEIGHT = '120px';
export const GRIDSTACK_MIN_ROW = 1; export const GRIDSTACK_MIN_ROW = 1;
export const I18N_PANEL_EMPTY_STATE_MESSAGE = s__(
'Analytics|No results match your query or filter',
);
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import dataSources from 'ee/analytics/analytics_dashboards/data_sources'; import dataSources from 'ee/analytics/analytics_dashboards/data_sources';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; 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';
export default { export default {
name: 'AnalyticsDashboardPanel', name: 'AnalyticsDashboardPanel',
...@@ -46,6 +48,11 @@ export default { ...@@ -46,6 +48,11 @@ export default {
loading: true, loading: true,
}; };
}, },
computed: {
showEmptyState() {
return !this.error && isEmptyPanelData(this.visualization.type, this.data);
},
},
watch: { watch: {
visualization: { visualization: {
handler: 'fetchData', handler: 'fetchData',
...@@ -78,6 +85,7 @@ export default { ...@@ -78,6 +85,7 @@ export default {
} }
}, },
}, },
I18N_PANEL_EMPTY_STATE_MESSAGE,
}; };
</script> </script>
...@@ -94,8 +102,16 @@ export default { ...@@ -94,8 +102,16 @@ export default {
> >
<strong>{{ title }}</strong> <strong>{{ title }}</strong>
</tooltip-on-truncate> </tooltip-on-truncate>
<div class="gl-overflow-y-auto gl-h-full" :class="{ 'gl--flex-center': loading }"> <div
class="gl-overflow-y-auto gl-h-full"
:class="{ 'gl--flex-center': loading || showEmptyState }"
>
<gl-loading-icon v-if="loading" size="lg" /> <gl-loading-icon v-if="loading" size="lg" />
<div v-else-if="showEmptyState" class="gl-text-center gl-text-secondary">
{{ $options.I18N_PANEL_EMPTY_STATE_MESSAGE }}
</div>
<component <component
:is="visualization.type" :is="visualization.type"
v-else-if="!error" v-else-if="!error"
......
import isEmpty from 'lodash/isEmpty';
import { queryToObject } from '~/lib/utils/url_utility'; import { queryToObject } from '~/lib/utils/url_utility';
import { formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; import { formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility';
import { ISO_SHORT_FORMAT } from '~/vue_shared/constants'; import { ISO_SHORT_FORMAT } from '~/vue_shared/constants';
...@@ -51,3 +52,12 @@ export const filtersToQueryParams = ({ dateRangeOption, startDate, endDate }) => ...@@ -51,3 +52,12 @@ export const filtersToQueryParams = ({ dateRangeOption, startDate, endDate }) =>
endDate: customDateRange ? formatDate(endDate, ISO_SHORT_FORMAT) : null, endDate: customDateRange ? formatDate(endDate, ISO_SHORT_FORMAT) : null,
}); });
}; };
export const isEmptyPanelData = (visualizationType, data) => {
if (visualizationType === 'SingleStat') {
// SingleStat visualizations currently do not show an empty state, and instead show a default "0" value
// This will be revisited: https://gitlab.com/gitlab-org/gitlab/-/issues/398792
return false;
}
return isEmpty(data);
};
...@@ -5,6 +5,7 @@ import PanelsBase from 'ee/vue_shared/components/customizable_dashboard/panels_b ...@@ -5,6 +5,7 @@ import PanelsBase from 'ee/vue_shared/components/customizable_dashboard/panels_b
import dataSources from 'ee/analytics/analytics_dashboards/data_sources'; import dataSources from 'ee/analytics/analytics_dashboards/data_sources';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; 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 { dashboard } from './mock_data'; import { dashboard } from './mock_data';
jest.mock('ee/analytics/analytics_dashboards/data_sources', () => ({ jest.mock('ee/analytics/analytics_dashboards/data_sources', () => ({
...@@ -70,22 +71,45 @@ describe('PanelsBase', () => { ...@@ -70,22 +71,45 @@ describe('PanelsBase', () => {
}); });
describe('when the data has been fetched', () => { describe('when the data has been fetched', () => {
const mockData = [{ name: 'foo' }]; describe('and there is data', () => {
const mockData = [{ name: 'foo' }];
beforeEach(() => { beforeEach(() => {
jest.spyOn(dataSources.cube_analytics(), 'fetch').mockReturnValue(mockData); jest.spyOn(dataSources.cube_analytics(), 'fetch').mockReturnValue(mockData);
createWrapper(); createWrapper();
return waitForPromises(); return waitForPromises();
}); });
it('should not render the loading icon', () => { it('should not render the loading icon', () => {
expect(findLoadingIcon().exists()).toBe(false); expect(findLoadingIcon().exists()).toBe(false);
});
it('should not render the empty state', () => {
expect(wrapper.text()).not.toContain(I18N_PANEL_EMPTY_STATE_MESSAGE);
});
it('should render the visualization with the fetched data', () => {
expect(findVisualization().props()).toMatchObject({
data: mockData,
options: panelConfig.visualization.options,
});
});
}); });
it('should render the visualization with the fetched data', () => { describe('and there is no data', () => {
expect(findVisualization().props()).toMatchObject({ beforeEach(() => {
data: mockData, jest.spyOn(dataSources.cube_analytics(), 'fetch').mockReturnValue(undefined);
options: panelConfig.visualization.options, createWrapper();
return waitForPromises();
});
it('should not render the loading icon', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
it('should render the empty state', () => {
const text = wrapper.text();
expect(text).toContain(I18N_PANEL_EMPTY_STATE_MESSAGE);
}); });
}); });
}); });
...@@ -103,6 +127,10 @@ describe('PanelsBase', () => { ...@@ -103,6 +127,10 @@ describe('PanelsBase', () => {
expect(findLoadingIcon().exists()).toBe(false); expect(findLoadingIcon().exists()).toBe(false);
}); });
it('should not render the empty state', () => {
expect(wrapper.text()).not.toContain(I18N_PANEL_EMPTY_STATE_MESSAGE);
});
it('should not render the visualization', () => { it('should not render the visualization', () => {
expect(findVisualization().exists()).toBe(false); expect(findVisualization().exists()).toBe(false);
}); });
......
import { import {
getDateRangeOption,
dateRangeOptionToFilter,
buildDefaultDashboardFilters, buildDefaultDashboardFilters,
dateRangeOptionToFilter,
filtersToQueryParams, filtersToQueryParams,
getDateRangeOption,
isEmptyPanelData,
} from 'ee/vue_shared/components/customizable_dashboard/utils'; } from 'ee/vue_shared/components/customizable_dashboard/utils';
import { parsePikadayDate } from '~/lib/utils/datetime_utility'; import { parsePikadayDate } from '~/lib/utils/datetime_utility';
import { import {
DATE_RANGE_OPTIONS,
CUSTOM_DATE_RANGE_KEY, CUSTOM_DATE_RANGE_KEY,
DATE_RANGE_OPTIONS,
DEFAULT_SELECTED_OPTION_INDEX, DEFAULT_SELECTED_OPTION_INDEX,
} from 'ee/vue_shared/components/customizable_dashboard/filters/constants'; } from 'ee/vue_shared/components/customizable_dashboard/filters/constants';
import { mockDateRangeFilterChangePayload } from './mock_data'; import { mockDateRangeFilterChangePayload } from './mock_data';
...@@ -99,3 +100,19 @@ describe('filtersToQueryParams', () => { ...@@ -99,3 +100,19 @@ describe('filtersToQueryParams', () => {
}); });
}); });
}); });
describe('isEmptyPanelData', () => {
it.each`
visualizationType | value | expected
${'SingleStat'} | ${[]} | ${false}
${'SingleStat'} | ${1} | ${false}
${'LineChart'} | ${[]} | ${true}
${'LineChart'} | ${[1]} | ${false}
`(
'returns $expected for visualization "$visualizationType" with value "$value"',
async ({ visualizationType, value, expected }) => {
const result = isEmptyPanelData(visualizationType, value);
expect(result).toBe(expected);
},
);
});
...@@ -4834,6 +4834,9 @@ msgstr "" ...@@ -4834,6 +4834,9 @@ msgstr ""
msgid "Analytics|No dashboard matches the specified URL path." msgid "Analytics|No dashboard matches the specified URL path."
msgstr "" msgstr ""
   
msgid "Analytics|No results match your query or filter"
msgstr ""
msgid "Analytics|OS" msgid "Analytics|OS"
msgstr "" msgstr ""
   
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment