Commit 74824494 authored by Martin Wortschack's avatar Martin Wortschack Committed by Ezekiel Kigbo

[VSA] Show record count for the stage (FE)

parent 4c103b09
......@@ -77,6 +77,7 @@ export default {
'cycleAnalyticsRequestParams',
'pathNavigationData',
'isOverviewStageSelected',
'selectedStageCount',
]),
...mapGetters('customStages', ['customStageFormActive']),
shouldRenderEmptyState() {
......@@ -262,6 +263,7 @@ export default {
:is-loading="isLoading || isLoadingStage"
:stage-events="currentStageEvents"
:selected-stage="selectedStage"
:stage-count="selectedStageCount"
:empty-state-message="selectedStageError"
:no-data-svg-path="noDataSvgPath"
:pagination="pagination"
......
......@@ -37,6 +37,9 @@ export default {
showPopover({ id }) {
return id && id !== OVERVIEW_STAGE_ID;
},
hasStageCount({ stageCount }) {
return stageCount !== null;
},
},
popoverOptions: {
triggers: 'hover',
......@@ -64,6 +67,19 @@ export default {
<div class="gl-pb-4 gl-font-weight-bold">{{ pathItem.metric }}</div>
</div>
</div>
<div class="gl-px-4">
<div class="gl-display-flex gl-justify-content-space-between">
<div class="gl-pr-4 gl-pb-4">
{{ s__('ValueStreamEvent|Items in stage') }}
</div>
<div class="gl-pb-4 gl-font-weight-bold">
<template v-if="hasStageCount(pathItem)">{{
n__('%d item', '%d items', pathItem.stageCount)
}}</template>
<template v-else>-</template>
</div>
</div>
</div>
<div class="gl-px-4 gl-pt-4 gl-border-t-1 gl-border-t-solid gl-border-gray-50">
<div
v-if="pathItem.startEventHtmlDescription"
......
<script>
import { GlEmptyState, GlIcon, GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui';
import {
GlEmptyState,
GlIcon,
GlLink,
GlLoadingIcon,
GlPagination,
GlTable,
GlBadge,
} from '@gitlab/ui';
import { __ } from '~/locale';
import {
NOT_ENOUGH_DATA_ERROR,
......@@ -31,6 +39,7 @@ export default {
GlLoadingIcon,
GlPagination,
GlTable,
GlBadge,
TotalTime,
},
props: {
......@@ -46,6 +55,11 @@ export default {
type: Array,
required: true,
},
stageCount: {
type: Number,
required: false,
default: null,
},
noDataSvgPath: {
type: String,
required: true,
......@@ -157,6 +171,10 @@ export default {
:empty-text="emptyStateMessage"
@sort-changed="onSort"
>
<template #head(end_event)="data">
<span>{{ data.label }}</span
><gl-badge v-if="stageCount" class="gl-ml-2" size="sm">{{ stageCount }}</gl-badge>
</template>
<template #cell(end_event)="{ item }">
<div data-testid="vsa-stage-event">
<div v-if="item.id" data-testid="vsa-stage-content">
......
......@@ -129,6 +129,43 @@ export const fetchStageMedianValues = ({ dispatch, commit, getters }) => {
.catch((error) => dispatch('receiveStageMedianValuesError', error));
};
const fetchStageCount = ({ groupId, valueStreamId, stageId, params }) =>
Api.cycleAnalyticsStageCount({ groupId, valueStreamId, stageId, params }).then(({ data }) => {
return {
id: stageId,
...(data?.error
? {
error: data.error,
value: null,
}
: data),
};
});
export const fetchStageCountValues = ({ commit, getters }) => {
const {
currentGroupPath,
cycleAnalyticsRequestParams,
activeStages,
currentValueStreamId,
} = getters;
const stageIds = activeStages.map((s) => s.slug);
commit(types.REQUEST_STAGE_COUNTS);
return Promise.all(
stageIds.map((stageId) =>
fetchStageCount({
groupId: currentGroupPath,
valueStreamId: currentValueStreamId,
stageId,
params: cycleAnalyticsRequestParams,
}),
),
)
.then((data) => commit(types.RECEIVE_STAGE_COUNTS_SUCCESS, data))
.catch((error) => commit(types.RECEIVE_STAGE_COUNTS_ERROR, error));
};
export const requestCycleAnalyticsData = ({ commit }) => commit(types.REQUEST_VALUE_STREAM_DATA);
export const receiveCycleAnalyticsDataSuccess = ({ commit, dispatch }) => {
......@@ -430,11 +467,17 @@ export const receiveValueStreamsSuccess = (
data = [],
) => {
commit(types.RECEIVE_VALUE_STREAMS_SUCCESS, data);
if (!selectedValueStream && data.length) {
const [firstStream] = data;
return dispatch('setSelectedValueStream', firstStream);
return Promise.resolve()
.then(() => dispatch('setSelectedValueStream', firstStream))
.then(() => dispatch('fetchStageCountValues'));
}
return dispatch(FETCH_VALUE_STREAM_DATA);
return Promise.resolve()
.then(() => dispatch(FETCH_VALUE_STREAM_DATA))
.then(() => dispatch('fetchStageCountValues'));
};
export const fetchValueStreams = ({ commit, dispatch, getters }) => {
......
......@@ -77,9 +77,13 @@ export const isOverviewStageSelected = ({ selectedStage }) =>
*
* https://gitlab.com/gitlab-org/gitlab/-/issues/216227
*/
export const pathNavigationData = ({ stages, medians, selectedStage }) =>
export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage }) =>
transformStagesForPathNavigation({
stages: [OVERVIEW_STAGE_CONFIG, ...filterStagesByHiddenStatus(stages, false)],
medians,
stageCounts,
selectedStage,
});
export const selectedStageCount = ({ selectedStage, stageCounts }) =>
stageCounts[selectedStage.id] || null;
......@@ -18,6 +18,10 @@ export const REQUEST_STAGE_MEDIANS = 'REQUEST_STAGE_MEDIANS';
export const RECEIVE_STAGE_MEDIANS_SUCCESS = 'RECEIVE_STAGE_MEDIANS_SUCCESS';
export const RECEIVE_STAGE_MEDIANS_ERROR = 'RECEIVE_STAGE_MEDIANS_ERROR';
export const REQUEST_STAGE_COUNTS = 'REQUEST_STAGE_COUNTS';
export const RECEIVE_STAGE_COUNTS_SUCCESS = 'RECEIVE_STAGE_COUNTS_SUCCESS';
export const RECEIVE_STAGE_COUNTS_ERROR = 'RECEIVE_STAGE_COUNTS_ERROR';
export const REQUEST_GROUP_STAGES = 'REQUEST_GROUP_STAGES';
export const RECEIVE_GROUP_STAGES_SUCCESS = 'RECEIVE_GROUP_STAGES_SUCCESS';
export const RECEIVE_GROUP_STAGES_ERROR = 'RECEIVE_GROUP_STAGES_ERROR';
......
......@@ -56,6 +56,21 @@ export default {
[types.RECEIVE_STAGE_MEDIANS_ERROR](state) {
state.medians = {};
},
[types.REQUEST_STAGE_COUNTS](state) {
state.stageCounts = {};
},
[types.RECEIVE_STAGE_COUNTS_SUCCESS](state, stageCounts = []) {
state.stageCounts = stageCounts.reduce(
(acc, { id, count }) => ({
...acc,
[id]: count,
}),
{},
);
},
[types.RECEIVE_STAGE_COUNTS_ERROR](state) {
state.stageCounts = {};
},
[types.REQUEST_GROUP_STAGES](state) {
state.stages = [];
},
......
......@@ -43,4 +43,5 @@ export default () => ({
sort: PAGINATION_SORT_FIELD_END_EVENT,
direction: PAGINATION_SORT_DIRECTION_DESC,
},
stageCounts: {},
});
......@@ -432,14 +432,21 @@ export const formatMedianValuesWithOverview = (medians = []) => {
*
* @param {Array} stages - The stages available to the group / project
* @param {Object} medians - The median values for the stages available to the group / project
* @param {Object} stageCounts - The total item count for the stages available
* @param {Object} selectedStage - The currently selected stage
* @returns {Array} An array of stages formatted with data required for the path navigation
*/
export const transformStagesForPathNavigation = ({ stages, medians, selectedStage }) => {
export const transformStagesForPathNavigation = ({
stages,
medians,
stageCounts,
selectedStage,
}) => {
const formattedStages = stages.map((stage) => {
return {
metric: medians[stage?.id],
selected: stage.id === selectedStage.id,
stageCount: stageCounts[stage?.id],
icon: null,
...stage,
};
......
......@@ -26,6 +26,8 @@ export default {
'/groups/:id/-/analytics/value_stream_analytics/value_streams/:value_stream_id/stages/:stage_id/records',
cycleAnalyticsStageMedianPath:
'/groups/:id/-/analytics/value_stream_analytics/value_streams/:value_stream_id/stages/:stage_id/median',
cycleAnalyticsStageCountPath:
'/groups/:id/-/analytics/value_stream_analytics/value_streams/:value_stream_id/stages/:stage_id/count',
cycleAnalyticsStagePath:
'/groups/:id/-/analytics/value_stream_analytics/value_streams/:value_stream_id/stages/:stage_id',
cycleAnalyticsDurationChartPath:
......@@ -187,6 +189,15 @@ export default {
return axios.get(url, { params });
},
cycleAnalyticsStageCount({ groupId, valueStreamId, stageId, params = {} }) {
const url = Api.buildUrl(this.cycleAnalyticsStageCountPath)
.replace(':id', groupId)
.replace(':value_stream_id', valueStreamId)
.replace(':stage_id', stageId);
return axios.get(url, { params });
},
cycleAnalyticsCreateStage({ groupId, valueStreamId, data }) {
const url = Api.buildUrl(this.cycleAnalyticsGroupStagesAndEventsPath)
.replace(':id', groupId)
......
---
title: Add stage count to group-level VSA
merge_request: 60598
author:
type: added
......@@ -91,6 +91,28 @@ exports[`PathNavigation displays correctly loading is false matches the snapshot
</div>
</div>
<div
class="gl-px-4"
>
<div
class="gl-display-flex gl-justify-content-space-between"
>
<div
class="gl-pr-4 gl-pb-4"
>
Items in stage
</div>
<div
class="gl-pb-4 gl-font-weight-bold"
>
172800 items
</div>
</div>
</div>
<div
class="gl-px-4 gl-pt-4 gl-border-t-1 gl-border-t-solid gl-border-gray-50"
>
......@@ -183,6 +205,28 @@ exports[`PathNavigation displays correctly loading is false matches the snapshot
</div>
</div>
<div
class="gl-px-4"
>
<div
class="gl-display-flex gl-justify-content-space-between"
>
<div
class="gl-pr-4 gl-pb-4"
>
Items in stage
</div>
<div
class="gl-pb-4 gl-font-weight-bold"
>
86400 items
</div>
</div>
</div>
<div
class="gl-px-4 gl-pt-4 gl-border-t-1 gl-border-t-solid gl-border-gray-50"
>
......@@ -275,6 +319,28 @@ exports[`PathNavigation displays correctly loading is false matches the snapshot
</div>
</div>
<div
class="gl-px-4"
>
<div
class="gl-display-flex gl-justify-content-space-between"
>
<div
class="gl-pr-4 gl-pb-4"
>
Items in stage
</div>
<div
class="gl-pb-4 gl-font-weight-bold"
>
129600 items
</div>
</div>
</div>
<div
class="gl-px-4 gl-pt-4 gl-border-t-1 gl-border-t-solid gl-border-gray-50"
>
......
......@@ -24,6 +24,7 @@ const fixtureEndpoints = {
customizableCycleAnalyticsStagesAndEvents: 'analytics/value_stream_analytics/stages.json', // customizable stages and events endpoint
stageEvents: (stage) => `analytics/value_stream_analytics/stages/${stage}/records.json`,
stageMedian: (stage) => `analytics/value_stream_analytics/stages/${stage}/median.json`,
stageCount: (stage) => `analytics/value_stream_analytics/stages/${stage}/count.json`,
recentActivityData: 'analytics/metrics/value_stream_analytics/summary.json',
timeMetricsData: 'analytics/metrics/value_stream_analytics/time_summary.json',
groupLabels: 'api/group_labels.json',
......@@ -36,6 +37,7 @@ export const endpoints = {
durationData: /analytics\/value_stream_analytics\/value_streams\/\w+\/stages\/\w+\/average_duration_chart/,
stageData: /analytics\/value_stream_analytics\/value_streams\/\w+\/stages\/\w+\/records/,
stageMedian: /analytics\/value_stream_analytics\/value_streams\/\w+\/stages\/\w+\/median/,
stageCount: /analytics\/value_stream_analytics\/value_streams\/\w+\/stages\/\w+\/count/,
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/,
......@@ -142,6 +144,29 @@ export const stageMediansWithNumericIds = rawStageMedians.reduce((acc, { id, val
};
}, {});
export const rawStageCounts = defaultStages.map((id) => ({
id,
...getJSONFixture(fixtureEndpoints.stageCount(id)),
}));
// Once https://gitlab.com/gitlab-org/gitlab/-/issues/328422 is fixed
// we should be able to use the rawStageCounts for building
// the stage counts mock data
/*
export const stageCounts = rawStageCounts.reduce(
(acc, { id, value }) => ({
...acc,
[id]: value,
}),
{},
);
*/
export const stageCounts = rawStageMedians.reduce((acc, { id, value }) => {
const { id: stageId } = getStageByTitle(dummyState.stages, id);
return { ...acc, [stageId]: value };
}, {});
export const endDate = new Date(2019, 0, 14);
export const startDate = getDateInPast(endDate, DEFAULT_DAYS_IN_PAST);
......@@ -215,6 +240,7 @@ export const transformedTasksByTypeData = getTasksByTypeData(apiTasksByTypeData)
export const transformedStagePathData = transformStagesForPathNavigation({
stages: [{ ...OVERVIEW_STAGE_CONFIG }, ...allowedStages],
medians,
stageCounts,
selectedStage: issueStage,
});
......
......@@ -766,6 +766,37 @@ describe('Value Stream Analytics actions', () => {
});
});
describe('fetchStageCountValues', () => {
const fetchCountResponse = activeStages.map(({ slug: id }) => ({ events: [], id }));
beforeEach(() => {
state = {
...state,
stages,
currentGroup,
featureFlags: {
...state.featureFlags,
hasPathNavigation: true,
},
};
mock = new MockAdapter(axios);
mock.onGet(endpoints.stageCount).reply(httpStatusCodes.OK, { events: [] });
});
it('dispatches receiveStageCountValuesSuccess with received data on success', () => {
return testAction(
actions.fetchStageCountValues,
null,
state,
[
{ type: types.REQUEST_STAGE_COUNTS },
{ type: types.RECEIVE_STAGE_COUNTS_SUCCESS, payload: fetchCountResponse },
],
[],
);
});
});
describe('initializeCycleAnalytics', () => {
let mockDispatch;
let mockCommit;
......@@ -1176,7 +1207,7 @@ describe('Value Stream Analytics actions', () => {
});
describe('receiveValueStreamsSuccess', () => {
it(`with a selectedValueStream in state commits the ${types.RECEIVE_VALUE_STREAMS_SUCCESS} mutation and dispatches 'fetchValueStreamData'`, () => {
it(`with a selectedValueStream in state commits the ${types.RECEIVE_VALUE_STREAMS_SUCCESS} mutation and dispatches 'fetchValueStreamData' and 'fetchStageCountValues'`, () => {
return testAction(
actions.receiveValueStreamsSuccess,
valueStreams,
......@@ -1187,11 +1218,11 @@ describe('Value Stream Analytics actions', () => {
payload: valueStreams,
},
],
[{ type: 'fetchValueStreamData' }],
[{ type: 'fetchValueStreamData' }, { type: 'fetchStageCountValues' }],
);
});
it(`commits the ${types.RECEIVE_VALUE_STREAMS_SUCCESS} mutation and dispatches 'setSelectedValueStream'`, () => {
it(`commits the ${types.RECEIVE_VALUE_STREAMS_SUCCESS} mutation and dispatches 'setSelectedValueStream' and 'fetchStageCountValues'`, () => {
return testAction(
actions.receiveValueStreamsSuccess,
valueStreams,
......@@ -1205,7 +1236,10 @@ describe('Value Stream Analytics actions', () => {
payload: valueStreams,
},
],
[{ type: 'setSelectedValueStream', payload: selectedValueStream }],
[
{ type: 'setSelectedValueStream', payload: selectedValueStream },
{ type: 'fetchStageCountValues' },
],
);
});
});
......
......@@ -16,6 +16,7 @@ import {
transformedStagePathData,
issueStage,
stageMedians,
stageCounts,
basePaginationResult,
initialPaginationState,
} from '../mock_data';
......@@ -215,6 +216,7 @@ describe('Value Stream Analytics getters', () => {
stages: allowedStages,
medians: stageMedians,
selectedStage: issueStage,
stageCounts,
};
expect(getters.pathNavigationData(state)).toEqual(transformedStagePathData);
......@@ -240,4 +242,16 @@ describe('Value Stream Analytics getters', () => {
expect(getters.paginationParams(state)).toEqual({ ...basePaginationResult, page });
});
});
describe('selectedStageCount', () => {
it('returns the count when a value exist for the given stage', () => {
state = { selectedStage: { id: 1 }, stageCounts: { 1: 10, 2: 20 } };
expect(getters.selectedStageCount(state)).toEqual(10);
});
it('returns null if there is no value for the given stage', () => {
state = { selectedStage: { id: 3 }, stageCounts: { 1: 10, 2: 20 } };
expect(getters.selectedStageCount(state)).toEqual(null);
});
});
});
......@@ -63,6 +63,8 @@ describe('Value Stream Analytics mutations', () => {
${types.RECEIVE_DELETE_VALUE_STREAM_SUCCESS} | ${'deleteValueStreamError'} | ${null}
${types.RECEIVE_DELETE_VALUE_STREAM_SUCCESS} | ${'selectedValueStream'} | ${null}
${types.INITIALIZE_VALUE_STREAM_SUCCESS} | ${'isLoading'} | ${false}
${types.REQUEST_STAGE_COUNTS} | ${'stageCounts'} | ${{}}
${types.RECEIVE_STAGE_COUNTS_ERROR} | ${'stageCounts'} | ${{}}
`('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => {
mutations[mutation](state);
......@@ -197,6 +199,23 @@ describe('Value Stream Analytics mutations', () => {
});
});
describe(`${types.RECEIVE_STAGE_COUNTS_SUCCESS}`, () => {
beforeEach(() => {
state = {
stageCounts: {},
};
mutations[types.RECEIVE_STAGE_COUNTS_SUCCESS](state, [
{ id: 1, count: 10 },
{ id: 2, count: 20 },
]);
});
it('sets each id as a key in the stageCounts object with the corresponding count', () => {
expect(state.stageCounts).toMatchObject({ 1: 10, 2: 20 });
});
});
describe(`${types.INITIALIZE_VSA}`, () => {
const initialData = {
group: { fullPath: 'cool-group' },
......
......@@ -43,6 +43,7 @@ import {
pathNavIssueMetric,
timeMetricsData,
rawStageMedians,
stageCounts,
} from './mock_data';
const labelEventIds = labelEvents.map((ev) => ev.identifier);
......@@ -346,6 +347,7 @@ describe('Value Stream Analytics utils', () => {
stages,
medians: stageMediansWithNumericIds,
selectedStage: issueStage,
stageCounts,
});
describe('transforms the data as expected', () => {
......
......@@ -152,6 +152,13 @@ RSpec.describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do
expect(response).to be_successful
end
it "analytics/value_stream_analytics/stages/#{stage[:name]}/count.json" do
stage_id = group.cycle_analytics_stages.find_by(name: stage[:name]).id
get(:count, params: params.merge({ id: stage_id }), format: :json)
expect(response).to be_successful
end
end
it "analytics/value_stream_analytics/stages/label-based-stage/records.json" do
......@@ -165,5 +172,11 @@ RSpec.describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do
expect(response).to be_successful
end
it "analytics/value_stream_analytics/stages/label-based-stage/count.json" do
get(:count, params: params.merge({ id: label_based_stage.id }), format: :json)
expect(response).to be_successful
end
end
end
......@@ -267,6 +267,11 @@ msgid_plural "%d issues successfully imported with the label"
msgstr[0] ""
msgstr[1] ""
msgid "%d item"
msgid_plural "%d items"
msgstr[0] ""
msgstr[1] ""
msgid "%d layer"
msgid_plural "%d layers"
msgstr[0] ""
......@@ -35555,6 +35560,9 @@ msgstr ""
msgid "ValueStreamAnalytics|Total number of deploys to production."
msgstr ""
msgid "ValueStreamEvent|Items in stage"
msgstr ""
msgid "ValueStreamEvent|Stage time (median)"
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