Commit cef050d2 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch...

Merge branch '225544-vsa-implement-shadow-loader-and-address-timing-issues-with-data-load' into 'master'

VSA - Implement shadow loaders

Closes #225544

See merge request gitlab-org/gitlab!38447
parents e492d82c 53a8afc6
<script>
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import { GlEmptyState } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
import { PROJECTS_PER_PAGE } from '../constants';
......@@ -24,7 +24,6 @@ export default {
components: {
DateRange,
DurationChart,
GlLoadingIcon,
GlEmptyState,
GroupsDropdownFilter,
ProjectsDropdownFilter,
......@@ -87,16 +86,16 @@ export default {
]),
...mapGetters('customStages', ['customStageFormActive']),
shouldRenderEmptyState() {
return !this.selectedGroup;
return !this.selectedGroup && !this.isLoading;
},
shouldDisplayFilters() {
return this.selectedGroup && !this.errorCode;
},
shouldDisplayDurationChart() {
return this.featureFlags.hasDurationChart && !this.hasNoAccessError && !this.isLoading;
return this.featureFlags.hasDurationChart && !this.hasNoAccessError;
},
shouldDisplayTypeOfWorkCharts() {
return !this.hasNoAccessError && !this.isLoading;
return !this.hasNoAccessError;
},
shouldDisplayPathNavigation() {
return this.featureFlags.hasPathNavigation && !this.hasNoAccessError && this.selectedStage;
......@@ -111,9 +110,6 @@ export default {
this.featureFlags.hasCreateMultipleValueStreams && !this.isLoadingValueStreams,
);
},
isLoadingTypeOfWork() {
return this.isLoadingTasksByTypeChartTopLabels || this.isLoadingTasksByTypeChart;
},
hasDateRangeSet() {
return this.startDate && this.endDate;
},
......@@ -288,18 +284,14 @@ export default {
)
"
/>
<div v-else-if="!errorCode">
<metrics :group-path="currentGroupPath" :request-params="cycleAnalyticsRequestParams" />
<div v-if="isLoading">
<gl-loading-icon class="mt-4" size="md" />
</div>
<div v-else>
<metrics :group-path="currentGroupPath" :request-params="cycleAnalyticsRequestParams" />
<stage-table
v-if="selectedStage"
:key="stageCount"
class="js-stage-table"
:current-stage="selectedStage"
:is-loading="isLoadingStage"
:is-loading="isLoading"
:is-loading-stage="isLoadingStage"
:is-empty-stage="isEmptyStage"
:custom-stage-form-active="customStageFormActive"
:current-stage-events="currentStageEvents"
......@@ -329,11 +321,10 @@ export default {
/>
</template>
</stage-table>
</div>
<url-sync :query="query" />
</div>
<duration-chart v-if="shouldDisplayDurationChart" class="mt-3" :stages="activeStages" />
<type-of-work-charts v-if="shouldDisplayTypeOfWorkCharts" :is-loading="isLoadingTypeOfWork" />
<duration-chart v-if="shouldDisplayDurationChart" class="gl-mt-3" :stages="activeStages" />
<type-of-work-charts v-if="shouldDisplayTypeOfWorkCharts" />
</div>
</div>
</template>
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { dateFormats } from '../../shared/constants';
import Scatterplot from '../../shared/components/scatterplot.vue';
import StageDropdownFilter from './stage_dropdown_filter.vue';
......@@ -8,9 +8,9 @@ import StageDropdownFilter from './stage_dropdown_filter.vue';
export default {
name: 'DurationChart',
components: {
GlLoadingIcon,
Scatterplot,
StageDropdownFilter,
ChartSkeletonLoader,
},
props: {
stages: {
......@@ -22,7 +22,7 @@ export default {
...mapState('durationChart', ['isLoading']),
...mapGetters('durationChart', ['durationChartPlottableData']),
hasData() {
return Boolean(this.durationChartPlottableData.length);
return Boolean(!this.isLoading && this.durationChartPlottableData.length);
},
},
methods: {
......@@ -36,17 +36,15 @@ export default {
</script>
<template>
<gl-loading-icon v-if="isLoading" size="md" class="my-4 py-4" />
<div v-else>
<div class="d-flex">
<h4 class="mt-0">{{ s__('CycleAnalytics|Days to completion') }}</h4>
<chart-skeleton-loader v-if="isLoading" size="md" class="gl-my-4 gl-py-4" />
<div v-else class="gl-display-flex gl-flex-direction-column">
<h4 class="gl-mt-0">{{ s__('CycleAnalytics|Days to completion') }}</h4>
<stage-dropdown-filter
v-if="stages.length"
class="ml-auto"
class="gl-ml-auto"
:stages="stages"
@selected="onDurationStageSelect"
/>
</div>
<scatterplot
v-if="hasData"
:x-axis-title="s__('CycleAnalytics|Date')"
......
<script>
import { mapState } from 'vuex';
import { GlTooltipDirective, GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import StageEventList from './stage_event_list.vue';
import StageTableHeader from './stage_table_header.vue';
const MIN_TABLE_HEIGHT = 420;
export default {
name: 'StageTable',
components: {
......@@ -19,7 +20,8 @@ export default {
props: {
currentStage: {
type: Object,
required: true,
required: false,
default: () => {},
},
isLoading: {
type: Boolean,
......@@ -29,6 +31,10 @@ export default {
type: Boolean,
required: true,
},
isLoadingStage: {
type: Boolean,
required: true,
},
customStageFormActive: {
type: Boolean,
required: true,
......@@ -44,16 +50,15 @@ export default {
},
data() {
return {
stageNavHeight: 0,
stageNavHeight: MIN_TABLE_HEIGHT,
};
},
computed: {
...mapState(['customStageFormInitialData']),
stageEventsHeight() {
return `${this.stageNavHeight}px`;
},
stageName() {
return this.currentStage ? this.currentStage.title : __('Related Issues');
return this.currentStage?.title || __('Related Issues');
},
shouldDisplayStage() {
const { currentStageEvents = [], isLoading, isEmptyStage } = this;
......@@ -88,15 +93,24 @@ export default {
];
},
},
mounted() {
updated() {
if (!this.isLoading && this.$refs.stageNav) {
this.$set(this, 'stageNavHeight', this.$refs.stageNav.clientHeight);
}
},
};
</script>
<template>
<div class="stage-panel-container">
<div class="card stage-panel">
<div class="card-header border-bottom-0">
<div
v-if="isLoading"
class="gl-display-flex gl-justify-content-center gl-align-items-center gl-w-full"
:style="{ height: stageEventsHeight }"
>
<gl-loading-icon size="lg" />
</div>
<div v-else class="card stage-panel">
<div class="card-header gl-border-b-0">
<nav class="col-headers">
<ul>
<stage-table-header
......@@ -111,12 +125,12 @@ export default {
</nav>
</div>
<div class="stage-panel-body">
<nav ref="stageNav" class="stage-nav pl-2">
<nav ref="stageNav" class="stage-nav gl-pl-2">
<slot name="nav"></slot>
</nav>
<div class="section stage-events overflow-auto" :style="{ height: stageEventsHeight }">
<slot name="content">
<gl-loading-icon v-if="isLoading" class="mt-4" size="md" />
<gl-loading-icon v-if="isLoadingStage" class="gl-mt-4" size="md" />
<template v-else>
<stage-event-list
v-if="shouldDisplayStage"
......
......@@ -15,7 +15,8 @@ export default {
props: {
currentStage: {
type: Object,
required: true,
required: false,
default: () => {},
},
medians: {
type: Object,
......@@ -74,6 +75,10 @@ export default {
medianValue(id) {
return this.medians[id] ? this.medians[id] : null;
},
isActiveStage(stageId) {
const { currentStage, isCreatingCustomStage } = this;
return Boolean(!isCreatingCustomStage && currentStage && stageId === currentStage.id);
},
},
STAGE_ACTIONS,
noDragClass: NO_DRAG_CLASS,
......@@ -87,7 +92,7 @@ export default {
:key="`ca-stage-title-${stage.title}`"
:title="stage.title"
:value="medianValue(stage.id)"
:is-active="!isCreatingCustomStage && stage.id === currentStage.id"
:is-active="isActiveStage(stage.id)"
:is-default-stage="!stage.custom"
@remove="$emit($options.STAGE_ACTIONS.REMOVE, stage.id)"
@hide="$emit($options.STAGE_ACTIONS.HIDE, { id: stage.id, hidden: true })"
......@@ -97,7 +102,7 @@ export default {
<add-stage-button
:class="$options.noDragClass"
:active="isCreatingCustomStage"
@showform="$emit('showAddStageForm')"
@showform="$emit('show-add-stage-form')"
/>
</ul>
</template>
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import TasksByTypeChart from './tasks_by_type/tasks_by_type_chart.vue';
import TasksByTypeFilters from './tasks_by_type/tasks_by_type_filters.vue';
import { s__, sprintf } from '~/locale';
......@@ -9,7 +9,7 @@ import { TASKS_BY_TYPE_SUBJECT_ISSUE } from '../constants';
export default {
name: 'TypeOfWorkCharts',
components: { GlLoadingIcon, TasksByTypeChart, TasksByTypeFilters },
components: { ChartSkeletonLoader, TasksByTypeChart, TasksByTypeFilters },
computed: {
...mapState('typeOfWork', ['isLoadingTasksByTypeChart', 'isLoadingTasksByTypeChartTopLabels']),
...mapGetters('typeOfWork', ['selectedTasksByTypeFilters', 'tasksByTypeChartData']),
......@@ -63,7 +63,7 @@ export default {
</script>
<template>
<div class="js-tasks-by-type-chart row">
<gl-loading-icon v-if="isLoading" size="md" class="col-12 my-4 py-4" />
<chart-skeleton-loader v-if="isLoading" class="gl-my-4 gl-py-4" />
<div v-else class="col-12">
<h3>{{ s__('CycleAnalytics|Type of work') }}</h3>
<p>{{ summaryDescription }}</p>
......
......@@ -122,22 +122,28 @@ export const receiveCycleAnalyticsDataSuccess = ({ commit, dispatch }) => {
};
export const receiveCycleAnalyticsDataError = ({ commit }, { response }) => {
const { status = null } = response; // non api errors thrown won't have a status field
commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR, status);
const { status = httpStatus.INTERNAL_SERVER_ERROR } = response;
if (!status || status !== httpStatus.FORBIDDEN)
commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR, status);
if (status !== httpStatus.FORBIDDEN) {
createFlash(__('There was an error while fetching value stream analytics data.'));
}
};
export const fetchCycleAnalyticsData = ({ dispatch }) => {
removeFlash();
dispatch('requestCycleAnalyticsData');
return Promise.resolve()
.then(() => dispatch('requestCycleAnalyticsData'))
.then(() => dispatch('fetchValueStreams'))
.then(() => dispatch('receiveCycleAnalyticsDataSuccess'))
.catch(error => dispatch('receiveCycleAnalyticsDataError', error));
.catch(error => {
return Promise.all([
dispatch('receiveCycleAnalyticsDataError', error),
dispatch('durationChart/setLoading', false),
dispatch('typeOfWork/setLoading', false),
]);
});
};
export const requestGroupStages = ({ commit }) => commit(types.REQUEST_GROUP_STAGES);
......@@ -280,6 +286,8 @@ export const initializeCycleAnalytics = ({ dispatch, commit }, initialData = {})
selectedAssignees,
selectedLabels,
}),
dispatch('durationChart/setLoading', true),
dispatch('typeOfWork/setLoading', true),
])
.then(() => dispatch('fetchCycleAnalyticsData'))
.then(() => dispatch('initializeCycleAnalyticsSuccess'));
......@@ -313,7 +321,7 @@ export const reorderStage = ({ dispatch, getters }, initialData) => {
export const receiveCreateValueStreamSuccess = ({ commit, dispatch }) => {
commit(types.RECEIVE_CREATE_VALUE_STREAM_SUCCESS);
return dispatch('fetchValueStreams');
return dispatch('fetchCycleAnalyticsData');
};
export const createValueStream = ({ commit, dispatch, getters }, data) => {
......@@ -359,9 +367,12 @@ export const fetchValueStreams = ({ commit, dispatch, getters, state }) => {
return Api.cycleAnalyticsValueStreams(currentGroupPath)
.then(({ data }) => dispatch('receiveValueStreamsSuccess', data))
.catch(response => {
const { data } = response;
commit(types.RECEIVE_VALUE_STREAMS_ERROR, data);
.catch(error => {
const {
response: { status },
} = error;
commit(types.RECEIVE_VALUE_STREAMS_ERROR, status);
throw error;
});
}
return dispatch('fetchValueStreamData');
......
......@@ -3,6 +3,8 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
import { __ } from '~/locale';
import * as types from './mutation_types';
export const setLoading = ({ commit }, loading) => commit(types.SET_LOADING, loading);
export const requestDurationData = ({ commit }) => commit(types.REQUEST_DURATION_DATA);
export const receiveDurationDataError = ({ commit }) => {
......
export const SET_LOADING = 'SET_LOADING';
export const UPDATE_SELECTED_DURATION_CHART_STAGES = 'UPDATE_SELECTED_DURATION_CHART_STAGES';
export const REQUEST_DURATION_DATA = 'REQUEST_DURATION_DATA';
......
import * as types from './mutation_types';
export default {
[types.SET_LOADING](state, loading) {
state.isLoading = loading;
},
[types.UPDATE_SELECTED_DURATION_CHART_STAGES](state, { updatedDurationStageData }) {
state.durationData = updatedDurationStageData;
},
......
......@@ -4,6 +4,8 @@ import { __ } from '~/locale';
import * as types from './mutation_types';
import { handleErrorOrRethrow } from '../../../utils';
export const setLoading = ({ commit }, loading) => commit(types.SET_LOADING, loading);
export const receiveTopRankedGroupLabelsSuccess = ({ commit, dispatch }, data) => {
commit(types.RECEIVE_TOP_RANKED_GROUP_LABELS_SUCCESS, data);
dispatch('fetchTasksByTypeData');
......
export const SET_LOADING = 'SET_LOADING';
export const REQUEST_TOP_RANKED_GROUP_LABELS = 'REQUEST_TOP_RANKED_GROUP_LABELS';
export const RECEIVE_TOP_RANKED_GROUP_LABELS_SUCCESS = 'RECEIVE_TOP_RANKED_GROUP_LABELS_SUCCESS';
export const RECEIVE_TOP_RANKED_GROUP_LABELS_ERROR = 'RECEIVE_TOP_RANKED_GROUP_LABELS_ERROR';
......
......@@ -4,6 +4,10 @@ import { transformRawTasksByTypeData, toggleSelectedLabel } from '../../../utils
import { TASKS_BY_TYPE_FILTERS } from '../../../constants';
export default {
[types.SET_LOADING](state, loading) {
state.isLoadingTasksByTypeChartTopLabels = loading;
state.isLoadingTasksByTypeChart = loading;
},
[types.REQUEST_TOP_RANKED_GROUP_LABELS](state) {
state.isLoadingTasksByTypeChartTopLabels = true;
state.topRankedLabels = [];
......
......@@ -134,7 +134,8 @@ export default {
state.isLoadingValueStreams = true;
state.valueStreams = [];
},
[types.RECEIVE_VALUE_STREAMS_ERROR](state) {
[types.RECEIVE_VALUE_STREAMS_ERROR](state, errCode) {
state.errCode = errCode;
state.isLoadingValueStreams = false;
state.valueStreams = [];
},
......
---
title: Added loading animations for value stream analytics
merge_request: 38447
author:
type: added
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DurationChart renders the duration chart 1`] = `
"<div>
<div class=\\"d-flex\\">
<h4 class=\\"mt-0\\">Days to completion</h4>
<stagedropdownfilter-stub stages=\\"[object Object],[object Object],[object Object]\\" label=\\"stage dropdown\\" class=\\"ml-auto\\"></stagedropdownfilter-stub>
</div>
"<div class=\\"gl-display-flex gl-flex-direction-column\\">
<h4 class=\\"gl-mt-0\\">Days to completion</h4>
<stagedropdownfilter-stub stages=\\"[object Object],[object Object],[object Object]\\" label=\\"stage dropdown\\" class=\\"gl-ml-auto\\"></stagedropdownfilter-stub>
<scatterplot-stub xaxistitle=\\"Date\\" yaxistitle=\\"Total days to completion\\" scatterdata=\\"2019-01-01,29,2019-01-01,2019-01-02,100,2019-01-02\\" medianlinedata=\\"\\" tooltipdateformat=\\"mmm d, yyyy\\"></scatterplot-stub>
</div>"
`;
import Vuex from 'vuex';
import { shallowMount, mount, createLocalVue } from '@vue/test-utils';
import { GlLoadingIcon, GlNewDropdownItem } from '@gitlab/ui';
import { GlNewDropdownItem } from '@gitlab/ui';
import Scatterplot from 'ee/analytics/shared/components/scatterplot.vue';
import DurationChart from 'ee/analytics/cycle_analytics/components/duration_chart.vue';
import StageDropdownFilter from 'ee/analytics/cycle_analytics/components/stage_dropdown_filter.vue';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { allowedStages as stages, durationChartPlottableData as durationData } from '../mock_data';
const localVue = createLocalVue();
......@@ -47,7 +48,7 @@ function createComponent({
...props,
},
stubs: {
GlLoadingIcon: true,
ChartSkeletonLoader: true,
Scatterplot: true,
StageDropdownFilter: true,
...stubs,
......@@ -61,7 +62,7 @@ describe('DurationChart', () => {
const findNoDataContainer = _wrapper => _wrapper.find({ ref: 'duration-chart-no-data' });
const findScatterPlot = _wrapper => _wrapper.find(Scatterplot);
const findStageDropdown = _wrapper => _wrapper.find(StageDropdownFilter);
const findLoader = _wrapper => _wrapper.find(GlLoadingIcon);
const findLoader = _wrapper => _wrapper.find(ChartSkeletonLoader);
const selectStage = (_wrapper, index = 0) => {
findStageDropdown(_wrapper)
......
import { shallowMount, mount } from '@vue/test-utils';
import { getByText } from '@testing-library/dom';
import { GlLoadingIcon } from '@gitlab/ui';
import StageTable from 'ee/analytics/cycle_analytics/components/stage_table.vue';
import { issueEvents, issueStage, allowedStages } from '../mock_data';
......@@ -28,6 +29,7 @@ function createComponent(props = {}, shallow = false) {
propsData: {
currentStage: issueStage,
isLoading: false,
isLoadingStage: false,
isEmptyStage: false,
currentStageEvents: issueEvents,
noDataSvgPath,
......@@ -117,6 +119,24 @@ describe('StageTable', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toEqual(true);
});
describe('isLoadingStage = true', () => {
beforeEach(() => {
wrapper = createComponent({ isLoadingStage: true }, true);
});
it('will render the list of stages', () => {
const navEl = wrapper.find($sel.nav).element;
allowedStages.forEach(stage => {
expect(getByText(navEl, stage.title, { selector: 'li' })).not.toBe(null);
});
});
it('will render a loading icon', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toEqual(true);
});
});
describe('isEmptyStage = true', () => {
beforeEach(() => {
wrapper = createComponent({ isEmptyStage: true });
......
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import TypeOfWorkCharts from 'ee/analytics/cycle_analytics/components/type_of_work_charts.vue';
import TasksByTypeChart from 'ee/analytics/cycle_analytics/components/tasks_by_type/tasks_by_type_chart.vue';
import TasksByTypeFilters from 'ee/analytics/cycle_analytics/components/tasks_by_type/tasks_by_type_filters.vue';
......@@ -8,6 +7,7 @@ import {
TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST,
TASKS_BY_TYPE_FILTERS,
} from 'ee/analytics/cycle_analytics/constants';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { tasksByTypeData, taskByTypeFilters } from '../mock_data';
const localVue = createLocalVue();
......@@ -53,7 +53,7 @@ describe('TypeOfWorkCharts', () => {
const findSubjectFilters = _wrapper => _wrapper.find(TasksByTypeFilters);
const findTasksByTypeChart = _wrapper => _wrapper.find(TasksByTypeChart);
const findLoader = _wrapper => _wrapper.find(GlLoadingIcon);
const findLoader = _wrapper => _wrapper.find(ChartSkeletonLoader);
const selectedFilterText =
"Type of work Showing data for group 'Gitlab Org' from Dec 11, 2019 to Jan 10, 2020";
......
......@@ -1053,24 +1053,25 @@ describe('Cycle analytics actions', () => {
});
describe('with a failing request', () => {
const resp = { data: {} };
let mockCommit;
beforeEach(() => {
mock.onGet(endpoints.valueStreamData).reply(httpStatusCodes.NOT_FOUND, resp);
mockCommit = jest.fn();
mock.onGet(endpoints.valueStreamData).reply(httpStatusCodes.NOT_FOUND);
});
it(`will commit ${types.RECEIVE_VALUE_STREAMS_ERROR}`, () => {
return testAction(
actions.fetchValueStreams,
null,
state,
[
{ type: types.REQUEST_VALUE_STREAMS },
{
type: types.RECEIVE_VALUE_STREAMS_ERROR,
},
],
[],
);
return actions.fetchValueStreams({ state, getters, commit: mockCommit }).catch(() => {
expect(mockCommit.mock.calls).toEqual([
['REQUEST_VALUE_STREAMS'],
['RECEIVE_VALUE_STREAMS_ERROR', httpStatusCodes.NOT_FOUND],
]);
});
});
it(`throws an error`, () => {
return expect(
actions.fetchValueStreams({ state, getters, commit: mockCommit }),
).rejects.toThrow('Request failed with status code 404');
});
});
......
......@@ -53,6 +53,18 @@ describe('DurationChart actions', () => {
mock.restore();
});
describe('setLoading', () => {
it(`commits the '${types.SET_LOADING}' action`, () => {
return testAction(
actions.setLoading,
true,
state,
[{ type: types.SET_LOADING, payload: true }],
[],
);
});
});
describe('fetchDurationData', () => {
beforeEach(() => {
mock.onGet(endpoints.durationData).reply(200, [...rawDurationData]);
......
......@@ -27,6 +27,7 @@ describe('DurationChart mutations', () => {
it.each`
mutation | payload | expectedState
${types.UPDATE_SELECTED_DURATION_CHART_STAGES} | ${{ updatedDurationStageData: transformedDurationData }} | ${{ durationData: transformedDurationData }}
${types.SET_LOADING} | ${true} | ${{ isLoading: true }}
`(
'$mutation with payload $payload will update state with $expectedState',
({ mutation, payload, expectedState }) => {
......
......@@ -44,6 +44,18 @@ describe('Type of work actions', () => {
state = { ...mockedState, selectedGroup: null };
});
describe('setLoading', () => {
it(`commits the '${types.SET_LOADING}' action`, () => {
return testAction(
actions.setLoading,
true,
state,
[{ type: types.SET_LOADING, payload: true }],
[],
);
});
});
describe('fetchTopRankedGroupLabels', () => {
beforeEach(() => {
gon.api_version = 'v4';
......
......@@ -27,6 +27,19 @@ describe('Cycle analytics mutations', () => {
expect(state[stateKey]).toEqual(value);
});
it.each`
mutation | payload | expectedState
${types.SET_LOADING} | ${true} | ${{ isLoadingTasksByTypeChart: true, isLoadingTasksByTypeChartTopLabels: true }}
`(
'$mutation with payload $payload will update state with $expectedState',
({ mutation, payload, expectedState }) => {
state = {};
mutations[mutation](state, payload);
expect(state).toMatchObject(expectedState);
},
);
describe(`${types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS}`, () => {
it('sets isLoadingTasksByTypeChart to false', () => {
mutations[types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS](state, {});
......
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