diff --git a/app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue index 247c147609b6c28a03567c0df072766b6d81af6c..cebc48f4ba754ddad16d20e4482416303bf16ab3 100644 --- a/app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue +++ b/app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue @@ -53,6 +53,9 @@ export default { return { errors: { ...generateDataKeys(this.queries, '') }, ...generateDataKeys(this.queries, []), + loadingStates: { + ...generateDataKeys(this.queries, true), + }, }; }, computed: { @@ -60,7 +63,7 @@ export default { return Object.values(this.errors); }, isLoading() { - return some(this.$apollo.queries, (query) => query?.loading); + return Object.values(this.loadingStates).some(Boolean); }, allQueriesFailed() { return every(this.errorMessages, (message) => message.length); @@ -115,8 +118,13 @@ export default { }, created() { this.queries.forEach(({ query, identifier, loadError }) => { + this.loadingStates[identifier] = true; + this.$apollo.addSmartQuery(identifier, { query, + context: { + batchKey: 'UsageTrendsCountChart', + }, variables() { return { identifier, @@ -140,6 +148,8 @@ export default { pageInfo, identifier, }); + } else { + this.loadingStates[identifier] = false; } }, error(error) { @@ -159,7 +169,7 @@ export default { : 0; }, handleError({ identifier, error, message }) { - this.loadingError = true; + this.loadingStates[identifier] = false; this.errors = { ...this.errors, [identifier]: message }; Sentry.captureException(error); }, @@ -187,12 +197,24 @@ export default { <template> <div> <h3>{{ chartTitle }}</h3> - <gl-alert v-if="hasLoadingErrors" variant="danger" :dismissible="false" class="gl-mt-3"> + <chart-skeleton-loader v-if="isLoading" /> + <gl-alert + v-else-if="hasLoadingErrors" + data-testid="usage-trends-count-error-alert" + variant="danger" + :dismissible="false" + class="gl-mt-3" + > {{ errorMessage }} </gl-alert> - <div v-if="!allQueriesFailed"> - <chart-skeleton-loader v-if="isLoading" /> - <gl-alert v-else-if="hasEmptyDataSet" variant="info" :dismissible="false" class="gl-mt-3"> + <div v-else> + <gl-alert + v-if="hasEmptyDataSet" + data-testid="usage-trends-count-info-alert" + variant="info" + :dismissible="false" + class="gl-mt-3" + > {{ noDataMessage }} </gl-alert> <gl-line-chart diff --git a/spec/frontend/analytics/usage_trends/components/__snapshots__/usage_trends_count_chart_spec.js.snap b/spec/frontend/analytics/usage_trends/components/__snapshots__/usage_trends_count_chart_spec.js.snap index dd3bc5d2a4f7fe4279a44772cef948c54fcc60cc..ca8ac2b54b61d8531cdf87646b9e6963ede3fba4 100644 --- a/spec/frontend/analytics/usage_trends/components/__snapshots__/usage_trends_count_chart_spec.js.snap +++ b/spec/frontend/analytics/usage_trends/components/__snapshots__/usage_trends_count_chart_spec.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`UsageTrendsCountChart when fetching more data when the fetchMore query returns data passes the data to the line chart 1`] = ` +exports[`UsageTrendsCountChart with data passes the data to the line chart 1`] = ` [ { "data": [ @@ -12,17 +12,26 @@ exports[`UsageTrendsCountChart when fetching more data when the fetchMore query "2020-06-01", 22, ], + ], + "name": "Mock Query 1", + }, + { + "data": [ [ - "2020-08-01", - 5, + "2020-07-01", + 10, + ], + [ + "2020-06-01", + 21, ], ], - "name": "Mock Query", + "name": "Mock Query 2", }, ] `; -exports[`UsageTrendsCountChart with data passes the data to the line chart 1`] = ` +exports[`UsageTrendsCountChart with multiple pages of data fetched more data passes the data to the line chart 1`] = ` [ { "data": [ @@ -34,8 +43,29 @@ exports[`UsageTrendsCountChart with data passes the data to the line chart 1`] = "2020-06-01", 22, ], + [ + "2020-08-01", + 5, + ], + ], + "name": "Mock Query 1", + }, + { + "data": [ + [ + "2020-07-01", + 10, + ], + [ + "2020-06-01", + 21, + ], + [ + "2020-08-01", + 10, + ], ], - "name": "Mock Query", + "name": "Mock Query 2", }, ] `; diff --git a/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js b/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js index 322d05e663a30f8e1711108c0b84a708da4a6a79..c8f2df4dc3bbcb30ee95d91066c5c382ddd25d14 100644 --- a/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js +++ b/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js @@ -1,29 +1,38 @@ import { GlAlert } from '@gitlab/ui'; import { GlLineChart } from '@gitlab/ui/dist/charts'; -import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import UsageTrendsCountChart from '~/analytics/usage_trends/components/usage_trends_count_chart.vue'; import statsQuery from '~/analytics/usage_trends/graphql/queries/usage_count.query.graphql'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; import { mockQueryResponse, mockApolloResponse } from '../apollo_mock_data'; -import { mockCountsData1 } from '../mock_data'; +import { mockCountsData1, mockCountsData2 } from '../mock_data'; Vue.use(VueApollo); const loadChartErrorMessage = 'My load error message'; const noDataMessage = 'My no data message'; +const mockError = new Error('Something went wrong'); + const queryResponseDataKey = 'usageTrendsMeasurements'; -const identifier = 'MOCK_QUERY'; -const mockQueryConfig = { - identifier, - title: 'Mock Query', - query: statsQuery, - loadError: 'Failed to load mock query data', -}; +const mockQueries = [ + { + identifier: 'MOCK_QUERY_1', + title: 'Mock Query 1', + query: statsQuery, + loadError: 'Failed to load mock query 1 data', + }, + { + identifier: 'MOCK_QUERY_2', + title: 'Mock Query 2', + query: statsQuery, + loadError: 'Failed to load mock query 2 data', + }, +]; const mockChartConfig = { loadChartErrorMessage, @@ -31,83 +40,151 @@ const mockChartConfig = { chartTitle: 'Foo', yAxisTitle: 'Bar', xAxisTitle: 'Baz', - queries: [mockQueryConfig], + queries: mockQueries, }; describe('UsageTrendsCountChart', () => { let wrapper; let queryHandler; - const createComponent = ({ responseHandler }) => { - return shallowMount(UsageTrendsCountChart, { + const createWrapper = async ({ responseHandler }) => { + wrapper = shallowMountExtended(UsageTrendsCountChart, { apolloProvider: createMockApollo([[statsQuery, responseHandler]]), propsData: { ...mockChartConfig }, }); + + await waitForPromises(); }; const findLoader = () => wrapper.findComponent(ChartSkeletonLoader); const findChart = () => wrapper.findComponent(GlLineChart); - const findAlert = () => wrapper.findComponent(GlAlert); + const findAllAlerts = () => wrapper.findAllComponents(GlAlert); + const findErrorsAlert = () => wrapper.findByTestId('usage-trends-count-error-alert'); + const findInfoAlert = () => wrapper.findByTestId('usage-trends-count-info-alert'); describe('while loading', () => { beforeEach(() => { queryHandler = mockQueryResponse({ key: queryResponseDataKey, loading: true }); - wrapper = createComponent({ responseHandler: queryHandler }); + createWrapper({ responseHandler: queryHandler }); }); it('requests data', () => { - expect(queryHandler).toHaveBeenCalledTimes(1); + expect(queryHandler).toHaveBeenCalledTimes(2); + + mockQueries.forEach(({ identifier }, idx) => { + expect(queryHandler).toHaveBeenNthCalledWith(idx + 1, { + identifier, + first: 365, + after: null, + }); + }); }); it('displays the skeleton loader', () => { expect(findLoader().exists()).toBe(true); }); - it('hides the chart', () => { + it('does not display chart', () => { expect(findChart().exists()).toBe(false); }); - it('does not show an error', () => { - expect(findAlert().exists()).toBe(false); + it('does not display any alerts', () => { + expect(findAllAlerts()).toHaveLength(0); + }); + }); + + describe('with errors', () => { + describe('all queries failed', () => { + beforeEach(() => { + return createWrapper({ responseHandler: jest.fn().mockRejectedValue(mockError) }); + }); + + it('displays alert with generic error message', () => { + expect(findAllAlerts()).toHaveLength(1); + expect(findErrorsAlert().text()).toBe(loadChartErrorMessage); + }); + + it('does not display the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('does not display the chart', () => { + expect(findChart().exists()).toBe(false); + }); + }); + + describe('a single query failed', () => { + beforeEach(() => { + queryHandler = jest + .fn() + .mockResolvedValueOnce( + mockApolloResponse({ + key: queryResponseDataKey, + data: mockCountsData1, + }), + ) + .mockRejectedValueOnce(mockError); + + return createWrapper({ + responseHandler: queryHandler, + }); + }); + + it('displays alert with correct error message', () => { + expect(findAllAlerts()).toHaveLength(1); + expect(findErrorsAlert().text()).toBe('Failed to load mock query 2 data'); + }); + + it('does not display the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('does not display the chart', () => { + expect(findChart().exists()).toBe(false); + }); }); }); describe('without data', () => { - beforeEach(async () => { + beforeEach(() => { queryHandler = mockQueryResponse({ key: queryResponseDataKey, data: [] }); - wrapper = createComponent({ responseHandler: queryHandler }); - await waitForPromises(); + return createWrapper({ responseHandler: queryHandler }); }); - it('renders an no data message', () => { - expect(findAlert().text()).toBe(noDataMessage); + it('displays info alert with no data message', () => { + expect(findAllAlerts()).toHaveLength(1); + expect(findInfoAlert().text()).toBe(noDataMessage); }); - it('hides the skeleton loader', () => { + it('does not display the skeleton loader', () => { expect(findLoader().exists()).toBe(false); }); - it('renders the chart', () => { + it('does not display the chart', () => { expect(findChart().exists()).toBe(false); }); }); describe('with data', () => { - beforeEach(async () => { - queryHandler = mockQueryResponse({ key: queryResponseDataKey, data: mockCountsData1 }); - wrapper = createComponent({ responseHandler: queryHandler }); - await waitForPromises(); - }); - - it('requests data', () => { - expect(queryHandler).toHaveBeenCalledTimes(1); - }); - - it('hides the skeleton loader', () => { - expect(findLoader().exists()).toBe(false); + beforeEach(() => { + queryHandler = jest + .fn() + .mockResolvedValueOnce( + mockApolloResponse({ + key: queryResponseDataKey, + data: mockCountsData1, + }), + ) + .mockResolvedValueOnce( + mockApolloResponse({ + key: queryResponseDataKey, + data: mockCountsData2, + }), + ); + return createWrapper({ responseHandler: queryHandler }); }); - it('renders the chart', () => { + it('displays the chart', () => { expect(findChart().exists()).toBe(true); }); @@ -115,59 +192,173 @@ describe('UsageTrendsCountChart', () => { expect(findChart().props('data')).toMatchSnapshot(); }); - it('does not show an error', () => { - expect(findAlert().exists()).toBe(false); + it('does not display the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('does not display any alerts', () => { + expect(findAllAlerts()).toHaveLength(0); }); }); - describe('when fetching more data', () => { - const recordedAt = '2020-08-01'; - describe('when the fetchMore query returns data', () => { - beforeEach(async () => { - const newData = [{ __typename: 'UsageTrendsMeasurement', recordedAt, count: 5 }]; - queryHandler = mockQueryResponse({ - key: queryResponseDataKey, - data: mockCountsData1, - additionalData: newData, - }); + describe('with multiple pages of data', () => { + describe('while fetching more data', () => { + beforeEach(() => { + queryHandler = jest + .fn() + .mockResolvedValueOnce( + mockApolloResponse({ + key: queryResponseDataKey, + data: mockCountsData1, + hasNextPage: false, + }), + ) + .mockResolvedValueOnce( + mockApolloResponse({ + key: queryResponseDataKey, + data: mockCountsData2, + hasNextPage: true, + }), + ) + .mockReturnValue(new Promise(() => {})); + + return createWrapper({ responseHandler: queryHandler }); + }); + + it('displays the skeleton loader', () => { + expect(findLoader().exists()).toBe(true); + }); + + it('does not display the chart', () => { + expect(findChart().exists()).toBe(false); + }); + + it('does not display any alerts', () => { + expect(findAllAlerts()).toHaveLength(0); + }); + }); + + describe('fetched more data', () => { + beforeEach(() => { + queryHandler = jest + .fn() + .mockResolvedValueOnce( + mockApolloResponse({ + key: queryResponseDataKey, + data: mockCountsData1, + hasNextPage: true, + }), + ) + .mockResolvedValueOnce( + mockApolloResponse({ + key: queryResponseDataKey, + data: mockCountsData2, + hasNextPage: true, + }), + ) + .mockResolvedValueOnce( + mockApolloResponse({ + key: queryResponseDataKey, + data: [{ __typename: 'UsageTrendsMeasurement', recordedAt: '2020-08-01', count: 5 }], + hasNextPage: false, + }), + ) + .mockResolvedValueOnce( + mockApolloResponse({ + key: queryResponseDataKey, + data: [{ __typename: 'UsageTrendsMeasurement', recordedAt: '2020-08-05', count: 10 }], + hasNextPage: false, + }), + ); - wrapper = createComponent({ responseHandler: queryHandler }); - await waitForPromises(); + return createWrapper({ responseHandler: queryHandler }); }); - it('requests data twice', () => { - expect(queryHandler).toHaveBeenCalledTimes(2); + it('requests data twice for each query', () => { + expect(queryHandler).toHaveBeenCalledTimes(4); }); it('passes the data to the line chart', () => { expect(findChart().props('data')).toMatchSnapshot(); }); + + it('does not display the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('does not display any alerts', () => { + expect(findAllAlerts()).toHaveLength(0); + }); }); - describe('when the fetchMore query throws an error', () => { - beforeEach(async () => { - queryHandler = jest.fn().mockResolvedValueOnce( - mockApolloResponse({ - key: queryResponseDataKey, - data: mockCountsData1, - hasNextPage: true, - }), - ); + describe('has errors', () => { + describe('fetching more data for all queries fails', () => { + beforeEach(() => { + queryHandler = jest + .fn() + .mockResolvedValue( + mockApolloResponse({ + key: queryResponseDataKey, + data: mockCountsData1, + hasNextPage: true, + }), + ) + .mockRejectedValue(mockError); - wrapper = createComponent({ responseHandler: queryHandler }); - jest - .spyOn(wrapper.vm.$apollo.queries[identifier], 'fetchMore') - .mockImplementation(jest.fn().mockRejectedValue()); + return createWrapper({ responseHandler: queryHandler }); + }); - await nextTick(); - }); + it('displays alert with generic error message', () => { + expect(findAllAlerts()).toHaveLength(1); + expect(findErrorsAlert().text()).toBe(loadChartErrorMessage); + }); + + it('does not display the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); - it('calls fetchMore', () => { - expect(wrapper.vm.$apollo.queries[identifier].fetchMore).toHaveBeenCalledTimes(1); + it('does not display the chart', () => { + expect(findChart().exists()).toBe(false); + }); }); - it('show an error message', () => { - expect(findAlert().text()).toBe(loadChartErrorMessage); + describe('fetching more data for a single query fails', () => { + beforeEach(() => { + queryHandler = jest + .fn() + .mockResolvedValue( + mockApolloResponse({ + key: queryResponseDataKey, + data: mockCountsData1, + hasNextPage: true, + }), + ) + .mockResolvedValueOnce( + mockApolloResponse({ + key: queryResponseDataKey, + data: [ + { __typename: 'UsageTrendsMeasurement', recordedAt: '2020-08-01', count: 5 }, + ], + hasNextPage: false, + }), + ) + .mockRejectedValueOnce(mockError); + + return createWrapper({ responseHandler: queryHandler }); + }); + + it('displays alert with correct error message', () => { + expect(findAllAlerts()).toHaveLength(1); + expect(findErrorsAlert().text()).toBe('Failed to load mock query 2 data'); + }); + + it('does not display the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('does not display the chart', () => { + expect(findChart().exists()).toBe(false); + }); }); }); });