Commit 89bff282 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '217943-vsa-update-vuex-state-with-tokens' into 'master'

Update vuex state when filter bar values change

See merge request gitlab-org/gitlab!34598
parents 9bd1e55b 941c2341
......@@ -2,7 +2,6 @@
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { PROJECTS_PER_PAGE, STAGE_ACTIONS } from '../constants';
import GroupsDropdownFilter from '../../shared/components/groups_dropdown_filter.vue';
import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue';
......@@ -40,7 +39,7 @@ export default {
MetricCard,
FilterBar,
},
mixins: [glFeatureFlagsMixin(), UrlSyncMixin],
mixins: [UrlSyncMixin],
props: {
emptyStateSvgPath: {
type: String,
......@@ -58,14 +57,6 @@ export default {
type: Boolean,
required: true,
},
milestonesPath: {
type: String,
required: true,
},
labelsPath: {
type: String,
required: true,
},
},
computed: {
...mapState([
......@@ -76,6 +67,10 @@ export default {
'selectedGroup',
'selectedProjects',
'selectedStage',
'selectedMilestone',
'selectedAuthor',
'selectedLabels',
'selectedAssignees',
'stages',
'summary',
'currentStageEvents',
......@@ -110,7 +105,12 @@ export default {
return !this.hasNoAccessError && !this.isLoading;
},
shouldDsiplayPathNavigation() {
return this.featureFlags.hasPathNavigation && !this.hasNoAccessError;
return this.featureFlags.hasPathNavigation && !this.hasNoAccessError && this.selectedStage;
},
shouldDisplayFilterBar() {
// TODO: After we remove instance VSA currentGroupPath will be always set
// https://gitlab.com/gitlab-org/gitlab/-/issues/223735
return this.featureFlags.hasFilterBar && this.currentGroupPath;
},
isLoadingTypeOfWork() {
return this.isLoadingTasksByTypeChartTopLabels || this.isLoadingTasksByTypeChart;
......@@ -124,6 +124,10 @@ export default {
'project_ids[]': this.selectedProjectIds,
created_after: toYmd(this.startDate),
created_before: toYmd(this.endDate),
milestone_title: this.selectedMilestone,
author_username: this.selectedAuthor,
'label_name[]': this.selectedLabels,
'assignee_username[]': this.selectedAssignees,
};
},
stageCount() {
......@@ -133,27 +137,7 @@ export default {
return this.selectedProjectIds.length > 0;
},
},
mounted() {
const {
labelsPath,
milestonesPath,
glFeatures: {
cycleAnalyticsScatterplotEnabled: hasDurationChart,
cycleAnalyticsScatterplotMedianEnabled: hasDurationChartMedian,
valueStreamAnalyticsPathNavigation: hasPathNavigation,
valueStreamAnalyticsFilterBar: hasFilterBar,
},
} = this;
this.setFeatureFlags({
hasDurationChart,
hasDurationChartMedian,
hasPathNavigation,
hasFilterBar,
});
this.setPaths({ labelsPath, milestonesPath });
},
methods: {
...mapActions([
'fetchCycleAnalyticsData',
......@@ -164,9 +148,9 @@ export default {
'setDateRange',
'updateStage',
'removeStage',
'setFeatureFlags',
'updateStage',
'reorderStage',
'setSelectedFilters',
]),
...mapActions('customStages', [
'hideForm',
......@@ -175,7 +159,6 @@ export default {
'createStage',
'clearFormErrors',
]),
...mapActions('filters', ['setPaths']),
onGroupSelect(group) {
this.setSelectedGroup(group);
this.fetchCycleAnalyticsData();
......@@ -264,7 +247,7 @@ export default {
/>
</div>
<filter-bar
v-if="featureFlags.hasFilterBar"
v-if="shouldDisplayFilterBar"
class="js-filter-bar filtered-search-box gl-display-flex gl-mt-3 mt-md-0 gl-mr-3"
:disabled="!hasProject"
/>
......
......@@ -6,6 +6,24 @@ import MilestoneToken from '../../shared/components/tokens/milestone_token.vue';
import LabelToken from '../../shared/components/tokens/label_token.vue';
import UserToken from '../../shared/components/tokens/user_token.vue';
export const prepareTokens = ({
milestone = null,
author = null,
assignees = [],
labels = [],
} = {}) => {
const authorToken = author ? [{ type: 'author', value: { data: author } }] : [];
const milestoneToken = milestone ? [{ type: 'milestone', value: { data: milestone } }] : [];
const assigneeTokens = assignees?.length
? assignees.map(data => ({ type: 'assignees', value: { data } }))
: [];
const labelTokens = labels?.length
? labels.map(data => ({ type: 'labels', value: { data } }))
: [];
return [...authorToken, ...milestoneToken, ...assigneeTokens, ...labelTokens];
};
export default {
name: 'FilterBar',
components: {
......@@ -33,6 +51,7 @@ export default {
authorsLoading: state => state.authors.isLoading,
assignees: state => state.assignees.data,
assigneesLoading: state => state.assignees.isLoading,
initialTokens: state => state.initialTokens,
}),
availableTokens() {
return [
......@@ -81,8 +100,21 @@ export default {
];
},
},
mounted() {
this.initializeTokens();
},
methods: {
...mapActions('filters', ['setFilters']),
...mapActions('filters', ['setFilters', 'fetchTokenData']),
initializeTokens() {
const {
selectedMilestone: milestone = null,
selectedAuthor: author = null,
selectedAssignees: assignees = [],
selectedLabels: labels = [],
} = this.initialTokens;
const preparedTokens = prepareTokens({ milestone, author, assignees, labels });
this.value = preparedTokens;
},
processFilters(filters) {
return filters.reduce((acc, token) => {
const { type, value } = token;
......
......@@ -6,18 +6,20 @@ import { parseBoolean } from '~/lib/utils/common_utils';
export default () => {
const el = document.querySelector('#js-cycle-analytics-app');
const {
emptyStateSvgPath,
noDataSvgPath,
noAccessSvgPath,
hideGroupDropDown,
milestonesPath = '',
labelsPath = '',
} = el.dataset;
const { emptyStateSvgPath, noDataSvgPath, noAccessSvgPath, hideGroupDropDown } = el.dataset;
const initialData = buildCycleAnalyticsInitialData(el.dataset);
const store = createStore();
store.dispatch('initializeCycleAnalytics', initialData);
const {
cycleAnalyticsScatterplotEnabled: hasDurationChart = false,
cycleAnalyticsScatterplotMedianEnabled: hasDurationChartMedian = false,
valueStreamAnalyticsPathNavigation: hasPathNavigation = false,
valueStreamAnalyticsFilterBar: hasFilterBar = false,
} = gon?.features;
store.dispatch('initializeCycleAnalytics', {
...initialData,
featureFlags: { hasDurationChart, hasDurationChartMedian, hasPathNavigation, hasFilterBar },
});
return new Vue({
el,
......@@ -30,8 +32,6 @@ export default () => {
noDataSvgPath,
noAccessSvgPath,
hideGroupDropDown: parseBoolean(hideGroupDropDown),
milestonesPath,
labelsPath,
},
}),
});
......
......@@ -8,11 +8,19 @@ import { removeFlash, handleErrorOrRethrow, isStageNameExistsError } from '../ut
export const setFeatureFlags = ({ commit }, featureFlags) =>
commit(types.SET_FEATURE_FLAGS, featureFlags);
export const setSelectedGroup = ({ commit }, group) => commit(types.SET_SELECTED_GROUP, group);
export const setSelectedGroup = ({ commit, dispatch, state }, group) => {
commit(types.SET_SELECTED_GROUP, group);
const { featureFlags } = state;
if (featureFlags?.hasFilterBar) {
return dispatch('filters/initialize', {
groupPath: group.full_path,
});
}
return Promise.resolve();
};
export const setSelectedProjects = ({ commit }, projects) => {
export const setSelectedProjects = ({ commit }, projects) =>
commit(types.SET_SELECTED_PROJECTS, projects);
};
export const setSelectedStage = ({ commit }, stage) => commit(types.SET_SELECTED_STAGE, stage);
......@@ -234,20 +242,36 @@ export const removeStage = ({ dispatch, state }, stageId) => {
.catch(error => dispatch('receiveRemoveStageError', error));
};
export const setSelectedFilters = ({ commit }, filters) =>
export const setSelectedFilters = ({ commit, dispatch, getters }, filters = {}) => {
commit(types.SET_SELECTED_FILTERS, filters);
const { currentGroupPath } = getters;
if (currentGroupPath) {
return dispatch('fetchCycleAnalyticsData');
}
return Promise.resolve();
};
export const initializeCycleAnalyticsSuccess = ({ commit }) =>
commit(types.INITIALIZE_CYCLE_ANALYTICS_SUCCESS);
export const initializeCycleAnalytics = ({ dispatch, commit }, initialData = {}) => {
commit(types.INITIALIZE_CYCLE_ANALYTICS, initialData);
if (initialData?.group?.fullPath) {
const { featureFlags = {} } = initialData;
commit(types.SET_FEATURE_FLAGS, featureFlags);
if (initialData.group?.fullPath) {
if (featureFlags?.hasFilterBar) {
dispatch('filters/initialize', {
groupPath: initialData.group.fullPath,
...initialData,
});
}
return dispatch('fetchCycleAnalyticsData').then(() =>
dispatch('initializeCycleAnalyticsSuccess'),
);
}
return dispatch('initializeCycleAnalyticsSuccess');
};
......
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import Api from '~/api';
import * as types from './mutation_types';
const appendExtension = path => (path.indexOf('.') > -1 ? path : `${path}.json`);
export const setPaths = ({ commit }, { milestonesPath = '', labelsPath = '' }) => {
commit(types.SET_MILESTONES_PATH, appendExtension(milestonesPath));
commit(types.SET_LABELS_PATH, appendExtension(labelsPath));
// TODO: After we remove instance VSA we can rely on the paths from the BE
// https://gitlab.com/gitlab-org/gitlab/-/issues/223735
export const setPaths = ({ commit }, { groupPath = '', milestonesPath = '', labelsPath = '' }) => {
const ms = milestonesPath || `/groups/${groupPath}/-/milestones`;
const ls = labelsPath || `/groups/${groupPath}/-/labels`;
commit(types.SET_MILESTONES_PATH, appendExtension(ms));
commit(types.SET_LABELS_PATH, appendExtension(ls));
};
export const setFilters = ({ dispatch, state }, params) => {
const { selectedLabels: labelNames = [], ...rest } = params;
const {
labels: { data: labelsList = [] },
} = state;
export const fetchTokenData = ({ dispatch }) => {
return Promise.all([
dispatch('fetchLabels'),
dispatch('fetchMilestones'),
dispatch('fetchAuthors'),
dispatch('fetchAssignees'),
]);
};
export const fetchMilestones = ({ commit, state }) => {
commit(types.REQUEST_MILESTONES);
const { milestonesPath } = state;
return axios
.get(milestonesPath)
.then(({ data }) => commit(types.RECEIVE_MILESTONES_SUCCESS, data))
.catch(({ response }) => {
const { status } = response;
commit(types.RECEIVE_MILESTONES_ERROR, status);
createFlash(__('Failed to load milestones. Please try again.'));
});
};
export const fetchLabels = ({ commit, state }) => {
commit(types.REQUEST_LABELS);
return axios
.get(state.labelsPath)
.then(({ data }) => commit(types.RECEIVE_LABELS_SUCCESS, data))
.catch(({ response }) => {
const { status } = response;
commit(types.RECEIVE_LABELS_ERROR, status);
createFlash(__('Failed to load labels. Please try again.'));
});
};
const fetchUser = ({ commit, endpoint, query, action, errorMessage }) => {
commit(`REQUEST_${action}`);
return Api.groupMembers(endpoint, { query })
.then(({ data }) => commit(`RECEIVE_${action}_SUCCESS`, data))
.catch(({ response }) => {
const { status } = response;
commit(`RECEIVE_${action}_ERROR`, status);
createFlash(errorMessage);
});
};
export const fetchAuthors = ({ commit, rootGetters }, query = '') => {
const { currentGroupPath } = rootGetters;
return fetchUser({
commit,
query,
endpoint: currentGroupPath,
action: 'AUTHORS',
errorMessage: __('Failed to load authors. Please try again.'),
});
};
export const fetchAssignees = ({ commit, rootGetters }, query = '') => {
const { currentGroupPath } = rootGetters;
return fetchUser({
commit,
query,
endpoint: currentGroupPath,
action: 'ASSIGNEES',
errorMessage: __('Failed to load assignees. Please try again.'),
});
};
const selectedLabels = labelsList.filter(({ title }) => labelNames.includes(title));
const nextFilters = {
...rest,
selectedLabels,
};
export const setFilters = ({ dispatch }, nextFilters) =>
dispatch('setSelectedFilters', nextFilters, { root: true });
return dispatch('setSelectedFilters', nextFilters, { root: true });
export const initialize = ({ dispatch, commit }, initialFilters) => {
commit(types.INITIALIZE, initialFilters);
return dispatch('setPaths', initialFilters)
.then(() => dispatch('setFilters', initialFilters))
.then(() => dispatch('fetchTokenData'));
};
export const INITIALIZE = 'INITIALIZE';
export const SET_MILESTONES_PATH = 'SET_MILESTONES_PATH';
export const SET_LABELS_PATH = 'SET_LABELS_PATH';
export const REQUEST_MILESTONES = 'REQUEST_MILESTONES';
export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS';
export const RECEIVE_MILESTONES_ERROR = 'RECEIVE_MILESTONES_ERROR';
export const REQUEST_LABELS = 'REQUEST_LABELS';
export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS';
export const RECEIVE_LABELS_ERROR = 'RECEIVE_LABELS_ERROR';
export const REQUEST_AUTHORS = 'REQUEST_AUTHORS';
export const RECEIVE_AUTHORS_SUCCESS = 'RECEIVE_AUTHORS_SUCCESS';
export const RECEIVE_AUTHORS_ERROR = 'RECEIVE_AUTHORS_ERROR';
export const REQUEST_ASSIGNEES = 'REQUEST_ASSIGNEES';
export const RECEIVE_ASSIGNEES_SUCCESS = 'RECEIVE_ASSIGNEES_SUCCESS';
export const RECEIVE_ASSIGNEES_ERROR = 'RECEIVE_ASSIGNEES_ERROR';
import * as types from './mutation_types';
export default {
[types.INITIALIZE](
state,
{
selectedAuthor = null,
selectedMilestone = null,
selectedAssignees = [],
selectedLabels = [],
} = {},
) {
state.initialTokens = {
selectedAuthor,
selectedMilestone,
selectedAssignees,
selectedLabels,
};
},
[types.SET_MILESTONES_PATH](state, milestonesPath) {
state.milestonesPath = milestonesPath;
},
[types.SET_LABELS_PATH](state, labelsPath) {
state.labelsPath = labelsPath;
},
[types.REQUEST_MILESTONES](state) {
state.milestones.isLoading = true;
},
[types.RECEIVE_MILESTONES_SUCCESS](state, data) {
state.milestones.isLoading = false;
state.milestones.data = data;
},
[types.RECEIVE_MILESTONES_ERROR](state) {
state.milestones.isLoading = false;
state.milestones.data = [];
},
[types.REQUEST_LABELS](state) {
state.labels.isLoading = true;
},
[types.RECEIVE_LABELS_SUCCESS](state, data) {
state.labels.isLoading = false;
state.labels.data = data;
},
[types.RECEIVE_LABELS_ERROR](state) {
state.labels.isLoading = false;
state.labels.data = [];
},
[types.REQUEST_AUTHORS](state) {
state.authors.isLoading = true;
},
[types.RECEIVE_AUTHORS_SUCCESS](state, data) {
state.authors.isLoading = false;
state.authors.data = data;
},
[types.RECEIVE_AUTHORS_ERROR](state) {
state.authors.isLoading = false;
state.authors.data = [];
},
[types.REQUEST_ASSIGNEES](state) {
state.assignees.isLoading = true;
},
[types.RECEIVE_ASSIGNEES_SUCCESS](state, data) {
state.assignees.isLoading = false;
state.assignees.data = data;
},
[types.RECEIVE_ASSIGNEES_ERROR](state) {
state.assignees.isLoading = false;
state.assignees.data = [];
},
};
......@@ -17,4 +17,10 @@ export default () => ({
isLoading: false,
data: [],
},
initialTokens: {
selectedMilestone: null,
selectedAuthor: null,
selectedAssignees: [],
selectedLabels: [],
},
});
......@@ -115,5 +115,11 @@ export default {
state.isSavingStageOrder = false;
state.errorSavingStageOrder = true;
},
[types.SET_SELECTED_FILTERS]() {},
[types.SET_SELECTED_FILTERS](state, params) {
const { selectedAuthor, selectedAssignees, selectedMilestone, selectedLabels } = params;
state.selectedAuthor = selectedAuthor;
state.selectedAssignees = selectedAssignees;
state.selectedMilestone = selectedMilestone;
state.selectedLabels = selectedLabels;
},
};
......@@ -16,6 +16,10 @@ export default () => ({
selectedGroup: null,
selectedProjects: [],
selectedStage: null,
selectedAuthor: null,
selectedMilestone: null,
selectedAssignees: [],
selectedLabels: [],
currentStageEvents: [],
......
......@@ -30,12 +30,15 @@ export default {
return this.config.labels;
},
filteredLabels() {
return this.labels
.filter(label => label.title.toLowerCase().indexOf(this.value.data?.toLowerCase()) !== -1)
.map(label => ({
...label,
value: this.getEscapedText(label.title),
}));
const labelsList = this.labels.map(label => ({
...label,
value: this.getEscapedText(label.title),
}));
return this.value?.data
? labelsList.filter(
label => label.title.toLowerCase().indexOf(this.value.data?.toLowerCase()) !== -1,
)
: labelsList;
},
},
methods: {
......
......@@ -79,6 +79,12 @@ export const buildCycleAnalyticsInitialData = ({
groupFullPath = null,
groupParentId = null,
groupAvatarUrl = null,
author = null,
milestone = null,
labels = null,
assignees = null,
labelsPath = '',
milestonesPath = '',
} = {}) => ({
group: groupId
? convertObjectPropsToCamelCase(
......@@ -96,6 +102,12 @@ export const buildCycleAnalyticsInitialData = ({
selectedProjects: projects
? buildProjectsFromJSON(projects).map(convertObjectPropsToCamelCase)
: [],
selectedAuthor: author,
selectedMilestone: milestone,
selectedLabels: labels ? JSON.parse(labels) : [],
selectedAssignees: assignees ? JSON.parse(assignees) : [],
labelsPath,
milestonesPath,
});
export const filterBySearchTerm = (data = [], searchTerm = '', filterByKey = 'name') => {
......
......@@ -30,8 +30,6 @@ import UrlSyncMixin from 'ee/analytics/shared/mixins/url_sync_mixin';
const noDataSvgPath = 'path/to/no/data';
const noAccessSvgPath = 'path/to/no/access';
const emptyStateSvgPath = 'path/to/empty/state';
const milestonesPath = '/some/milestones/endpoint';
const labelsPath = '/some/labels/endpoint';
const hideGroupDropDown = false;
const selectedGroup = convertObjectPropsToCamelCase(mockData.group);
......@@ -48,15 +46,30 @@ const defaultStubs = {
GroupsDropdownFilter: true,
};
const defaultFeatureFlags = {
hasDurationChart: true,
hasDurationChartMedian: true,
hasPathNavigation: false,
hasFilterBar: false,
};
const initialCycleAnalyticsState = {
createdAfter: mockData.startDate,
createdBefore: mockData.endDate,
selectedMilestone: null,
selectedAuthor: null,
selectedAssignees: [],
selectedLabels: [],
group: selectedGroup,
};
function createComponent({
opts = {
stubs: defaultStubs,
},
shallow = true,
withStageSelected = false,
scatterplotEnabled = true,
pathNavigationEnabled = false,
filterBarEnabled = false,
featureFlags = {},
props = {},
} = {}) {
const func = shallow ? shallowMount : mount;
......@@ -68,29 +81,24 @@ function createComponent({
emptyStateSvgPath,
noDataSvgPath,
noAccessSvgPath,
milestonesPath,
labelsPath,
baseStagesEndpoint: mockData.endpoints.baseStagesEndpoint,
hideGroupDropDown,
...props,
},
provide: {
glFeatures: {
cycleAnalyticsScatterplotEnabled: scatterplotEnabled,
valueStreamAnalyticsPathNavigation: pathNavigationEnabled,
valueStreamAnalyticsFilterBar: filterBarEnabled,
},
},
...opts,
});
comp.vm.$store.dispatch('initializeCycleAnalytics', {
createdAfter: mockData.startDate,
createdBefore: mockData.endDate,
featureFlags: {
...defaultFeatureFlags,
...featureFlags,
},
});
if (withStageSelected) {
comp.vm.$store.dispatch('setSelectedGroup', {
comp.vm.$store.commit('SET_SELECTED_GROUP', {
...selectedGroup,
});
......@@ -163,11 +171,11 @@ describe('Cycle Analytics component', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent();
wrapper.vm.$store.dispatch('initializeCycleAnalytics', {
createdAfter: mockData.startDate,
createdBefore: mockData.endDate,
wrapper = createComponent({
featureFlags: {
hasPathNavigation: true,
hasFilterBar: true,
},
});
});
......@@ -247,6 +255,10 @@ describe('Cycle Analytics component', () => {
mock = new MockAdapter(axios);
wrapper = createComponent({
withStageSelected: true,
featureFlags: {
hasPathNavigation: true,
hasFilterBar: true,
},
});
});
......@@ -311,6 +323,15 @@ describe('Cycle Analytics component', () => {
describe('path navigation', () => {
describe('disabled', () => {
beforeEach(() => {
wrapper = createComponent({
withStageSelected: true,
featureFlags: {
hasPathNavigation: false,
},
});
});
it('does not display the path navigation', () => {
displaysPathNavigation(false);
});
......@@ -320,7 +341,9 @@ describe('Cycle Analytics component', () => {
beforeEach(() => {
wrapper = createComponent({
withStageSelected: true,
pathNavigationEnabled: true,
featureFlags: {
hasPathNavigation: true,
},
});
});
......@@ -332,6 +355,15 @@ describe('Cycle Analytics component', () => {
describe('filter bar', () => {
describe('disabled', () => {
beforeEach(() => {
wrapper = createComponent({
withStageSelected: true,
featureFlags: {
hasFilterBar: false,
},
});
});
it('does not display the filter bar', () => {
displaysFilterBar(false);
});
......@@ -341,7 +373,9 @@ describe('Cycle Analytics component', () => {
beforeEach(() => {
wrapper = createComponent({
withStageSelected: true,
filterBarEnabled: true,
featureFlags: {
hasFilterBar: true,
},
});
});
......@@ -354,6 +388,7 @@ describe('Cycle Analytics component', () => {
describe('StageTable', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent({
opts: {
stubs: {
......@@ -601,30 +636,31 @@ describe('Cycle Analytics component', () => {
name: 'New test group',
};
const defaultParams = {
created_after: toYmd(mockData.startDate),
created_before: toYmd(mockData.endDate),
group_id: selectedGroup.fullPath,
'project_ids[]': [],
milestone_title: null,
author_username: null,
'assignee_username[]': [],
'label_name[]': [],
};
const selectedProjectIds = mockData.selectedProjects.map(({ id }) => id);
beforeEach(() => {
commonUtils.historyPushState = jest.fn();
urlUtils.setUrlParams = jest.fn();
mock = new MockAdapter(axios);
wrapper = createComponent();
wrapper = createComponent({
shallow: false,
scatterplotEnabled: false,
stubs: {
...defaultStubs,
},
});
return wrapper.vm.$nextTick();
wrapper.vm.$store.dispatch('initializeCycleAnalytics', initialCycleAnalyticsState);
});
it('sets the created_after and created_before url parameters', () => {
return shouldSetUrlParams({
created_after: toYmd(mockData.startDate),
created_before: toYmd(mockData.endDate),
group_id: null,
'project_ids[]': [],
});
return shouldSetUrlParams(defaultParams);
});
describe('with hideGroupDropDown=true', () => {
......@@ -635,31 +671,23 @@ describe('Cycle Analytics component', () => {
mock = new MockAdapter(axios);
wrapper = createComponent({
shallow: false,
scatterplotEnabled: false,
stubs: {
...defaultStubs,
},
props: {
hideGroupDropDown: true,
},
});
wrapper.vm.$store.dispatch('initializeCycleAnalytics', {
createdAfter: mockData.startDate,
createdBefore: mockData.endDate,
...initialCycleAnalyticsState,
group: fakeGroup,
});
return wrapper.vm.$nextTick();
});
it('sets the group_id url parameter', () => {
return shouldSetUrlParams({
...defaultParams,
created_after: toYmd(mockData.startDate),
created_before: toYmd(mockData.endDate),
group_id: null,
'project_ids[]': [],
});
});
});
......@@ -669,34 +697,29 @@ describe('Cycle Analytics component', () => {
wrapper.vm.$store.dispatch('setSelectedGroup', {
...fakeGroup,
});
return wrapper.vm.$nextTick();
});
it('sets the group_id url parameter', () => {
return shouldSetUrlParams({
created_after: toYmd(mockData.startDate),
created_before: toYmd(mockData.endDate),
...defaultParams,
group_id: fakeGroup.fullPath,
'project_ids[]': [],
});
});
});
describe('with a group and selectedProjectIds set', () => {
const selectedProjectIds = mockData.selectedProjects.map(({ id }) => id);
beforeEach(() => {
wrapper.vm.$store.dispatch('setSelectedGroup', {
...selectedGroup,
});
wrapper.vm.$store.dispatch('setSelectedProjects', mockData.selectedProjects);
return wrapper.vm.$nextTick();
});
it('sets the project_ids url parameter', () => {
return shouldSetUrlParams({
...defaultParams,
created_after: toYmd(mockData.startDate),
created_before: toYmd(mockData.endDate),
group_id: selectedGroup.fullPath,
......@@ -704,5 +727,28 @@ describe('Cycle Analytics component', () => {
});
});
});
describe.each`
stateKey | payload | paramKey
${'selectedMilestone'} | ${'12.0'} | ${'milestone_title'}
${'selectedAuthor'} | ${'rootUser'} | ${'author_username'}
${'selectedAssignees'} | ${['rootUser', 'secondaryUser']} | ${'assignee_username[]'}
${'selectedLabels'} | ${['Afternix', 'Brouceforge']} | ${'label_name[]'}
`('with a $stateKey updates the $paramKey url parameter', ({ stateKey, payload, paramKey }) => {
beforeEach(() => {
wrapper.vm.$store.dispatch('filters/setFilters', {
...initialCycleAnalyticsState,
group: selectedGroup,
selectedProjects: mockData.selectedProjects,
[stateKey]: payload,
});
});
it(`sets the ${paramKey} url parameter`, () => {
return shouldSetUrlParams({
...defaultParams,
[paramKey]: payload,
});
});
});
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlFilteredSearch } from '@gitlab/ui';
import FilterBar from 'ee/analytics/cycle_analytics/components/filter_bar.vue';
import FilterBar, { prepareTokens } from 'ee/analytics/cycle_analytics/components/filter_bar.vue';
import initialFiltersState from 'ee/analytics/cycle_analytics/store/modules/filters/state';
import { filterMilestones, filterLabels } from '../mock_data';
......@@ -57,11 +57,15 @@ describe('Filter bar', () => {
.props('availableTokens')
.filter(token => token.type === type)[0];
it('renders GlFilteredSearch component', () => {
store = createStore();
wrapper = createComponent(store);
describe('default', () => {
beforeEach(() => {
store = createStore();
wrapper = createComponent(store);
});
expect(findFilteredSearch().exists()).toBe(true);
it('renders GlFilteredSearch component', () => {
expect(findFilteredSearch().exists()).toBe(true);
});
});
describe('when the state has data', () => {
......@@ -176,4 +180,27 @@ describe('Filter bar', () => {
);
});
});
describe('prepareTokens', () => {
describe('with empty data', () => {
it('returns an empty array', () => {
expect(prepareTokens()).toEqual([]);
expect(prepareTokens({})).toEqual([]);
expect(prepareTokens({ milestone: null, author: null, assignees: [], labels: [] })).toEqual(
[],
);
});
});
it.each`
token | value | result
${'milestone'} | ${'v1.0'} | ${[{ type: 'milestone', value: { data: 'v1.0' } }]}
${'author'} | ${'mr.popo'} | ${[{ type: 'author', value: { data: 'mr.popo' } }]}
${'labels'} | ${['z-fighters']} | ${[{ type: 'labels', value: { data: 'z-fighters' } }]}
${'assignees'} | ${['krillin', 'piccolo']} | ${[{ type: 'assignees', value: { data: 'krillin' } }, { type: 'assignees', value: { data: 'piccolo' } }]}
`('with $token=$value sets the $token key', ({ token, value, result }) => {
const res = prepareTokens({ [token]: value });
expect(res).toEqual(result);
});
});
});
......@@ -56,11 +56,10 @@ describe('Cycle analytics actions', () => {
it.each`
action | type | stateKey | payload
${'setFeatureFlags'} | ${'SET_FEATURE_FLAGS'} | ${'featureFlags'} | ${{ hasDurationChart: true }}
${'setSelectedGroup'} | ${'SET_SELECTED_GROUP'} | ${'selectedGroup'} | ${'someNewGroup'}
${'setSelectedProjects'} | ${'SET_SELECTED_PROJECTS'} | ${'selectedProjectIds'} | ${[10, 20, 30, 40]}
${'setSelectedStage'} | ${'SET_SELECTED_STAGE'} | ${'selectedStage'} | ${{ id: 'someStageId' }}
`('$action should set $stateKey with $payload and type $type', ({ action, type, payload }) => {
testAction(
return testAction(
actions[action],
payload,
state,
......@@ -88,6 +87,48 @@ describe('Cycle analytics actions', () => {
});
});
describe('setSelectedGroup', () => {
it('commits the setSelectedGroup mutation', () => {
return testAction(
actions.setSelectedGroup,
{ ...selectedGroup },
state,
[{ type: types.SET_SELECTED_GROUP, payload: selectedGroup }],
[],
);
});
describe('with hasFilterBar=true', () => {
beforeEach(() => {
state = {
...state,
featureFlags: {
...state.featureFlags,
hasFilterBar: true,
},
};
mock = new MockAdapter(axios);
});
it('commits the setSelectedGroup mutation', () => {
return testAction(
actions.setSelectedGroup,
{ full_path: selectedGroup.fullPath },
state,
[{ type: types.SET_SELECTED_GROUP, payload: { full_path: selectedGroup.fullPath } }],
[
{
type: 'filters/initialize',
payload: {
groupPath: selectedGroup.fullPath,
},
},
],
);
});
});
});
describe('fetchStageData', () => {
beforeEach(() => {
state = { ...state, selectedGroup };
......@@ -198,7 +239,7 @@ describe('Cycle analytics actions', () => {
});
it(`dispatches actions for required value stream analytics analytics data`, () => {
testAction(
return testAction(
actions.fetchCycleAnalyticsData,
state,
null,
......@@ -648,9 +689,7 @@ describe('Cycle analytics actions', () => {
});
describe('receiveStageMedianValuesError', () => {
beforeEach(() => {});
it(`commits the ${types.RECEIVE_STAGE_MEDIANS_ERROR} mutation`, () => {
it(`commits the ${types.RECEIVE_STAGE_MEDIANS_ERROR} mutation`, () =>
testAction(
actions.receiveStageMedianValuesError,
null,
......@@ -661,8 +700,7 @@ describe('Cycle analytics actions', () => {
},
],
[],
);
});
));
it('will flash an error message', () => {
actions.receiveStageMedianValuesError({ commit: () => {} });
......
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import httpStatusCodes from '~/lib/utils/http_status';
import * as actions from 'ee/analytics/cycle_analytics/store/modules/filters/actions';
import * as types from 'ee/analytics/cycle_analytics/store/modules/filters/mutation_types';
import initialState from 'ee/analytics/cycle_analytics/store/modules/filters/state';
import { filterLabels } from '../../../mock_data';
import createFlash from '~/flash';
import { filterMilestones, filterUsers, filterLabels } from '../../../mock_data';
const milestonesPath = 'fake_milestones_path';
const labelsPath = 'fake_labels_path';
......@@ -14,16 +16,63 @@ jest.mock('~/flash', () => jest.fn());
describe('Filters actions', () => {
let state;
let mock;
let mockDispatch;
let mockCommit;
beforeEach(() => {
state = initialState();
mock = new MockAdapter(axios);
mockDispatch = jest.fn().mockResolvedValue();
mockCommit = jest.fn();
});
afterEach(() => {
mock.restore();
});
describe('initialize', () => {
const initialData = {
milestonesPath,
labelsPath,
selectedAuthor: 'Mr cool',
selectedMilestone: 'NEXT',
};
it('dispatches setPaths, setFilters and fetchTokenData', () => {
return actions
.initialize(
{
state,
dispatch: mockDispatch,
commit: mockCommit,
},
initialData,
)
.then(() => {
expect(mockDispatch).toHaveBeenCalledTimes(3);
expect(mockDispatch).toHaveBeenCalledWith('setPaths', initialData);
expect(mockDispatch).toHaveBeenCalledWith('setFilters', initialData);
expect(mockDispatch).toHaveBeenCalledWith('fetchTokenData');
});
});
it(`commits the ${types.INITIALIZE}`, () => {
return actions
.initialize(
{
state,
dispatch: mockDispatch,
commit: mockCommit,
},
initialData,
)
.then(() => {
expect(mockCommit).toHaveBeenCalledWith(types.INITIALIZE, initialData);
});
});
});
describe('setFilters', () => {
const nextFilters = {
selectedAuthor: 'Mr cool',
......@@ -39,10 +88,7 @@ describe('Filters actions', () => {
[
{
type: 'setSelectedFilters',
payload: {
...nextFilters,
selectedLabels: [],
},
payload: nextFilters,
},
],
);
......@@ -59,7 +105,7 @@ describe('Filters actions', () => {
type: 'setSelectedFilters',
payload: {
...nextFilters,
selectedLabels: [filterLabels[1]],
selectedLabels: [filterLabels[1].title],
},
},
],
......@@ -68,7 +114,7 @@ describe('Filters actions', () => {
});
describe('setPaths', () => {
it('sets the api paths and dispatches requests for initial data', () => {
it('sets the api paths', () => {
return testAction(
actions.setPaths,
{ milestonesPath, labelsPath },
......@@ -81,4 +127,193 @@ describe('Filters actions', () => {
);
});
});
describe('fetchTokenData', () => {
it('dispatches requests for token data', () => {
return testAction(
actions.fetchTokenData,
{ milestonesPath, labelsPath },
state,
[],
[
{ type: 'fetchLabels' },
{ type: 'fetchMilestones' },
{ type: 'fetchAuthors' },
{ type: 'fetchAssignees' },
],
);
});
});
describe('fetchAuthors', () => {
describe('success', () => {
beforeEach(() => {
mock.onAny().replyOnce(httpStatusCodes.OK, filterUsers);
});
it('dispatches RECEIVE_AUTHORS_SUCCESS with received data', () => {
testAction(
actions.fetchAuthors,
null,
state,
[
{ type: types.REQUEST_AUTHORS },
{ type: types.RECEIVE_AUTHORS_SUCCESS, payload: filterUsers },
],
[],
);
});
});
describe('error', () => {
beforeEach(() => {
mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE);
});
it('dispatches RECEIVE_AUTHORS_ERROR', () => {
return testAction(
actions.fetchAuthors,
null,
state,
[
{ type: types.REQUEST_AUTHORS },
{
type: types.RECEIVE_AUTHORS_ERROR,
payload: httpStatusCodes.SERVICE_UNAVAILABLE,
},
],
[],
).then(() => expect(createFlash).toHaveBeenCalled());
});
});
});
describe('fetchMilestones', () => {
describe('success', () => {
beforeEach(() => {
mock.onGet(milestonesPath).replyOnce(httpStatusCodes.OK, filterMilestones);
});
it('dispatches RECEIVE_MILESTONES_SUCCESS with received data', () => {
testAction(
actions.fetchMilestones,
null,
{ ...state, milestonesPath },
[
{ type: types.REQUEST_MILESTONES },
{ type: types.RECEIVE_MILESTONES_SUCCESS, payload: filterMilestones },
],
[],
);
});
});
describe('error', () => {
beforeEach(() => {
mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE);
});
it('dispatches RECEIVE_MILESTONES_ERROR', () => {
return testAction(
actions.fetchMilestones,
null,
state,
[
{ type: types.REQUEST_MILESTONES },
{
type: types.RECEIVE_MILESTONES_ERROR,
payload: httpStatusCodes.SERVICE_UNAVAILABLE,
},
],
[],
).then(() => expect(createFlash).toHaveBeenCalled());
});
});
});
describe('fetchAssignees', () => {
describe('success', () => {
beforeEach(() => {
mock.onAny().replyOnce(httpStatusCodes.OK, filterUsers);
});
it('dispatches RECEIVE_ASSIGNEES_SUCCESS with received data', () => {
testAction(
actions.fetchAssignees,
null,
{ ...state, milestonesPath },
[
{ type: types.REQUEST_ASSIGNEES },
{ type: types.RECEIVE_ASSIGNEES_SUCCESS, payload: filterUsers },
],
[],
);
});
});
describe('error', () => {
beforeEach(() => {
mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE);
});
it('dispatches RECEIVE_ASSIGNEES_ERROR', () => {
return testAction(
actions.fetchAssignees,
null,
state,
[
{ type: types.REQUEST_ASSIGNEES },
{
type: types.RECEIVE_ASSIGNEES_ERROR,
payload: httpStatusCodes.SERVICE_UNAVAILABLE,
},
],
[],
).then(() => expect(createFlash).toHaveBeenCalled());
});
});
});
describe('fetchLabels', () => {
describe('success', () => {
beforeEach(() => {
mock.onGet(labelsPath).replyOnce(httpStatusCodes.OK, filterLabels);
});
it('dispatches RECEIVE_LABELS_SUCCESS with received data', () => {
testAction(
actions.fetchLabels,
null,
{ ...state, labelsPath },
[
{ type: types.REQUEST_LABELS },
{ type: types.RECEIVE_LABELS_SUCCESS, payload: filterLabels },
],
[],
);
});
});
describe('error', () => {
beforeEach(() => {
mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE);
});
it('dispatches RECEIVE_LABELS_ERROR', () => {
return testAction(
actions.fetchLabels,
null,
state,
[
{ type: types.REQUEST_LABELS },
{
type: types.RECEIVE_LABELS_ERROR,
payload: httpStatusCodes.SERVICE_UNAVAILABLE,
},
],
[],
).then(() => expect(createFlash).toHaveBeenCalled());
});
});
});
});
import mutations from 'ee/analytics/cycle_analytics/store/modules/filters/mutations';
import * as types from 'ee/analytics/cycle_analytics/store/modules/filters/mutation_types';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { filterMilestones, filterUsers, filterLabels } from '../../../mock_data';
let state = null;
const milestones = filterMilestones.map(convertObjectPropsToCamelCase);
const users = filterUsers.map(convertObjectPropsToCamelCase);
const labels = filterLabels.map(convertObjectPropsToCamelCase);
describe('Filters mutations', () => {
beforeEach(() => {
state = { initialTokens: {}, milestones: {}, authors: {}, labels: {}, assignees: {} };
......@@ -21,4 +27,44 @@ describe('Filters mutations', () => {
expect(state[stateKey]).toEqual(value);
});
it.each`
mutation | rootKey | stateKey | value
${types.INITIALIZE} | ${'initialTokens'} | ${'selectedAuthor'} | ${null}
${types.INITIALIZE} | ${'initialTokens'} | ${'selectedMilestone'} | ${null}
${types.INITIALIZE} | ${'initialTokens'} | ${'selectedAssignees'} | ${[]}
${types.INITIALIZE} | ${'initialTokens'} | ${'selectedLabels'} | ${[]}
`('$mutation will set $stateKey with a given value', ({ mutation, rootKey, stateKey, value }) => {
mutations[mutation](state);
expect(state[rootKey][stateKey]).toEqual(value);
});
it.each`
mutation | rootKey | stateKey | value
${types.REQUEST_MILESTONES} | ${'milestones'} | ${'isLoading'} | ${true}
${types.RECEIVE_MILESTONES_SUCCESS} | ${'milestones'} | ${'isLoading'} | ${false}
${types.RECEIVE_MILESTONES_SUCCESS} | ${'milestones'} | ${'data'} | ${milestones}
${types.RECEIVE_MILESTONES_ERROR} | ${'milestones'} | ${'isLoading'} | ${false}
${types.RECEIVE_MILESTONES_ERROR} | ${'milestones'} | ${'data'} | ${[]}
${types.REQUEST_AUTHORS} | ${'authors'} | ${'isLoading'} | ${true}
${types.RECEIVE_AUTHORS_SUCCESS} | ${'authors'} | ${'isLoading'} | ${false}
${types.RECEIVE_AUTHORS_SUCCESS} | ${'authors'} | ${'data'} | ${users}
${types.RECEIVE_AUTHORS_ERROR} | ${'authors'} | ${'isLoading'} | ${false}
${types.RECEIVE_AUTHORS_ERROR} | ${'authors'} | ${'data'} | ${[]}
${types.REQUEST_LABELS} | ${'labels'} | ${'isLoading'} | ${true}
${types.RECEIVE_LABELS_SUCCESS} | ${'labels'} | ${'isLoading'} | ${false}
${types.RECEIVE_LABELS_SUCCESS} | ${'labels'} | ${'data'} | ${labels}
${types.RECEIVE_LABELS_ERROR} | ${'labels'} | ${'isLoading'} | ${false}
${types.RECEIVE_LABELS_ERROR} | ${'labels'} | ${'data'} | ${[]}
${types.REQUEST_ASSIGNEES} | ${'assignees'} | ${'isLoading'} | ${true}
${types.RECEIVE_ASSIGNEES_SUCCESS} | ${'assignees'} | ${'isLoading'} | ${false}
${types.RECEIVE_ASSIGNEES_SUCCESS} | ${'assignees'} | ${'data'} | ${users}
${types.RECEIVE_ASSIGNEES_ERROR} | ${'assignees'} | ${'isLoading'} | ${false}
${types.RECEIVE_ASSIGNEES_ERROR} | ${'assignees'} | ${'data'} | ${[]}
`('$mutation will set $stateKey with a given value', ({ mutation, rootKey, stateKey, value }) => {
mutations[mutation](state, value);
expect(state[rootKey][stateKey]).toEqual(value);
});
});
......@@ -76,11 +76,17 @@ describe('buildProjectFromDataset', () => {
describe('buildCycleAnalyticsInitialData', () => {
it.each`
field | value
${'group'} | ${null}
${'createdBefore'} | ${null}
${'createdAfter'} | ${null}
${'selectedProjects'} | ${[]}
field | value
${'group'} | ${null}
${'createdBefore'} | ${null}
${'createdAfter'} | ${null}
${'selectedProjects'} | ${[]}
${'selectedAuthor'} | ${null}
${'selectedMilestone'} | ${null}
${'selectedLabels'} | ${[]}
${'selectedAssignees'} | ${[]}
${'labelsPath'} | ${''}
${'milestonesPath'} | ${''}
`('will set a default value for "$field" if is not present', ({ field, value }) => {
expect(buildCycleAnalyticsInitialData()).toMatchObject({
[field]: value,
......@@ -132,6 +138,62 @@ describe('buildCycleAnalyticsInitialData', () => {
});
});
describe('selectedAssignees', () => {
it('will be set given an array of assignees', () => {
const selectedAssignees = ['krillin', 'chiao-tzu'];
expect(
buildCycleAnalyticsInitialData({ assignees: JSON.stringify(selectedAssignees) }),
).toMatchObject({
selectedAssignees,
});
});
it.each`
field | value
${'selectedAssignees'} | ${null}
${'selectedAssignees'} | ${[]}
${'selectedAssignees'} | ${''}
`('will be an empty array if given a value of `$value`', ({ value, field }) => {
expect(buildCycleAnalyticsInitialData({ projects: value })).toMatchObject({
[field]: [],
});
});
});
describe('selectedLabels', () => {
it('will be set given an array of labels', () => {
const selectedLabels = ['krillin', 'chiao-tzu'];
expect(
buildCycleAnalyticsInitialData({ labels: JSON.stringify(selectedLabels) }),
).toMatchObject({ selectedLabels });
});
it.each`
field | value
${'selectedLabels'} | ${null}
${'selectedLabels'} | ${[]}
${'selectedLabels'} | ${''}
`('will be an empty array if given a value of `$value`', ({ value, field }) => {
expect(buildCycleAnalyticsInitialData({ projects: value })).toMatchObject({
[field]: [],
});
});
});
describe.each`
field | key | value
${'milestone'} | ${'selectedMilestone'} | ${'cell-saga'}
${'author'} | ${'selectedAuthor'} | ${'cell'}
`('$field', ({ field, value, key }) => {
it(`will set ${key} field with the given value`, () => {
expect(buildCycleAnalyticsInitialData({ [field]: value })).toMatchObject({ [key]: value });
});
it(`will set ${key} to null if omitted`, () => {
expect(buildCycleAnalyticsInitialData()).toMatchObject({ [key]: null });
});
});
describe.each`
field | value
${'createdBefore'} | ${'2019-12-31'}
......
......@@ -9509,6 +9509,12 @@ msgstr ""
msgid "Failed to install."
msgstr ""
msgid "Failed to load assignees. Please try again."
msgstr ""
msgid "Failed to load authors. Please try again."
msgstr ""
msgid "Failed to load emoji list."
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