Commit d79e9ba2 authored by Michael Lunøe's avatar Michael Lunøe Committed by Paul Slaughter

Feat(Insights Charts): use skeleton chart loader

To add a nicer loading experience for the user
we use the general skeleton chart loader

Ref. https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35012
parent 55c3ea37
...@@ -34,8 +34,18 @@ export default { ...@@ -34,8 +34,18 @@ export default {
'configLoading', 'configLoading',
'activeTab', 'activeTab',
'activePage', 'activePage',
'pageLoading', 'chartData',
]), ]),
pageLoading() {
const requestedChartKeys = this.activePage?.charts?.map(chart => chart.title) || [];
const storeChartKeys = Object.keys(this.chartData);
const loadedCharts = storeChartKeys.filter(key => this.chartData[key].loaded);
const chartsLoaded =
Boolean(requestedChartKeys.length) &&
requestedChartKeys.every(key => loadedCharts.includes(key));
const chartsErrored = storeChartKeys.some(key => this.chartData[key].error);
return !chartsLoaded && !chartsErrored;
},
pages() { pages() {
const { configData, activeTab } = this; const { configData, activeTab } = this;
......
...@@ -3,6 +3,7 @@ import { GlColumnChart, GlLineChart, GlStackedColumnChart } from '@gitlab/ui/dis ...@@ -3,6 +3,7 @@ import { GlColumnChart, GlLineChart, GlStackedColumnChart } from '@gitlab/ui/dis
import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue'; import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import InsightsChartError from './insights_chart_error.vue'; import InsightsChartError from './insights_chart_error.vue';
import { CHART_TYPES } from '../constants'; import { CHART_TYPES } from '../constants';
...@@ -14,8 +15,9 @@ export default { ...@@ -14,8 +15,9 @@ export default {
GlColumnChart, GlColumnChart,
GlLineChart, GlLineChart,
GlStackedColumnChart, GlStackedColumnChart,
ResizableChartContainer,
InsightsChartError, InsightsChartError,
ResizableChartContainer,
ChartSkeletonLoader,
}, },
props: { props: {
loaded: { loaded: {
...@@ -117,7 +119,15 @@ export default { ...@@ -117,7 +119,15 @@ export default {
}; };
</script> </script>
<template> <template>
<resizable-chart-container v-if="loaded" class="insights-chart"> <div v-if="error" class="insights-chart">
<insights-chart-error
:chart-name="title"
:title="__('This chart could not be displayed')"
:summary="__('Please check the configuration file for this chart')"
:error="error"
/>
</div>
<resizable-chart-container v-else-if="loaded" class="insights-chart">
<h5 class="text-center">{{ title }}</h5> <h5 class="text-center">{{ title }}</h5>
<p v-if="description" class="text-center">{{ description }}</p> <p v-if="description" class="text-center">{{ description }}</p>
<gl-column-chart <gl-column-chart
...@@ -153,12 +163,5 @@ export default { ...@@ -153,12 +163,5 @@ export default {
@created="onChartCreated" @created="onChartCreated"
/> />
</resizable-chart-container> </resizable-chart-container>
<div v-else-if="error" class="insights-chart"> <chart-skeleton-loader v-else />
<insights-chart-error
:chart-name="title"
:title="__('This chart could not be displayed')"
:summary="__('Please check the configuration file for this chart')"
:error="error"
/>
</div>
</template> </template>
<script> <script>
import { GlLoadingIcon } from '@gitlab/ui'; import { GlEmptyState } from '@gitlab/ui';
import { isUndefined } from 'lodash'; import { isUndefined } from 'lodash';
import { mapActions, mapState } from 'vuex'; import { mapActions, mapState } from 'vuex';
import InsightsConfigWarning from './insights_config_warning.vue';
import InsightsChart from './insights_chart.vue'; import InsightsChart from './insights_chart.vue';
import { __ } from '~/locale';
export default { export default {
components: { components: {
GlLoadingIcon, GlEmptyState,
InsightsChart, InsightsChart,
InsightsConfigWarning,
}, },
props: { props: {
queryEndpoint: { queryEndpoint: {
...@@ -23,23 +22,27 @@ export default { ...@@ -23,23 +22,27 @@ export default {
}, },
}, },
computed: { computed: {
...mapState('insights', ['chartData', 'pageLoading']), ...mapState('insights', ['chartData']),
emptyState() {
return {
title: __('There are no charts configured for this page'),
description: __(
'Please check the configuration file to ensure that a collection of charts has been declared.',
),
};
},
charts() { charts() {
return this.pageConfig.charts; return this.pageConfig.charts;
}, },
chartKeys() { chartKeys() {
return this.charts.map(chart => chart.title); return this.charts.map(chart => chart.title);
}, },
storeKeys() {
return Object.keys(this.chartData);
},
hasChartsConfigured() { hasChartsConfigured() {
return !isUndefined(this.charts) && this.charts.length > 0; return !isUndefined(this.charts) && this.charts.length > 0;
}, },
}, },
watch: { watch: {
pageConfig() { pageConfig() {
this.setPageLoading(true);
this.fetchCharts(); this.fetchCharts();
}, },
}, },
...@@ -47,26 +50,14 @@ export default { ...@@ -47,26 +50,14 @@ export default {
this.fetchCharts(); this.fetchCharts();
}, },
methods: { methods: {
...mapActions('insights', ['fetchChartData', 'initChartData', 'setPageLoading']), ...mapActions('insights', ['fetchChartData', 'initChartData']),
fetchCharts() { fetchCharts() {
if (this.hasChartsConfigured) { if (this.hasChartsConfigured) {
this.initChartData(this.chartKeys); this.initChartData(this.chartKeys);
const insightsRequests = this.charts.map(chart => this.charts.forEach(chart => this.fetchChartData({ endpoint: this.queryEndpoint, chart }));
this.fetchChartData({ endpoint: this.queryEndpoint, chart }),
);
Promise.all(insightsRequests)
.then(() => {
this.setPageLoading(!this.storePopulated());
})
.catch(() => {
this.setPageLoading(false);
});
} }
}, },
storePopulated() {
return this.chartKeys.filter(key => this.storeKeys.includes(key)).length > 0;
},
}, },
}; };
</script> </script>
...@@ -86,19 +77,12 @@ export default { ...@@ -86,19 +77,12 @@ export default {
:error="error" :error="error"
/> />
</div> </div>
<div v-if="pageLoading" class="insights-chart-loading text-center p-5">
<gl-loading-icon :inline="true" size="lg" />
</div>
</div> </div>
<insights-config-warning <gl-empty-state
v-else v-else
:title="__('There are no charts configured for this page')" :title="emptyState.title"
:summary=" :description="emptyState.description"
__( svg-path="/assets/illustrations/monitoring/getting_started.svg"
'Please check the configuration file to ensure that a collection of charts has been declared.',
)
"
image="illustrations/monitoring/getting_started.svg"
/> />
</div> </div>
</template> </template>
...@@ -70,6 +70,4 @@ export const setActiveTab = ({ commit, state }, key) => { ...@@ -70,6 +70,4 @@ export const setActiveTab = ({ commit, state }, key) => {
} }
}; };
export const initChartData = ({ commit }, store) => commit(types.INIT_CHART_DATA, store); export const initChartData = ({ commit }, keys) => commit(types.INIT_CHART_DATA, keys);
export const setPageLoading = ({ commit }, pageLoading) =>
commit(types.SET_PAGE_LOADING, pageLoading);
...@@ -53,7 +53,4 @@ export default { ...@@ -53,7 +53,4 @@ export default {
return acc; return acc;
}, {}); }, {});
}, },
[types.SET_PAGE_LOADING](state, pageLoading) {
state.pageLoading = pageLoading;
},
}; };
...@@ -4,5 +4,4 @@ export default () => ({ ...@@ -4,5 +4,4 @@ export default () => ({
activeTab: null, activeTab: null,
activePage: null, activePage: null,
chartData: {}, chartData: {},
pageLoading: true,
}); });
---
title: Use new chart loader on Insights page
merge_request: 37815
author:
type: changed
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
} from 'ee_jest/insights/mock_data'; } from 'ee_jest/insights/mock_data';
import InsightsChart from 'ee/insights/components/insights_chart.vue'; import InsightsChart from 'ee/insights/components/insights_chart.vue';
import InsightsChartError from 'ee/insights/components/insights_chart_error.vue'; import InsightsChartError from 'ee/insights/components/insights_chart_error.vue';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { CHART_TYPES } from 'ee/insights/constants'; import { CHART_TYPES } from 'ee/insights/constants';
describe('Insights chart component', () => { describe('Insights chart component', () => {
...@@ -24,6 +25,20 @@ describe('Insights chart component', () => { ...@@ -24,6 +25,20 @@ describe('Insights chart component', () => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('when chart is loading', () => {
it('displays the chart loader', () => {
wrapper = factory({
loaded: false,
type: CHART_TYPES.BAR,
title: chartInfo.title,
data: null,
error: '',
});
expect(wrapper.contains(ChartSkeletonLoader)).toBe(true);
});
});
describe('when chart is loaded', () => { describe('when chart is loaded', () => {
it('displays a bar chart', () => { it('displays a bar chart', () => {
wrapper = factory({ wrapper = factory({
......
import Vue from 'vue'; import Vuex from 'vuex';
import { GlEmptyState } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlColumnChart } from '@gitlab/ui/dist/charts';
import InsightsPage from 'ee/insights/components/insights_page.vue'; import InsightsPage from 'ee/insights/components/insights_page.vue';
import InsightsChart from 'ee/insights/components/insights_chart.vue';
import { createStore } from 'ee/insights/stores'; import { createStore } from 'ee/insights/stores';
import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import { chartInfo, pageInfo, pageInfoNoCharts } from 'ee_jest/insights/mock_data'; import { chartInfo, pageInfo, pageInfoNoCharts, barChartData } from 'ee_jest/insights/mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Insights page component', () => { describe('Insights page component', () => {
let component;
let store; let store;
let Component; let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(InsightsPage, {
localVue,
store,
propsData: {
queryEndpoint: `${TEST_HOST}/query`,
pageConfig: pageInfoNoCharts,
...props,
},
});
};
const createLoadingChartData = () => {
return pageInfo.charts.reduce((memo, chart) => {
return { ...memo, [chart.title]: {} };
}, {});
};
const createLoadedChartData = () => {
return pageInfo.charts.reduce((memo, chart) => {
return {
...memo,
[chart.title]: {
loaded: true,
type: chart.type,
description: '',
data: barChartData,
error: null,
},
};
}, {});
};
const findInsightsChartData = () => wrapper.find(InsightsChart);
beforeEach(() => { beforeEach(() => {
store = createStore(); store = createStore();
jest.spyOn(store, 'dispatch').mockImplementation(() => {}); jest.spyOn(store, 'dispatch').mockImplementation(() => {});
Component = Vue.extend(InsightsPage);
}); });
afterEach(() => { afterEach(() => {
component.$destroy(); wrapper.destroy();
wrapper = null;
}); });
describe('no chart config available', () => { describe('no chart config available', () => {
it('shows an empty state', () => { beforeEach(() => {
component = mountComponentWithStore(Component, { createComponent();
store, });
props: {
queryEndpoint: `${TEST_HOST}/query`, it('does not fetch chart data when mounted', () => {
pageConfig: pageInfoNoCharts, expect(store.dispatch).not.toHaveBeenCalled();
},
}); });
expect(component.$el.querySelector('.js-empty-state')).not.toBe(null); it('shows an empty state', () => {
expect(wrapper.contains(GlEmptyState)).toBe(true);
}); });
}); });
describe('charts configured', () => { describe('charts configured', () => {
beforeEach(() => { beforeEach(() => {
component = mountComponentWithStore(Component, { createComponent({ pageConfig: pageInfo });
store,
props: {
queryEndpoint: `${TEST_HOST}/query`,
pageConfig: pageInfo,
},
});
}); });
it('fetches chart data when mounted', () => { it('fetches chart data when mounted', () => {
...@@ -52,44 +87,44 @@ describe('Insights page component', () => { ...@@ -52,44 +87,44 @@ describe('Insights page component', () => {
}); });
}); });
describe('when charts loading', () => { it('does not show empty state', () => {
beforeEach(() => { expect(wrapper.contains(GlEmptyState)).toBe(false);
component.$store.state.insights.pageLoading = true;
}); });
it('renders loading state', () => { describe('pageConfig changes', () => {
return component.$nextTick(() => { it('reflects new state', async () => {
expect( wrapper.setProps({ pageConfig: pageInfoNoCharts });
component.$el.querySelector('.js-insights-page-container .insights-chart-loading'),
).not.toBe(null); await wrapper.vm.$nextTick();
expect(wrapper.contains(GlEmptyState)).toBe(true);
});
}); });
describe('when charts loading', () => {
beforeEach(() => {
store.state.insights.chartData = createLoadingChartData();
}); });
it('does display chart area', () => { it('renders loading state', () => {
return component.$nextTick(() => { expect(findInsightsChartData().props()).toMatchObject({
expect( loaded: false,
component.$el.querySelector('.js-insights-page-container .insights-charts'),
).not.toBe(null);
}); });
}); });
it('does not display chart', () => { it('does not display chart', () => {
return component.$nextTick(() => { expect(wrapper.contains(GlColumnChart)).toBe(false);
expect(
component.$el.querySelector(
'.js-insights-page-container .insights-charts .insights-chart',
),
).toBe(null);
});
}); });
}); });
describe('pageConfig changes', () => { describe('charts configured and loaded', () => {
it('reflects new state', () => { beforeEach(() => {
component.pageConfig = pageInfoNoCharts; store.state.insights.chartData = createLoadedChartData();
});
return component.$nextTick(() => { it('does not render loading state', () => {
expect(component.$el.querySelector('.js-empty-state')).not.toBe(null); expect(findInsightsChartData().props()).toMatchObject({
loaded: true,
}); });
}); });
}); });
......
...@@ -56,8 +56,13 @@ describe('Insights component', () => { ...@@ -56,8 +56,13 @@ describe('Insights component', () => {
describe('when config loaded', () => { describe('when config loaded', () => {
const title = 'Bugs Per Team'; const title = 'Bugs Per Team';
const chart1 = { title: 'foo' };
const chart2 = { title: 'bar' };
describe('when charts have not been initialized', () => {
const page = { const page = {
title, title,
charts: [],
}; };
beforeEach(() => { beforeEach(() => {
...@@ -71,15 +76,63 @@ describe('Insights component', () => { ...@@ -71,15 +76,63 @@ describe('Insights component', () => {
it('has the correct nav tabs', () => { it('has the correct nav tabs', () => {
return vm.$nextTick(() => { return vm.$nextTick(() => {
expect(vm.$el.querySelector('.js-insights-dropdown')).not.toBe(null); expect(vm.$el.querySelector('.js-insights-dropdown')).not.toBe(null);
expect(vm.$el.querySelector('.js-insights-dropdown .dropdown-item').innerText.trim()).toBe( expect(
vm.$el.querySelector('.js-insights-dropdown .dropdown-item').innerText.trim(),
).toBe(title);
});
});
it('disables the tab selector', () => {
return vm.$nextTick(() => {
expect(
vm.$el.querySelector('.js-insights-dropdown > button').getAttribute('disabled'),
).toBe('disabled');
});
});
});
describe('when charts have been initialized', () => {
const page = {
title, title,
); charts: [chart1, chart2],
};
beforeEach(() => {
vm.$store.state.insights.configLoading = false;
vm.$store.state.insights.activePage = page;
vm.$store.state.insights.configData = {
bugsPerTeam: page,
};
vm.$store.state.insights.chartData = {
[chart1.title]: {},
[chart2.title]: {},
};
});
it('enables the tab selector', () => {
return vm.$nextTick(() => {
expect(
vm.$el.querySelector('.js-insights-dropdown > button').getAttribute('disabled'),
).toBe('disabled');
});
}); });
}); });
describe('when loading page', () => { describe('when some charts have been loaded', () => {
const page = {
title,
charts: [chart1],
};
beforeEach(() => { beforeEach(() => {
vm.$store.state.insights.pageLoading = true; vm.$store.state.insights.configLoading = false;
vm.$store.state.insights.activePage = page;
vm.$store.state.insights.configData = {
bugsPerTeam: page,
};
vm.$store.state.insights.chartData = {
[chart2.title]: { loaded: true },
};
}); });
it('disables the tab selector', () => { it('disables the tab selector', () => {
...@@ -90,6 +143,60 @@ describe('Insights component', () => { ...@@ -90,6 +143,60 @@ describe('Insights component', () => {
}); });
}); });
}); });
describe('when all charts have loaded', () => {
const page = {
title,
charts: [chart1, chart2],
};
beforeEach(() => {
vm.$store.state.insights.configLoading = false;
vm.$store.state.insights.activePage = page;
vm.$store.state.insights.configData = {
bugsPerTeam: page,
};
vm.$store.state.insights.chartData = {
[chart1.title]: { loaded: true },
[chart2.title]: { loaded: true },
};
});
it('enables the tab selector', () => {
return vm.$nextTick(() => {
expect(
vm.$el.querySelector('.js-insights-dropdown > button').getAttribute('disabled'),
).toBe(null);
});
});
});
describe('when one chart has an error', () => {
const page = {
title,
charts: [chart1, chart2],
};
beforeEach(() => {
vm.$store.state.insights.configLoading = false;
vm.$store.state.insights.activePage = page;
vm.$store.state.insights.configData = {
bugsPerTeam: page,
};
vm.$store.state.insights.chartData = {
[chart1.title]: { error: 'Baz' },
[chart2.title]: { loaded: true },
};
});
it('enables the tab selector', () => {
return vm.$nextTick(() => {
expect(
vm.$el.querySelector('.js-insights-dropdown > button').getAttribute('disabled'),
).toBe(null);
});
});
});
}); });
describe('empty config', () => { describe('empty config', () => {
...@@ -105,6 +212,12 @@ describe('Insights component', () => { ...@@ -105,6 +212,12 @@ describe('Insights component', () => {
); );
}); });
}); });
it('does not display dropdown', () => {
return vm.$nextTick(() => {
expect(vm.$el.querySelector('.js-insights-dropdown > button')).toBe(null);
});
});
}); });
describe('filtered out items', () => { describe('filtered out items', () => {
...@@ -120,6 +233,12 @@ describe('Insights component', () => { ...@@ -120,6 +233,12 @@ describe('Insights component', () => {
); );
}); });
}); });
it('does not display dropdown', () => {
return vm.$nextTick(() => {
expect(vm.$el.querySelector('.js-insights-dropdown > button')).toBe(null);
});
});
}); });
describe('hash fragment present', () => { describe('hash fragment present', () => {
......
...@@ -295,18 +295,4 @@ describe('Insights store actions', () => { ...@@ -295,18 +295,4 @@ describe('Insights store actions', () => {
); );
}); });
}); });
describe('setPageLoading', () => {
it('commits SET_PAGE_LOADING', () => {
const pageLoading = false;
return testAction(
actions.setPageLoading,
pageLoading,
null,
[{ type: 'SET_PAGE_LOADING', payload: false }],
[],
);
});
});
}); });
...@@ -114,6 +114,10 @@ describe('Insights mutations', () => { ...@@ -114,6 +114,10 @@ describe('Insights mutations', () => {
seriesNames: ['Dataset 1', 'Dataset 2'], seriesNames: ['Dataset 1', 'Dataset 2'],
}; };
beforeEach(() => {
mutations[types.INIT_CHART_DATA](state, [chart.title]);
});
it('sets charts loaded state to true on success', () => { it('sets charts loaded state to true on success', () => {
mutations[types.RECEIVE_CHART_SUCCESS](state, { chart, data: incomingData }); mutations[types.RECEIVE_CHART_SUCCESS](state, { chart, data: incomingData });
...@@ -192,14 +196,4 @@ describe('Insights mutations', () => { ...@@ -192,14 +196,4 @@ describe('Insights mutations', () => {
expect(state.chartData).toEqual({ a: {}, b: {} }); expect(state.chartData).toEqual({ a: {}, b: {} });
}); });
}); });
describe(types.SET_PAGE_LOADING, () => {
const pageLoading = true;
it('sets pageLoading state', () => {
mutations[types.SET_PAGE_LOADING](state, pageLoading);
expect(state.pageLoading).toBe(pageLoading);
});
});
}); });
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