Commit 8b625105 authored by Rajat Jain's avatar Rajat Jain

Add NOT filtering to epic roadmap filtered search

In the epic roadmap, we're introducing the NOT filters
we've had for a while in issue search, etc. NOT filter is
now available for `author`, `reaction` and `label` filter
types.

Changelog: added
MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64483
EE: true
parent c28ba2af
......@@ -10,8 +10,11 @@ export const FILTER_CURRENT = 'Current';
export const OPERATOR_IS = '=';
export const OPERATOR_IS_TEXT = __('is');
export const OPERATOR_IS_NOT = '!=';
export const OPERATOR_IS_NOT_TEXT = __('is not');
export const OPERATOR_IS_ONLY = [{ value: OPERATOR_IS, description: OPERATOR_IS_TEXT }];
export const OPERATOR_IS_NOT_ONLY = [{ value: OPERATOR_IS_NOT, description: OPERATOR_IS_NOT_TEXT }];
export const OPERATOR_IS_AND_IS_NOT = [...OPERATOR_IS_ONLY, ...OPERATOR_IS_NOT_ONLY];
export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __(FILTER_NONE) };
export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __(FILTER_ANY) };
......
......@@ -5,7 +5,12 @@ import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import {
OPERATOR_IS_ONLY,
OPERATOR_IS_NOT,
OPERATOR_IS,
OPERATOR_IS_AND_IS_NOT,
} from '~/vue_shared/components/filtered_search_bar/constants';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue';
......@@ -24,7 +29,11 @@ export default {
confidential,
myReactionEmoji,
epicIid,
'not[authorUsername]': notAuthorUsername,
'not[myReactionEmoji]': notMyReactionEmoji,
'not[labelName]': notLabelName,
} = this.filterParams || {};
return {
state: this.currentState || this.epicsState,
page: this.currentPage,
......@@ -38,6 +47,9 @@ export default {
my_reaction_emoji: myReactionEmoji,
epic_iid: epicIid,
search,
'not[author_username]': notAuthorUsername,
'not[my_reaction_emoji]': notMyReactionEmoji,
'not[label_name][]': notLabelName,
};
},
},
......@@ -64,7 +76,7 @@ export default {
unique: true,
symbol: '@',
token: AuthorToken,
operators: OPERATOR_IS_ONLY,
operators: OPERATOR_IS_AND_IS_NOT,
recentSuggestionsStorageKey: `${this.groupFullPath}-epics-recent-tokens-author_username`,
fetchAuthors: Api.users.bind(Api),
preloadedAuthors,
......@@ -76,7 +88,7 @@ export default {
unique: false,
symbol: '~',
token: LabelToken,
operators: OPERATOR_IS_ONLY,
operators: OPERATOR_IS_AND_IS_NOT,
recentSuggestionsStorageKey: `${this.groupFullPath}-epics-recent-tokens-label_name`,
fetchLabels: (search = '') => {
const params = {
......@@ -170,7 +182,7 @@ export default {
title: __('My-Reaction'),
unique: true,
token: EmojiToken,
operators: OPERATOR_IS_ONLY,
operators: OPERATOR_IS_AND_IS_NOT,
fetchEmojis: (search = '') => {
return axios
.get(`${gon.relative_url_root || ''}/-/autocomplete/award_emojis`)
......@@ -197,13 +209,23 @@ export default {
myReactionEmoji,
search,
epicIid,
'not[authorUsername]': notAuthorUsername,
'not[myReactionEmoji]': notMyReactionEmoji,
'not[labelName]': notLabelName,
} = this.filterParams || {};
const filteredSearchValue = [];
if (authorUsername) {
filteredSearchValue.push({
type: 'author_username',
value: { data: authorUsername },
value: { data: authorUsername, operator: OPERATOR_IS },
});
}
if (notAuthorUsername) {
filteredSearchValue.push({
type: 'author_username',
value: { data: notAuthorUsername, operator: OPERATOR_IS_NOT },
});
}
......@@ -211,7 +233,15 @@ export default {
filteredSearchValue.push(
...labelName.map((label) => ({
type: 'label_name',
value: { data: label },
value: { data: label, operator: OPERATOR_IS },
})),
);
}
if (notLabelName?.length) {
filteredSearchValue.push(
...notLabelName.map((label) => ({
type: 'label_name',
value: { data: label, operator: OPERATOR_IS_NOT },
})),
);
}
......@@ -233,7 +263,13 @@ export default {
if (myReactionEmoji) {
filteredSearchValue.push({
type: 'my_reaction_emoji',
value: { data: myReactionEmoji },
value: { data: myReactionEmoji, operator: OPERATOR_IS },
});
}
if (notMyReactionEmoji) {
filteredSearchValue.push({
type: 'my_reaction_emoji',
value: { data: notMyReactionEmoji, operator: OPERATOR_IS_NOT },
});
}
......@@ -253,15 +289,23 @@ export default {
getFilterParams(filters = []) {
const filterParams = {};
const labels = [];
const notLabels = [];
const plainText = [];
filters.forEach((filter) => {
switch (filter.type) {
case 'author_username':
filterParams.authorUsername = filter.value.data;
case 'author_username': {
const key =
filter.value.operator === OPERATOR_IS_NOT ? 'not[authorUsername]' : 'authorUsername';
filterParams[key] = filter.value.data;
break;
}
case 'label_name':
labels.push(filter.value.data);
if (filter.value.operator === OPERATOR_IS_NOT) {
notLabels.push(filter.value.data);
} else {
labels.push(filter.value.data);
}
break;
case 'milestone_title':
filterParams.milestoneTitle = filter.value.data;
......@@ -269,9 +313,15 @@ export default {
case 'confidential':
filterParams.confidential = filter.value.data;
break;
case 'my_reaction_emoji':
filterParams.myReactionEmoji = filter.value.data;
case 'my_reaction_emoji': {
const key =
filter.value.operator === OPERATOR_IS_NOT
? 'not[myReactionEmoji]'
: 'myReactionEmoji';
filterParams[key] = filter.value.data;
break;
}
case 'epic_iid':
filterParams.epicIid = filter.value.data;
break;
......@@ -287,6 +337,10 @@ export default {
filterParams.labelName = labels;
}
if (notLabels.length) {
filterParams[`not[labelName]`] = notLabels;
}
if (plainText.length) {
filterParams.search = plainText.join(' ');
}
......
......@@ -13,6 +13,7 @@ query groupEpics(
$confidential: Boolean
$search: String = ""
$first: Int = 1001
$not: NegatedEpicFilterInput
) {
group(fullPath: $fullPath) {
id
......@@ -29,6 +30,7 @@ query groupEpics(
search: $search
first: $first
timeframe: $timeframe
not: $not
) {
edges {
node {
......
......@@ -32,9 +32,11 @@ const fetchGroupEpics = (
}),
};
const transformedFilterParams = roadmapItemUtils.transformFetchEpicFilterParams(filterParams);
// When epicIid is present,
// Roadmap is being accessed from within an Epic,
// and then we don't need to pass `filterParams`.
// and then we don't need to pass `transformedFilterParams`.
if (epicIid) {
query = epicChildEpics;
variables.iid = epicIid;
......@@ -42,12 +44,12 @@ const fetchGroupEpics = (
query = groupEpics;
variables = {
...variables,
...filterParams,
...transformedFilterParams,
first: gon.roadmap_epics_limit + 1,
};
if (filterParams?.epicIid) {
variables.iid = filterParams.epicIid.split('::&').pop();
if (transformedFilterParams?.epicIid) {
variables.iid = transformedFilterParams.epicIid.split('::&').pop();
}
}
......
......@@ -148,3 +148,33 @@ export const timeframeEndDate = (presetType, timeframe) => {
endDate.setDate(endDate.getDate() + DAYS_IN_WEEK);
return endDate;
};
/**
* Returns transformed `filterParams` by congregating all `not` params into a
* single object like { not: { labelName: [], ... }, authorUsername: '' }
*
* @param {Object} filterParams
*/
export const transformFetchEpicFilterParams = (filterParams) => {
if (!filterParams) {
return filterParams;
}
const newParams = {};
Object.keys(filterParams).forEach((param) => {
if (param.startsWith('not')) {
// Get the param name like `authorUsername` from `not[authorUsername]`
const key = param.match(/not\[(.+)\]/)[1];
if (key) {
newParams.not = newParams.not || {};
newParams.not[key] = filterParams[param];
}
} else {
newParams[param] = filterParams[param];
}
});
return newParams;
};
......@@ -19,7 +19,6 @@ import {
import { TEST_HOST } from 'helpers/test_constants';
import { visitUrl, mergeUrlParams, updateHistory } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
jest.mock('~/lib/utils/url_utility', () => ({
......@@ -151,11 +150,19 @@ describe('RoadmapFilters', () => {
const mockInitialFilterValue = [
{
type: 'author_username',
value: { data: 'root' },
value: { data: 'root', operator: '=' },
},
{
type: 'author_username',
value: { data: 'John', operator: '!=' },
},
{
type: 'label_name',
value: { data: 'Bug' },
value: { data: 'Bug', operator: '=' },
},
{
type: 'label_name',
value: { data: 'Feature', operator: '!=' },
},
{
type: 'milestone_title',
......@@ -165,6 +172,10 @@ describe('RoadmapFilters', () => {
type: 'confidential',
value: { data: true },
},
{
type: 'my_reaction_emoji',
value: { data: 'thumbs_up', operator: '!=' },
},
];
let filteredSearchBar;
......@@ -215,6 +226,9 @@ describe('RoadmapFilters', () => {
labelName: ['Bug'],
milestoneTitle: '4.0',
confidential: true,
'not[authorUsername]': 'John',
'not[labelName]': ['Feature'],
'not[myReactionEmoji]': 'thumbs_up',
});
await wrapper.vm.$nextTick();
......@@ -237,6 +251,9 @@ describe('RoadmapFilters', () => {
labelName: ['Bug'],
milestoneTitle: '4.0',
confidential: true,
'not[authorUsername]': 'John',
'not[labelName]': ['Feature'],
'not[myReactionEmoji]': 'thumbs_up',
});
expect(wrapper.vm.fetchEpics).toHaveBeenCalled();
});
......
......@@ -6,7 +6,10 @@ import {
} from 'ee/roadmap/utils/roadmap_utils';
import { dateFromString } from 'helpers/datetime_helpers';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import {
OPERATOR_IS_ONLY,
OPERATOR_IS_AND_IS_NOT,
} from '~/vue_shared/components/filtered_search_bar/constants';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
......@@ -774,7 +777,7 @@ export const mockAuthorTokenConfig = {
unique: true,
symbol: '@',
token: AuthorToken,
operators: OPERATOR_IS_ONLY,
operators: OPERATOR_IS_AND_IS_NOT,
recentSuggestionsStorageKey: 'gitlab-org-epics-recent-tokens-author_username',
fetchAuthors: expect.any(Function),
preloadedAuthors: [],
......@@ -787,7 +790,7 @@ export const mockLabelTokenConfig = {
unique: false,
symbol: '~',
token: LabelToken,
operators: OPERATOR_IS_ONLY,
operators: OPERATOR_IS_AND_IS_NOT,
recentSuggestionsStorageKey: 'gitlab-org-epics-recent-tokens-label_name',
fetchLabels: expect.any(Function),
};
......@@ -834,6 +837,6 @@ export const mockReactionEmojiTokenConfig = {
title: 'My-Reaction',
unique: true,
token: EmojiToken,
operators: OPERATOR_IS_ONLY,
operators: OPERATOR_IS_AND_IS_NOT,
fetchEmojis: expect.any(Function),
};
......@@ -167,7 +167,7 @@ describe('timeframeEndDate', () => {
The same is true of quarterly timeframes generated with getTimeframeForQuarterlyView
E.g., [ ..., { range: [ Oct 1, Nov 1, Dec 31 ] }]
In comparison, a weekly timeframe won't have its last item set to the ending date for the week.
E.g., [ Oct 25, Nov 1, Nov 8 ]
......@@ -186,3 +186,21 @@ describe('timeframeEndDate', () => {
},
);
});
describe('transformFetchEpicFilterParams', () => {
it('should return congregated `not[]` params in a single key', () => {
const filterParams = {
'not[authorUsername]': 'foo',
'not[myReactionEmoji]': ':emoji:',
authorUsername: 'baz',
};
expect(roadmapItemUtils.transformFetchEpicFilterParams(filterParams)).toEqual({
not: {
authorUsername: 'foo',
myReactionEmoji: ':emoji:',
},
authorUsername: 'baz',
});
});
});
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