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 {
},
computed: {
urlParams() {
const { authorUsername, labelName, search } = this.filterParams;
const { authorUsername, labelName, assigneeUsername, search } = this.filterParams;
let notParams = {};
if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) {
......@@ -35,6 +35,7 @@ export default {
{
'not[label_name][]': this.filterParams.not.labelName,
'not[author_username]': this.filterParams.not.authorUsername,
'not[assignee_username]': this.filterParams.not.assigneeUsername,
},
undefined,
);
......@@ -44,6 +45,7 @@ export default {
...notParams,
author_username: authorUsername,
'label_name[]': labelName,
assignee_username: assigneeUsername,
search,
};
},
......@@ -62,7 +64,7 @@ export default {
this.performSearch();
},
getFilteredSearchValue() {
const { authorUsername, labelName, search } = this.filterParams;
const { authorUsername, labelName, assigneeUsername, search } = this.filterParams;
const filteredSearchValue = [];
if (authorUsername) {
......@@ -72,6 +74,13 @@ export default {
});
}
if (assigneeUsername) {
filteredSearchValue.push({
type: 'assignee_username',
value: { data: assigneeUsername, operator: '=' },
});
}
if (labelName?.length) {
filteredSearchValue.push(
...labelName.map((label) => ({
......@@ -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]']) {
filteredSearchValue.push(
...this.filterParams['not[labelName]'].map((label) => ({
......@@ -121,6 +137,9 @@ export default {
case 'author_username':
filterParams.authorUsername = filter.value.data;
break;
case 'assignee_username':
filterParams.assigneeUsername = filter.value.data;
break;
case 'label_name':
labels.push(filter.value.data);
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';
import { issuableTypes } from '~/boards/constants';
import eventHub from '~/boards/eventhub';
import FilteredSearchBoards from '~/boards/filtered_search_boards';
import initBoardsFilteredSearch from '~/boards/mount_filtered_search_issue_boards';
import store from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
import toggleFocusMode from '~/boards/toggle_focus';
......@@ -78,6 +79,10 @@ export default () => {
issueBoardsApp.$destroy(true);
}
if (gon?.features?.issueBoardsFilteredSearch) {
initBoardsFilteredSearch(apolloProvider);
}
if (!gon?.features?.graphqlBoardLists) {
boardsStore.create();
boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours);
......@@ -184,9 +189,14 @@ export default () => {
eventHub.$off('initialBoardLoad', this.initialBoardLoad);
},
mounted() {
this.filterManager = new FilteredSearchBoards(boardsStore.filter, true, boardsStore.cantEdit);
this.filterManager.setup();
if (!gon?.features?.issueBoardsFilteredSearch) {
this.filterManager = new FilteredSearchBoards(
boardsStore.filter,
true,
boardsStore.cantEdit,
);
this.filterManager.setup();
}
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
before_action :assign_endpoint_vars
before_action do
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(:swimlanes_buffered_rendering, group, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, group, default_enabled: :yaml)
......
......@@ -9,6 +9,7 @@ class Projects::BoardsController < Projects::ApplicationController
before_action do
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(:issue_boards_filtered_search, 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)
end
......
......@@ -25,6 +25,8 @@
= check_box_tag checkbox_id, nil, false, class: "check-all-issues left"
- if is_epic_board
#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
.issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row
.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>
import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue';
import issueBoardFilter 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';
import groupLabelsQuery from '../graphql/group_labels.query.graphql';
import groupUsersQuery from '../graphql/group_members.query.graphql';
export default {
i18n: {
......@@ -17,9 +16,24 @@ export default {
isNot: __('is not'),
},
components: { BoardFilteredSearch },
inject: ['fullPath'],
props: {
fullPath: {
type: String,
required: true,
},
boardType: {
type: String,
required: true,
},
},
computed: {
tokens() {
const { fetchLabels, fetchAuthors } = issueBoardFilter(
this.$apollo,
this.fullPath,
this.boardType,
);
const { label, is, isNot, author } = this.$options.i18n;
return [
{
......@@ -32,9 +46,9 @@ export default {
],
token: LabelToken,
unique: false,
// eslint-disable-next-line @gitlab/require-i18n-strings
defaultLabels: [{ value: 'No label', text: __('No label') }],
fetchLabels: this.fetchLabels,
symbol: '~',
defaultLabels: [{ value: __('No label'), text: __('No label') }],
fetchLabels,
},
{
icon: 'pencil',
......@@ -47,37 +61,13 @@ export default {
symbol: '@',
token: AuthorToken,
unique: true,
fetchAuthors: this.fetchAuthors,
fetchAuthors,
preloadedAuthors: this.preloadedAuthors(),
},
];
},
},
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() {
return gon?.current_user_id
? [
......
......@@ -12,7 +12,7 @@ export default (apolloProvider) => {
const initialFilterParams = {
...convertObjectPropsToCamelCase(rawFilterParams, {}),
};
const { fullPath } = el.dataset;
if (!el) {
return null;
}
......@@ -21,10 +21,12 @@ export default (apolloProvider) => {
el,
provide: {
initialFilterParams,
fullPath,
},
store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094
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 EpicFilteredSearch from 'ee_component/boards/components/epic_filtered_search.vue';
import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue';
import issueBoardFilters from '~/boards/issue_board_filters';
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';
describe('EpicFilteredSearch', () => {
let wrapper;
let store;
const { fetchAuthors, fetchLabels } = issueBoardFilters({}, '', 'group');
const createComponent = ({ initialFilterParams = {} } = {}) => {
wrapper = shallowMount(EpicFilteredSearch, {
provide: { initialFilterParams, fullPath: '' },
store,
provide: { initialFilterParams },
props: {
fullPath: '',
boardType: '',
},
});
};
......@@ -48,8 +52,8 @@ describe('EpicFilteredSearch', () => {
],
token: LabelToken,
unique: false,
defaultLabels: [{ value: 'No label', text: 'No label' }],
fetchLabels: wrapper.vm.fetchLabels,
symbol: '~',
fetchLabels,
},
{
icon: 'pencil',
......@@ -62,13 +66,13 @@ describe('EpicFilteredSearch', () => {
symbol: '@',
token: AuthorToken,
unique: true,
fetchAuthors: wrapper.vm.fetchAuthors,
fetchAuthors,
preloadedAuthors: [
{ 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';
import '~/boards/models/list';
import { ListType } from '~/boards/constants';
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 = {
id: 1,
......@@ -526,3 +529,44 @@ export const mockMoveData = {
originalIssue: { foo: 'bar' },
...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|
# 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(issue_boards_filtered_search: false)
# 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.
# 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