Commit de5ad3e4 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch 'ss/filtered-search-issue-boards' into 'master'

Add new filtered search to issue boards [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!61752
parents 7acdd4d1 582485a8
...@@ -27,7 +27,7 @@ export default { ...@@ -27,7 +27,7 @@ export default {
}, },
computed: { computed: {
urlParams() { urlParams() {
const { authorUsername, labelName, search } = this.filterParams; const { authorUsername, labelName, assigneeUsername, search } = this.filterParams;
let notParams = {}; let notParams = {};
if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) { if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) {
...@@ -35,6 +35,7 @@ export default { ...@@ -35,6 +35,7 @@ export default {
{ {
'not[label_name][]': this.filterParams.not.labelName, 'not[label_name][]': this.filterParams.not.labelName,
'not[author_username]': this.filterParams.not.authorUsername, 'not[author_username]': this.filterParams.not.authorUsername,
'not[assignee_username]': this.filterParams.not.assigneeUsername,
}, },
undefined, undefined,
); );
...@@ -44,6 +45,7 @@ export default { ...@@ -44,6 +45,7 @@ export default {
...notParams, ...notParams,
author_username: authorUsername, author_username: authorUsername,
'label_name[]': labelName, 'label_name[]': labelName,
assignee_username: assigneeUsername,
search, search,
}; };
}, },
...@@ -62,7 +64,7 @@ export default { ...@@ -62,7 +64,7 @@ export default {
this.performSearch(); this.performSearch();
}, },
getFilteredSearchValue() { getFilteredSearchValue() {
const { authorUsername, labelName, search } = this.filterParams; const { authorUsername, labelName, assigneeUsername, search } = this.filterParams;
const filteredSearchValue = []; const filteredSearchValue = [];
if (authorUsername) { if (authorUsername) {
...@@ -72,6 +74,13 @@ export default { ...@@ -72,6 +74,13 @@ export default {
}); });
} }
if (assigneeUsername) {
filteredSearchValue.push({
type: 'assignee_username',
value: { data: assigneeUsername, operator: '=' },
});
}
if (labelName?.length) { if (labelName?.length) {
filteredSearchValue.push( filteredSearchValue.push(
...labelName.map((label) => ({ ...labelName.map((label) => ({
...@@ -88,6 +97,13 @@ export default { ...@@ -88,6 +97,13 @@ export default {
}); });
} }
if (this.filterParams['not[assigneeUsername]']) {
filteredSearchValue.push({
type: 'assignee_username',
value: { data: this.filterParams['not[assigneeUsername]'], operator: '!=' },
});
}
if (this.filterParams['not[labelName]']) { if (this.filterParams['not[labelName]']) {
filteredSearchValue.push( filteredSearchValue.push(
...this.filterParams['not[labelName]'].map((label) => ({ ...this.filterParams['not[labelName]'].map((label) => ({
...@@ -121,6 +137,9 @@ export default { ...@@ -121,6 +137,9 @@ export default {
case 'author_username': case 'author_username':
filterParams.authorUsername = filter.value.data; filterParams.authorUsername = filter.value.data;
break; break;
case 'assignee_username':
filterParams.assigneeUsername = filter.value.data;
break;
case 'label_name': case 'label_name':
labels.push(filter.value.data); labels.push(filter.value.data);
break; break;
......
<script>
import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue';
import issueBoardFilters from '~/boards/issue_board_filters';
import { TYPE_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
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';
export default {
i18n: {
search: __('Search'),
label: __('Label'),
author: __('Author'),
assignee: __('Assignee'),
is: __('is'),
isNot: __('is not'),
},
components: { BoardFilteredSearch },
props: {
fullPath: {
type: String,
required: true,
},
boardType: {
type: String,
required: true,
},
},
computed: {
tokens() {
const { label, is, isNot, author, assignee } = this.$options.i18n;
const { fetchAuthors, fetchLabels } = issueBoardFilters(
this.$apollo,
this.fullPath,
this.boardType,
);
return [
{
icon: 'labels',
title: label,
type: 'label_name',
operators: [
{ value: '=', description: is },
{ value: '!=', description: isNot },
],
token: LabelToken,
unique: false,
symbol: '~',
fetchLabels,
},
{
icon: 'pencil',
title: author,
type: 'author_username',
operators: [
{ value: '=', description: is },
{ value: '!=', description: isNot },
],
symbol: '@',
token: AuthorToken,
unique: true,
fetchAuthors,
preloadedAuthors: this.preloadedAuthors(),
},
{
icon: 'user',
title: assignee,
type: 'assignee_username',
operators: [
{ value: '=', description: is },
{ value: '!=', description: isNot },
],
token: AuthorToken,
unique: true,
fetchAuthors,
preloadedAuthors: this.preloadedAuthors(),
},
];
},
},
methods: {
preloadedAuthors() {
return gon?.current_user_id
? [
{
id: convertToGraphQLId(TYPE_USER, gon.current_user_id),
name: gon.current_user_fullname,
username: gon.current_username,
avatarUrl: gon.current_user_avatar_url,
},
]
: [];
},
},
};
</script>
<template>
<board-filtered-search data-testid="issue-board-filtered-search" :tokens="tokens" />
</template>
...@@ -25,6 +25,7 @@ import '~/boards/filters/due_date_filters'; ...@@ -25,6 +25,7 @@ import '~/boards/filters/due_date_filters';
import { issuableTypes } from '~/boards/constants'; import { issuableTypes } from '~/boards/constants';
import eventHub from '~/boards/eventhub'; import eventHub from '~/boards/eventhub';
import FilteredSearchBoards from '~/boards/filtered_search_boards'; import FilteredSearchBoards from '~/boards/filtered_search_boards';
import initBoardsFilteredSearch from '~/boards/mount_filtered_search_issue_boards';
import store from '~/boards/stores'; import store from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store'; import boardsStore from '~/boards/stores/boards_store';
import toggleFocusMode from '~/boards/toggle_focus'; import toggleFocusMode from '~/boards/toggle_focus';
...@@ -78,6 +79,10 @@ export default () => { ...@@ -78,6 +79,10 @@ export default () => {
issueBoardsApp.$destroy(true); issueBoardsApp.$destroy(true);
} }
if (gon?.features?.issueBoardsFilteredSearch) {
initBoardsFilteredSearch(apolloProvider);
}
if (!gon?.features?.graphqlBoardLists) { if (!gon?.features?.graphqlBoardLists) {
boardsStore.create(); boardsStore.create();
boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours); boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours);
...@@ -184,9 +189,14 @@ export default () => { ...@@ -184,9 +189,14 @@ export default () => {
eventHub.$off('initialBoardLoad', this.initialBoardLoad); eventHub.$off('initialBoardLoad', this.initialBoardLoad);
}, },
mounted() { mounted() {
this.filterManager = new FilteredSearchBoards(boardsStore.filter, true, boardsStore.cantEdit); if (!gon?.features?.issueBoardsFilteredSearch) {
this.filterManager = new FilteredSearchBoards(
this.filterManager.setup(); boardsStore.filter,
true,
boardsStore.cantEdit,
);
this.filterManager.setup();
}
this.performSearch(); this.performSearch();
......
import groupBoardMembers from '~/boards/graphql/group_board_members.query.graphql';
import projectBoardMembers from '~/boards/graphql/project_board_members.query.graphql';
import { BoardType } from './constants';
import boardLabels from './graphql/board_labels.query.graphql';
export default function issueBoardFilters(apollo, fullPath, boardType) {
const isGroupBoard = boardType === BoardType.group;
const isProjectBoard = boardType === BoardType.project;
const transformLabels = ({ data }) => {
return isGroupBoard ? data.group?.labels.nodes || [] : data.project?.labels.nodes || [];
};
const boardAssigneesQuery = () => {
return isGroupBoard ? groupBoardMembers : projectBoardMembers;
};
const fetchAuthors = (authorsSearchTerm) => {
return apollo
.query({
query: boardAssigneesQuery(),
variables: {
fullPath,
search: authorsSearchTerm,
},
})
.then(({ data }) => data.workspace?.assignees.nodes.map(({ user }) => user));
};
const fetchLabels = (labelSearchTerm) => {
return apollo
.query({
query: boardLabels,
variables: {
fullPath,
searchTerm: labelSearchTerm,
isGroup: isGroupBoard,
isProject: isProjectBoard,
},
})
.then(transformLabels);
};
return {
fetchLabels,
fetchAuthors,
};
}
import Vue from 'vue';
import IssueBoardFilteredSearch from '~/boards/components/issue_board_filtered_search.vue';
import store from '~/boards/stores';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { queryToObject } from '~/lib/utils/url_utility';
export default (apolloProvider) => {
const el = document.getElementById('js-issue-board-filtered-search');
const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true });
const initialFilterParams = {
...convertObjectPropsToCamelCase(rawFilterParams, {}),
};
if (!el) {
return null;
}
return new Vue({
el,
provide: {
initialFilterParams,
},
store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094
apolloProvider,
render: (createElement) =>
createElement(IssueBoardFilteredSearch, {
props: { fullPath: store.state?.fullPath || '', boardType: store.state?.boardType || '' },
}),
});
};
...@@ -8,6 +8,7 @@ class Groups::BoardsController < Groups::ApplicationController ...@@ -8,6 +8,7 @@ class Groups::BoardsController < Groups::ApplicationController
before_action :assign_endpoint_vars before_action :assign_endpoint_vars
before_action do before_action do
push_frontend_feature_flag(:graphql_board_lists, group, default_enabled: false) push_frontend_feature_flag(:graphql_board_lists, group, default_enabled: false)
push_frontend_feature_flag(:issue_boards_filtered_search, group, default_enabled: :yaml)
push_frontend_feature_flag(:board_multi_select, group, default_enabled: :yaml) push_frontend_feature_flag(:board_multi_select, group, default_enabled: :yaml)
push_frontend_feature_flag(:swimlanes_buffered_rendering, group, default_enabled: :yaml) push_frontend_feature_flag(:swimlanes_buffered_rendering, group, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, group, default_enabled: :yaml) push_frontend_feature_flag(:iteration_cadences, group, default_enabled: :yaml)
......
...@@ -9,6 +9,7 @@ class Projects::BoardsController < Projects::ApplicationController ...@@ -9,6 +9,7 @@ class Projects::BoardsController < Projects::ApplicationController
before_action do before_action do
push_frontend_feature_flag(:swimlanes_buffered_rendering, project, default_enabled: :yaml) push_frontend_feature_flag(:swimlanes_buffered_rendering, project, default_enabled: :yaml)
push_frontend_feature_flag(:graphql_board_lists, project, default_enabled: :yaml) push_frontend_feature_flag(:graphql_board_lists, project, default_enabled: :yaml)
push_frontend_feature_flag(:issue_boards_filtered_search, project, default_enabled: :yaml)
push_frontend_feature_flag(:board_multi_select, project, default_enabled: :yaml) push_frontend_feature_flag(:board_multi_select, project, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml) push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml)
end end
......
...@@ -25,6 +25,8 @@ ...@@ -25,6 +25,8 @@
= check_box_tag checkbox_id, nil, false, class: "check-all-issues left" = check_box_tag checkbox_id, nil, false, class: "check-all-issues left"
- if is_epic_board - if is_epic_board
#js-board-filtered-search{ data: { full_path: @group&.full_path } } #js-board-filtered-search{ data: { full_path: @group&.full_path } }
- elsif Feature.enabled?(:issue_boards_filtered_search, board&.resource_parent) && board
#js-issue-board-filtered-search
- else - else
.issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row .issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row
.filtered-search-box .filtered-search-box
......
---
name: issue_boards_filtered_search
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61752
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/331649
milestone: '14.1'
type: development
group: group::product planning
default_enabled: false
<script> <script>
import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue'; import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue';
import issueBoardFilter from '~/boards/issue_board_filters';
import { TYPE_USER } from '~/graphql_shared/constants'; import { TYPE_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils'; import { convertToGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale'; import { __ } from '~/locale';
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 LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import groupLabelsQuery from '../graphql/group_labels.query.graphql';
import groupUsersQuery from '../graphql/group_members.query.graphql';
export default { export default {
i18n: { i18n: {
...@@ -17,9 +16,24 @@ export default { ...@@ -17,9 +16,24 @@ export default {
isNot: __('is not'), isNot: __('is not'),
}, },
components: { BoardFilteredSearch }, components: { BoardFilteredSearch },
inject: ['fullPath'], props: {
fullPath: {
type: String,
required: true,
},
boardType: {
type: String,
required: true,
},
},
computed: { computed: {
tokens() { tokens() {
const { fetchLabels, fetchAuthors } = issueBoardFilter(
this.$apollo,
this.fullPath,
this.boardType,
);
const { label, is, isNot, author } = this.$options.i18n; const { label, is, isNot, author } = this.$options.i18n;
return [ return [
{ {
...@@ -32,9 +46,9 @@ export default { ...@@ -32,9 +46,9 @@ export default {
], ],
token: LabelToken, token: LabelToken,
unique: false, unique: false,
// eslint-disable-next-line @gitlab/require-i18n-strings symbol: '~',
defaultLabels: [{ value: 'No label', text: __('No label') }], defaultLabels: [{ value: __('No label'), text: __('No label') }],
fetchLabels: this.fetchLabels, fetchLabels,
}, },
{ {
icon: 'pencil', icon: 'pencil',
...@@ -47,37 +61,13 @@ export default { ...@@ -47,37 +61,13 @@ export default {
symbol: '@', symbol: '@',
token: AuthorToken, token: AuthorToken,
unique: true, unique: true,
fetchAuthors: this.fetchAuthors, fetchAuthors,
preloadedAuthors: this.preloadedAuthors(), preloadedAuthors: this.preloadedAuthors(),
}, },
]; ];
}, },
}, },
methods: { methods: {
fetchAuthors(authorsSearchTerm) {
return this.$apollo
.query({
query: groupUsersQuery,
variables: {
fullPath: this.fullPath,
search: authorsSearchTerm,
},
})
.then(({ data }) =>
data.group?.groupMembers.nodes.filter((x) => x?.user).map(({ user }) => user),
);
},
fetchLabels(labelSearchTerm) {
return this.$apollo
.query({
query: groupLabelsQuery,
variables: {
fullPath: this.fullPath,
search: labelSearchTerm,
},
})
.then(({ data }) => data.group?.labels.nodes || []);
},
preloadedAuthors() { preloadedAuthors() {
return gon?.current_user_id return gon?.current_user_id
? [ ? [
......
...@@ -12,7 +12,7 @@ export default (apolloProvider) => { ...@@ -12,7 +12,7 @@ export default (apolloProvider) => {
const initialFilterParams = { const initialFilterParams = {
...convertObjectPropsToCamelCase(rawFilterParams, {}), ...convertObjectPropsToCamelCase(rawFilterParams, {}),
}; };
const { fullPath } = el.dataset;
if (!el) { if (!el) {
return null; return null;
} }
...@@ -21,10 +21,12 @@ export default (apolloProvider) => { ...@@ -21,10 +21,12 @@ export default (apolloProvider) => {
el, el,
provide: { provide: {
initialFilterParams, initialFilterParams,
fullPath,
}, },
store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094 store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094
apolloProvider, apolloProvider,
render: (createElement) => createElement(EpicFilteredSearch), render: (createElement) =>
createElement(EpicFilteredSearch, {
props: { fullPath: store.state?.fullPath || '', boardType: store.state?.boardType || '' },
}),
}); });
}; };
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import EpicFilteredSearch from 'ee_component/boards/components/epic_filtered_search.vue'; import EpicFilteredSearch from 'ee_component/boards/components/epic_filtered_search.vue';
import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue'; import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue';
import issueBoardFilters from '~/boards/issue_board_filters';
import { __ } from '~/locale'; import { __ } from '~/locale';
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 LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
describe('EpicFilteredSearch', () => { describe('EpicFilteredSearch', () => {
let wrapper; let wrapper;
let store; const { fetchAuthors, fetchLabels } = issueBoardFilters({}, '', 'group');
const createComponent = ({ initialFilterParams = {} } = {}) => { const createComponent = ({ initialFilterParams = {} } = {}) => {
wrapper = shallowMount(EpicFilteredSearch, { wrapper = shallowMount(EpicFilteredSearch, {
provide: { initialFilterParams, fullPath: '' }, provide: { initialFilterParams },
store, props: {
fullPath: '',
boardType: '',
},
}); });
}; };
...@@ -48,8 +52,8 @@ describe('EpicFilteredSearch', () => { ...@@ -48,8 +52,8 @@ describe('EpicFilteredSearch', () => {
], ],
token: LabelToken, token: LabelToken,
unique: false, unique: false,
defaultLabels: [{ value: 'No label', text: 'No label' }], symbol: '~',
fetchLabels: wrapper.vm.fetchLabels, fetchLabels,
}, },
{ {
icon: 'pencil', icon: 'pencil',
...@@ -62,13 +66,13 @@ describe('EpicFilteredSearch', () => { ...@@ -62,13 +66,13 @@ describe('EpicFilteredSearch', () => {
symbol: '@', symbol: '@',
token: AuthorToken, token: AuthorToken,
unique: true, unique: true,
fetchAuthors: wrapper.vm.fetchAuthors, fetchAuthors,
preloadedAuthors: [ preloadedAuthors: [
{ id: 'gid://gitlab/User/4', name: 'Admin', username: 'root', avatarUrl: 'url' }, { id: 'gid://gitlab/User/4', name: 'Admin', username: 'root', avatarUrl: 'url' },
], ],
}, },
]; ];
expect(wrapper.find(BoardFilteredSearch).props('tokens')).toEqual(tokens); expect(wrapper.find(BoardFilteredSearch).props('tokens').toString()).toBe(tokens.toString());
}); });
}); });
}); });
import { shallowMount } from '@vue/test-utils';
import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue';
import IssueBoardFilteredSpec from '~/boards/components/issue_board_filtered_search.vue';
import { BoardType } from '~/boards/constants';
import issueBoardFilters from '~/boards/issue_board_filters';
import { mockTokens } from '../mock_data';
describe('IssueBoardFilter', () => {
let wrapper;
const createComponent = ({ initialFilterParams = {} } = {}) => {
wrapper = shallowMount(IssueBoardFilteredSpec, {
provide: { initialFilterParams },
props: { fullPath: '', boardType: '' },
});
};
afterEach(() => {
wrapper.destroy();
});
describe('default', () => {
beforeEach(() => {
createComponent();
});
it('finds BoardFilteredSearch', () => {
expect(wrapper.find(BoardFilteredSearch).exists()).toBe(true);
});
it.each([[BoardType.group], [BoardType.project]])(
'when boardType is %s we pass the correct tokens to BoardFilteredSearch',
(boardType) => {
const { fetchAuthors, fetchLabels } = issueBoardFilters({}, '', boardType);
const tokens = mockTokens(fetchLabels, fetchAuthors);
expect(wrapper.find(BoardFilteredSearch).props('tokens').toString()).toBe(
tokens.toString(),
);
},
);
});
});
...@@ -5,6 +5,9 @@ import Vue from 'vue'; ...@@ -5,6 +5,9 @@ import Vue from 'vue';
import '~/boards/models/list'; import '~/boards/models/list';
import { ListType } from '~/boards/constants'; import { ListType } from '~/boards/constants';
import boardsStore from '~/boards/stores/boards_store'; import boardsStore from '~/boards/stores/boards_store';
import { __ } from '~/locale';
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';
export const boardObj = { export const boardObj = {
id: 1, id: 1,
...@@ -526,3 +529,44 @@ export const mockMoveData = { ...@@ -526,3 +529,44 @@ export const mockMoveData = {
originalIssue: { foo: 'bar' }, originalIssue: { foo: 'bar' },
...mockMoveIssueParams, ...mockMoveIssueParams,
}; };
export const mockTokens = (fetchLabels, fetchAuthors) => [
{
icon: 'labels',
title: __('Label'),
type: 'label_name',
operators: [
{ value: '=', description: 'is' },
{ value: '!=', description: 'is not' },
],
token: LabelToken,
unique: false,
symbol: '~',
fetchLabels,
},
{
icon: 'pencil',
title: __('Author'),
type: 'author_username',
operators: [
{ value: '=', description: 'is' },
{ value: '!=', description: 'is not' },
],
symbol: '@',
token: AuthorToken,
unique: true,
fetchAuthors,
},
{
icon: 'user',
title: __('Assignee'),
type: 'assignee_username',
operators: [
{ value: '=', description: 'is' },
{ value: '!=', description: 'is not' },
],
token: AuthorToken,
unique: true,
fetchAuthors,
},
];
...@@ -293,6 +293,8 @@ RSpec.configure do |config| ...@@ -293,6 +293,8 @@ RSpec.configure do |config|
# As we're ready to change `master` usages to `main`, let's enable it # As we're ready to change `master` usages to `main`, let's enable it
stub_feature_flags(main_branch_over_master: false) stub_feature_flags(main_branch_over_master: false)
stub_feature_flags(issue_boards_filtered_search: false)
# Disable issue respositioning to avoid heavy load on database when importing big projects. # Disable issue respositioning to avoid heavy load on database when importing big projects.
# This is only turned on when app is handling heavy project imports. # This is only turned on when app is handling heavy project imports.
# Can be removed when we find a better way to deal with the problem. # Can be removed when we find a better way to deal with the problem.
......
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