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