Commit ae451305 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '321438-add-overview-stage-to-vsa-navigation' into 'master'

Adds an overview stage to VSA

See merge request gitlab-org/gitlab!56621
parents 2c5dc906 a972b1f2
......@@ -6,7 +6,7 @@ import DateRange from '../../shared/components/daterange.vue';
import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue';
import { DATE_RANGE_LIMIT } from '../../shared/constants';
import { toYmd } from '../../shared/utils';
import { PROJECTS_PER_PAGE } from '../constants';
import { PROJECTS_PER_PAGE, OVERVIEW_STAGE_ID } from '../constants';
import CustomStageForm from './custom_stage_form.vue';
import DurationChart from './duration_chart.vue';
import FilterBar from './filter_bar.vue';
......@@ -86,17 +86,28 @@ export default {
shouldDisplayFilters() {
return !this.errorCode;
},
isOverviewStageSelected() {
return this.selectedStage?.id === OVERVIEW_STAGE_ID;
},
shouldDisplayDurationChart() {
return this.featureFlags.hasDurationChart && !this.hasNoAccessError;
return (
!this.featureFlags.hasPathNavigation ||
(this.featureFlags.hasDurationChart &&
this.isOverviewStageSelected &&
!this.hasNoAccessError)
);
},
shouldDisplayTypeOfWorkCharts() {
return !this.hasNoAccessError;
return (
!this.featureFlags.hasPathNavigation ||
(this.isOverviewStageSelected && !this.hasNoAccessError)
);
},
selectedStageReady() {
return !this.hasNoAccessError && this.selectedStage;
},
shouldDisplayPathNavigation() {
return this.featureFlags.hasPathNavigation && !this.hasNoAccessError;
return this.featureFlags.hasPathNavigation && this.selectedStageReady;
},
shouldDisplayVerticalNavigation() {
return !this.featureFlags.hasPathNavigation && this.selectedStageReady;
......@@ -134,6 +145,7 @@ export default {
'fetchStageData',
'setSelectedProjects',
'setSelectedStage',
'setDefaultSelectedStage',
'setDateRange',
'removeStage',
'updateStage',
......@@ -146,8 +158,12 @@ export default {
},
onStageSelect(stage) {
this.hideForm();
this.setSelectedStage(stage);
this.fetchStageData(this.selectedStage.slug);
if (stage.slug === OVERVIEW_STAGE_ID) {
this.setDefaultSelectedStage();
} else {
this.setSelectedStage(stage);
this.fetchStageData(stage.slug);
}
},
onShowAddStageForm() {
this.showCreateForm();
......@@ -235,7 +251,7 @@ export default {
</div>
</div>
</div>
<div v-if="!shouldRenderEmptyState" class="cycle-analytics gl-mt-0">
<div v-if="!shouldRenderEmptyState" class="cycle-analytics gl-mt-2">
<gl-empty-state
v-if="hasNoAccessError"
class="js-empty-state"
......@@ -248,8 +264,13 @@ export default {
"
/>
<div v-else>
<metrics :group-path="currentGroupPath" :request-params="cycleAnalyticsRequestParams" />
<metrics
v-if="!featureFlags.hasPathNavigation || isOverviewStageSelected"
:group-path="currentGroupPath"
:request-params="cycleAnalyticsRequestParams"
/>
<stage-table
v-if="!featureFlags.hasPathNavigation || !isOverviewStageSelected"
:key="stageCount"
class="js-stage-table"
:current-stage="selectedStage"
......
......@@ -47,7 +47,7 @@ export default {
</template>
<gl-link :href="url" class="pipeline-id">#{{ id }}</gl-link>
<gl-icon :size="16" name="fork" />
<gl-link :href="branch.url" class="ref-name">{{ branch.name }}</gl-link>
<gl-link v-if="branch" :href="branch.url" class="ref-name">{{ branch.name }}</gl-link>
<span class="icon-branch gl-text-gray-400">
<gl-icon name="commit" :size="14" />
</span>
......
......@@ -61,3 +61,11 @@ export const OVERVIEW_METRICS = {
};
export const FETCH_VALUE_STREAM_DATA = 'fetchValueStreamData';
export const OVERVIEW_STAGE_ID = 'overview';
export const OVERVIEW_STAGE_CONFIG = {
id: OVERVIEW_STAGE_ID,
slug: OVERVIEW_STAGE_ID,
title: __('Overview'),
icon: 'home',
};
......@@ -2,7 +2,7 @@ import Api from 'ee/api';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import httpStatus from '~/lib/utils/http_status';
import { __, sprintf } from '~/locale';
import { FETCH_VALUE_STREAM_DATA } from '../constants';
import { FETCH_VALUE_STREAM_DATA, OVERVIEW_STAGE_CONFIG } from '../constants';
import {
removeFlash,
throwIfUserForbidden,
......@@ -151,7 +151,11 @@ export const receiveGroupStagesError = ({ commit }, error) => {
createFlash(__('There was an error fetching value stream analytics stages.'));
};
export const setDefaultSelectedStage = ({ dispatch, getters }) => {
export const setDefaultSelectedStage = ({ dispatch, getters, state: { featureFlags } = {} }) => {
if (featureFlags?.hasPathNavigation) {
return dispatch('setSelectedStage', OVERVIEW_STAGE_CONFIG);
}
const { activeStages = [] } = getters;
if (activeStages?.length) {
......
......@@ -4,7 +4,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import httpStatus from '~/lib/utils/http_status';
import { filterToQueryObject } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { dateFormats } from '../../shared/constants';
import { DEFAULT_VALUE_STREAM_ID } from '../constants';
import { DEFAULT_VALUE_STREAM_ID, OVERVIEW_STAGE_CONFIG } from '../constants';
import { transformStagesForPathNavigation } from '../utils';
export const hasNoAccessError = (state) => state.errorCode === httpStatus.FORBIDDEN;
......@@ -64,7 +64,7 @@ export const customStageFormActive = ({ isCreatingCustomStage, isEditingCustomSt
*/
export const pathNavigationData = ({ stages, medians, selectedStage }) =>
transformStagesForPathNavigation({
stages: filterStagesByHiddenStatus(stages, false),
stages: [OVERVIEW_STAGE_CONFIG, ...filterStagesByHiddenStatus(stages, false)],
medians,
selectedStage,
});
......@@ -374,11 +374,10 @@ export const transformStagesForPathNavigation = ({ stages, medians, selectedStag
});
return {
...stage,
metric: days ? sprintf(s__('ValueStreamAnalytics|%{days}d'), { days }) : null,
selected: stage.title === selectedStage.title,
title: stage.title,
icon: null,
...stage,
};
});
......
---
title: Adds an overview stage to value stream analytics
merge_request: 56621
author:
type: added
......@@ -122,6 +122,10 @@ RSpec.describe 'Group value stream analytics filters and data', :js do
shared_examples 'group value stream analytics' do
context 'stage panel' do
before do
select_stage("Issue")
end
it 'displays the stage table headers' do
expect(page).to have_selector('.event-header', visible: true)
expect(page).to have_selector('.total-time-header', visible: true)
......@@ -155,10 +159,6 @@ RSpec.describe 'Group value stream analytics filters and data', :js do
end
shared_examples 'has default filters' do
it 'hides the empty state' do
expect(page).to have_selector('.row.empty-state', visible: false)
end
it 'shows the projects filter' do
expect(page).to have_selector('.dropdown-projects', visible: true)
end
......@@ -222,6 +222,8 @@ RSpec.describe 'Group value stream analytics filters and data', :js do
context 'with fake parameters' do
before do
visit "#{group_analytics_cycle_analytics_path(group)}?beans=not-cool"
select_stage("Issue")
end
it_behaves_like 'empty state'
......@@ -354,9 +356,14 @@ RSpec.describe 'Group value stream analytics filters and data', :js do
end
end
it 'will have data available' do
expect(page.find('[data-testid="vsa-stage-table"]')).not_to have_text(_("We don't have enough data to show this stage."))
it 'will not display the stage table on the overview stage' do
expect(page).not_to have_selector('[data-testid="vsa-stage-table"]')
select_stage("Issue")
expect(page).to have_selector('[data-testid="vsa-stage-table"]')
end
it 'will have data available' do
duration_chart_content = page.find('[data-testid="vsa-duration-chart"]')
expect(duration_chart_content).not_to have_text(_("There is no data available. Please change your selection."))
expect(duration_chart_content).to have_text(_('Total days to completion'))
......@@ -373,8 +380,6 @@ RSpec.describe 'Group value stream analytics filters and data', :js do
end
it 'will filter the data' do
expect(page.find('[data-testid="vsa-stage-table"]')).to have_text(_("We don't have enough data to show this stage."))
duration_chart_content = page.find('[data-testid="vsa-duration-chart"]')
expect(duration_chart_content).not_to have_text(_('Total days to completion'))
expect(duration_chart_content).to have_text(_("There is no data available. Please change your selection."))
......
......@@ -97,7 +97,7 @@ RSpec.describe 'Multiple value streams', :js do
create_value_stream
expect(page).to have_text(_("'%{name}' Value Stream created") % { name: custom_value_stream_name })
expect(page.all("[data-testid='gl-path-nav'] .gl-path-button").count).to eq(4)
expect(page.find('[data-testid="gl-path-nav"]')).to have_text("Cool custom stage - name")
end
end
......
......@@ -32,6 +32,28 @@ exports[`PathNavigation displays correctly loading is false matches the snapshot
<li
class="gl-path-nav-list-item"
id="path-6-item-0"
>
<button
class="gl-path-button"
>
<svg
aria-hidden="true"
class="gl-mr-2 gl-icon s16"
data-testid="gl-path-item-icon"
>
<use
href="#home"
/>
</svg>
Overview
<!---->
</button>
</li>
<li
class="gl-path-nav-list-item"
id="path-6-item-1"
>
<button
class="gl-path-button gl-path-active-item-indigo"
......@@ -99,7 +121,7 @@ exports[`PathNavigation displays correctly loading is false matches the snapshot
</li>
<li
class="gl-path-nav-list-item"
id="path-6-item-1"
id="path-6-item-2"
>
<button
class="gl-path-button"
......@@ -167,7 +189,7 @@ exports[`PathNavigation displays correctly loading is false matches the snapshot
</li>
<li
class="gl-path-nav-list-item"
id="path-6-item-2"
id="path-6-item-3"
>
<button
class="gl-path-button"
......
......@@ -376,42 +376,55 @@ describe('Value Stream Analytics component', () => {
displaysDateRangePicker(true);
});
it('displays the filter bar', () => {
displaysFilterBar(true);
});
it('displays the metrics', () => {
displaysMetrics(true);
});
it('displays the stage table', () => {
displaysStageTable(true);
it('displays the type of work chart', () => {
displaysTypeOfWork(true);
});
it('displays the filter bar', () => {
displaysFilterBar(true);
it('displays the duration chart', () => {
displaysDurationChart(true);
});
it('displays the add stage button', async () => {
wrapper = await createComponent({
opts: {
stubs: {
StageTable,
StageTableNav,
AddStageButton,
},
},
withStageSelected: true,
});
await wrapper.vm.$nextTick();
displaysAddStageButton(true);
it('hides the stage table', () => {
displaysStageTable(false);
});
it('displays the tasks by type chart', async () => {
wrapper = await createComponent({ shallow: false, withStageSelected: true });
await wrapper.vm.$nextTick();
expect(wrapper.find('.js-tasks-by-type-chart').exists()).toBe(true);
it('hides the add stage button', () => {
displaysAddStageButton(false);
});
it('displays the duration chart', () => {
displaysDurationChart(true);
describe('Without the overview stage selected', () => {
beforeEach(async () => {
await store.dispatch('setSelectedStage', mockData.issueStage);
await wrapper.vm.$nextTick();
});
it('displays the stage table', () => {
displaysStageTable(true);
});
it('displays the add stage button', async () => {
wrapper = await createComponent({
opts: {
stubs: {
StageTable,
StageTableNav,
AddStageButton,
},
},
withStageSelected: true,
});
await wrapper.vm.$nextTick();
displaysAddStageButton(true);
});
});
describe('path navigation', () => {
......@@ -463,7 +476,7 @@ describe('Value Stream Analytics component', () => {
});
});
it('has the first stage selected by default', async () => {
it('has the first stage selected by default', () => {
const first = findStageNavItemAtIndex(0);
const second = findStageNavItemAtIndex(1);
......
......@@ -63,9 +63,9 @@ describe('PathNavigation', () => {
describe('popovers', () => {
const modifiedStages = [
...transformedStagePathData.slice(0, 2),
...transformedStagePathData.slice(0, 3),
{
...transformedStagePathData[2],
...transformedStagePathData[3],
startEventHtmlDescription: null,
endEventHtmlDescription: null,
},
......@@ -80,16 +80,16 @@ describe('PathNavigation', () => {
});
it('shows the sanitized start event description for the first stage item', () => {
const firsPpopover = wrapper.findAll('[data-testid="stage-item-popover"]').at(0);
const firstPopover = wrapper.findAll('[data-testid="stage-item-popover"]').at(0);
const expectedStartEventDescription = 'Issue created';
expect(firsPpopover.text()).toContain(expectedStartEventDescription);
expect(firstPopover.text()).toContain(expectedStartEventDescription);
});
it('shows the sanitized end event description for the first stage item', () => {
const firsPpopover = wrapper.findAll('[data-testid="stage-item-popover"]').at(0);
const firstPopover = wrapper.findAll('[data-testid="stage-item-popover"]').at(0);
const expectedStartEventDescription =
'Issue first associated with a milestone or issue first added to a board';
expect(firsPpopover.text()).toContain(expectedStartEventDescription);
expect(firstPopover.text()).toContain(expectedStartEventDescription);
});
});
});
......
......@@ -2,6 +2,7 @@ import { uniq } from 'lodash';
import {
DEFAULT_DAYS_IN_PAST,
TASKS_BY_TYPE_SUBJECT_ISSUE,
OVERVIEW_STAGE_CONFIG,
} from 'ee/analytics/cycle_analytics/constants';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import mutations from 'ee/analytics/cycle_analytics/store/mutations';
......@@ -205,7 +206,7 @@ export const rawTasksByTypeData = transformRawTasksByTypeData(apiTasksByTypeData
export const transformedTasksByTypeData = getTasksByTypeData(apiTasksByTypeData);
export const transformedStagePathData = transformStagesForPathNavigation({
stages: allowedStages,
stages: [OVERVIEW_STAGE_CONFIG, ...allowedStages],
medians,
selectedStage: issueStage,
});
......
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { OVERVIEW_STAGE_CONFIG } from 'ee/analytics/cycle_analytics/constants';
import * as actions from 'ee/analytics/cycle_analytics/store/actions';
import * as getters from 'ee/analytics/cycle_analytics/store/getters';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
......@@ -325,8 +326,6 @@ describe('Value Stream Analytics actions', () => {
});
describe('receiveCycleAnalyticsDataError', () => {
beforeEach(() => {});
it(`commits the ${types.RECEIVE_VALUE_STREAM_DATA_ERROR} mutation on a 403 response`, () => {
const response = { status: 403 };
return testAction(
......@@ -373,8 +372,6 @@ describe('Value Stream Analytics actions', () => {
});
describe('receiveGroupStagesSuccess', () => {
beforeEach(() => {});
it(`commits the ${types.RECEIVE_GROUP_STAGES_SUCCESS} mutation and dispatches 'setDefaultSelectedStage'`, () => {
return testAction(
actions.receiveGroupStagesSuccess,
......@@ -392,39 +389,84 @@ describe('Value Stream Analytics actions', () => {
});
describe('setDefaultSelectedStage', () => {
it("dispatches the 'fetchStageData' action", () => {
return testAction(
actions.setDefaultSelectedStage,
null,
state,
[],
[
{ type: 'setSelectedStage', payload: selectedStage },
{ type: 'fetchStageData', payload: selectedStageSlug },
],
);
});
describe('when the `hasPathNavigation` feature flag is enabled', () => {
beforeEach(() => {
state = {
...state,
featureFlags: {
...state.featureFlags,
hasPathNavigation: true,
},
};
});
it.each`
data
${[]}
${null}
`('with $data will flash an error', ({ data }) => {
actions.setDefaultSelectedStage({ getters: { activeStages: data }, dispatch: () => {} }, {});
shouldFlashAMessage(flashErrorMessage);
afterEach(() => {
mock.restore();
});
it("dispatches the 'setSelectedStage' with the overview stage", () => {
return testAction(
actions.setDefaultSelectedStage,
null,
state,
[],
[{ type: 'setSelectedStage', payload: OVERVIEW_STAGE_CONFIG }],
);
});
});
it('will select the first active stage', () => {
return testAction(
actions.setDefaultSelectedStage,
null,
state,
[],
[
{ type: 'setSelectedStage', payload: stages[1] },
{ type: 'fetchStageData', payload: stages[1].slug },
],
);
describe('when the `hasPathNavigation` feature flag is disabled', () => {
beforeEach(() => {
state = {
...state,
featureFlags: {
...state.featureFlags,
hasPathNavigation: false,
},
};
});
afterEach(() => {
mock.restore();
});
it("dispatches the 'fetchStageData' action", () => {
return testAction(
actions.setDefaultSelectedStage,
null,
state,
[],
[
{ type: 'setSelectedStage', payload: selectedStage },
{ type: 'fetchStageData', payload: selectedStageSlug },
],
);
});
it.each`
data
${[]}
${null}
`('with $data will flash an error', ({ data }) => {
actions.setDefaultSelectedStage(
{ getters: { activeStages: data }, dispatch: () => {} },
{},
);
shouldFlashAMessage(flashErrorMessage);
});
it('will select the first active stage', () => {
return testAction(
actions.setDefaultSelectedStage,
null,
state,
[],
[
{ type: 'setSelectedStage', payload: stages[1] },
{ type: 'fetchStageData', payload: stages[1].slug },
],
);
});
});
});
......
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