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

Add URL param for linking to a specific stage in group-level VSA

parent 4b5b3064
...@@ -129,6 +129,9 @@ export default { ...@@ -129,6 +129,9 @@ export default {
project_ids: selectedProjectIds, project_ids: selectedProjectIds,
created_after: toYmd(this.startDate), created_after: toYmd(this.startDate),
created_before: toYmd(this.endDate), created_before: toYmd(this.endDate),
// the `overview` stage is always the default, so dont persist the id if its selected
stage_id:
this.selectedStage?.id && !this.isOverviewStageSelected ? this.selectedStage.id : null,
}; };
}, },
stageCount() { stageCount() {
......
...@@ -157,13 +157,13 @@ export const receiveGroupStagesError = ({ commit }, error) => { ...@@ -157,13 +157,13 @@ export const receiveGroupStagesError = ({ commit }, error) => {
}); });
}; };
export const setDefaultSelectedStage = ({ dispatch, getters, state: { featureFlags } = {} }) => { export const setDefaultSelectedStage = ({ state: { featureFlags }, dispatch, getters }) => {
const { activeStages = [] } = getters;
if (featureFlags?.hasPathNavigation) { if (featureFlags?.hasPathNavigation) {
return dispatch('setSelectedStage', OVERVIEW_STAGE_CONFIG); return dispatch('setSelectedStage', OVERVIEW_STAGE_CONFIG);
} }
const { activeStages = [] } = getters;
if (activeStages?.length) { if (activeStages?.length) {
const [firstActiveStage] = activeStages; const [firstActiveStage] = activeStages;
return Promise.all([ return Promise.all([
...@@ -175,13 +175,21 @@ export const setDefaultSelectedStage = ({ dispatch, getters, state: { featureFla ...@@ -175,13 +175,21 @@ export const setDefaultSelectedStage = ({ dispatch, getters, state: { featureFla
createFlash({ createFlash({
message: __('There was an error while fetching value stream analytics data.'), message: __('There was an error while fetching value stream analytics data.'),
}); });
return Promise.resolve(); return Promise.resolve();
}; };
export const receiveGroupStagesSuccess = ({ commit, dispatch }, stages) => { export const receiveGroupStagesSuccess = (
{ state: { featureFlags }, commit, dispatch },
stages,
) => {
commit(types.RECEIVE_GROUP_STAGES_SUCCESS, stages); commit(types.RECEIVE_GROUP_STAGES_SUCCESS, stages);
return dispatch('setDefaultSelectedStage'); if (!featureFlags?.hasPathNavigation) {
return dispatch('setDefaultSelectedStage');
}
return Promise.resolve();
}; };
export const fetchGroupStagesAndEvents = ({ dispatch, getters }) => { export const fetchGroupStagesAndEvents = ({ dispatch, getters }) => {
...@@ -309,12 +317,17 @@ export const initializeCycleAnalytics = ({ dispatch, commit }, initialData = {}) ...@@ -309,12 +317,17 @@ export const initializeCycleAnalytics = ({ dispatch, commit }, initialData = {})
selectedMilestone, selectedMilestone,
selectedAssigneeList, selectedAssigneeList,
selectedLabelList, selectedLabelList,
stage: selectedStage,
group, group,
} = initialData; } = initialData;
commit(types.SET_FEATURE_FLAGS, featureFlags); commit(types.SET_FEATURE_FLAGS, featureFlags);
if (group?.fullPath) { if (group?.fullPath) {
return Promise.all([ return Promise.all([
selectedStage
? dispatch('setSelectedStage', selectedStage)
: dispatch('setDefaultSelectedStage'),
selectedStage?.id ? dispatch('fetchStageData', selectedStage.id) : Promise.resolve(),
dispatch('setPaths', { groupPath: group.fullPath, milestonesPath, labelsPath }), dispatch('setPaths', { groupPath: group.fullPath, milestonesPath, labelsPath }),
dispatch('filters/initialize', { dispatch('filters/initialize', {
selectedAuthor, selectedAuthor,
......
...@@ -438,7 +438,7 @@ export const transformStagesForPathNavigation = ({ stages, medians, selectedStag ...@@ -438,7 +438,7 @@ export const transformStagesForPathNavigation = ({ stages, medians, selectedStag
const formattedStages = stages.map((stage) => { const formattedStages = stages.map((stage) => {
return { return {
metric: medians[stage?.id], metric: medians[stage?.id],
selected: stage.title === selectedStage.title, selected: stage.id === selectedStage.id,
icon: null, icon: null,
...stage, ...stage,
}; };
......
...@@ -105,6 +105,7 @@ export const buildCycleAnalyticsInitialData = ({ ...@@ -105,6 +105,7 @@ export const buildCycleAnalyticsInitialData = ({
labelsPath = '', labelsPath = '',
milestonesPath = '', milestonesPath = '',
defaultStages = null, defaultStages = null,
stage = null,
} = {}) => ({ } = {}) => ({
selectedValueStream: buildValueStreamFromJson(valueStream), selectedValueStream: buildValueStreamFromJson(valueStream),
group: groupId group: groupId
...@@ -128,6 +129,7 @@ export const buildCycleAnalyticsInitialData = ({ ...@@ -128,6 +129,7 @@ export const buildCycleAnalyticsInitialData = ({
defaultStageConfig: defaultStages defaultStageConfig: defaultStages
? buildDefaultStagesFromJSON(defaultStages).map(convertObjectPropsToCamelCase) ? buildDefaultStagesFromJSON(defaultStages).map(convertObjectPropsToCamelCase)
: [], : [],
stage: JSON.parse(stage),
}); });
export const filterBySearchTerm = (data = [], searchTerm = '', filterByKey = 'name') => { export const filterBySearchTerm = (data = [], searchTerm = '', filterByKey = 'name') => {
......
...@@ -7,6 +7,7 @@ module Gitlab ...@@ -7,6 +7,7 @@ module Gitlab
include ActiveModel::Model include ActiveModel::Model
include ActiveModel::Validations include ActiveModel::Validations
include ActiveModel::Attributes include ActiveModel::Attributes
include Gitlab::Utils::StrongMemoize
MAX_RANGE_DAYS = 180.days.freeze MAX_RANGE_DAYS = 180.days.freeze
DEFAULT_DATE_RANGE = 29.days # 30 including Date.today DEFAULT_DATE_RANGE = 29.days # 30 including Date.today
...@@ -19,6 +20,7 @@ module Gitlab ...@@ -19,6 +20,7 @@ module Gitlab
:sort, :sort,
:direction, :direction,
:page, :page,
:stage_id,
label_name: [].freeze, label_name: [].freeze,
assignee_username: [].freeze, assignee_username: [].freeze,
project_ids: [].freeze project_ids: [].freeze
...@@ -41,6 +43,7 @@ module Gitlab ...@@ -41,6 +43,7 @@ module Gitlab
attribute :sort attribute :sort
attribute :direction attribute :direction
attribute :page attribute :page
attribute :stage_id
FINDER_PARAM_NAMES.each do |param_name| FINDER_PARAM_NAMES.each do |param_name|
attribute param_name attribute param_name
...@@ -88,6 +91,7 @@ module Gitlab ...@@ -88,6 +91,7 @@ module Gitlab
attrs[:milestone] = milestone_title if milestone_title.present? attrs[:milestone] = milestone_title if milestone_title.present?
attrs[:sort] = sort if sort.present? attrs[:sort] = sort if sort.present?
attrs[:direction] = direction if direction.present? attrs[:direction] = direction if direction.present?
attrs[:stage] = stage_data_attributes.to_json if stage_id.present?
end end
end end
...@@ -133,6 +137,15 @@ module Gitlab ...@@ -133,6 +137,15 @@ module Gitlab
} }
end end
def stage_data_attributes
return unless stage
{
id: stage.id || stage.name,
title: stage.name
}
end
def validate_created_before def validate_created_before
return if created_after.nil? || created_before.nil? return if created_after.nil? || created_before.nil?
...@@ -154,6 +167,14 @@ module Gitlab ...@@ -154,6 +167,14 @@ module Gitlab
DEFAULT_DATE_RANGE.ago DEFAULT_DATE_RANGE.ago
end end
end end
def stage
return unless value_stream
strong_memoize(:stage) do
::Analytics::CycleAnalytics::StageFinder.new(parent: group, stage_id: stage_id).execute if stage_id
end
end
end end
end end
end end
......
...@@ -33,6 +33,7 @@ const noDataSvgPath = 'path/to/no/data'; ...@@ -33,6 +33,7 @@ const noDataSvgPath = 'path/to/no/data';
const noAccessSvgPath = 'path/to/no/access'; const noAccessSvgPath = 'path/to/no/access';
const currentGroup = convertObjectPropsToCamelCase(mockData.group); const currentGroup = convertObjectPropsToCamelCase(mockData.group);
const emptyStateSvgPath = 'path/to/empty/state'; const emptyStateSvgPath = 'path/to/empty/state';
const stage = null;
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -60,6 +61,7 @@ const initialCycleAnalyticsState = { ...@@ -60,6 +61,7 @@ const initialCycleAnalyticsState = {
createdAfter: mockData.startDate, createdAfter: mockData.startDate,
createdBefore: mockData.endDate, createdBefore: mockData.endDate,
group: currentGroup, group: currentGroup,
stage,
}; };
const mocks = { const mocks = {
...@@ -610,6 +612,7 @@ describe('Value Stream Analytics component', () => { ...@@ -610,6 +612,7 @@ describe('Value Stream Analytics component', () => {
created_after: toYmd(mockData.startDate), created_after: toYmd(mockData.startDate),
created_before: toYmd(mockData.endDate), created_before: toYmd(mockData.endDate),
project_ids: null, project_ids: null,
stage_id: null,
}; };
const selectedProjectIds = mockData.selectedProjects.map(({ id }) => getIdFromGraphQLId(id)); const selectedProjectIds = mockData.selectedProjects.map(({ id }) => getIdFromGraphQLId(id));
...@@ -663,7 +666,7 @@ describe('Value Stream Analytics component', () => { ...@@ -663,7 +666,7 @@ describe('Value Stream Analytics component', () => {
describe('with selectedProjectIds set', () => { describe('with selectedProjectIds set', () => {
beforeEach(async () => { beforeEach(async () => {
wrapper = await createComponent(); wrapper = await createComponent();
store.dispatch('setSelectedProjects', mockData.selectedProjects); await store.dispatch('setSelectedProjects', mockData.selectedProjects);
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
}); });
...@@ -673,6 +676,30 @@ describe('Value Stream Analytics component', () => { ...@@ -673,6 +676,30 @@ describe('Value Stream Analytics component', () => {
created_after: toYmd(mockData.startDate), created_after: toYmd(mockData.startDate),
created_before: toYmd(mockData.endDate), created_before: toYmd(mockData.endDate),
project_ids: selectedProjectIds, project_ids: selectedProjectIds,
stage_id: 1,
});
});
});
describe('with selectedStage set', () => {
const selectedStage = {
title: 'Plan',
id: 2,
};
beforeEach(async () => {
wrapper = await createComponent();
store.dispatch('setSelectedStage', selectedStage);
await wrapper.vm.$nextTick();
});
it('sets the stage_id url parameter', async () => {
await shouldMergeUrlParams(wrapper, {
...defaultParams,
created_after: toYmd(mockData.startDate),
created_before: toYmd(mockData.endDate),
project_ids: null,
stage_id: 2,
}); });
}); });
}); });
......
...@@ -373,19 +373,58 @@ describe('Value Stream Analytics actions', () => { ...@@ -373,19 +373,58 @@ describe('Value Stream Analytics actions', () => {
}); });
describe('receiveGroupStagesSuccess', () => { describe('receiveGroupStagesSuccess', () => {
it(`commits the ${types.RECEIVE_GROUP_STAGES_SUCCESS} mutation and dispatches 'setDefaultSelectedStage'`, () => { describe('when the `hasPathNavigation` feature flag is enabled', () => {
return testAction( beforeEach(() => {
actions.receiveGroupStagesSuccess, state = {
{ ...customizableStagesAndEvents.stages }, ...state,
state, featureFlags: {
[ ...state.featureFlags,
{ hasPathNavigation: true,
type: types.RECEIVE_GROUP_STAGES_SUCCESS,
payload: { ...customizableStagesAndEvents.stages },
}, },
], };
[{ type: 'setDefaultSelectedStage' }], });
);
it(`commits the ${types.RECEIVE_GROUP_STAGES_SUCCESS} mutation'`, () => {
return testAction(
actions.receiveGroupStagesSuccess,
{ ...customizableStagesAndEvents.stages },
state,
[
{
type: types.RECEIVE_GROUP_STAGES_SUCCESS,
payload: { ...customizableStagesAndEvents.stages },
},
],
[],
);
});
});
describe('when the `hasPathNavigation` feature flag is disabled', () => {
beforeEach(() => {
state = {
...state,
featureFlags: {
...state.featureFlags,
hasPathNavigation: false,
},
};
});
it(`commits the ${types.RECEIVE_GROUP_STAGES_SUCCESS} mutation and dispatches 'setDefaultSelectedStage`, () => {
return testAction(
actions.receiveGroupStagesSuccess,
{ ...customizableStagesAndEvents.stages },
state,
[
{
type: types.RECEIVE_GROUP_STAGES_SUCCESS,
payload: { ...customizableStagesAndEvents.stages },
},
],
[{ type: 'setDefaultSelectedStage' }],
);
});
}); });
}); });
...@@ -450,7 +489,7 @@ describe('Value Stream Analytics actions', () => { ...@@ -450,7 +489,7 @@ describe('Value Stream Analytics actions', () => {
${null} ${null}
`('with $data will flash an error', ({ data }) => { `('with $data will flash an error', ({ data }) => {
actions.setDefaultSelectedStage( actions.setDefaultSelectedStage(
{ getters: { activeStages: data }, dispatch: () => {} }, { state, getters: { activeStages: data }, dispatch: () => {} },
{}, {},
); );
expect(createFlash).toHaveBeenCalledWith({ message: flashErrorMessage }); expect(createFlash).toHaveBeenCalledWith({ message: flashErrorMessage });
...@@ -876,6 +915,30 @@ describe('Value Stream Analytics actions', () => { ...@@ -876,6 +915,30 @@ describe('Value Stream Analytics actions', () => {
expect(mockDispatch).toHaveBeenCalledWith('initializeCycleAnalyticsSuccess'); expect(mockDispatch).toHaveBeenCalledWith('initializeCycleAnalyticsSuccess');
}); });
describe('with a selected stage', () => {
it('dispatches "setSelectedStage" and "fetchStageData"', async () => {
const stage = { id: 2, title: 'plan' };
await actions.initializeCycleAnalytics(store, {
...initialData,
stage,
});
expect(mockDispatch).toHaveBeenCalledWith('setSelectedStage', stage);
expect(mockDispatch).toHaveBeenCalledWith('fetchStageData', stage.id);
});
});
describe('without a selected stage', () => {
it('dispatches "setDefaultSelectedStage"', async () => {
await actions.initializeCycleAnalytics(store, {
...initialData,
stage: null,
});
expect(mockDispatch).not.toHaveBeenCalledWith('setSelectedStage');
expect(mockDispatch).not.toHaveBeenCalledWith('fetchStageData');
expect(mockDispatch).toHaveBeenCalledWith('setDefaultSelectedStage');
});
});
it('commits "INITIALIZE_VSA"', async () => { it('commits "INITIALIZE_VSA"', async () => {
await actions.initializeCycleAnalytics(store, initialData); await actions.initializeCycleAnalytics(store, initialData);
expect(mockCommit).toHaveBeenCalledWith('INITIALIZE_VSA', initialData); expect(mockCommit).toHaveBeenCalledWith('INITIALIZE_VSA', initialData);
......
...@@ -90,6 +90,7 @@ describe('buildCycleAnalyticsInitialData', () => { ...@@ -90,6 +90,7 @@ describe('buildCycleAnalyticsInitialData', () => {
${'selectedProjects'} | ${[]} ${'selectedProjects'} | ${[]}
${'labelsPath'} | ${''} ${'labelsPath'} | ${''}
${'milestonesPath'} | ${''} ${'milestonesPath'} | ${''}
${'stage'} | ${null}
`('will set a default value for "$field" if is not present', ({ field, value }) => { `('will set a default value for "$field" if is not present', ({ field, value }) => {
expect(buildCycleAnalyticsInitialData()).toMatchObject({ expect(buildCycleAnalyticsInitialData()).toMatchObject({
[field]: value, [field]: value,
......
...@@ -189,12 +189,16 @@ RSpec.describe Gitlab::Analytics::CycleAnalytics::RequestParams do ...@@ -189,12 +189,16 @@ RSpec.describe Gitlab::Analytics::CycleAnalytics::RequestParams do
end end
describe 'issuable filter params' do describe 'issuable filter params' do
let_it_be(:stage) { create(:cycle_analytics_group_stage, group: root_group) }
before do before do
params.merge!( params.merge!(
milestone_title: 'title', milestone_title: 'title',
assignee_username: ['username1'], assignee_username: ['username1'],
label_name: %w[label1 label2], label_name: %w[label1 label2],
author_username: 'author' author_username: 'author',
stage_id: stage.id,
value_stream: stage.value_stream
) )
end end
...@@ -204,6 +208,7 @@ RSpec.describe Gitlab::Analytics::CycleAnalytics::RequestParams do ...@@ -204,6 +208,7 @@ RSpec.describe Gitlab::Analytics::CycleAnalytics::RequestParams do
it { expect(subject[:assignees]).to eq('["username1"]') } it { expect(subject[:assignees]).to eq('["username1"]') }
it { expect(subject[:labels]).to eq('["label1","label2"]') } it { expect(subject[:labels]).to eq('["label1","label2"]') }
it { expect(subject[:author]).to eq('author') } it { expect(subject[:author]).to eq('author') }
it { expect(subject[:stage]).to eq('{"id":1,"title":"Stage #1"}') }
end end
describe 'sorting params' do describe 'sorting params' do
......
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