diff --git a/app/assets/javascripts/work_items/list/components/work_items_list_app.vue b/app/assets/javascripts/work_items/list/components/work_items_list_app.vue index 8cbd6940404f4c911b69b982d5f093511bca5288..0899d4073d84ae52a1d1503d938d82e91280aba8 100644 --- a/app/assets/javascripts/work_items/list/components/work_items_list_app.vue +++ b/app/assets/javascripts/work_items/list/components/work_items_list_app.vue @@ -5,7 +5,15 @@ import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_st import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue'; import { TYPENAME_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { STATUS_ALL, STATUS_CLOSED, STATUS_OPEN } from '~/issues/constants'; +import { + STATUS_ALL, + STATUS_CLOSED, + STATUS_OPEN, + WORKSPACE_GROUP, + WORKSPACE_PROJECT, +} from '~/issues/constants'; +import { defaultTypeTokenOptions } from '~/issues/list/constants'; +import searchLabelsQuery from '~/issues/list/queries/search_labels.query.graphql'; import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql'; import { convertToApiParams, @@ -13,22 +21,37 @@ import { deriveSortKey, getInitialPageParams, } from '~/issues/list/utils'; +import { fetchPolicies } from '~/lib/graphql'; import { scrollUp } from '~/lib/utils/scroll_utils'; import { __, s__ } from '~/locale'; import { OPERATORS_IS, + OPERATORS_IS_NOT_OR, + TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, + TOKEN_TITLE_LABEL, + TOKEN_TITLE_MILESTONE, TOKEN_TITLE_SEARCH_WITHIN, + TOKEN_TITLE_TYPE, + TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, TOKEN_TYPE_SEARCH_WITHIN, + TOKEN_TYPE_TYPE, } from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { DEFAULT_PAGE_SIZE, issuableListTabs } from '~/vue_shared/issuable/list/constants'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { STATE_CLOSED } from '../../constants'; import { sortOptions, urlSortParams } from '../constants'; import getWorkItemsQuery from '../queries/get_work_items.query.graphql'; const UserToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'); +const LabelToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'); +const MilestoneToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'); export default { issuableListTabs, @@ -39,7 +62,8 @@ export default { IssueCardStatistics, IssueCardTimeInfo, }, - inject: ['fullPath', 'initialSort', 'isSignedIn', 'workItemType'], + mixins: [glFeatureFlagMixin()], + inject: ['fullPath', 'initialSort', 'isGroup', 'isSignedIn', 'workItemType'], props: { eeCreatedWorkItemsCount: { type: Number, @@ -73,7 +97,7 @@ export default { search: this.searchQuery, ...this.apiFilterParams, ...this.pageParams, - types: [this.workItemType], + types: this.apiFilterParams.types || [this.workItemType], }; }, update(data) { @@ -115,6 +139,9 @@ export default { isOpenTab() { return this.state === STATUS_OPEN; }, + namespace() { + return this.isGroup ? WORKSPACE_GROUP : WORKSPACE_PROJECT; + }, searchQuery() { return convertToSearchQuery(this.filterTokens); }, @@ -130,7 +157,20 @@ export default { }); } - return [ + const tokens = [ + { + type: TOKEN_TYPE_ASSIGNEE, + title: TOKEN_TITLE_ASSIGNEE, + icon: 'user', + token: UserToken, + dataType: 'user', + operators: OPERATORS_IS_NOT_OR, + fullPath: this.fullPath, + isProject: !this.isGroup, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-assignee`, + preloadedUsers, + multiSelect: this.glFeatures.groupMultiSelectTokens, + }, { type: TOKEN_TYPE_AUTHOR, title: TOKEN_TITLE_AUTHOR, @@ -138,11 +178,33 @@ export default { token: UserToken, dataType: 'user', defaultUsers: [], - operators: OPERATORS_IS, + operators: OPERATORS_IS_NOT_OR, fullPath: this.fullPath, - isProject: false, + isProject: !this.isGroup, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-author`, preloadedUsers, + multiSelect: this.glFeatures.groupMultiSelectTokens, + }, + { + type: TOKEN_TYPE_LABEL, + title: TOKEN_TITLE_LABEL, + icon: 'labels', + token: LabelToken, + operators: OPERATORS_IS_NOT_OR, + fetchLabels: this.fetchLabels, + fetchLatestLabels: this.glFeatures.frontendCaching ? this.fetchLatestLabels : null, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-label`, + multiSelect: this.glFeatures.groupMultiSelectTokens, + }, + { + type: TOKEN_TYPE_MILESTONE, + title: TOKEN_TITLE_MILESTONE, + icon: 'milestone', + token: MilestoneToken, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-milestone`, + shouldSkipSort: true, + fullPath: this.fullPath, + isProject: !this.isGroup, }, { type: TOKEN_TYPE_SEARCH_WITHIN, @@ -157,6 +219,21 @@ export default { ], }, ]; + + if (!this.workItemType) { + tokens.push({ + type: TOKEN_TYPE_TYPE, + title: TOKEN_TITLE_TYPE, + icon: 'issues', + token: GlFilteredSearchToken, + operators: OPERATORS_IS, + options: defaultTypeTokenOptions, + }); + } + + tokens.sort((a, b) => a.title.localeCompare(b.title)); + + return tokens; }, showPaginationControls() { return !this.isLoading && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage); @@ -175,6 +252,26 @@ export default { }, }, methods: { + fetchLabelsWithFetchPolicy(search, fetchPolicy = fetchPolicies.CACHE_FIRST) { + return this.$apollo + .query({ + query: searchLabelsQuery, + variables: { fullPath: this.fullPath, search, isProject: !this.isGroup }, + fetchPolicy, + }) + .then(({ data }) => { + // TODO remove once we can search by title-only on the backend + // https://gitlab.com/gitlab-org/gitlab/-/issues/346353 + const labels = data[this.namespace]?.labels.nodes; + return labels.filter((label) => label.title.toLowerCase().includes(search.toLowerCase())); + }); + }, + fetchLabels(search) { + return this.fetchLabelsWithFetchPolicy(search); + }, + fetchLatestLabels(search) { + return this.fetchLabelsWithFetchPolicy(search, fetchPolicies.NETWORK_ONLY); + }, getStatus(issue) { return issue.state === STATE_CLOSED ? __('Closed') : undefined; }, @@ -256,6 +353,7 @@ export default { namespace="work-items" recent-searches-storage-key="issues" :search-tokens="searchTokens" + show-filtered-search-friendly-text :show-page-size-selector="showPageSizeSelector" :show-pagination-controls="showPaginationControls" show-work-item-type-icon diff --git a/app/assets/javascripts/work_items/list/index.js b/app/assets/javascripts/work_items/list/index.js index dc146667a60960e6073900b3b7e094bbb24cc8fb..0648347d6a1b1998180f73f48baedd558e29da54 100644 --- a/app/assets/javascripts/work_items/list/index.js +++ b/app/assets/javascripts/work_items/list/index.js @@ -18,6 +18,7 @@ export const mountWorkItemsListApp = () => { hasEpicsFeature, hasIssuableHealthStatusFeature, hasIssueWeightsFeature, + hasScopedLabelsFeature, initialSort, isSignedIn, showNewIssueLink, @@ -37,6 +38,7 @@ export const mountWorkItemsListApp = () => { hasEpicsFeature: parseBoolean(hasEpicsFeature), hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), + hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature), initialSort, isSignedIn: parseBoolean(isSignedIn), isGroup: true, diff --git a/app/assets/javascripts/work_items/list/queries/get_work_items.query.graphql b/app/assets/javascripts/work_items/list/queries/get_work_items.query.graphql index 3d08e9a9216dc2a1cc34df182932400125c4851b..2ac0762315ce81f6223387b0c3f753496b744230 100644 --- a/app/assets/javascripts/work_items/list/queries/get_work_items.query.graphql +++ b/app/assets/javascripts/work_items/list/queries/get_work_items.query.graphql @@ -6,17 +6,37 @@ query getWorkItems( $search: String $sort: WorkItemSort $state: IssuableState + $assigneeWildcardId: AssigneeWildcardId + $assigneeUsernames: [String!] $authorUsername: String + $labelName: [String!] + $milestoneTitle: [String!] + $milestoneWildcardId: MilestoneWildcardId + $types: [IssueType!] $in: [IssuableSearchableField!] + $not: NegatedWorkItemFilterInput + $or: UnionedWorkItemFilterInput $afterCursor: String $beforeCursor: String $firstPageSize: Int $lastPageSize: Int - $types: [IssueType!] = null ) { group(fullPath: $fullPath) { id - workItemStateCounts(includeDescendants: true, sort: $sort, state: $state, types: $types) { + workItemStateCounts( + includeDescendants: true + sort: $sort + state: $state + assigneeUsernames: $assigneeUsernames + assigneeWildcardId: $assigneeWildcardId + authorUsername: $authorUsername + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + types: $types + not: $not + or: $or + ) { all closed opened @@ -26,13 +46,20 @@ query getWorkItems( search: $search sort: $sort state: $state + assigneeUsernames: $assigneeUsernames + assigneeWildcardId: $assigneeWildcardId authorUsername: $authorUsername + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + types: $types in: $in + not: $not + or: $or after: $afterCursor before: $beforeCursor first: $firstPageSize last: $lastPageSize - types: $types ) { pageInfo { ...PageInfo diff --git a/ee/app/helpers/ee/work_items_helper.rb b/ee/app/helpers/ee/work_items_helper.rb index 5c8c7ed48caeb158e37e36e3c5d8213c9e3cc468..9c4b87821d35d4da03b6585815c1e222a6bf0eec 100644 --- a/ee/app/helpers/ee/work_items_helper.rb +++ b/ee/app/helpers/ee/work_items_helper.rb @@ -30,7 +30,7 @@ def work_items_list_data(group, current_user) has_epics_feature: group.licensed_feature_available?(:epics).to_s, has_issuable_health_status_feature: group.licensed_feature_available?(:issuable_health_status).to_s, has_issue_weights_feature: group.licensed_feature_available?(:issue_weights).to_s, - has_epics_color_feature: group.licensed_feature_available?(:epic_colors).to_s + has_scoped_labels_feature: group.licensed_feature_available?(:scoped_labels).to_s ) end end diff --git a/ee/spec/features/groups/work_items/work_items_list_filters_spec.rb b/ee/spec/features/groups/work_items/work_items_list_filters_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..5798e2bad32c3d74ecaa6410e32099e17fc36156 --- /dev/null +++ b/ee/spec/features/groups/work_items/work_items_list_filters_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Work items list filters', :js, feature_category: :team_planning do + include FilteredSearchHelpers + + let_it_be(:user) { create(:user) } + + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :public, group: group, developers: user) } + + let_it_be(:incident) do + create(:incident, project: project) + end + + let_it_be(:issue) do + create(:issue, project: project) + end + + let_it_be(:task) do + create(:work_item, :task, project: project) + end + + let_it_be(:test_case) do + create(:quality_test_case, project: project) + end + + context 'for signed in user' do + before do + sign_in(user) + visit group_work_items_path(group) + end + + describe 'type' do + it 'filters', :aggregate_failures do + select_tokens 'Type', 'Issue', submit: true + + expect(page).to have_css('.issue', count: 1) + expect(page).to have_link(issue.title) + + click_button 'Clear' + + select_tokens 'Type', 'Incident', submit: true + + expect(page).to have_css('.issue', count: 1) + expect(page).to have_link(incident.title) + + click_button 'Clear' + + select_tokens 'Type', 'Test case', submit: true + + expect(page).to have_css('.issue', count: 1) + expect(page).to have_link(test_case.title) + + click_button 'Clear' + + select_tokens 'Type', 'Task', submit: true + + expect(page).to have_css('.issue', count: 1) + expect(page).to have_link(task.title) + end + end + end +end diff --git a/spec/features/projects/work_items/work_items_list_filters_spec.rb b/spec/features/projects/work_items/work_items_list_filters_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..64b409cc9e93a739c0aa51cd5773bebba6c0ff84 --- /dev/null +++ b/spec/features/projects/work_items/work_items_list_filters_spec.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Work items list filters', :js, feature_category: :team_planning do + include FilteredSearchHelpers + + let_it_be(:user1) { create(:user) } + let_it_be(:user2) { create(:user) } + + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :public, group: group, developers: [user1, user2]) } + + let_it_be(:label1) { create(:label, project: project) } + let_it_be(:label2) { create(:label, project: project) } + + let_it_be(:milestone1) { create(:milestone, group: group, start_date: 5.days.ago, due_date: 13.days.from_now) } + let_it_be(:milestone2) { create(:milestone, group: group, start_date: 2.days.from_now, due_date: 9.days.from_now) } + + let_it_be(:incident) do + create(:incident, project: project, assignees: [user1], author: user1, labels: [label1], description: 'aaa') + end + + let_it_be(:issue) do + create(:issue, project: project, author: user1, labels: [label1, label2], milestone: milestone1, title: 'eee') + end + + let_it_be(:task) do + create(:work_item, :task, project: project, assignees: [user2], author: user2, milestone: milestone2) + end + + context 'for signed in user' do + before do + sign_in(user1) + visit group_work_items_path(group) + end + + describe 'assignees' do + it 'filters', :aggregate_failures do + select_tokens 'Assignee', '=', user1.username, submit: true + + expect(page).to have_css('.issue', count: 1) + expect(page).to have_link(incident.title) + + click_button 'Clear' + + select_tokens 'Assignee', '!=', user1.username, submit: true + + expect(page).to have_css('.issue', count: 2) + expect(page).to have_link(issue.title) + expect(page).to have_link(task.title) + + click_button 'Clear' + + select_tokens 'Assignee', '||', user1.username, 'Assignee', '||', user2.username, submit: true + + expect(page).to have_css('.issue', count: 2) + expect(page).to have_link(incident.title) + expect(page).to have_link(task.title) + + click_button 'Clear' + + select_tokens 'Assignee', '=', 'None', submit: true + + expect(page).to have_css('.issue', count: 1) + expect(page).to have_link(issue.title) + + click_button 'Clear' + + select_tokens 'Assignee', '=', 'Any', submit: true + + expect(page).to have_css('.issue', count: 2) + expect(page).to have_link(incident.title) + expect(page).to have_link(task.title) + end + end + + describe 'author' do + it 'filters', :aggregate_failures do + select_tokens 'Author', '=', user1.username, submit: true + + expect(page).to have_css('.issue', count: 2) + expect(page).to have_link(incident.title) + expect(page).to have_link(issue.title) + + click_button 'Clear' + + select_tokens 'Author', '!=', user1.username, submit: true + + expect(page).to have_css('.issue', count: 1) + expect(page).to have_link(task.title) + + click_button 'Clear' + + select_tokens 'Author', '||', user1.username, 'Author', '||', user2.username, submit: true + + expect(page).to have_css('.issue', count: 3) + expect(page).to have_link(incident.title) + expect(page).to have_link(issue.title) + expect(page).to have_link(task.title) + end + end + + describe 'labels' do + it 'filters', :aggregate_failures do + select_tokens 'Label', '=', label1.title, submit: true + + expect(page).to have_css('.issue', count: 2) + expect(page).to have_link(incident.title) + expect(page).to have_link(issue.title) + + click_button 'Clear' + + select_tokens 'Label', '!=', label1.title, submit: true + + expect(page).to have_css('.issue', count: 1) + expect(page).to have_link(task.title) + + click_button 'Clear' + + select_tokens 'Label', '||', label1.title, 'Label', '||', label2.title, submit: true + + expect(page).to have_css('.issue', count: 2) + expect(page).to have_link(incident.title) + expect(page).to have_link(issue.title) + + click_button 'Clear' + + select_tokens 'Label', '=', 'None', submit: true + + expect(page).to have_css('.issue', count: 1) + expect(page).to have_link(task.title) + + click_button 'Clear' + + select_tokens 'Label', '=', 'Any', submit: true + + expect(page).to have_css('.issue', count: 2) + expect(page).to have_link(incident.title) + expect(page).to have_link(issue.title) + end + end + + describe 'milestones' do + it 'filters', :aggregate_failures do + select_tokens 'Milestone', '=', milestone1.title, submit: true + + expect(page).to have_css('.issue', count: 1) + expect(page).to have_link(issue.title) + + click_button 'Clear' + + select_tokens 'Milestone', '!=', milestone1.title, submit: true + + expect(page).to have_css('.issue', count: 2) + expect(page).to have_link(incident.title) + expect(page).to have_link(task.title) + + click_button 'Clear' + + select_tokens 'Milestone', '=', 'None', submit: true + + expect(page).to have_css('.issue', count: 1) + expect(page).to have_link(incident.title) + + click_button 'Clear' + + select_tokens 'Milestone', '=', 'Any', submit: true + + expect(page).to have_css('.issue', count: 2) + expect(page).to have_link(issue.title) + expect(page).to have_link(task.title) + + click_button 'Clear' + + select_tokens 'Milestone', '=', 'Upcoming', submit: true + + expect(page).to have_css('.issue', count: 1) + expect(page).to have_link(task.title) + + click_button 'Clear' + + select_tokens 'Milestone', '=', 'Started', submit: true + + expect(page).to have_css('.issue', count: 1) + expect(page).to have_link(issue.title) + end + end + + describe 'search within' do + it 'filters', :aggregate_failures do + select_tokens 'Search Within', 'Titles' + send_keys 'eee', :enter, :enter + + expect(page).to have_css('.issue', count: 1) + expect(page).to have_link(issue.title) + + click_button 'Clear' + + select_tokens 'Search Within', 'Descriptions' + send_keys 'aaa', :enter, :enter + + expect(page).to have_css('.issue', count: 1) + expect(page).to have_link(incident.title) + end + end + end +end diff --git a/spec/frontend/work_items/list/components/work_items_list_app_spec.js b/spec/frontend/work_items/list/components/work_items_list_app_spec.js index 740cc378083168b89fe8b0675727d739f90d8457..43c175ab104b52d273a0ce451257bab7e9413b64 100644 --- a/spec/frontend/work_items/list/components/work_items_list_app_spec.js +++ b/spec/frontend/work_items/list/components/work_items_list_app_spec.js @@ -21,8 +21,12 @@ import { scrollUp } from '~/lib/utils/scroll_utils'; import { FILTERED_SEARCH_TERM, OPERATOR_IS, + TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, TOKEN_TYPE_SEARCH_WITHIN, + TOKEN_TYPE_TYPE, } from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import WorkItemsListApp from '~/work_items/list/components/work_items_list_app.vue'; @@ -58,6 +62,7 @@ describe('WorkItemsListApp component', () => { provide: { fullPath: 'full/path', initialSort: CREATED_DESC, + isGroup: true, isSignedIn: true, workItemType: null, ...provide, @@ -200,28 +205,47 @@ describe('WorkItemsListApp component', () => { username: 'root', avatar_url: 'avatar/url', }; + const preloadedUsers = [ + { ...mockCurrentUser, id: convertToGraphQLId(TYPENAME_USER, mockCurrentUser.id) }, + ]; - beforeEach(async () => { + beforeEach(() => { window.gon = { current_user_id: mockCurrentUser.id, current_user_fullname: mockCurrentUser.name, current_username: mockCurrentUser.username, current_user_avatar_url: mockCurrentUser.avatar_url, }; - mountComponent(); - await waitForPromises(); }); - it('renders all tokens', () => { - const preloadedUsers = [ - { ...mockCurrentUser, id: convertToGraphQLId(TYPENAME_USER, mockCurrentUser.id) }, - ]; + it('renders all tokens', async () => { + mountComponent(); + await waitForPromises(); expect(findIssuableList().props('searchTokens')).toMatchObject([ + { type: TOKEN_TYPE_ASSIGNEE, preloadedUsers }, { type: TOKEN_TYPE_AUTHOR, preloadedUsers }, + { type: TOKEN_TYPE_LABEL }, + { type: TOKEN_TYPE_MILESTONE }, { type: TOKEN_TYPE_SEARCH_WITHIN }, + { type: TOKEN_TYPE_TYPE }, ]); }); + + describe('when workItemType is defined', () => { + it('renders all tokens except "Type"', async () => { + mountComponent({ provide: { workItemType: 'EPIC' } }); + await waitForPromises(); + + expect(findIssuableList().props('searchTokens')).toMatchObject([ + { type: TOKEN_TYPE_ASSIGNEE, preloadedUsers }, + { type: TOKEN_TYPE_AUTHOR, preloadedUsers }, + { type: TOKEN_TYPE_LABEL }, + { type: TOKEN_TYPE_MILESTONE }, + { type: TOKEN_TYPE_SEARCH_WITHIN }, + ]); + }); + }); }); describe('events', () => {