diff --git a/ee/app/assets/javascripts/analytics/cycle_analytics/components/base.vue b/ee/app/assets/javascripts/analytics/cycle_analytics/components/base.vue index 422dee25401e521a56399a77aedb115f3ad83aa5..e4a9892912f312f4feddc347fa6fc57dd1b8f647 100644 --- a/ee/app/assets/javascripts/analytics/cycle_analytics/components/base.vue +++ b/ee/app/assets/javascripts/analytics/cycle_analytics/components/base.vue @@ -14,7 +14,7 @@ import UrlSync from '~/vue_shared/components/url_sync.vue'; import PageHeading from '~/vue_shared/components/page_heading.vue'; import { METRICS_REQUESTS } from '../constants'; import DurationChart from './duration_chart.vue'; -import TypeOfWorkCharts from './type_of_work_charts.vue'; +import TypeOfWorkChartsLoader from './type_of_work_charts_loader.vue'; import ValueStreamAggregationStatus from './value_stream_aggregation_status.vue'; import ValueStreamAggregatingWarning from './value_stream_aggregating_warning.vue'; import ValueStreamEmptyState from './value_stream_empty_state.vue'; @@ -27,7 +27,7 @@ export default { PageHeading, DurationChart, GlEmptyState, - TypeOfWorkCharts, + TypeOfWorkChartsLoader, StageTable, PathNavigation, ValueStreamAggregationStatus, @@ -271,7 +271,7 @@ export default { <div :class="[isOverviewStageSelected ? 'gl-mt-2' : 'gl-mt-6']"> <duration-overview-chart v-if="isOverviewStageSelected" class="gl-mb-6" /> <duration-chart v-else class="gl-mb-6" /> - <type-of-work-charts + <type-of-work-charts-loader v-if="enableTasksByTypeChart" v-show="isOverviewStageSelected" class="gl-mb-6" diff --git a/ee/app/assets/javascripts/analytics/cycle_analytics/components/type_of_work_charts.stories.js b/ee/app/assets/javascripts/analytics/cycle_analytics/components/type_of_work_charts.stories.js index 41fec714fbba19284eed22e9c726e2aa77989b50..fbe28a3c607227338a63296e3051de08f79465e4 100644 --- a/ee/app/assets/javascripts/analytics/cycle_analytics/components/type_of_work_charts.stories.js +++ b/ee/app/assets/javascripts/analytics/cycle_analytics/components/type_of_work_charts.stories.js @@ -1,6 +1,6 @@ import { withVuexStore } from 'storybook_addons/vuex_store'; import TypeOfWorkCharts from './type_of_work_charts.vue'; -import { defaultGroupLabels } from './stories_constants'; +import { tasksByTypeChartData, defaultGroupLabels } from './stories_constants'; export default { component: TypeOfWorkCharts, @@ -8,7 +8,7 @@ export default { decorators: [withVuexStore], }; -const createStoryWithState = ({ typeOfWork: { getters, state } = {} }) => { +const createStoryWithState = ({ state = {} }) => { return (args, { argTypes, createVuexStore }) => ({ components: { TypeOfWorkCharts }, props: Object.keys(argTypes), @@ -22,31 +22,13 @@ const createStoryWithState = ({ typeOfWork: { getters, state } = {} }) => { }, getters: { selectedProjectIds: () => [], - cycleAnalyticsRequestParams: () => ({ - project_ids: null, - created_after: '2024-01-01', - created_before: '2024-03-01', - author_username: null, - milestone_title: null, - assignee_username: null, - }), }, modules: { typeOfWork: { namespaced: true, + state, getters: { selectedLabelNames: () => [], - ...getters, - }, - state: { - isLoading: false, - errorMessage: null, - topRankedLabels: [], - ...state, - }, - actions: { - fetchTopRankedGroupLabels: () => {}, - setTasksByTypeFilters: () => {}, }, }, }, @@ -54,21 +36,13 @@ const createStoryWithState = ({ typeOfWork: { getters, state } = {} }) => { }); }; -const defaultState = {}; -export const Default = createStoryWithState(defaultState).bind({}); +export const Default = createStoryWithState({}).bind({}); +Default.args = { chartData: tasksByTypeChartData }; -const noDataState = { typeOfWork: { state: { data: [] } } }; -export const NoData = createStoryWithState(noDataState).bind({}); +export const NoData = createStoryWithState({}).bind({}); +NoData.args = { chartData: { data: [] } }; -const isLoadingState = { - typeOfWork: { state: { isLoading: true } }, -}; -export const IsLoading = createStoryWithState(isLoadingState).bind({}); - -const errorState = { - typeOfWork: { - ...noDataState.typeOfWork, - state: { errorMessage: 'Failed to load chart' }, - }, -}; -export const ErrorMessage = createStoryWithState(errorState).bind({}); +export const ErrorMessage = createStoryWithState({ + state: { errorMessage: 'Failed to load chart' }, +}).bind({}); +ErrorMessage.args = { chartData: { data: [] } }; diff --git a/ee/app/assets/javascripts/analytics/cycle_analytics/components/type_of_work_charts.vue b/ee/app/assets/javascripts/analytics/cycle_analytics/components/type_of_work_charts.vue index 762322b093ef061e4faced6ca0a93f40b2e5fe47..cc472ac21224afd185db7f27621dec2fab99d3e6 100644 --- a/ee/app/assets/javascripts/analytics/cycle_analytics/components/type_of_work_charts.vue +++ b/ee/app/assets/javascripts/analytics/cycle_analytics/components/type_of_work_charts.vue @@ -1,18 +1,9 @@ <script> // eslint-disable-next-line no-restricted-imports -import { mapActions, mapGetters, mapState } from 'vuex'; +import { mapGetters, mapState } from 'vuex'; import { GlAlert, GlIcon, GlTooltip } from '@gitlab/ui'; -import { __ } from '~/locale'; import SafeHtml from '~/vue_shared/directives/safe_html'; -import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; -import { getTypeOfWorkTasksByType } from 'ee/api/analytics_api'; -import { - generateFilterTextDescription, - getTasksByTypeData, - checkForDataError, - alertErrorIfStatusNotOk, - transformRawTasksByTypeData, -} from '../utils'; +import { generateFilterTextDescription } from '../utils'; import { formattedDate } from '../../shared/utils'; import { TASKS_BY_TYPE_SUBJECT_ISSUE, TASKS_BY_TYPE_SUBJECT_FILTER_OPTIONS } from '../constants'; import TasksByTypeChart from './tasks_by_type/chart.vue'; @@ -22,7 +13,6 @@ import NoDataAvailableState from './no_data_available_state.vue'; export default { name: 'TypeOfWorkCharts', components: { - ChartSkeletonLoader, GlAlert, GlIcon, GlTooltip, @@ -33,29 +23,20 @@ export default { directives: { SafeHtml, }, - data() { - return { - tasksByType: [], - isLoadingTasksByType: false, - }; + props: { + chartData: { + type: Object, + required: true, + }, }, computed: { ...mapState(['namespace', 'createdAfter', 'createdBefore']), - ...mapState('typeOfWork', ['subject', 'errorMessage', 'isLoading']), - ...mapGetters(['selectedProjectIds', 'cycleAnalyticsRequestParams']), + ...mapState('typeOfWork', ['subject', 'errorMessage']), + ...mapGetters(['selectedProjectIds']), ...mapGetters('typeOfWork', ['selectedLabelNames']), - chartData() { - const { tasksByType, createdAfter, createdBefore } = this; - return tasksByType.length - ? getTasksByTypeData({ data: tasksByType, createdAfter, createdBefore }) - : { groupBy: [], data: [] }; - }, hasData() { return Boolean(this.chartData?.data.length); }, - hasError() { - return this.errorMessage && this.errorMessage !== ''; - }, tooltipText() { return generateFilterTextDescription({ groupName: this.namespace.name, @@ -76,96 +57,31 @@ export default { TASKS_BY_TYPE_SUBJECT_FILTER_OPTIONS[TASKS_BY_TYPE_SUBJECT_ISSUE] ); }, - tasksByTypeParams() { - const { - subject, - selectedLabelNames, - cycleAnalyticsRequestParams: { - project_ids, - created_after, - created_before, - author_username, - milestone_title, - assignee_username, - }, - } = this; - return { - project_ids, - created_after, - created_before, - author_username, - milestone_title, - assignee_username, - subject, - label_names: selectedLabelNames, - }; - }, - }, - async created() { - await this.fetchTopRankedGroupLabels(); - - if (!this.hasError) { - this.fetchTasksByType(); - } - }, - methods: { - ...mapActions('typeOfWork', ['fetchTopRankedGroupLabels', 'setTasksByTypeFilters']), - onUpdateFilter(e) { - this.setTasksByTypeFilters(e); - this.fetchTasksByType(); - }, - fetchTasksByType() { - // dont request if we have no labels selected - if (!this.selectedLabelNames.length) { - this.tasksByType = []; - return; - } - - this.isLoadingTasksByType = true; - - getTypeOfWorkTasksByType(this.namespace.fullPath, this.tasksByTypeParams) - .then(checkForDataError) - .then(({ data }) => { - this.tasksByType = transformRawTasksByTypeData(data); - }) - .catch((error) => { - alertErrorIfStatusNotOk({ - error, - message: __('There was an error fetching data for the tasks by type chart'), - }); - }) - .finally(() => { - this.isLoadingTasksByType = false; - }); - }, }, }; </script> <template> - <div class="js-tasks-by-type-chart"> - <chart-skeleton-loader v-if="isLoading || isLoadingTasksByType" class="gl-my-4 gl-py-4" /> - <div v-else> - <div class="gl-flex gl-justify-between"> - <h4 class="gl-mt-0"> - {{ s__('ValueStreamAnalytics|Tasks by type') }} - <span ref="tooltipTrigger" data-testid="vsa-task-by-type-description"> - <gl-icon name="information-o" /> - </span> - <gl-tooltip :target="() => $refs.tooltipTrigger" boundary="viewport" placement="top"> - <span v-safe-html="tooltipText"></span> - </gl-tooltip> - </h4> - <tasks-by-type-filters - :selected-label-names="selectedLabelNames" - :subject-filter="selectedSubjectFilter" - @update-filter="onUpdateFilter" - /> - </div> - <tasks-by-type-chart v-if="hasData" :data="chartData.data" :group-by="chartData.groupBy" /> - <gl-alert v-else-if="errorMessage" variant="info" :dismissible="false" class="gl-mt-3"> - {{ errorMessage }} - </gl-alert> - <no-data-available-state v-else /> + <div> + <div class="gl-flex gl-justify-between"> + <h4 class="gl-mt-0"> + {{ s__('ValueStreamAnalytics|Tasks by type') }} + <span ref="tooltipTrigger" data-testid="vsa-task-by-type-description"> + <gl-icon name="information-o" /> + </span> + <gl-tooltip :target="() => $refs.tooltipTrigger" boundary="viewport" placement="top"> + <span v-safe-html="tooltipText"></span> + </gl-tooltip> + </h4> + <tasks-by-type-filters + :selected-label-names="selectedLabelNames" + :subject-filter="selectedSubjectFilter" + @update-filter="$emit('update-filter', $event)" + /> </div> + <tasks-by-type-chart v-if="hasData" :data="chartData.data" :group-by="chartData.groupBy" /> + <gl-alert v-else-if="errorMessage" variant="info" :dismissible="false" class="gl-mt-3"> + {{ errorMessage }} + </gl-alert> + <no-data-available-state v-else /> </div> </template> diff --git a/ee/app/assets/javascripts/analytics/cycle_analytics/components/type_of_work_charts_loader.vue b/ee/app/assets/javascripts/analytics/cycle_analytics/components/type_of_work_charts_loader.vue new file mode 100644 index 0000000000000000000000000000000000000000..a4f8d3781f564ac44ad806ed09827e5709905a85 --- /dev/null +++ b/ee/app/assets/javascripts/analytics/cycle_analytics/components/type_of_work_charts_loader.vue @@ -0,0 +1,111 @@ +<script> +// eslint-disable-next-line no-restricted-imports +import { mapActions, mapGetters, mapState } from 'vuex'; +import { __ } from '~/locale'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +import { getTypeOfWorkTasksByType } from 'ee/api/analytics_api'; +import { + getTasksByTypeData, + checkForDataError, + alertErrorIfStatusNotOk, + transformRawTasksByTypeData, +} from '../utils'; +import TypeOfWorkCharts from './type_of_work_charts.vue'; + +export default { + name: 'TypeOfWorkChartsLoader', + components: { + ChartSkeletonLoader, + TypeOfWorkCharts, + }, + data() { + return { + tasksByType: [], + isLoadingTasksByType: false, + }; + }, + computed: { + ...mapState(['namespace', 'createdAfter', 'createdBefore']), + ...mapState('typeOfWork', ['subject', 'errorMessage', 'isLoading']), + ...mapGetters(['cycleAnalyticsRequestParams']), + ...mapGetters('typeOfWork', ['selectedLabelNames']), + chartData() { + const { tasksByType, createdAfter, createdBefore } = this; + return tasksByType.length + ? getTasksByTypeData({ data: tasksByType, createdAfter, createdBefore }) + : { groupBy: [], data: [] }; + }, + hasError() { + return this.errorMessage && this.errorMessage !== ''; + }, + tasksByTypeParams() { + const { + subject, + selectedLabelNames, + cycleAnalyticsRequestParams: { + project_ids, + created_after, + created_before, + author_username, + milestone_title, + assignee_username, + }, + } = this; + return { + project_ids, + created_after, + created_before, + author_username, + milestone_title, + assignee_username, + subject, + label_names: selectedLabelNames, + }; + }, + }, + async created() { + await this.fetchTopRankedGroupLabels(); + + if (!this.hasError) { + this.fetchTasksByType(); + } + }, + methods: { + ...mapActions('typeOfWork', ['fetchTopRankedGroupLabels', 'setTasksByTypeFilters']), + onUpdateFilter(e) { + this.setTasksByTypeFilters(e); + this.fetchTasksByType(); + }, + fetchTasksByType() { + // dont request if we have no labels selected + if (!this.selectedLabelNames.length) { + this.tasksByType = []; + return; + } + + this.isLoadingTasksByType = true; + + getTypeOfWorkTasksByType(this.namespace.fullPath, this.tasksByTypeParams) + .then(checkForDataError) + .then(({ data }) => { + this.tasksByType = transformRawTasksByTypeData(data); + }) + .catch((error) => { + alertErrorIfStatusNotOk({ + error, + message: __('There was an error fetching data for the tasks by type chart'), + }); + }) + .finally(() => { + this.isLoadingTasksByType = false; + }); + }, + }, +}; +</script> +<template> + <div class="js-tasks-by-type-chart"> + <chart-skeleton-loader v-if="isLoading || isLoadingTasksByType" class="gl-my-4 gl-py-4" /> + <type-of-work-charts v-else :chart-data="chartData" @update-filter="onUpdateFilter" /> + </div> +</template> diff --git a/ee/spec/frontend/analytics/cycle_analytics/components/base_spec.js b/ee/spec/frontend/analytics/cycle_analytics/components/base_spec.js index 96df488569673f6ef0e9f380bdcec90b26d28c04..2ff3af857077818db2b3ff4d607bc2ff16898721 100644 --- a/ee/spec/frontend/analytics/cycle_analytics/components/base_spec.js +++ b/ee/spec/frontend/analytics/cycle_analytics/components/base_spec.js @@ -8,7 +8,7 @@ import Vuex from 'vuex'; import Component from 'ee/analytics/cycle_analytics/components/base.vue'; import DurationChart from 'ee/analytics/cycle_analytics/components/duration_chart.vue'; import DurationOverviewChart from 'ee/analytics/cycle_analytics/components/duration_overview_chart.vue'; -import TypeOfWorkCharts from 'ee/analytics/cycle_analytics/components/type_of_work_charts.vue'; +import TypeOfWorkChartsLoader from 'ee/analytics/cycle_analytics/components/type_of_work_charts_loader.vue'; import ValueStreamSelect from 'ee/analytics/cycle_analytics/components/value_stream_select.vue'; import ValueStreamAggregationStatus from 'ee/analytics/cycle_analytics/components/value_stream_aggregation_status.vue'; import ValueStreamEmptyState from 'ee/analytics/cycle_analytics/components/value_stream_empty_state.vue'; @@ -188,7 +188,7 @@ describe('EE Value Stream Analytics component', () => { const findFilterBar = () => wrapper.findComponent(ValueStreamFilters); const findDurationChart = () => wrapper.findComponent(DurationChart); const findDurationOverviewChart = () => wrapper.findComponent(DurationOverviewChart); - const findTypeOfWorkCharts = () => wrapper.findComponent(TypeOfWorkCharts); + const findTypeOfWorkCharts = () => wrapper.findComponent(TypeOfWorkChartsLoader); const findValueStreamSelect = () => wrapper.findComponent(ValueStreamSelect); const findUrlSync = () => wrapper.findComponent(UrlSync); diff --git a/ee/spec/frontend/analytics/cycle_analytics/components/type_of_work_charts_loader_spec.js b/ee/spec/frontend/analytics/cycle_analytics/components/type_of_work_charts_loader_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0b962c67e7728274b8f85b102b53a9fb056ba048 --- /dev/null +++ b/ee/spec/frontend/analytics/cycle_analytics/components/type_of_work_charts_loader_spec.js @@ -0,0 +1,187 @@ +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports +import Vuex from 'vuex'; +import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import waitForPromises from 'helpers/wait_for_promises'; +import TypeOfWorkChartsLoader from 'ee/analytics/cycle_analytics/components/type_of_work_charts_loader.vue'; +import TypeOfWorkCharts from 'ee/analytics/cycle_analytics/components/type_of_work_charts.vue'; +import typeOfWorkModule from 'ee/analytics/cycle_analytics/store/modules/type_of_work'; +import { + TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST, + TASKS_BY_TYPE_FILTERS, + TASKS_BY_TYPE_SUBJECT_ISSUE, +} from 'ee/analytics/cycle_analytics/constants'; +import { createAlert } from '~/alert'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +import { rawTasksByTypeData, groupLabelNames, endpoints } from '../mock_data'; + +Vue.use(Vuex); +jest.mock('~/alert'); + +describe('TypeOfWorkChartsLoader', () => { + let wrapper; + let mock; + + const fetchTopRankedGroupLabels = jest.fn(); + const setTasksByTypeFilters = jest.fn(); + + const cycleAnalyticsRequestParams = { + project_ids: null, + created_after: '2019-12-11', + created_before: '2020-01-10', + author_username: null, + milestone_title: null, + assignee_username: null, + }; + + const createStore = (state) => + new Vuex.Store({ + state: { + namespace: { + fullPath: 'fake/group/path', + }, + createdAfter: new Date('2019-12-11'), + createdBefore: new Date('2020-01-10'), + }, + getters: { + cycleAnalyticsRequestParams: () => cycleAnalyticsRequestParams, + }, + modules: { + typeOfWork: { + ...typeOfWorkModule, + state: { + subject: TASKS_BY_TYPE_SUBJECT_ISSUE, + ...typeOfWorkModule.state, + ...state, + }, + getters: { + ...typeOfWorkModule.getters, + selectedLabelNames: () => groupLabelNames, + }, + actions: { + ...typeOfWorkModule.actions, + fetchTopRankedGroupLabels, + setTasksByTypeFilters, + }, + }, + }, + }); + + const createWrapper = ({ state = {} } = {}) => { + wrapper = shallowMount(TypeOfWorkChartsLoader, { + store: createStore(state), + }); + + return waitForPromises(); + }; + + const findLoader = () => wrapper.findComponent(ChartSkeletonLoader); + const findTypeOfWorkCharts = () => wrapper.findComponent(TypeOfWorkCharts); + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('when loading', () => { + beforeEach(() => { + createWrapper({ state: { isLoading: true } }); + }); + + it('renders skeleton loader', () => { + expect(findLoader().exists()).toBe(true); + }); + }); + + describe('with data', () => { + beforeEach(() => { + mock.onGet(endpoints.tasksByTypeData).replyOnce(HTTP_STATUS_OK, rawTasksByTypeData); + return createWrapper(); + }); + + it('calls the `fetchTopRankedGroupLabels` action', () => { + expect(fetchTopRankedGroupLabels).toHaveBeenCalled(); + }); + + it('fetches tasks by type', () => { + expect(mock.history.get.length).toBe(1); + expect(mock.history.get[0]).toEqual( + expect.objectContaining({ + url: '/fake/group/path/-/analytics/type_of_work/tasks_by_type', + params: { + ...cycleAnalyticsRequestParams, + subject: TASKS_BY_TYPE_SUBJECT_ISSUE, + label_names: groupLabelNames, + }, + }), + ); + }); + + it('renders the type of work charts', () => { + expect(findTypeOfWorkCharts().exists()).toBe(true); + }); + + it('does not render the loading icon', () => { + expect(findLoader().exists()).toBe(false); + }); + + describe('when update filter is emitted', () => { + const payload = { + filter: TASKS_BY_TYPE_FILTERS.SUBJECT, + value: TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST, + }; + + beforeEach(() => { + findTypeOfWorkCharts(wrapper).vm.$emit('update-filter', payload); + }); + + it('calls the setTasksByTypeFilters method', () => { + expect(setTasksByTypeFilters).toHaveBeenCalledWith(expect.any(Object), payload); + }); + + it('refetches the tasks by type', () => { + expect(mock.history.get.length).toBe(2); + expect(mock.history.get[1]).toEqual( + expect.objectContaining({ + url: '/fake/group/path/-/analytics/type_of_work/tasks_by_type', + params: { + ...cycleAnalyticsRequestParams, + subject: TASKS_BY_TYPE_SUBJECT_ISSUE, + label_names: groupLabelNames, + }, + }), + ); + }); + }); + }); + + describe('when tasks by type returns 200 with a data error', () => { + beforeEach(() => { + mock.onGet(endpoints.tasksByTypeData).replyOnce(HTTP_STATUS_OK, { error: 'Too much data' }); + return createWrapper(); + }); + + it('does not show an alert', () => { + expect(createAlert).not.toHaveBeenCalled(); + }); + }); + + describe('when tasks by type throws an error', () => { + beforeEach(() => { + mock.onGet(endpoints.tasksByTypeData).replyOnce(HTTP_STATUS_NOT_FOUND, { error: 'error' }); + return createWrapper(); + }); + + it('shows an error alert', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: 'There was an error fetching data for the tasks by type chart', + }); + }); + }); +}); diff --git a/ee/spec/frontend/analytics/cycle_analytics/components/type_of_work_charts_spec.js b/ee/spec/frontend/analytics/cycle_analytics/components/type_of_work_charts_spec.js index 5d6e4807843c69948d567b37757f2971374ab91b..48860f42fd75d24d5931ca541c4d50e3c6b2c4e4 100644 --- a/ee/spec/frontend/analytics/cycle_analytics/components/type_of_work_charts_spec.js +++ b/ee/spec/frontend/analytics/cycle_analytics/components/type_of_work_charts_spec.js @@ -1,10 +1,7 @@ import { shallowMount } from '@vue/test-utils'; -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; import Vue from 'vue'; // eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; -import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import waitForPromises from 'helpers/wait_for_promises'; import TasksByTypeChart from 'ee/analytics/cycle_analytics/components/tasks_by_type/chart.vue'; import TasksByTypeFilters from 'ee/analytics/cycle_analytics/components/tasks_by_type/filters.vue'; @@ -16,44 +13,24 @@ import { TASKS_BY_TYPE_FILTERS, TASKS_BY_TYPE_SUBJECT_ISSUE, } from 'ee/analytics/cycle_analytics/constants'; -import { createAlert } from '~/alert'; -import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; -import { rawTasksByTypeData, groupLabelNames, endpoints } from '../mock_data'; +import { tasksByTypeData, groupLabelNames } from '../mock_data'; Vue.use(Vuex); -jest.mock('~/alert'); describe('TypeOfWorkCharts', () => { let wrapper; - let mock; - - const fetchTopRankedGroupLabels = jest.fn(); - const setTasksByTypeFilters = jest.fn(); - - const cycleAnalyticsRequestParams = { - project_ids: null, - created_after: '2019-12-11', - created_before: '2020-01-10', - author_username: null, - milestone_title: null, - assignee_username: null, - }; const createStore = (state, rootGetters) => new Vuex.Store({ state: { namespace: { - fullPath: 'fake/group/path', name: 'Gitlab Org', - type: 'Group', }, createdAfter: new Date('2019-12-11'), createdBefore: new Date('2020-01-10'), }, getters: { - namespacePath: () => 'fake/group/path', selectedProjectIds: () => [], - cycleAnalyticsRequestParams: () => cycleAnalyticsRequestParams, ...rootGetters, }, modules: { @@ -68,22 +45,20 @@ describe('TypeOfWorkCharts', () => { ...typeOfWorkModule.getters, selectedLabelNames: () => groupLabelNames, }, - actions: { - ...typeOfWorkModule.actions, - fetchTopRankedGroupLabels, - setTasksByTypeFilters, - }, }, }, }); - const createWrapper = ({ state = {}, rootGetters = {}, stubs = {} } = {}) => { + const createWrapper = ({ state = {}, rootGetters = {}, props = {} } = {}) => { wrapper = shallowMount(TypeOfWorkCharts, { store: createStore(state, rootGetters), stubs: { TasksByTypeChart: true, TasksByTypeFilters: true, - ...stubs, + }, + propsData: { + chartData: tasksByTypeData, + ...props, }, }); @@ -92,53 +67,18 @@ describe('TypeOfWorkCharts', () => { const findSubjectFilters = () => wrapper.findComponent(TasksByTypeFilters); const findTasksByTypeChart = () => wrapper.findComponent(TasksByTypeChart); - const findLoader = () => wrapper.findComponent(ChartSkeletonLoader); const findNoDataAvailableState = () => wrapper.findComponent(NoDataAvailableState); - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - describe('when loading', () => { - beforeEach(() => { - createWrapper({ state: { isLoading: true } }); - }); - - it('renders skeleton loader', () => { - expect(findLoader().exists()).toBe(true); - }); - }); - describe('with data', () => { beforeEach(() => { - mock.onGet(endpoints.tasksByTypeData).replyOnce(HTTP_STATUS_OK, rawTasksByTypeData); - return createWrapper(); - }); - - it('calls the `fetchTopRankedGroupLabels` action', () => { - expect(fetchTopRankedGroupLabels).toHaveBeenCalled(); - }); - - it('fetches tasks by type', () => { - expect(mock.history.get.length).toBe(1); - expect(mock.history.get[0]).toEqual( - expect.objectContaining({ - url: '/fake/group/path/-/analytics/type_of_work/tasks_by_type', - params: { - ...cycleAnalyticsRequestParams, - subject: TASKS_BY_TYPE_SUBJECT_ISSUE, - label_names: groupLabelNames, - }, - }), - ); + createWrapper(); }); it('renders the task by type chart', () => { - expect(findTasksByTypeChart().exists()).toBe(true); + expect(findTasksByTypeChart().props()).toEqual({ + data: tasksByTypeData.data, + groupBy: tasksByTypeData.groupBy, + }); }); it('renders a description of the current filters', () => { @@ -147,81 +87,39 @@ describe('TypeOfWorkCharts', () => { ); }); - it('does not render the loading icon', () => { - expect(findLoader(wrapper).exists()).toBe(false); + it('renders the subject filters', () => { + expect(findSubjectFilters().props()).toEqual( + expect.objectContaining({ + selectedLabelNames: groupLabelNames, + subjectFilter: TASKS_BY_TYPE_SUBJECT_ISSUE, + }), + ); }); - describe('when a filter is selected', () => { + it('emits the update-filter when a filter is selected', () => { const payload = { filter: TASKS_BY_TYPE_FILTERS.SUBJECT, value: TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST, }; - beforeEach(() => { - findSubjectFilters(wrapper).vm.$emit('update-filter', payload); - }); - - it('calls the setTasksByTypeFilters method', () => { - expect(setTasksByTypeFilters).toHaveBeenCalledWith(expect.any(Object), payload); - }); + findSubjectFilters().vm.$emit('update-filter', payload); - it('refetches the tasks by type', () => { - expect(mock.history.get.length).toBe(2); - expect(mock.history.get[1]).toEqual( - expect.objectContaining({ - url: '/fake/group/path/-/analytics/type_of_work/tasks_by_type', - params: { - ...cycleAnalyticsRequestParams, - subject: TASKS_BY_TYPE_SUBJECT_ISSUE, - label_names: groupLabelNames, - }, - }), - ); - }); - }); - }); - - describe('when tasks by type returns 200 with a data error', () => { - beforeEach(() => { - mock.onGet(endpoints.tasksByTypeData).replyOnce(HTTP_STATUS_OK, { error: 'Too much data' }); - return createWrapper(); - }); - - it('does not show an alert', () => { - expect(createAlert).not.toHaveBeenCalled(); - }); - }); - - describe('when tasks by type throws an error', () => { - beforeEach(() => { - mock.onGet(endpoints.tasksByTypeData).replyOnce(HTTP_STATUS_NOT_FOUND, { error: 'error' }); - return createWrapper(); - }); - - it('shows an error alert', () => { - expect(createAlert).toHaveBeenCalledWith({ - message: 'There was an error fetching data for the tasks by type chart', - }); + expect(wrapper.emitted('update-filter')[0][0]).toEqual(payload); }); }); describe('with selected projects', () => { - const createWithProjects = (projectIds) => - createWrapper({ - rootGetters: { - selectedProjectIds: () => projectIds, - }, - }); + it('renders multiple selected project counts', () => { + createWrapper({ rootGetters: { selectedProjectIds: () => [1, 2] } }); - it('renders multiple selected project counts', async () => { - await createWithProjects([1, 2]); expect(wrapper.text()).toContain( "Shows issues and 3 labels for group 'Gitlab Org' and 2 projects from Dec 11, 2019 to Jan 10, 2020", ); }); - it('renders one selected project count', async () => { - await createWithProjects([1]); + it('renders one selected project count', () => { + createWrapper({ rootGetters: { selectedProjectIds: () => [1] } }); + expect(wrapper.text()).toContain( "Shows issues and 3 labels for group 'Gitlab Org' and 1 project from Dec 11, 2019 to Jan 10, 2020", ); @@ -230,15 +128,15 @@ describe('TypeOfWorkCharts', () => { describe('with no data', () => { beforeEach(() => { - return createWrapper({ state: { data: [] } }); + createWrapper({ props: { chartData: { data: [] } } }); }); it('does not renders the task by type chart', () => { - expect(findTasksByTypeChart(wrapper).exists()).toBe(false); + expect(findTasksByTypeChart().exists()).toBe(false); }); it('renders the no data available message', () => { - expect(findNoDataAvailableState(wrapper).exists()).toBe(true); + expect(findNoDataAvailableState().exists()).toBe(true); }); }); });