Commit f3cd5856 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '225405-vsa-says-not-enough-data-when-there-is-too-much-data' into 'master'

FE - VSA gracefully handle too much data error

Closes #225405

See merge request gitlab-org/gitlab!39179
parents 20bdc02e b4bf9df5
...@@ -71,6 +71,7 @@ export default { ...@@ -71,6 +71,7 @@ export default {
'endDate', 'endDate',
'medians', 'medians',
'isLoadingValueStreams', 'isLoadingValueStreams',
'selectedStageError',
]), ]),
// NOTE: formEvents are fetched in the same request as the list of stages (fetchGroupStagesAndEvents) // NOTE: formEvents are fetched in the same request as the list of stages (fetchGroupStagesAndEvents)
// so i think its ok to bind formEvents here even though its only used as a prop to the custom-stage-form // so i think its ok to bind formEvents here even though its only used as a prop to the custom-stage-form
...@@ -296,6 +297,7 @@ export default { ...@@ -296,6 +297,7 @@ export default {
:custom-stage-form-active="customStageFormActive" :custom-stage-form-active="customStageFormActive"
:current-stage-events="currentStageEvents" :current-stage-events="currentStageEvents"
:no-data-svg-path="noDataSvgPath" :no-data-svg-path="noDataSvgPath"
:empty-state-message="selectedStageError"
> >
<template #nav> <template #nav>
<stage-table-nav <stage-table-nav
......
<script> <script>
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import { __ } from '~/locale';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { dateFormats } from '../../shared/constants'; import { dateFormats } from '../../shared/constants';
import Scatterplot from '../../shared/components/scatterplot.vue'; import Scatterplot from '../../shared/components/scatterplot.vue';
...@@ -19,11 +20,16 @@ export default { ...@@ -19,11 +20,16 @@ export default {
}, },
}, },
computed: { computed: {
...mapState('durationChart', ['isLoading']), ...mapState('durationChart', ['isLoading', 'errorMessage']),
...mapGetters('durationChart', ['durationChartPlottableData']), ...mapGetters('durationChart', ['durationChartPlottableData']),
hasData() { hasData() {
return Boolean(!this.isLoading && this.durationChartPlottableData.length); return Boolean(!this.isLoading && this.durationChartPlottableData.length);
}, },
error() {
return this.errorMessage
? this.errorMessage
: __('There is no data available. Please change your selection.');
},
}, },
methods: { methods: {
...mapActions('durationChart', ['updateSelectedDurationChartStages']), ...mapActions('durationChart', ['updateSelectedDurationChartStages']),
...@@ -53,7 +59,7 @@ export default { ...@@ -53,7 +59,7 @@ export default {
:scatter-data="durationChartPlottableData" :scatter-data="durationChartPlottableData"
/> />
<div v-else ref="duration-chart-no-data" class="bs-callout bs-callout-info"> <div v-else ref="duration-chart-no-data" class="bs-callout bs-callout-info">
{{ __('There is no data available. Please change your selection.') }} {{ error }}
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import { GlButton, GlTooltip, GlTooltipDirective } from '@gitlab/ui'; import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import { approximateDuration } from '~/lib/utils/datetime_utility'; import { approximateDuration } from '~/lib/utils/datetime_utility';
import StageCardListItem from './stage_card_list_item.vue'; import StageCardListItem from './stage_card_list_item.vue';
const ERROR_MESSAGES = {
tooMuchData: __('There is too much data to calculate. Please change your selection.'),
};
const ERROR_NAV_ITEM_CONTENT = {
[ERROR_MESSAGES.tooMuchData]: __('Too much data'),
fallback: __('Not enough data'),
};
export default { export default {
name: 'StageNavItem', name: 'StageNavItem',
components: { components: {
StageCardListItem, StageCardListItem,
GlButton, GlButton,
GlTooltip,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -39,6 +48,11 @@ export default { ...@@ -39,6 +48,11 @@ export default {
type: [String, Number], type: [String, Number],
required: true, required: true,
}, },
errorMessage: {
type: String,
required: false,
default: '',
},
}, },
data() { data() {
return { return {
...@@ -56,6 +70,12 @@ export default { ...@@ -56,6 +70,12 @@ export default {
openMenuClasses() { openMenuClasses() {
return this.isHover ? 'd-flex justify-content-end' : ''; return this.isHover ? 'd-flex justify-content-end' : '';
}, },
error() {
return ERROR_NAV_ITEM_CONTENT[this.errorMessage] || ERROR_NAV_ITEM_CONTENT.fallback;
},
stageTitleTooltip() {
return this.isTitleOverflowing ? this.title : null;
},
}, },
mounted() { mounted() {
this.checkIfTitleOverflows(); this.checkIfTitleOverflows();
...@@ -100,15 +120,14 @@ export default { ...@@ -100,15 +120,14 @@ export default {
class="stage-nav-item-cell stage-name text-truncate w-50 pr-2" class="stage-nav-item-cell stage-name text-truncate w-50 pr-2"
:class="{ 'font-weight-bold': isActive }" :class="{ 'font-weight-bold': isActive }"
> >
<gl-tooltip v-if="isTitleOverflowing" :target="() => $refs.titleSpan"> <span v-gl-tooltip="{ title: stageTitleTooltip }" data-testid="stage-title">{{
{{ title }} title
</gl-tooltip> }}</span>
<span ref="titleSpan">{{ title }}</span>
</div> </div>
<div class="stage-nav-item-cell w-50 d-flex justify-content-between"> <div class="stage-nav-item-cell w-50 d-flex justify-content-between">
<div ref="median" class="stage-median w-75 align-items-start"> <div ref="median" class="stage-median w-75 align-items-start">
<span v-if="hasValue">{{ median }}</span> <span v-if="hasValue">{{ median }}</span>
<span v-else class="stage-empty">{{ __('Not enough data') }}</span> <span v-else v-gl-tooltip="{ title: errorMessage }" class="stage-empty">{{ error }}</span>
</div> </div>
<div v-show="isHover" ref="dropdown" :class="[openMenuClasses]" class="dropdown w-25"> <div v-show="isHover" ref="dropdown" :class="[openMenuClasses]" class="dropdown w-25">
<gl-button <gl-button
......
...@@ -5,6 +5,9 @@ import StageEventList from './stage_event_list.vue'; ...@@ -5,6 +5,9 @@ import StageEventList from './stage_event_list.vue';
import StageTableHeader from './stage_table_header.vue'; import StageTableHeader from './stage_table_header.vue';
const MIN_TABLE_HEIGHT = 420; const MIN_TABLE_HEIGHT = 420;
const NOT_ENOUGH_DATA_ERROR = s__(
"ValueStreamAnalyticsStage|We don't have enough data to show this stage.",
);
export default { export default {
name: 'StageTable', name: 'StageTable',
...@@ -47,6 +50,11 @@ export default { ...@@ -47,6 +50,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
emptyStateMessage: {
type: String,
required: false,
default: '',
},
}, },
data() { data() {
return { return {
...@@ -92,6 +100,10 @@ export default { ...@@ -92,6 +100,10 @@ export default {
}, },
]; ];
}, },
emptyStateTitle() {
const { emptyStateMessage } = this;
return emptyStateMessage.length ? emptyStateMessage : NOT_ENOUGH_DATA_ERROR;
},
}, },
updated() { updated() {
if (!this.isLoading && this.$refs.stageNav) { if (!this.isLoading && this.$refs.stageNav) {
...@@ -139,7 +151,7 @@ export default { ...@@ -139,7 +151,7 @@ export default {
/> />
<gl-empty-state <gl-empty-state
v-if="isEmptyStage" v-if="isEmptyStage"
:title="__('We don\'t have enough data to show this stage.')" :title="emptyStateTitle"
:description="currentStage.emptyStageText" :description="currentStage.emptyStageText"
:svg-path="noDataSvgPath" :svg-path="noDataSvgPath"
/> />
......
...@@ -73,12 +73,15 @@ export default { ...@@ -73,12 +73,15 @@ export default {
}, },
methods: { methods: {
medianValue(id) { medianValue(id) {
return this.medians[id] ? this.medians[id] : null; return this.medians[id]?.value || null;
}, },
isActiveStage(stageId) { isActiveStage(stageId) {
const { currentStage, isCreatingCustomStage } = this; const { currentStage, isCreatingCustomStage } = this;
return Boolean(!isCreatingCustomStage && currentStage && stageId === currentStage.id); return Boolean(!isCreatingCustomStage && currentStage && stageId === currentStage.id);
}, },
medianError(id) {
return this.medians[id]?.error || '';
},
}, },
STAGE_ACTIONS, STAGE_ACTIONS,
noDragClass: NO_DRAG_CLASS, noDragClass: NO_DRAG_CLASS,
...@@ -94,6 +97,7 @@ export default { ...@@ -94,6 +97,7 @@ export default {
:value="medianValue(stage.id)" :value="medianValue(stage.id)"
:is-active="isActiveStage(stage.id)" :is-active="isActiveStage(stage.id)"
:is-default-stage="!stage.custom" :is-default-stage="!stage.custom"
:error-message="medianError(stage.id)"
@remove="$emit($options.STAGE_ACTIONS.REMOVE, stage.id)" @remove="$emit($options.STAGE_ACTIONS.REMOVE, stage.id)"
@hide="$emit($options.STAGE_ACTIONS.HIDE, { id: stage.id, hidden: true })" @hide="$emit($options.STAGE_ACTIONS.HIDE, { id: stage.id, hidden: true })"
@select="$emit($options.STAGE_ACTIONS.SELECT, stage)" @select="$emit($options.STAGE_ACTIONS.SELECT, stage)"
......
...@@ -3,7 +3,7 @@ import { mapActions, mapGetters, mapState } from 'vuex'; ...@@ -3,7 +3,7 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import TasksByTypeChart from './tasks_by_type/tasks_by_type_chart.vue'; import TasksByTypeChart from './tasks_by_type/tasks_by_type_chart.vue';
import TasksByTypeFilters from './tasks_by_type/tasks_by_type_filters.vue'; import TasksByTypeFilters from './tasks_by_type/tasks_by_type_filters.vue';
import { s__, sprintf } from '~/locale'; import { s__, sprintf, __ } from '~/locale';
import { formattedDate } from '../../shared/utils'; import { formattedDate } from '../../shared/utils';
import { TASKS_BY_TYPE_SUBJECT_ISSUE } from '../constants'; import { TASKS_BY_TYPE_SUBJECT_ISSUE } from '../constants';
...@@ -11,7 +11,11 @@ export default { ...@@ -11,7 +11,11 @@ export default {
name: 'TypeOfWorkCharts', name: 'TypeOfWorkCharts',
components: { ChartSkeletonLoader, TasksByTypeChart, TasksByTypeFilters }, components: { ChartSkeletonLoader, TasksByTypeChart, TasksByTypeFilters },
computed: { computed: {
...mapState('typeOfWork', ['isLoadingTasksByTypeChart', 'isLoadingTasksByTypeChartTopLabels']), ...mapState('typeOfWork', [
'isLoadingTasksByTypeChart',
'isLoadingTasksByTypeChartTopLabels',
'errorMessage',
]),
...mapGetters('typeOfWork', ['selectedTasksByTypeFilters', 'tasksByTypeChartData']), ...mapGetters('typeOfWork', ['selectedTasksByTypeFilters', 'tasksByTypeChartData']),
hasData() { hasData() {
return Boolean(this.tasksByTypeChartData?.data.length); return Boolean(this.tasksByTypeChartData?.data.length);
...@@ -52,6 +56,11 @@ export default { ...@@ -52,6 +56,11 @@ export default {
selectedLabelIdsFilter() { selectedLabelIdsFilter() {
return this.selectedTasksByTypeFilters?.selectedLabelIds || []; return this.selectedTasksByTypeFilters?.selectedLabelIds || [];
}, },
error() {
return this.errorMessage
? this.errorMessage
: __('There is no data available. Please change your selection.');
},
}, },
methods: { methods: {
...mapActions('typeOfWork', ['setTasksByTypeFilters']), ...mapActions('typeOfWork', ['setTasksByTypeFilters']),
...@@ -80,7 +89,7 @@ export default { ...@@ -80,7 +89,7 @@ export default {
:series-names="tasksByTypeChartData.seriesNames" :series-names="tasksByTypeChartData.seriesNames"
/> />
<div v-else class="bs-callout bs-callout-info"> <div v-else class="bs-callout bs-callout-info">
<p>{{ __('There is no data available. Please change your selection.') }}</p> <p>{{ error }}</p>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -3,7 +3,13 @@ import { deprecatedCreateFlash as createFlash } from '~/flash'; ...@@ -3,7 +3,13 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { removeFlash, handleErrorOrRethrow, isStageNameExistsError } from '../utils'; import {
removeFlash,
throwIfUserForbidden,
isStageNameExistsError,
checkForDataError,
flashErrorIfStatusNotOk,
} from '../utils';
const appendExtension = path => (path.indexOf('.') > -1 ? path : `${path}.json`); const appendExtension = path => (path.indexOf('.') > -1 ? path : `${path}.json`);
...@@ -48,9 +54,13 @@ export const receiveStageDataSuccess = ({ commit }, data) => { ...@@ -48,9 +54,13 @@ export const receiveStageDataSuccess = ({ commit }, data) => {
commit(types.RECEIVE_STAGE_DATA_SUCCESS, data); commit(types.RECEIVE_STAGE_DATA_SUCCESS, data);
}; };
export const receiveStageDataError = ({ commit }) => { export const receiveStageDataError = ({ commit }, error) => {
commit(types.RECEIVE_STAGE_DATA_ERROR); const { message = '' } = error;
createFlash(__('There was an error fetching data for the selected stage')); flashErrorIfStatusNotOk({
error,
message: __('There was an error fetching data for the selected stage'),
});
commit(types.RECEIVE_STAGE_DATA_ERROR, message);
}; };
export const fetchStageData = ({ dispatch, getters }, stageId) => { export const fetchStageData = ({ dispatch, getters }, stageId) => {
...@@ -63,27 +73,32 @@ export const fetchStageData = ({ dispatch, getters }, stageId) => { ...@@ -63,27 +73,32 @@ export const fetchStageData = ({ dispatch, getters }, stageId) => {
stageId, stageId,
cycleAnalyticsRequestParams, cycleAnalyticsRequestParams,
}) })
.then(checkForDataError)
.then(({ data }) => dispatch('receiveStageDataSuccess', data)) .then(({ data }) => dispatch('receiveStageDataSuccess', data))
.catch(error => dispatch('receiveStageDataError', error)); .catch(error => dispatch('receiveStageDataError', error));
}; };
export const requestStageMedianValues = ({ commit }) => commit(types.REQUEST_STAGE_MEDIANS); export const requestStageMedianValues = ({ commit }) => commit(types.REQUEST_STAGE_MEDIANS);
export const receiveStageMedianValuesSuccess = ({ commit }, data) => {
commit(types.RECEIVE_STAGE_MEDIANS_SUCCESS, data);
};
export const receiveStageMedianValuesError = ({ commit }) => { export const receiveStageMedianValuesError = ({ commit }, error) => {
commit(types.RECEIVE_STAGE_MEDIANS_ERROR); commit(types.RECEIVE_STAGE_MEDIANS_ERROR, error);
createFlash(__('There was an error fetching median data for stages')); createFlash(__('There was an error fetching median data for stages'));
}; };
const fetchStageMedian = ({ groupId, valueStreamId, stageId, params }) => const fetchStageMedian = ({ groupId, valueStreamId, stageId, params }) =>
Api.cycleAnalyticsStageMedian({ groupId, valueStreamId, stageId, params }).then(({ data }) => ({ Api.cycleAnalyticsStageMedian({ groupId, valueStreamId, stageId, params }).then(({ data }) => {
id: stageId, return {
...data, id: stageId,
})); ...(data?.error
? {
error: data.error,
value: null,
}
: data),
};
});
export const fetchStageMedianValues = ({ dispatch, getters }) => { export const fetchStageMedianValues = ({ dispatch, commit, getters }) => {
const { const {
currentGroupPath, currentGroupPath,
cycleAnalyticsRequestParams, cycleAnalyticsRequestParams,
...@@ -103,13 +118,8 @@ export const fetchStageMedianValues = ({ dispatch, getters }) => { ...@@ -103,13 +118,8 @@ export const fetchStageMedianValues = ({ dispatch, getters }) => {
}), }),
), ),
) )
.then(data => dispatch('receiveStageMedianValuesSuccess', data)) .then(data => commit(types.RECEIVE_STAGE_MEDIANS_SUCCESS, data))
.catch(error => .catch(error => dispatch('receiveStageMedianValuesError', error));
handleErrorOrRethrow({
error,
action: () => dispatch('receiveStageMedianValuesError', error),
}),
);
}; };
export const requestCycleAnalyticsData = ({ commit }) => commit(types.REQUEST_CYCLE_ANALYTICS_DATA); export const requestCycleAnalyticsData = ({ commit }) => commit(types.REQUEST_CYCLE_ANALYTICS_DATA);
...@@ -119,7 +129,7 @@ export const receiveCycleAnalyticsDataSuccess = ({ commit, dispatch }) => { ...@@ -119,7 +129,7 @@ export const receiveCycleAnalyticsDataSuccess = ({ commit, dispatch }) => {
dispatch('typeOfWork/fetchTopRankedGroupLabels'); dispatch('typeOfWork/fetchTopRankedGroupLabels');
}; };
export const receiveCycleAnalyticsDataError = ({ commit }, { response }) => { export const receiveCycleAnalyticsDataError = ({ commit }, { response = {} }) => {
const { status = httpStatus.INTERNAL_SERVER_ERROR } = response; const { status = httpStatus.INTERNAL_SERVER_ERROR } = response;
commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR, status); commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR, status);
...@@ -192,12 +202,10 @@ export const fetchGroupStagesAndEvents = ({ dispatch, getters }) => { ...@@ -192,12 +202,10 @@ export const fetchGroupStagesAndEvents = ({ dispatch, getters }) => {
dispatch('receiveGroupStagesSuccess', stages); dispatch('receiveGroupStagesSuccess', stages);
dispatch('customStages/setStageEvents', events); dispatch('customStages/setStageEvents', events);
}) })
.catch(error => .catch(error => {
handleErrorOrRethrow({ throwIfUserForbidden(error);
error, return dispatch('receiveGroupStagesError', error);
action: () => dispatch('receiveGroupStagesError', error), });
}),
);
}; };
export const requestUpdateStage = ({ commit }) => commit(types.REQUEST_UPDATE_STAGE); export const requestUpdateStage = ({ commit }) => commit(types.REQUEST_UPDATE_STAGE);
......
import Api from 'ee/api'; import Api from 'ee/api';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { checkForDataError, flashErrorIfStatusNotOk } from '../../../utils';
export const setLoading = ({ commit }, loading) => commit(types.SET_LOADING, loading); export const setLoading = ({ commit }, loading) => commit(types.SET_LOADING, loading);
export const requestDurationData = ({ commit }) => commit(types.REQUEST_DURATION_DATA); export const requestDurationData = ({ commit }) => commit(types.REQUEST_DURATION_DATA);
export const receiveDurationDataError = ({ commit }) => { export const receiveDurationDataError = ({ commit }, error) => {
commit(types.RECEIVE_DURATION_DATA_ERROR); flashErrorIfStatusNotOk({
createFlash(__('There was an error while fetching value stream analytics duration data.')); error,
message: __('There was an error while fetching value stream analytics duration data.'),
});
commit(types.RECEIVE_DURATION_DATA_ERROR, error);
}; };
export const fetchDurationData = ({ dispatch, commit, rootGetters }) => { export const fetchDurationData = ({ dispatch, commit, rootGetters }) => {
...@@ -29,15 +32,13 @@ export const fetchDurationData = ({ dispatch, commit, rootGetters }) => { ...@@ -29,15 +32,13 @@ export const fetchDurationData = ({ dispatch, commit, rootGetters }) => {
valueStreamId: currentValueStreamId, valueStreamId: currentValueStreamId,
stageId: slug, stageId: slug,
cycleAnalyticsRequestParams, cycleAnalyticsRequestParams,
}).then(({ data }) => ({ })
slug, .then(checkForDataError)
selected: true, .then(({ data }) => ({ slug, selected: true, data }));
data,
}));
}), }),
) )
.then(data => commit(types.RECEIVE_DURATION_DATA_SUCCESS, data)) .then(data => commit(types.RECEIVE_DURATION_DATA_SUCCESS, data))
.catch(() => dispatch('receiveDurationDataError')); .catch(error => dispatch('receiveDurationDataError', error));
}; };
export const updateSelectedDurationChartStages = ({ state, commit }, stages) => { export const updateSelectedDurationChartStages = ({ state, commit }, stages) => {
......
...@@ -9,12 +9,18 @@ export default { ...@@ -9,12 +9,18 @@ export default {
}, },
[types.REQUEST_DURATION_DATA](state) { [types.REQUEST_DURATION_DATA](state) {
state.isLoading = true; state.isLoading = true;
state.errorCode = null;
state.errorMessage = '';
}, },
[types.RECEIVE_DURATION_DATA_SUCCESS](state, data) { [types.RECEIVE_DURATION_DATA_SUCCESS](state, data) {
state.durationData = data; state.durationData = data;
state.isLoading = false; state.isLoading = false;
state.errorCode = null;
state.errorMessage = '';
}, },
[types.RECEIVE_DURATION_DATA_ERROR](state) { [types.RECEIVE_DURATION_DATA_ERROR](state, { errorCode = null, message = '' } = {}) {
state.errorCode = errorCode;
state.errorMessage = message;
state.durationData = []; state.durationData = [];
state.isLoading = false; state.isLoading = false;
}, },
......
...@@ -2,4 +2,7 @@ export default () => ({ ...@@ -2,4 +2,7 @@ export default () => ({
isLoading: false, isLoading: false,
durationData: [], durationData: [],
errorCode: null,
errorMessage: '',
}); });
import Api from 'ee/api'; import Api from 'ee/api';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { handleErrorOrRethrow } from '../../../utils'; import { throwIfUserForbidden, checkForDataError, flashErrorIfStatusNotOk } from '../../../utils';
export const setLoading = ({ commit }, loading) => commit(types.SET_LOADING, loading); export const setLoading = ({ commit }, loading) => commit(types.SET_LOADING, loading);
...@@ -12,8 +11,11 @@ export const receiveTopRankedGroupLabelsSuccess = ({ commit, dispatch }, data) = ...@@ -12,8 +11,11 @@ export const receiveTopRankedGroupLabelsSuccess = ({ commit, dispatch }, data) =
}; };
export const receiveTopRankedGroupLabelsError = ({ commit }, error) => { export const receiveTopRankedGroupLabelsError = ({ commit }, error) => {
flashErrorIfStatusNotOk({
error,
message: __('There was an error fetching the top labels for the selected group'),
});
commit(types.RECEIVE_TOP_RANKED_GROUP_LABELS_ERROR, error); commit(types.RECEIVE_TOP_RANKED_GROUP_LABELS_ERROR, error);
createFlash(__('There was an error fetching the top labels for the selected group'));
}; };
export const fetchTopRankedGroupLabels = ({ dispatch, commit, state, rootGetters }) => { export const fetchTopRankedGroupLabels = ({ dispatch, commit, state, rootGetters }) => {
...@@ -40,18 +42,20 @@ export const fetchTopRankedGroupLabels = ({ dispatch, commit, state, rootGetters ...@@ -40,18 +42,20 @@ export const fetchTopRankedGroupLabels = ({ dispatch, commit, state, rootGetters
milestone_title, milestone_title,
assignee_username, assignee_username,
}) })
.then(checkForDataError)
.then(({ data }) => dispatch('receiveTopRankedGroupLabelsSuccess', data)) .then(({ data }) => dispatch('receiveTopRankedGroupLabelsSuccess', data))
.catch(error => .catch(error => {
handleErrorOrRethrow({ throwIfUserForbidden(error);
error, return dispatch('receiveTopRankedGroupLabelsError', error);
action: () => dispatch('receiveTopRankedGroupLabelsError', error), });
}),
);
}; };
export const receiveTasksByTypeDataError = ({ commit }, error) => { export const receiveTasksByTypeDataError = ({ commit }, error) => {
flashErrorIfStatusNotOk({
error,
message: __('There was an error fetching data for the tasks by type chart'),
});
commit(types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR, error); commit(types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR, error);
createFlash(__('There was an error fetching data for the tasks by type chart'));
}; };
export const fetchTasksByTypeData = ({ dispatch, commit, state, rootGetters }) => { export const fetchTasksByTypeData = ({ dispatch, commit, state, rootGetters }) => {
...@@ -84,6 +88,7 @@ export const fetchTasksByTypeData = ({ dispatch, commit, state, rootGetters }) = ...@@ -84,6 +88,7 @@ export const fetchTasksByTypeData = ({ dispatch, commit, state, rootGetters }) =
// until we resolve: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/34524 // until we resolve: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/34524
label_ids: selectedLabelIds, label_ids: selectedLabelIds,
}) })
.then(checkForDataError)
.then(({ data }) => commit(types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS, data)) .then(({ data }) => commit(types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS, data))
.catch(error => dispatch('receiveTasksByTypeDataError', error)); .catch(error => dispatch('receiveTasksByTypeDataError', error));
} }
......
...@@ -12,16 +12,23 @@ export default { ...@@ -12,16 +12,23 @@ export default {
state.isLoadingTasksByTypeChartTopLabels = true; state.isLoadingTasksByTypeChartTopLabels = true;
state.topRankedLabels = []; state.topRankedLabels = [];
state.selectedLabelIds = []; state.selectedLabelIds = [];
state.errorCode = null;
state.errorMessage = '';
}, },
[types.RECEIVE_TOP_RANKED_GROUP_LABELS_SUCCESS](state, data = []) { [types.RECEIVE_TOP_RANKED_GROUP_LABELS_SUCCESS](state, data = []) {
state.isLoadingTasksByTypeChartTopLabels = false; state.isLoadingTasksByTypeChartTopLabels = false;
state.topRankedLabels = data.map(convertObjectPropsToCamelCase); state.topRankedLabels = data.map(convertObjectPropsToCamelCase);
state.selectedLabelIds = data.map(({ id }) => id); state.selectedLabelIds = data.map(({ id }) => id);
state.errorCode = null;
state.errorMessage = '';
}, },
[types.RECEIVE_TOP_RANKED_GROUP_LABELS_ERROR](state) { [types.RECEIVE_TOP_RANKED_GROUP_LABELS_ERROR](state, { errorCode = null, message = '' } = {}) {
state.isLoadingTasksByTypeChartTopLabels = false; state.isLoadingTasksByTypeChartTopLabels = false;
state.isLoadingTasksByTypeChart = false;
state.topRankedLabels = []; state.topRankedLabels = [];
state.selectedLabelIds = []; state.selectedLabelIds = [];
state.errorCode = errorCode;
state.errorMessage = message;
}, },
[types.REQUEST_TASKS_BY_TYPE_DATA](state) { [types.REQUEST_TASKS_BY_TYPE_DATA](state) {
state.isLoadingTasksByTypeChart = true; state.isLoadingTasksByTypeChart = true;
......
...@@ -8,4 +8,7 @@ export default () => ({ ...@@ -8,4 +8,7 @@ export default () => ({
selectedLabelIds: [], selectedLabelIds: [],
topRankedLabels: [], topRankedLabels: [],
data: [], data: [],
errorCode: null,
errorMessage: '',
}); });
...@@ -34,6 +34,7 @@ export default { ...@@ -34,6 +34,7 @@ export default {
[types.REQUEST_STAGE_DATA](state) { [types.REQUEST_STAGE_DATA](state) {
state.isLoadingStage = true; state.isLoadingStage = true;
state.isEmptyStage = false; state.isEmptyStage = false;
state.selectedStageError = '';
}, },
[types.RECEIVE_STAGE_DATA_SUCCESS](state, events = []) { [types.RECEIVE_STAGE_DATA_SUCCESS](state, events = []) {
state.currentStageEvents = events.map(fields => state.currentStageEvents = events.map(fields =>
...@@ -41,19 +42,21 @@ export default { ...@@ -41,19 +42,21 @@ export default {
); );
state.isEmptyStage = !events.length; state.isEmptyStage = !events.length;
state.isLoadingStage = false; state.isLoadingStage = false;
state.selectedStageError = '';
}, },
[types.RECEIVE_STAGE_DATA_ERROR](state) { [types.RECEIVE_STAGE_DATA_ERROR](state, message) {
state.isEmptyStage = true; state.isEmptyStage = true;
state.isLoadingStage = false; state.isLoadingStage = false;
state.selectedStageError = message;
}, },
[types.REQUEST_STAGE_MEDIANS](state) { [types.REQUEST_STAGE_MEDIANS](state) {
state.medians = {}; state.medians = {};
}, },
[types.RECEIVE_STAGE_MEDIANS_SUCCESS](state, medians = []) { [types.RECEIVE_STAGE_MEDIANS_SUCCESS](state, medians = []) {
state.medians = medians.reduce( state.medians = medians.reduce(
(acc, { id, value }) => ({ (acc, { id, value, error = null }) => ({
...acc, ...acc,
[id]: value, [id]: { value, error },
}), }),
{}, {},
); );
......
...@@ -28,6 +28,7 @@ export default () => ({ ...@@ -28,6 +28,7 @@ export default () => ({
deleteValueStreamError: null, deleteValueStreamError: null,
stages: [], stages: [],
selectedStageError: '',
summary: [], summary: [],
medians: {}, medians: {},
valueStreams: [], valueStreams: [],
......
...@@ -4,7 +4,7 @@ import { s__, sprintf } from '~/locale'; ...@@ -4,7 +4,7 @@ import { s__, sprintf } from '~/locale';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
import { convertToSnakeCase, slugify } from '~/lib/utils/text_utility'; import { convertToSnakeCase, slugify } from '~/lib/utils/text_utility';
import { hideFlash } from '~/flash'; import { hideFlash, deprecatedCreateFlash as createFlash } from '~/flash';
import { import {
newDate, newDate,
dayAfter, dayAfter,
...@@ -295,11 +295,44 @@ export const getTasksByTypeData = ({ data = [], startDate = null, endDate = null ...@@ -295,11 +295,44 @@ export const getTasksByTypeData = ({ data = [], startDate = null, endDate = null
}; };
}; };
export const handleErrorOrRethrow = ({ action, error }) => { const buildDataError = ({ status = httpStatus.INTERNAL_SERVER_ERROR, error }) => {
const err = new Error(error);
err.errorCode = status;
return err;
};
/**
* Flashes an error message if the status code is not 200
*
* @param {Object} error - Axios error object
* @param {String} errorMessage - Error message to display
*/
export const flashErrorIfStatusNotOk = ({ error, message }) => {
if (error?.errorCode !== httpStatus.OK) {
createFlash(message);
}
};
/**
* Data errors can occur when DB queries for analytics data time out
* The server will respond with a status `200` success and include the
* relevant error in the response body
*
* @param {Object} Response - Axios ajax response
* @returns {Object} Returns the axios ajax response
*/
export const checkForDataError = response => {
const { data, status } = response;
if (data?.error) {
throw buildDataError({ status, error: data.error });
}
return response;
};
export const throwIfUserForbidden = error => {
if (error?.response?.status === httpStatus.FORBIDDEN) { if (error?.response?.status === httpStatus.FORBIDDEN) {
throw error; throw error;
} }
action();
}; };
export const isStageNameExistsError = ({ status, errors }) => export const isStageNameExistsError = ({ status, errors }) =>
......
// NOTE: more tests will be added in https://gitlab.com/gitlab-org/gitlab/issues/121613 // NOTE: more tests will be added in https://gitlab.com/gitlab-org/gitlab/issues/121613
import { GlTooltip } from '@gitlab/ui'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import StageNavItem from 'ee/analytics/cycle_analytics/components/stage_nav_item.vue'; import StageNavItem from 'ee/analytics/cycle_analytics/components/stage_nav_item.vue';
import { approximateDuration } from '~/lib/utils/datetime_utility'; import { approximateDuration } from '~/lib/utils/datetime_utility';
...@@ -17,16 +17,20 @@ describe('StageNavItem', () => { ...@@ -17,16 +17,20 @@ describe('StageNavItem', () => {
value: median, value: median,
...props, ...props,
}, },
directives: {
GlTooltip: createMockDirective(),
},
...opts, ...opts,
}); });
} }
let wrapper = null; let wrapper = null;
const findStageTitle = () => wrapper.find({ ref: 'title' }); const findStageTitle = () => wrapper.find('[data-testid="stage-title"]');
const findStageTooltip = () => getBinding(findStageTitle().element, 'gl-tooltip');
const findStageMedian = () => wrapper.find({ ref: 'median' }); const findStageMedian = () => wrapper.find({ ref: 'median' });
const findDropdown = () => wrapper.find({ ref: 'dropdown' }); const findDropdown = () => wrapper.find({ ref: 'dropdown' });
const setFakeTitleWidth = value => const setFakeTitleWidth = value =>
Object.defineProperty(wrapper.find({ ref: 'titleSpan' }).element, 'scrollWidth', { Object.defineProperty(findStageTitle().element, 'scrollWidth', {
value, value,
}); });
...@@ -52,6 +56,11 @@ describe('StageNavItem', () => { ...@@ -52,6 +56,11 @@ describe('StageNavItem', () => {
expect(findStageTitle().text()).toEqual(title); expect(findStageTitle().text()).toEqual(title);
}); });
it('renders the stage title without a tooltip', () => {
const tt = findStageTooltip();
expect(tt.value.title).toBeNull();
});
it('renders the dropdown with edit and remove options', () => { it('renders the dropdown with edit and remove options', () => {
expect(findDropdown().exists()).toBe(true); expect(findDropdown().exists()).toBe(true);
expect(wrapper.find('[data-testid="edit-btn"]').exists()).toBe(true); expect(wrapper.find('[data-testid="edit-btn"]').exists()).toBe(true);
...@@ -93,11 +102,9 @@ describe('StageNavItem', () => { ...@@ -93,11 +102,9 @@ describe('StageNavItem', () => {
}); });
it('renders the tooltip', () => { it('renders the tooltip', () => {
expect(wrapper.find(GlTooltip).exists()).toBe(true); const tt = findStageTooltip();
}); expect(tt.value).toBeDefined();
expect(tt.value.title).toBe(longTitle);
it('tooltip has the correct stage title', () => {
expect(wrapper.find(GlTooltip).text()).toBe(longTitle);
}); });
}); });
}); });
...@@ -17,6 +17,7 @@ const $sel = { ...@@ -17,6 +17,7 @@ const $sel = {
const headers = ['Stage', 'Median', issueStage.legend, 'Time']; const headers = ['Stage', 'Median', issueStage.legend, 'Time'];
const noDataSvgPath = 'path/to/no/data'; const noDataSvgPath = 'path/to/no/data';
const tooMuchDataError = "We don't have enough data to show this stage.";
const StageTableNavSlot = { const StageTableNavSlot = {
name: 'stage-table-nav-slot-stub', name: 'stage-table-nav-slot-stub',
...@@ -50,15 +51,15 @@ function createComponent(props = {}, shallow = false) { ...@@ -50,15 +51,15 @@ function createComponent(props = {}, shallow = false) {
} }
describe('StageTable', () => { describe('StageTable', () => {
afterEach(() => {
wrapper.destroy();
});
describe('headers', () => { describe('headers', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); wrapper = createComponent();
}); });
afterEach(() => {
wrapper.destroy();
});
it('will render the headers', () => { it('will render the headers', () => {
const renderedHeaders = wrapper.findAll($sel.headers); const renderedHeaders = wrapper.findAll($sel.headers);
expect(renderedHeaders).toHaveLength(headers.length); expect(renderedHeaders).toHaveLength(headers.length);
...@@ -75,10 +76,6 @@ describe('StageTable', () => { ...@@ -75,10 +76,6 @@ describe('StageTable', () => {
wrapper = createComponent(); wrapper = createComponent();
}); });
afterEach(() => {
wrapper.destroy();
});
it('will render the events list', () => { it('will render the events list', () => {
expect(wrapper.find($sel.eventList).exists()).toBeTruthy(); expect(wrapper.find($sel.eventList).exists()).toBeTruthy();
}); });
...@@ -112,6 +109,10 @@ describe('StageTable', () => { ...@@ -112,6 +109,10 @@ describe('StageTable', () => {
expect(evshtml).toContain(ev.title); expect(evshtml).toContain(ev.title);
}); });
}); });
it('will not display the default data message', () => {
expect(wrapper.html()).not.toContain(tooMuchDataError);
});
}); });
it('isLoading = true', () => { it('isLoading = true', () => {
...@@ -142,17 +143,28 @@ describe('StageTable', () => { ...@@ -142,17 +143,28 @@ describe('StageTable', () => {
wrapper = createComponent({ isEmptyStage: true }); wrapper = createComponent({ isEmptyStage: true });
}); });
afterEach(() => {
wrapper.destroy();
});
it('will render the empty stage illustration', () => { it('will render the empty stage illustration', () => {
expect(wrapper.find($sel.illustration).exists()).toBeTruthy(); expect(wrapper.find($sel.illustration).exists()).toBeTruthy();
expect(wrapper.find($sel.illustration).html()).toContain(noDataSvgPath); expect(wrapper.find($sel.illustration).html()).toContain(noDataSvgPath);
}); });
it('will display the no data message', () => { it('will display the default no data message', () => {
expect(wrapper.html()).toContain("We don't have enough data to show this stage."); expect(wrapper.html()).toContain(tooMuchDataError);
});
});
describe('emptyStateMessage set', () => {
const emptyStateMessage = 'Too much data';
beforeEach(() => {
wrapper = createComponent({ isEmptyStage: true, emptyStateMessage });
});
it('will not display the default data message', () => {
expect(wrapper.html()).not.toContain(tooMuchDataError);
});
it('will display the custom message', () => {
expect(wrapper.html()).toContain(emptyStateMessage);
}); });
}); });
}); });
...@@ -29,10 +29,10 @@ export const endpoints = { ...@@ -29,10 +29,10 @@ export const endpoints = {
groupLabels: /groups\/[A-Z|a-z|\d|\-|_]+\/-\/labels.json/, groupLabels: /groups\/[A-Z|a-z|\d|\-|_]+\/-\/labels.json/,
recentActivityData: /analytics\/value_stream_analytics\/summary/, recentActivityData: /analytics\/value_stream_analytics\/summary/,
timeMetricsData: /analytics\/value_stream_analytics\/time_summary/, timeMetricsData: /analytics\/value_stream_analytics\/time_summary/,
durationData: /analytics\/value_stream_analytics\/value_streams\/\d+\/stages\/\d+\/duration_chart/, durationData: /analytics\/value_stream_analytics\/value_streams\/\w+\/stages\/\w+\/duration_chart/,
stageData: /analytics\/value_stream_analytics\/value_streams\/\d+\/stages\/\d+\/records/, stageData: /analytics\/value_stream_analytics\/value_streams\/\w+\/stages\/\w+\/records/,
stageMedian: /analytics\/value_stream_analytics\/value_streams\/\d+\/stages\/\d+\/median/, stageMedian: /analytics\/value_stream_analytics\/value_streams\/\w+\/stages\/\w+\/median/,
baseStagesEndpoint: /analytics\/value_stream_analytics\/value_streams\/\d+\/stages$/, baseStagesEndpoint: /analytics\/value_stream_analytics\/value_streams\/\w+\/stages$/,
tasksByTypeData: /analytics\/type_of_work\/tasks_by_type/, tasksByTypeData: /analytics\/type_of_work\/tasks_by_type/,
tasksByTypeTopLabelsData: /analytics\/type_of_work\/tasks_by_type\/top_labels/, tasksByTypeTopLabelsData: /analytics\/type_of_work\/tasks_by_type\/top_labels/,
valueStreamData: /analytics\/value_stream_analytics\/value_streams/, valueStreamData: /analytics\/value_stream_analytics\/value_streams/,
......
...@@ -292,15 +292,17 @@ describe('Cycle analytics actions', () => { ...@@ -292,15 +292,17 @@ describe('Cycle analytics actions', () => {
}); });
describe('receiveStageDataError', () => { describe('receiveStageDataError', () => {
beforeEach(() => {}); const message = 'fake error';
it(`commits the ${types.RECEIVE_STAGE_DATA_ERROR} mutation`, () => { it(`commits the ${types.RECEIVE_STAGE_DATA_ERROR} mutation`, () => {
return testAction( return testAction(
actions.receiveStageDataError, actions.receiveStageDataError,
null, { message },
state, state,
[ [
{ {
type: types.RECEIVE_STAGE_DATA_ERROR, type: types.RECEIVE_STAGE_DATA_ERROR,
payload: message,
}, },
], ],
[], [],
...@@ -308,7 +310,7 @@ describe('Cycle analytics actions', () => { ...@@ -308,7 +310,7 @@ describe('Cycle analytics actions', () => {
}); });
it('will flash an error message', () => { it('will flash an error message', () => {
actions.receiveStageDataError({ commit: () => {} }); actions.receiveStageDataError({ commit: () => {} }, {});
shouldFlashAMessage('There was an error fetching data for the selected stage'); shouldFlashAMessage('There was an error fetching data for the selected stage');
}); });
}); });
...@@ -754,11 +756,8 @@ describe('Cycle analytics actions', () => { ...@@ -754,11 +756,8 @@ describe('Cycle analytics actions', () => {
actions.fetchStageMedianValues, actions.fetchStageMedianValues,
null, null,
state, state,
[], [{ type: types.RECEIVE_STAGE_MEDIANS_SUCCESS, payload: fetchMedianResponse }],
[ [{ type: 'requestStageMedianValues' }],
{ type: 'requestStageMedianValues' },
{ type: 'receiveStageMedianValuesSuccess', payload: fetchMedianResponse },
],
); );
}); });
...@@ -781,6 +780,25 @@ describe('Cycle analytics actions', () => { ...@@ -781,6 +780,25 @@ describe('Cycle analytics actions', () => {
}); });
}); });
describe(`Status ${httpStatusCodes.OK} and error message in response`, () => {
const dataError = 'Too much data';
const payload = activeStages.map(({ slug: id }) => ({ value: null, id, error: dataError }));
beforeEach(() => {
mock.onGet(endpoints.stageMedian).reply(httpStatusCodes.OK, { error: dataError });
});
it(`dispatches the 'RECEIVE_STAGE_MEDIANS_SUCCESS' with ${dataError}`, () => {
return testAction(
actions.fetchStageMedianValues,
null,
state,
[{ type: types.RECEIVE_STAGE_MEDIANS_SUCCESS, payload }],
[{ type: 'requestStageMedianValues' }],
);
});
});
describe('with a failing request', () => { describe('with a failing request', () => {
beforeEach(() => { beforeEach(() => {
mock.onGet(endpoints.stageMedian).reply(httpStatusCodes.NOT_FOUND, { error }); mock.onGet(endpoints.stageMedian).reply(httpStatusCodes.NOT_FOUND, { error });
...@@ -805,11 +823,12 @@ describe('Cycle analytics actions', () => { ...@@ -805,11 +823,12 @@ describe('Cycle analytics actions', () => {
it(`commits the ${types.RECEIVE_STAGE_MEDIANS_ERROR} mutation`, () => it(`commits the ${types.RECEIVE_STAGE_MEDIANS_ERROR} mutation`, () =>
testAction( testAction(
actions.receiveStageMedianValuesError, actions.receiveStageMedianValuesError,
null, {},
state, state,
[ [
{ {
type: types.RECEIVE_STAGE_MEDIANS_ERROR, type: types.RECEIVE_STAGE_MEDIANS_ERROR,
payload: {},
}, },
], ],
[], [],
...@@ -821,18 +840,6 @@ describe('Cycle analytics actions', () => { ...@@ -821,18 +840,6 @@ describe('Cycle analytics actions', () => {
}); });
}); });
describe('receiveStageMedianValuesSuccess', () => {
it(`commits the ${types.RECEIVE_STAGE_MEDIANS_SUCCESS} mutation`, () => {
return testAction(
actions.receiveStageMedianValuesSuccess,
{ ...stageData },
state,
[{ type: types.RECEIVE_STAGE_MEDIANS_SUCCESS, payload: { events: [] } }],
[],
);
});
});
describe('initializeCycleAnalytics', () => { describe('initializeCycleAnalytics', () => {
let mockDispatch; let mockDispatch;
let mockCommit; let mockCommit;
......
...@@ -5,6 +5,7 @@ import * as rootGetters from 'ee/analytics/cycle_analytics/store/getters'; ...@@ -5,6 +5,7 @@ import * as rootGetters from 'ee/analytics/cycle_analytics/store/getters';
import * as getters from 'ee/analytics/cycle_analytics/store/modules/duration_chart/getters'; import * as getters from 'ee/analytics/cycle_analytics/store/modules/duration_chart/getters';
import * as actions from 'ee/analytics/cycle_analytics/store/modules/duration_chart/actions'; import * as actions from 'ee/analytics/cycle_analytics/store/modules/duration_chart/actions';
import * as types from 'ee/analytics/cycle_analytics/store/modules/duration_chart/mutation_types'; import * as types from 'ee/analytics/cycle_analytics/store/modules/duration_chart/mutation_types';
import httpStatusCodes from '~/lib/utils/http_status';
import { import {
group, group,
allowedStages as stages, allowedStages as stages,
...@@ -22,6 +23,7 @@ const [stage1, stage2] = stages; ...@@ -22,6 +23,7 @@ const [stage1, stage2] = stages;
const hiddenStage = { ...stage1, hidden: true, id: 3, slug: 3 }; const hiddenStage = { ...stage1, hidden: true, id: 3, slug: 3 };
const activeStages = [stage1, stage2]; const activeStages = [stage1, stage2];
const [selectedValueStream] = valueStreams; const [selectedValueStream] = valueStreams;
const error = new Error(`Request failed with status code ${httpStatusCodes.BAD_REQUEST}`);
const rootState = { const rootState = {
startDate, startDate,
...@@ -104,9 +106,40 @@ describe('DurationChart actions', () => { ...@@ -104,9 +106,40 @@ describe('DurationChart actions', () => {
}); });
}); });
describe(`Status ${httpStatusCodes.OK} and error message in response`, () => {
const dataError = 'Too much data';
beforeEach(() => {
mock.onGet(endpoints.durationData).reply(httpStatusCodes.OK, { error: dataError });
});
it(`dispatches the 'receiveDurationDataError' with ${dataError}`, () => {
const dispatch = jest.fn();
const commit = jest.fn();
return actions
.fetchDurationData({
dispatch,
commit,
rootState,
rootGetters: {
...rootGetters,
activeStages,
},
})
.then(() => {
expect(commit).not.toHaveBeenCalled();
expect(dispatch.mock.calls).toEqual([
['requestDurationData'],
['receiveDurationDataError', new Error(dataError)],
]);
});
});
});
describe('receiveDurationDataError', () => { describe('receiveDurationDataError', () => {
beforeEach(() => { beforeEach(() => {
mock.onGet(endpoints.durationData).reply(404); mock.onGet(endpoints.durationData).reply(httpStatusCodes.BAD_REQUEST, error);
}); });
it("dispatches the 'receiveDurationDataError' action when there is an error", () => { it("dispatches the 'receiveDurationDataError' action when there is an error", () => {
...@@ -122,7 +155,10 @@ describe('DurationChart actions', () => { ...@@ -122,7 +155,10 @@ describe('DurationChart actions', () => {
}, },
}) })
.then(() => { .then(() => {
expect(dispatch).toHaveBeenCalledWith('receiveDurationDataError'); expect(dispatch.mock.calls).toEqual([
['requestDurationData'],
['receiveDurationDataError', error],
]);
}); });
}); });
}); });
...@@ -141,6 +177,7 @@ describe('DurationChart actions', () => { ...@@ -141,6 +177,7 @@ describe('DurationChart actions', () => {
[ [
{ {
type: types.RECEIVE_DURATION_DATA_ERROR, type: types.RECEIVE_DURATION_DATA_ERROR,
payload: {},
}, },
], ],
[], [],
......
...@@ -9,10 +9,16 @@ import { ...@@ -9,10 +9,16 @@ import {
TASKS_BY_TYPE_FILTERS, TASKS_BY_TYPE_FILTERS,
TASKS_BY_TYPE_SUBJECT_ISSUE, TASKS_BY_TYPE_SUBJECT_ISSUE,
} from 'ee/analytics/cycle_analytics/constants'; } from 'ee/analytics/cycle_analytics/constants';
import { deprecatedCreateFlash } from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status'; import httpStatusCodes from '~/lib/utils/http_status';
import { groupLabels, endpoints, startDate, endDate } from '../../../mock_data'; import { groupLabels, endpoints, startDate, endDate, rawTasksByTypeData } from '../../../mock_data';
import { shouldFlashAMessage } from '../../../helpers';
jest.mock('~/flash');
const shouldFlashAMessage = (msg, type = null) => {
const args = type ? [msg, type] : [msg];
expect(deprecatedCreateFlash).toHaveBeenCalledWith(...args);
};
const error = new Error(`Request failed with status code ${httpStatusCodes.NOT_FOUND}`); const error = new Error(`Request failed with status code ${httpStatusCodes.NOT_FOUND}`);
...@@ -24,7 +30,7 @@ describe('Type of work actions', () => { ...@@ -24,7 +30,7 @@ describe('Type of work actions', () => {
subject: TASKS_BY_TYPE_SUBJECT_ISSUE, subject: TASKS_BY_TYPE_SUBJECT_ISSUE,
topRankedLabels: [], topRankedLabels: [],
selectedLabelIds: [], selectedLabelIds: groupLabels.map(({ id }) => id),
data: [], data: [],
}; };
...@@ -64,7 +70,7 @@ describe('Type of work actions', () => { ...@@ -64,7 +70,7 @@ describe('Type of work actions', () => {
describe('succeeds', () => { describe('succeeds', () => {
beforeEach(() => { beforeEach(() => {
mock.onGet(endpoints.tasksByTypeTopLabelsData).replyOnce(200, groupLabels); mock.onGet(endpoints.tasksByTypeTopLabelsData).replyOnce(httpStatusCodes.OK, groupLabels);
}); });
it('dispatches receiveTopRankedGroupLabelsSuccess if the request succeeds', () => { it('dispatches receiveTopRankedGroupLabelsSuccess if the request succeeds', () => {
...@@ -78,10 +84,6 @@ describe('Type of work actions', () => { ...@@ -78,10 +84,6 @@ describe('Type of work actions', () => {
}); });
describe('receiveTopRankedGroupLabelsSuccess', () => { describe('receiveTopRankedGroupLabelsSuccess', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it(`commits the ${types.RECEIVE_TOP_RANKED_GROUP_LABELS_SUCCESS} mutation and dispatches the 'fetchTasksByTypeData' action`, () => { it(`commits the ${types.RECEIVE_TOP_RANKED_GROUP_LABELS_SUCCESS} mutation and dispatches the 'fetchTasksByTypeData' action`, () => {
return testAction( return testAction(
actions.receiveTopRankedGroupLabelsSuccess, actions.receiveTopRankedGroupLabelsSuccess,
...@@ -99,9 +101,33 @@ describe('Type of work actions', () => { ...@@ -99,9 +101,33 @@ describe('Type of work actions', () => {
}); });
}); });
describe(`Status ${httpStatusCodes.OK} and error message in response`, () => {
const dataError = 'Too much data';
beforeEach(() => {
mock
.onGet(endpoints.tasksByTypeTopLabelsData)
.reply(httpStatusCodes.OK, { error: dataError });
});
it(`dispatches the 'receiveTopRankedGroupLabelsError' with ${dataError}`, () => {
return testAction(
actions.fetchTopRankedGroupLabels,
null,
state,
[
{
type: types.REQUEST_TOP_RANKED_GROUP_LABELS,
},
],
[{ type: 'receiveTopRankedGroupLabelsError', payload: new Error(dataError) }],
);
});
});
describe('with an error', () => { describe('with an error', () => {
beforeEach(() => { beforeEach(() => {
mock.onGet(endpoints.fetchTopRankedGroupLabels).replyOnce(404); mock.onGet(endpoints.fetchTopRankedGroupLabels).replyOnce(httpStatusCodes.NOT_FOUND);
}); });
it('dispatches receiveTopRankedGroupLabelsError if the request fails', () => { it('dispatches receiveTopRankedGroupLabelsError if the request fails', () => {
...@@ -116,16 +142,82 @@ describe('Type of work actions', () => { ...@@ -116,16 +142,82 @@ describe('Type of work actions', () => {
}); });
describe('receiveTopRankedGroupLabelsError', () => { describe('receiveTopRankedGroupLabelsError', () => {
it('flashes an error message if the request fails', () => {
actions.receiveTopRankedGroupLabelsError({
commit: () => {},
});
shouldFlashAMessage('There was an error fetching the top labels for the selected group');
});
});
});
describe('fetchTasksByTypeData', () => {
beforeEach(() => {
gon.api_version = 'v4';
state = { ...mockedState, subject: TASKS_BY_TYPE_SUBJECT_ISSUE };
});
describe('succeeds', () => {
beforeEach(() => {
mock.onGet(endpoints.tasksByTypeData).replyOnce(httpStatusCodes.OK, rawTasksByTypeData);
});
it(`commits the ${types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS} if the request succeeds`, () => {
return testAction(
actions.fetchTasksByTypeData,
null,
state,
[
{ type: types.REQUEST_TASKS_BY_TYPE_DATA },
{ type: types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS, payload: rawTasksByTypeData },
],
[],
);
});
});
describe(`Status ${httpStatusCodes.OK} and error message in response`, () => {
const dataError = 'Too much data';
beforeEach(() => {
mock.onGet(endpoints.tasksByTypeData).reply(httpStatusCodes.OK, { error: dataError });
});
it(`dispatches the 'receiveTasksByTypeDataError' with ${dataError}`, () => {
return testAction(
actions.fetchTasksByTypeData,
null,
state,
[{ type: types.REQUEST_TASKS_BY_TYPE_DATA }],
[{ type: 'receiveTasksByTypeDataError', payload: new Error(dataError) }],
);
});
});
describe('with an error', () => {
beforeEach(() => { beforeEach(() => {
setFixtures('<div class="flash-container"></div>'); mock.onGet(endpoints.fetchTasksByTypeData).replyOnce(httpStatusCodes.NOT_FOUND);
});
it('dispatches receiveTasksByTypeDataError if the request fails', () => {
return testAction(
actions.fetchTasksByTypeData,
null,
state,
[{ type: 'REQUEST_TASKS_BY_TYPE_DATA' }],
[{ type: 'receiveTasksByTypeDataError', payload: error }],
);
}); });
});
describe('receiveTasksByTypeDataError', () => {
it('flashes an error message if the request fails', () => { it('flashes an error message if the request fails', () => {
actions.receiveTopRankedGroupLabelsError({ actions.receiveTasksByTypeDataError({
commit: () => {}, commit: () => {},
}); });
shouldFlashAMessage('There was an error fetching the top labels for the selected group'); shouldFlashAMessage('There was an error fetching data for the tasks by type chart');
}); });
}); });
}); });
......
...@@ -149,7 +149,7 @@ describe('Cycle analytics mutations', () => { ...@@ -149,7 +149,7 @@ describe('Cycle analytics mutations', () => {
}); });
describe(`${types.RECEIVE_STAGE_MEDIANS_SUCCESS}`, () => { describe(`${types.RECEIVE_STAGE_MEDIANS_SUCCESS}`, () => {
it('sets each id as a key in the median object with the corresponding value', () => { it('sets each id as a key in the median object with the corresponding value and error', () => {
const stateWithData = { const stateWithData = {
medians: {}, medians: {},
}; };
...@@ -159,7 +159,10 @@ describe('Cycle analytics mutations', () => { ...@@ -159,7 +159,10 @@ describe('Cycle analytics mutations', () => {
{ id: 2, value: 10 }, { id: 2, value: 10 },
]); ]);
expect(stateWithData.medians).toEqual({ '1': 20, '2': 10 }); expect(stateWithData.medians).toEqual({
'1': { value: 20, error: null },
'2': { value: 10, error: null },
});
}); });
}); });
......
...@@ -26696,6 +26696,9 @@ msgstr "" ...@@ -26696,6 +26696,9 @@ msgstr ""
msgid "Too many projects enabled. You will need to manage them via the console or the API." msgid "Too many projects enabled. You will need to manage them via the console or the API."
msgstr "" msgstr ""
msgid "Too much data"
msgstr ""
msgid "Topics (optional)" msgid "Topics (optional)"
msgstr "" msgstr ""
...@@ -27872,6 +27875,9 @@ msgstr "" ...@@ -27872,6 +27875,9 @@ msgstr ""
msgid "Value Stream Name" msgid "Value Stream Name"
msgstr "" msgstr ""
msgid "ValueStreamAnalyticsStage|We don't have enough data to show this stage."
msgstr ""
msgid "ValueStreamAnalytics|%{days}d" msgid "ValueStreamAnalytics|%{days}d"
msgstr "" msgstr ""
......
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