Commit 5e900d3e authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch 'kp-roadmap-filters-mixin' into 'master'

Extract Roadmap filtering to a mixin

See merge request gitlab-org/gitlab!52793
parents c34e72fe b07ee702
...@@ -6,18 +6,13 @@ import { ...@@ -6,18 +6,13 @@ import {
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
GlDropdownDivider, GlDropdownDivider,
GlFilteredSearchToken,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import { urlParamsToObject } from '~/lib/utils/common_utils';
import { visitUrl, mergeUrlParams, updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import { visitUrl, mergeUrlParams, updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import EpicsFilteredSearchMixin from '../mixins/filtered_search_mixin';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
import { EPICS_STATES, PRESET_TYPES } from '../constants'; import { EPICS_STATES, PRESET_TYPES } from '../constants';
...@@ -54,16 +49,9 @@ export default { ...@@ -54,16 +49,9 @@ export default {
GlDropdownDivider, GlDropdownDivider,
FilteredSearchBar, FilteredSearchBar,
}, },
mixins: [EpicsFilteredSearchMixin],
computed: { computed: {
...mapState([ ...mapState(['presetType', 'epicsState', 'sortedBy', 'filterParams']),
'presetType',
'epicsState',
'sortedBy',
'fullPath',
'groupLabelsEndpoint',
'groupMilestonesEndpoint',
'filterParams',
]),
selectedEpicStateTitle() { selectedEpicStateTitle() {
if (this.epicsState === EPICS_STATES.ALL) { if (this.epicsState === EPICS_STATES.ALL) {
return __('All epics'); return __('All epics');
...@@ -73,213 +61,37 @@ export default { ...@@ -73,213 +61,37 @@ export default {
return __('Closed epics'); return __('Closed epics');
}, },
}, },
watch: {
urlParams: {
deep: true,
immediate: true,
handler(params) {
if (Object.keys(params).length) {
updateHistory({
url: setUrlParams(params, window.location.href, true),
title: document.title,
replace: true,
});
}
},
},
},
methods: { methods: {
...mapActions(['setEpicsState', 'setFilterParams', 'setSortedBy', 'fetchEpics']), ...mapActions(['setEpicsState', 'setFilterParams', 'setSortedBy', 'fetchEpics']),
getFilteredSearchTokens() {
return [
{
type: 'author_username',
icon: 'user',
title: __('Author'),
unique: true,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
fetchAuthors: Api.users.bind(Api),
},
{
type: 'label_name',
icon: 'labels',
title: __('Label'),
unique: false,
symbol: '~',
token: LabelToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
fetchLabels: (search = '') => {
const params = {
only_group_labels: true,
include_ancestor_groups: true,
include_descendant_groups: true,
};
if (search) {
params.search = search;
}
return axios.get(this.groupLabelsEndpoint, {
params,
});
},
},
{
type: 'milestone_title',
icon: 'clock',
title: __('Milestone'),
unique: true,
symbol: '%',
token: MilestoneToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
fetchMilestones: (search = '') => {
return axios.get(this.groupMilestonesEndpoint).then(({ data }) => {
// TODO: Remove below condition check once either of the following is supported.
// a) Milestones Private API supports search param.
// b) Milestones Public API supports including child projects' milestones.
if (search) {
return {
data: data.filter((m) => m.title.toLowerCase().includes(search.toLowerCase())),
};
}
return { data };
});
},
},
{
type: 'confidential',
icon: 'eye-slash',
title: __('Confidential'),
unique: true,
token: GlFilteredSearchToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
options: [
{ icon: 'eye-slash', value: true, title: __('Yes') },
{ icon: 'eye', value: false, title: __('No') },
],
},
];
},
getFilteredSearchValue() {
const { authorUsername, labelName, milestoneTitle, confidential, search } =
this.filterParams || {};
const filteredSearchValue = [];
if (authorUsername) {
filteredSearchValue.push({
type: 'author_username',
value: { data: authorUsername },
});
}
if (milestoneTitle) {
filteredSearchValue.push({
type: 'milestone_title',
value: { data: milestoneTitle },
});
}
if (labelName?.length) {
filteredSearchValue.push(
...labelName.map((label) => ({
type: 'label_name',
value: { data: label },
})),
);
}
if (confidential !== undefined) {
filteredSearchValue.push({
type: 'confidential',
value: { data: confidential },
});
}
if (search) {
filteredSearchValue.push(search);
}
return filteredSearchValue;
},
updateUrl() {
const queryParams = urlParamsToObject(window.location.search);
const { authorUsername, labelName, milestoneTitle, confidential, search } =
this.filterParams || {};
queryParams.state = this.epicsState;
queryParams.sort = this.sortedBy;
if (authorUsername) {
queryParams.author_username = authorUsername;
} else {
delete queryParams.author_username;
}
if (milestoneTitle) {
queryParams.milestone_title = milestoneTitle;
} else {
delete queryParams.milestone_title;
}
delete queryParams.label_name;
if (labelName?.length) {
queryParams['label_name[]'] = labelName;
}
if (confidential !== undefined) {
queryParams.confidential = confidential;
} else {
delete queryParams.confidential;
}
if (search) {
queryParams.search = search;
} else {
delete queryParams.search;
}
// We want to replace the history state so that back button
// correctly reloads the page with previous URL.
updateHistory({
url: setUrlParams(queryParams, window.location.href, true),
title: document.title,
replace: true,
});
},
handleRoadmapLayoutChange(presetType) { handleRoadmapLayoutChange(presetType) {
visitUrl(mergeUrlParams({ layout: presetType }, window.location.href)); visitUrl(mergeUrlParams({ layout: presetType }, window.location.href));
}, },
handleEpicStateChange(epicsState) { handleEpicStateChange(epicsState) {
this.setEpicsState(epicsState); this.setEpicsState(epicsState);
this.fetchEpics(); this.fetchEpics();
this.updateUrl();
}, },
handleFilterEpics(filters) { handleFilterEpics(filters) {
const filterParams = filters.length ? {} : null; this.setFilterParams(this.getFilterParams(filters));
const labels = [];
filters.forEach((filter) => {
if (typeof filter === 'object') {
switch (filter.type) {
case 'author_username':
filterParams.authorUsername = filter.value.data;
break;
case 'milestone_title':
filterParams.milestoneTitle = filter.value.data;
break;
case 'label_name':
labels.push(filter.value.data);
break;
case 'confidential':
filterParams.confidential = filter.value.data;
break;
default:
break;
}
} else {
filterParams.search = filter;
}
});
if (labels.length) {
filterParams.labelName = labels;
}
this.setFilterParams(filterParams);
this.fetchEpics(); this.fetchEpics();
this.updateUrl();
}, },
handleSortEpics(sortedBy) { handleSortEpics(sortedBy) {
this.setSortedBy(sortedBy); this.setSortedBy(sortedBy);
this.fetchEpics(); this.fetchEpics();
this.updateUrl();
}, },
}, },
}; };
...@@ -325,7 +137,7 @@ export default { ...@@ -325,7 +137,7 @@ export default {
> >
</gl-dropdown> </gl-dropdown>
<filtered-search-bar <filtered-search-bar
:namespace="fullPath" :namespace="groupFullPath"
:search-input-placeholder="__('Search or filter results...')" :search-input-placeholder="__('Search or filter results...')"
:tokens="getFilteredSearchTokens()" :tokens="getFilteredSearchTokens()"
:sort-options="$options.availableSortOptions" :sort-options="$options.availableSortOptions"
......
import { s__ } from '~/locale'; import { s__, __ } from '~/locale';
/* /*
Update the counterparts in roadmap.scss when making changes. Update the counterparts in roadmap.scss when making changes.
...@@ -67,6 +67,11 @@ export const EPIC_LEVEL_MARGIN = { ...@@ -67,6 +67,11 @@ export const EPIC_LEVEL_MARGIN = {
4: 'ml-10', 4: 'ml-10',
}; };
export const FilterTokenOperators = [
{ value: '=', description: __('is'), default: 'true' },
// { value: '!=', description: __('is not') },
];
export const EPICS_LIMIT_DISMISSED_COOKIE_NAME = 'epics_limit_warning_dismissed'; export const EPICS_LIMIT_DISMISSED_COOKIE_NAME = 'epics_limit_warning_dismissed';
export const EPICS_LIMIT_DISMISSED_COOKIE_TIMEOUT = 365; export const EPICS_LIMIT_DISMISSED_COOKIE_TIMEOUT = 365;
import { GlFilteredSearchToken } from '@gitlab/ui';
import { __ } from '~/locale';
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
import { FilterTokenOperators } from '../constants';
export default {
inject: ['groupFullPath', 'groupMilestonesPath'],
computed: {
urlParams() {
const { search, authorUsername, labelName, milestoneTitle, confidential } =
this.filterParams || {};
return {
state: this.currentState || this.epicsState,
page: this.currentPage,
sort: this.sortedBy,
prev: this.prevPageCursor || undefined,
next: this.nextPageCursor || undefined,
author_username: authorUsername,
'label_name[]': labelName,
milestone_title: milestoneTitle,
confidential,
search,
};
},
},
methods: {
getFilteredSearchTokens() {
return [
{
type: 'author_username',
icon: 'user',
title: __('Author'),
unique: true,
symbol: '@',
token: AuthorToken,
operators: FilterTokenOperators,
fetchAuthors: Api.users.bind(Api),
},
{
type: 'label_name',
icon: 'labels',
title: __('Label'),
unique: false,
symbol: '~',
token: LabelToken,
operators: FilterTokenOperators,
fetchLabels: (search = '') => {
const params = {
only_group_labels: true,
include_ancestor_groups: true,
include_descendant_groups: true,
};
if (search) {
params.search = search;
}
return Api.groupLabels(this.groupFullPath, {
params,
});
},
},
{
type: 'milestone_title',
icon: 'clock',
title: __('Milestone'),
unique: true,
symbol: '%',
token: MilestoneToken,
operators: FilterTokenOperators,
fetchMilestones: (search = '') => {
return axios.get(this.groupMilestonesPath).then(({ data }) => {
// TODO: Remove below condition check once either of the following is supported.
// a) Milestones Private API supports search param.
// b) Milestones Public API supports including child projects' milestones.
if (search) {
return {
data: data.filter((m) => m.title.toLowerCase().includes(search.toLowerCase())),
};
}
return { data };
});
},
},
{
type: 'confidential',
icon: 'eye-slash',
title: __('Confidential'),
unique: true,
token: GlFilteredSearchToken,
operators: FilterTokenOperators,
options: [
{ icon: 'eye-slash', value: true, title: __('Yes') },
{ icon: 'eye', value: false, title: __('No') },
],
},
];
},
getFilteredSearchValue() {
const { authorUsername, labelName, milestoneTitle, confidential, search } =
this.filterParams || {};
const filteredSearchValue = [];
if (authorUsername) {
filteredSearchValue.push({
type: 'author_username',
value: { data: authorUsername },
});
}
if (labelName?.length) {
filteredSearchValue.push(
...labelName.map((label) => ({
type: 'label_name',
value: { data: label },
})),
);
}
if (milestoneTitle) {
filteredSearchValue.push({
type: 'milestone_title',
value: { data: milestoneTitle },
});
}
if (confidential !== undefined) {
filteredSearchValue.push({
type: 'confidential',
value: { data: confidential },
});
}
if (search) {
filteredSearchValue.push(search);
}
return filteredSearchValue;
},
getFilterParams(filters = []) {
const filterParams = {};
const labels = [];
const plainText = [];
filters.forEach((filter) => {
switch (filter.type) {
case 'author_username':
filterParams.authorUsername = filter.value.data;
break;
case 'label_name':
labels.push(filter.value.data);
break;
case 'milestone_title':
filterParams.milestoneTitle = filter.value.data;
break;
case 'confidential':
filterParams.confidential = filter.value.data;
break;
case 'filtered-search-term':
if (filter.value.data) plainText.push(filter.value.data);
break;
default:
break;
}
});
if (labels.length) {
filterParams.labelName = labels;
}
if (plainText.length) {
filterParams.search = plainText.join(' ');
}
return filterParams;
},
},
};
...@@ -57,6 +57,9 @@ export default () => { ...@@ -57,6 +57,9 @@ export default () => {
newEpicPath: dataset.newEpicPath, newEpicPath: dataset.newEpicPath,
listEpicsPath: dataset.listEpicsPath, listEpicsPath: dataset.listEpicsPath,
epicsDocsPath: dataset.epicsDocsPath, epicsDocsPath: dataset.epicsDocsPath,
groupFullPath: dataset.fullPath,
groupLabelsPath: dataset.groupLabelsEndpoint,
groupMilestonesPath: dataset.groupMilestonesEndpoint,
}; };
}, },
data() { data() {
...@@ -92,8 +95,6 @@ export default () => { ...@@ -92,8 +95,6 @@ export default () => {
basePath: dataset.epicsPath, basePath: dataset.epicsPath,
fullPath: dataset.fullPath, fullPath: dataset.fullPath,
epicIid: dataset.iid, epicIid: dataset.iid,
groupLabelsEndpoint: dataset.groupLabelsEndpoint,
groupMilestonesEndpoint: dataset.groupMilestonesEndpoint,
epicsState: dataset.epicsState, epicsState: dataset.epicsState,
sortedBy: dataset.sortedBy, sortedBy: dataset.sortedBy,
filterParams, filterParams,
...@@ -112,8 +113,6 @@ export default () => { ...@@ -112,8 +113,6 @@ export default () => {
timeframe: this.timeframe, timeframe: this.timeframe,
basePath: this.basePath, basePath: this.basePath,
filterParams: this.filterParams, filterParams: this.filterParams,
groupLabelsEndpoint: this.groupLabelsEndpoint,
groupMilestonesEndpoint: this.groupMilestonesEndpoint,
defaultInnerHeight: this.defaultInnerHeight, defaultInnerHeight: this.defaultInnerHeight,
isChildEpics: this.isChildEpics, isChildEpics: this.isChildEpics,
hasFiltersApplied: this.hasFiltersApplied, hasFiltersApplied: this.hasFiltersApplied,
......
...@@ -3,8 +3,6 @@ export default () => ({ ...@@ -3,8 +3,6 @@ export default () => ({
basePath: '', basePath: '',
epicsState: '', epicsState: '',
filterParams: null, filterParams: null,
groupLabelsEndpoint: '',
groupMilestonesEndpoint: '',
// Data // Data
epicIid: '', epicIid: '',
......
...@@ -46,6 +46,8 @@ describe('RoadmapApp', () => { ...@@ -46,6 +46,8 @@ describe('RoadmapApp', () => {
}, },
provide: { provide: {
glFeatures: { asyncFiltering: true }, glFeatures: { asyncFiltering: true },
groupFullPath: 'gitlab-org',
groupMilestonesPath: '/groups/gitlab-org/-/milestones.json',
}, },
store, store,
}); });
......
...@@ -26,8 +26,8 @@ const createComponent = ({ ...@@ -26,8 +26,8 @@ const createComponent = ({
presetType = PRESET_TYPES.MONTHS, presetType = PRESET_TYPES.MONTHS,
epicsState = EPICS_STATES.ALL, epicsState = EPICS_STATES.ALL,
sortedBy = mockSortedBy, sortedBy = mockSortedBy,
fullPath = 'gitlab-org', groupFullPath = 'gitlab-org',
groupLabelsEndpoint = '/groups/gitlab-org/-/labels.json', groupMilestonesPath = '/groups/gitlab-org/-/milestones.json',
timeframe = getTimeframeForMonthsView(mockTimeframeInitialDate), timeframe = getTimeframeForMonthsView(mockTimeframeInitialDate),
filterParams = {}, filterParams = {},
} = {}) => { } = {}) => {
...@@ -40,8 +40,6 @@ const createComponent = ({ ...@@ -40,8 +40,6 @@ const createComponent = ({
presetType, presetType,
epicsState, epicsState,
sortedBy, sortedBy,
fullPath,
groupLabelsEndpoint,
filterParams, filterParams,
timeframe, timeframe,
}); });
...@@ -49,6 +47,10 @@ const createComponent = ({ ...@@ -49,6 +47,10 @@ const createComponent = ({
return shallowMount(RoadmapFilters, { return shallowMount(RoadmapFilters, {
localVue, localVue,
store, store,
provide: {
groupFullPath,
groupMilestonesPath,
},
}); });
}; };
...@@ -81,8 +83,8 @@ describe('RoadmapFilters', () => { ...@@ -81,8 +83,8 @@ describe('RoadmapFilters', () => {
}); });
}); });
describe('methods', () => { describe('watch', () => {
describe('updateUrl', () => { describe('urlParams', () => {
it('updates window URL based on presence of props for state, filtered search and sort criteria', async () => { it('updates window URL based on presence of props for state, filtered search and sort criteria', async () => {
wrapper.vm.$store.dispatch('setEpicsState', EPICS_STATES.CLOSED); wrapper.vm.$store.dispatch('setEpicsState', EPICS_STATES.CLOSED);
wrapper.vm.$store.dispatch('setFilterParams', { wrapper.vm.$store.dispatch('setFilterParams', {
...@@ -95,10 +97,8 @@ describe('RoadmapFilters', () => { ...@@ -95,10 +97,8 @@ describe('RoadmapFilters', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
wrapper.vm.updateUrl();
expect(global.window.location.href).toBe( expect(global.window.location.href).toBe(
`${TEST_HOST}/?state=${EPICS_STATES.CLOSED}&sort=end_date_asc&author_username=root&milestone_title=4.0&label_name%5B%5D=Bug&confidential=true`, `${TEST_HOST}/?state=${EPICS_STATES.CLOSED}&sort=end_date_asc&author_username=root&label_name%5B%5D=Bug&milestone_title=4.0&confidential=true`,
); );
}); });
}); });
...@@ -144,14 +144,14 @@ describe('RoadmapFilters', () => { ...@@ -144,14 +144,14 @@ describe('RoadmapFilters', () => {
type: 'author_username', type: 'author_username',
value: { data: 'root' }, value: { data: 'root' },
}, },
{
type: 'milestone_title',
value: { data: '4.0' },
},
{ {
type: 'label_name', type: 'label_name',
value: { data: 'Bug' }, value: { data: 'Bug' },
}, },
{
type: 'milestone_title',
value: { data: '4.0' },
},
{ {
type: 'confidential', type: 'confidential',
value: { data: true }, value: { data: true },
...@@ -253,7 +253,6 @@ describe('RoadmapFilters', () => { ...@@ -253,7 +253,6 @@ describe('RoadmapFilters', () => {
it('fetches filtered epics when `onFilter` event is emitted', async () => { it('fetches filtered epics when `onFilter` event is emitted', async () => {
jest.spyOn(wrapper.vm, 'setFilterParams'); jest.spyOn(wrapper.vm, 'setFilterParams');
jest.spyOn(wrapper.vm, 'fetchEpics'); jest.spyOn(wrapper.vm, 'fetchEpics');
jest.spyOn(wrapper.vm, 'updateUrl');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
...@@ -268,13 +267,11 @@ describe('RoadmapFilters', () => { ...@@ -268,13 +267,11 @@ describe('RoadmapFilters', () => {
confidential: true, confidential: true,
}); });
expect(wrapper.vm.fetchEpics).toHaveBeenCalled(); expect(wrapper.vm.fetchEpics).toHaveBeenCalled();
expect(wrapper.vm.updateUrl).toHaveBeenCalled();
}); });
it('fetches epics with updated sort order when `onSort` event is emitted', async () => { it('fetches epics with updated sort order when `onSort` event is emitted', async () => {
jest.spyOn(wrapper.vm, 'setSortedBy'); jest.spyOn(wrapper.vm, 'setSortedBy');
jest.spyOn(wrapper.vm, 'fetchEpics'); jest.spyOn(wrapper.vm, 'fetchEpics');
jest.spyOn(wrapper.vm, 'updateUrl');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
...@@ -284,7 +281,6 @@ describe('RoadmapFilters', () => { ...@@ -284,7 +281,6 @@ describe('RoadmapFilters', () => {
expect(wrapper.vm.setSortedBy).toHaveBeenCalledWith('end_date_asc'); expect(wrapper.vm.setSortedBy).toHaveBeenCalledWith('end_date_asc');
expect(wrapper.vm.fetchEpics).toHaveBeenCalled(); expect(wrapper.vm.fetchEpics).toHaveBeenCalled();
expect(wrapper.vm.updateUrl).toHaveBeenCalled();
}); });
}); });
}); });
......
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