Commit d4df549d authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'filter-epic-roadmap' into 'master'

Add NOT filtering for epic roadmap filtered search

See merge request gitlab-org/gitlab!64483
parents c36bc2ff 8b625105
...@@ -10,8 +10,11 @@ export const FILTER_CURRENT = 'Current'; ...@@ -10,8 +10,11 @@ export const FILTER_CURRENT = 'Current';
export const OPERATOR_IS = '='; export const OPERATOR_IS = '=';
export const OPERATOR_IS_TEXT = __('is'); export const OPERATOR_IS_TEXT = __('is');
export const OPERATOR_IS_NOT = '!='; 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_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_NONE = { value: FILTER_NONE, text: __(FILTER_NONE) };
export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __(FILTER_ANY) }; export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __(FILTER_ANY) };
......
...@@ -5,7 +5,12 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -5,7 +5,12 @@ import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility'; import { joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale'; 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 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 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'; import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue';
...@@ -24,7 +29,11 @@ export default { ...@@ -24,7 +29,11 @@ export default {
confidential, confidential,
myReactionEmoji, myReactionEmoji,
epicIid, epicIid,
'not[authorUsername]': notAuthorUsername,
'not[myReactionEmoji]': notMyReactionEmoji,
'not[labelName]': notLabelName,
} = this.filterParams || {}; } = this.filterParams || {};
return { return {
state: this.currentState || this.epicsState, state: this.currentState || this.epicsState,
page: this.currentPage, page: this.currentPage,
...@@ -38,6 +47,9 @@ export default { ...@@ -38,6 +47,9 @@ export default {
my_reaction_emoji: myReactionEmoji, my_reaction_emoji: myReactionEmoji,
epic_iid: epicIid, epic_iid: epicIid,
search, search,
'not[author_username]': notAuthorUsername,
'not[my_reaction_emoji]': notMyReactionEmoji,
'not[label_name][]': notLabelName,
}; };
}, },
}, },
...@@ -64,7 +76,7 @@ export default { ...@@ -64,7 +76,7 @@ export default {
unique: true, unique: true,
symbol: '@', symbol: '@',
token: AuthorToken, token: AuthorToken,
operators: OPERATOR_IS_ONLY, operators: OPERATOR_IS_AND_IS_NOT,
recentSuggestionsStorageKey: `${this.groupFullPath}-epics-recent-tokens-author_username`, recentSuggestionsStorageKey: `${this.groupFullPath}-epics-recent-tokens-author_username`,
fetchAuthors: Api.users.bind(Api), fetchAuthors: Api.users.bind(Api),
preloadedAuthors, preloadedAuthors,
...@@ -76,7 +88,7 @@ export default { ...@@ -76,7 +88,7 @@ export default {
unique: false, unique: false,
symbol: '~', symbol: '~',
token: LabelToken, token: LabelToken,
operators: OPERATOR_IS_ONLY, operators: OPERATOR_IS_AND_IS_NOT,
recentSuggestionsStorageKey: `${this.groupFullPath}-epics-recent-tokens-label_name`, recentSuggestionsStorageKey: `${this.groupFullPath}-epics-recent-tokens-label_name`,
fetchLabels: (search = '') => { fetchLabels: (search = '') => {
const params = { const params = {
...@@ -170,7 +182,7 @@ export default { ...@@ -170,7 +182,7 @@ export default {
title: __('My-Reaction'), title: __('My-Reaction'),
unique: true, unique: true,
token: EmojiToken, token: EmojiToken,
operators: OPERATOR_IS_ONLY, operators: OPERATOR_IS_AND_IS_NOT,
fetchEmojis: (search = '') => { fetchEmojis: (search = '') => {
return axios return axios
.get(`${gon.relative_url_root || ''}/-/autocomplete/award_emojis`) .get(`${gon.relative_url_root || ''}/-/autocomplete/award_emojis`)
...@@ -197,13 +209,23 @@ export default { ...@@ -197,13 +209,23 @@ export default {
myReactionEmoji, myReactionEmoji,
search, search,
epicIid, epicIid,
'not[authorUsername]': notAuthorUsername,
'not[myReactionEmoji]': notMyReactionEmoji,
'not[labelName]': notLabelName,
} = this.filterParams || {}; } = this.filterParams || {};
const filteredSearchValue = []; const filteredSearchValue = [];
if (authorUsername) { if (authorUsername) {
filteredSearchValue.push({ filteredSearchValue.push({
type: 'author_username', 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 { ...@@ -211,7 +233,15 @@ export default {
filteredSearchValue.push( filteredSearchValue.push(
...labelName.map((label) => ({ ...labelName.map((label) => ({
type: 'label_name', 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 { ...@@ -233,7 +263,13 @@ export default {
if (myReactionEmoji) { if (myReactionEmoji) {
filteredSearchValue.push({ filteredSearchValue.push({
type: 'my_reaction_emoji', 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 { ...@@ -253,15 +289,23 @@ export default {
getFilterParams(filters = []) { getFilterParams(filters = []) {
const filterParams = {}; const filterParams = {};
const labels = []; const labels = [];
const notLabels = [];
const plainText = []; const plainText = [];
filters.forEach((filter) => { filters.forEach((filter) => {
switch (filter.type) { switch (filter.type) {
case 'author_username': case 'author_username': {
filterParams.authorUsername = filter.value.data; const key =
filter.value.operator === OPERATOR_IS_NOT ? 'not[authorUsername]' : 'authorUsername';
filterParams[key] = filter.value.data;
break; break;
}
case 'label_name': 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; break;
case 'milestone_title': case 'milestone_title':
filterParams.milestoneTitle = filter.value.data; filterParams.milestoneTitle = filter.value.data;
...@@ -269,9 +313,15 @@ export default { ...@@ -269,9 +313,15 @@ export default {
case 'confidential': case 'confidential':
filterParams.confidential = filter.value.data; filterParams.confidential = filter.value.data;
break; break;
case 'my_reaction_emoji': case 'my_reaction_emoji': {
filterParams.myReactionEmoji = filter.value.data; const key =
filter.value.operator === OPERATOR_IS_NOT
? 'not[myReactionEmoji]'
: 'myReactionEmoji';
filterParams[key] = filter.value.data;
break; break;
}
case 'epic_iid': case 'epic_iid':
filterParams.epicIid = filter.value.data; filterParams.epicIid = filter.value.data;
break; break;
...@@ -287,6 +337,10 @@ export default { ...@@ -287,6 +337,10 @@ export default {
filterParams.labelName = labels; filterParams.labelName = labels;
} }
if (notLabels.length) {
filterParams[`not[labelName]`] = notLabels;
}
if (plainText.length) { if (plainText.length) {
filterParams.search = plainText.join(' '); filterParams.search = plainText.join(' ');
} }
......
...@@ -13,6 +13,7 @@ query groupEpics( ...@@ -13,6 +13,7 @@ query groupEpics(
$confidential: Boolean $confidential: Boolean
$search: String = "" $search: String = ""
$first: Int = 1001 $first: Int = 1001
$not: NegatedEpicFilterInput
) { ) {
group(fullPath: $fullPath) { group(fullPath: $fullPath) {
id id
...@@ -29,6 +30,7 @@ query groupEpics( ...@@ -29,6 +30,7 @@ query groupEpics(
search: $search search: $search
first: $first first: $first
timeframe: $timeframe timeframe: $timeframe
not: $not
) { ) {
edges { edges {
node { node {
......
...@@ -32,9 +32,11 @@ const fetchGroupEpics = ( ...@@ -32,9 +32,11 @@ const fetchGroupEpics = (
}), }),
}; };
const transformedFilterParams = roadmapItemUtils.transformFetchEpicFilterParams(filterParams);
// When epicIid is present, // When epicIid is present,
// Roadmap is being accessed from within an Epic, // 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) { if (epicIid) {
query = epicChildEpics; query = epicChildEpics;
variables.iid = epicIid; variables.iid = epicIid;
...@@ -42,12 +44,12 @@ const fetchGroupEpics = ( ...@@ -42,12 +44,12 @@ const fetchGroupEpics = (
query = groupEpics; query = groupEpics;
variables = { variables = {
...variables, ...variables,
...filterParams, ...transformedFilterParams,
first: gon.roadmap_epics_limit + 1, first: gon.roadmap_epics_limit + 1,
}; };
if (filterParams?.epicIid) { if (transformedFilterParams?.epicIid) {
variables.iid = filterParams.epicIid.split('::&').pop(); variables.iid = transformedFilterParams.epicIid.split('::&').pop();
} }
} }
......
...@@ -148,3 +148,33 @@ export const timeframeEndDate = (presetType, timeframe) => { ...@@ -148,3 +148,33 @@ export const timeframeEndDate = (presetType, timeframe) => {
endDate.setDate(endDate.getDate() + DAYS_IN_WEEK); endDate.setDate(endDate.getDate() + DAYS_IN_WEEK);
return endDate; 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 { ...@@ -19,7 +19,6 @@ import {
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import { visitUrl, mergeUrlParams, updateHistory } from '~/lib/utils/url_utility'; import { visitUrl, mergeUrlParams, updateHistory } 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';
jest.mock('~/lib/utils/url_utility', () => ({ jest.mock('~/lib/utils/url_utility', () => ({
...@@ -151,11 +150,19 @@ describe('RoadmapFilters', () => { ...@@ -151,11 +150,19 @@ describe('RoadmapFilters', () => {
const mockInitialFilterValue = [ const mockInitialFilterValue = [
{ {
type: 'author_username', type: 'author_username',
value: { data: 'root' }, value: { data: 'root', operator: '=' },
},
{
type: 'author_username',
value: { data: 'John', operator: '!=' },
}, },
{ {
type: 'label_name', type: 'label_name',
value: { data: 'Bug' }, value: { data: 'Bug', operator: '=' },
},
{
type: 'label_name',
value: { data: 'Feature', operator: '!=' },
}, },
{ {
type: 'milestone_title', type: 'milestone_title',
...@@ -165,6 +172,10 @@ describe('RoadmapFilters', () => { ...@@ -165,6 +172,10 @@ describe('RoadmapFilters', () => {
type: 'confidential', type: 'confidential',
value: { data: true }, value: { data: true },
}, },
{
type: 'my_reaction_emoji',
value: { data: 'thumbs_up', operator: '!=' },
},
]; ];
let filteredSearchBar; let filteredSearchBar;
...@@ -215,6 +226,9 @@ describe('RoadmapFilters', () => { ...@@ -215,6 +226,9 @@ describe('RoadmapFilters', () => {
labelName: ['Bug'], labelName: ['Bug'],
milestoneTitle: '4.0', milestoneTitle: '4.0',
confidential: true, confidential: true,
'not[authorUsername]': 'John',
'not[labelName]': ['Feature'],
'not[myReactionEmoji]': 'thumbs_up',
}); });
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
...@@ -237,6 +251,9 @@ describe('RoadmapFilters', () => { ...@@ -237,6 +251,9 @@ describe('RoadmapFilters', () => {
labelName: ['Bug'], labelName: ['Bug'],
milestoneTitle: '4.0', milestoneTitle: '4.0',
confidential: true, confidential: true,
'not[authorUsername]': 'John',
'not[labelName]': ['Feature'],
'not[myReactionEmoji]': 'thumbs_up',
}); });
expect(wrapper.vm.fetchEpics).toHaveBeenCalled(); expect(wrapper.vm.fetchEpics).toHaveBeenCalled();
}); });
......
...@@ -6,7 +6,10 @@ import { ...@@ -6,7 +6,10 @@ import {
} from 'ee/roadmap/utils/roadmap_utils'; } from 'ee/roadmap/utils/roadmap_utils';
import { dateFromString } from 'helpers/datetime_helpers'; 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 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 EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
...@@ -774,7 +777,7 @@ export const mockAuthorTokenConfig = { ...@@ -774,7 +777,7 @@ export const mockAuthorTokenConfig = {
unique: true, unique: true,
symbol: '@', symbol: '@',
token: AuthorToken, token: AuthorToken,
operators: OPERATOR_IS_ONLY, operators: OPERATOR_IS_AND_IS_NOT,
recentSuggestionsStorageKey: 'gitlab-org-epics-recent-tokens-author_username', recentSuggestionsStorageKey: 'gitlab-org-epics-recent-tokens-author_username',
fetchAuthors: expect.any(Function), fetchAuthors: expect.any(Function),
preloadedAuthors: [], preloadedAuthors: [],
...@@ -787,7 +790,7 @@ export const mockLabelTokenConfig = { ...@@ -787,7 +790,7 @@ export const mockLabelTokenConfig = {
unique: false, unique: false,
symbol: '~', symbol: '~',
token: LabelToken, token: LabelToken,
operators: OPERATOR_IS_ONLY, operators: OPERATOR_IS_AND_IS_NOT,
recentSuggestionsStorageKey: 'gitlab-org-epics-recent-tokens-label_name', recentSuggestionsStorageKey: 'gitlab-org-epics-recent-tokens-label_name',
fetchLabels: expect.any(Function), fetchLabels: expect.any(Function),
}; };
...@@ -834,6 +837,6 @@ export const mockReactionEmojiTokenConfig = { ...@@ -834,6 +837,6 @@ export const mockReactionEmojiTokenConfig = {
title: 'My-Reaction', title: 'My-Reaction',
unique: true, unique: true,
token: EmojiToken, token: EmojiToken,
operators: OPERATOR_IS_ONLY, operators: OPERATOR_IS_AND_IS_NOT,
fetchEmojis: expect.any(Function), fetchEmojis: expect.any(Function),
}; };
...@@ -167,7 +167,7 @@ describe('timeframeEndDate', () => { ...@@ -167,7 +167,7 @@ describe('timeframeEndDate', () => {
The same is true of quarterly timeframes generated with getTimeframeForQuarterlyView The same is true of quarterly timeframes generated with getTimeframeForQuarterlyView
E.g., [ ..., { range: [ Oct 1, Nov 1, Dec 31 ] }] 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. 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 ] E.g., [ Oct 25, Nov 1, Nov 8 ]
...@@ -186,3 +186,21 @@ describe('timeframeEndDate', () => { ...@@ -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