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 {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlFilteredSearchToken,
} from '@gitlab/ui';
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 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 MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
import EpicsFilteredSearchMixin from '../mixins/filtered_search_mixin';
import { EPICS_STATES, PRESET_TYPES } from '../constants';
......@@ -54,16 +49,9 @@ export default {
GlDropdownDivider,
FilteredSearchBar,
},
mixins: [EpicsFilteredSearchMixin],
computed: {
...mapState([
'presetType',
'epicsState',
'sortedBy',
'fullPath',
'groupLabelsEndpoint',
'groupMilestonesEndpoint',
'filterParams',
]),
...mapState(['presetType', 'epicsState', 'sortedBy', 'filterParams']),
selectedEpicStateTitle() {
if (this.epicsState === EPICS_STATES.ALL) {
return __('All epics');
......@@ -73,213 +61,37 @@ export default {
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: {
...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) {
visitUrl(mergeUrlParams({ layout: presetType }, window.location.href));
},
handleEpicStateChange(epicsState) {
this.setEpicsState(epicsState);
this.fetchEpics();
this.updateUrl();
},
handleFilterEpics(filters) {
const filterParams = filters.length ? {} : null;
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.setFilterParams(this.getFilterParams(filters));
this.fetchEpics();
this.updateUrl();
},
handleSortEpics(sortedBy) {
this.setSortedBy(sortedBy);
this.fetchEpics();
this.updateUrl();
},
},
};
......@@ -325,7 +137,7 @@ export default {
>
</gl-dropdown>
<filtered-search-bar
:namespace="fullPath"
:namespace="groupFullPath"
:search-input-placeholder="__('Search or filter results...')"
:tokens="getFilteredSearchTokens()"
:sort-options="$options.availableSortOptions"
......
import { s__ } from '~/locale';
import { s__, __ } from '~/locale';
/*
Update the counterparts in roadmap.scss when making changes.
......@@ -67,6 +67,11 @@ export const EPIC_LEVEL_MARGIN = {
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_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 () => {
newEpicPath: dataset.newEpicPath,
listEpicsPath: dataset.listEpicsPath,
epicsDocsPath: dataset.epicsDocsPath,
groupFullPath: dataset.fullPath,
groupLabelsPath: dataset.groupLabelsEndpoint,
groupMilestonesPath: dataset.groupMilestonesEndpoint,
};
},
data() {
......@@ -92,8 +95,6 @@ export default () => {
basePath: dataset.epicsPath,
fullPath: dataset.fullPath,
epicIid: dataset.iid,
groupLabelsEndpoint: dataset.groupLabelsEndpoint,
groupMilestonesEndpoint: dataset.groupMilestonesEndpoint,
epicsState: dataset.epicsState,
sortedBy: dataset.sortedBy,
filterParams,
......@@ -112,8 +113,6 @@ export default () => {
timeframe: this.timeframe,
basePath: this.basePath,
filterParams: this.filterParams,
groupLabelsEndpoint: this.groupLabelsEndpoint,
groupMilestonesEndpoint: this.groupMilestonesEndpoint,
defaultInnerHeight: this.defaultInnerHeight,
isChildEpics: this.isChildEpics,
hasFiltersApplied: this.hasFiltersApplied,
......
......@@ -3,8 +3,6 @@ export default () => ({
basePath: '',
epicsState: '',
filterParams: null,
groupLabelsEndpoint: '',
groupMilestonesEndpoint: '',
// Data
epicIid: '',
......
......@@ -46,6 +46,8 @@ describe('RoadmapApp', () => {
},
provide: {
glFeatures: { asyncFiltering: true },
groupFullPath: 'gitlab-org',
groupMilestonesPath: '/groups/gitlab-org/-/milestones.json',
},
store,
});
......
......@@ -26,8 +26,8 @@ const createComponent = ({
presetType = PRESET_TYPES.MONTHS,
epicsState = EPICS_STATES.ALL,
sortedBy = mockSortedBy,
fullPath = 'gitlab-org',
groupLabelsEndpoint = '/groups/gitlab-org/-/labels.json',
groupFullPath = 'gitlab-org',
groupMilestonesPath = '/groups/gitlab-org/-/milestones.json',
timeframe = getTimeframeForMonthsView(mockTimeframeInitialDate),
filterParams = {},
} = {}) => {
......@@ -40,8 +40,6 @@ const createComponent = ({
presetType,
epicsState,
sortedBy,
fullPath,
groupLabelsEndpoint,
filterParams,
timeframe,
});
......@@ -49,6 +47,10 @@ const createComponent = ({
return shallowMount(RoadmapFilters, {
localVue,
store,
provide: {
groupFullPath,
groupMilestonesPath,
},
});
};
......@@ -81,8 +83,8 @@ describe('RoadmapFilters', () => {
});
});
describe('methods', () => {
describe('updateUrl', () => {
describe('watch', () => {
describe('urlParams', () => {
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('setFilterParams', {
......@@ -95,10 +97,8 @@ describe('RoadmapFilters', () => {
await wrapper.vm.$nextTick();
wrapper.vm.updateUrl();
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', () => {
type: 'author_username',
value: { data: 'root' },
},
{
type: 'milestone_title',
value: { data: '4.0' },
},
{
type: 'label_name',
value: { data: 'Bug' },
},
{
type: 'milestone_title',
value: { data: '4.0' },
},
{
type: 'confidential',
value: { data: true },
......@@ -253,7 +253,6 @@ describe('RoadmapFilters', () => {
it('fetches filtered epics when `onFilter` event is emitted', async () => {
jest.spyOn(wrapper.vm, 'setFilterParams');
jest.spyOn(wrapper.vm, 'fetchEpics');
jest.spyOn(wrapper.vm, 'updateUrl');
await wrapper.vm.$nextTick();
......@@ -268,13 +267,11 @@ describe('RoadmapFilters', () => {
confidential: true,
});
expect(wrapper.vm.fetchEpics).toHaveBeenCalled();
expect(wrapper.vm.updateUrl).toHaveBeenCalled();
});
it('fetches epics with updated sort order when `onSort` event is emitted', async () => {
jest.spyOn(wrapper.vm, 'setSortedBy');
jest.spyOn(wrapper.vm, 'fetchEpics');
jest.spyOn(wrapper.vm, 'updateUrl');
await wrapper.vm.$nextTick();
......@@ -284,7 +281,6 @@ describe('RoadmapFilters', () => {
expect(wrapper.vm.setSortedBy).toHaveBeenCalledWith('end_date_asc');
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