Commit 53a8afc6 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo Committed by Martin Wortschack

Added basic shadow loaders to VSA

Uses the chart skeleton loader for
the charts and a loading icon

Fix related specs

Updates stage table and vuex
specs with the additional loaders

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