diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue index 142438bec216f15375d3b0d7cd9dd8e44ffa8e03..d443f5b4b1daf7318385738a2622026c7da28b02 100644 --- a/app/assets/javascripts/issues_list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue @@ -37,6 +37,7 @@ import { __ } from '~/locale'; import { DEFAULT_NONE_ANY } 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'; import IterationToken from '~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; @@ -87,6 +88,9 @@ export default { exportCsvPath: { default: '', }, + groupEpicsPath: { + default: '', + }, hasBlockedIssuesFeature: { default: false, }, @@ -241,6 +245,17 @@ export default { }); } + if (this.groupEpicsPath) { + tokens.push({ + type: 'epic_id', + title: __('Epic'), + icon: 'epic', + token: EpicToken, + unique: true, + fetchEpics: this.fetchEpics, + }); + } + if (this.hasIssueWeightsFeature) { tokens.push({ type: 'weight', @@ -306,6 +321,16 @@ export default { fetchEmojis(search) { return this.fetchWithCache(this.autocompleteAwardEmojisPath, 'emojis', 'name', search); }, + async fetchEpics(search) { + const epics = await this.fetchWithCache(this.groupEpicsPath, 'epics'); + if (!search) { + return epics.slice(0, MAX_LIST_SIZE); + } + const number = Number(search); + return Number.isNaN(number) + ? fuzzaldrinPlus.filter(epics, search, { key: 'title' }) + : epics.filter((epic) => epic.id === number); + }, fetchLabels(search) { return this.fetchWithCache(this.projectLabelsPath, 'labels', 'title', search); }, diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js index 3b01d0df523be6f0628f66f8eac170ad6a91779d..c0441758558c9fea01677667c80c93133513aecd 100644 --- a/app/assets/javascripts/issues_list/constants.js +++ b/app/assets/javascripts/issues_list/constants.js @@ -324,6 +324,26 @@ export const filters = { }, }, }, + epic_id: { + apiParam: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'epic_id', + [SPECIAL_FILTER]: 'epic_id', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[epic_id]', + }, + }, + urlParam: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'epic_id', + [SPECIAL_FILTER]: 'epic_id', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[epic_id]', + }, + }, + }, weight: { apiParam: { [OPERATOR_IS]: { diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js index d543643b00333e9d1e93daf1e31e55bc07d6c20d..c4bd62bce5956d406ac31c7fbcc8191805dff56d 100644 --- a/app/assets/javascripts/issues_list/index.js +++ b/app/assets/javascripts/issues_list/index.js @@ -85,6 +85,7 @@ export function mountIssuesListApp() { emptyStateSvgPath, endpoint, exportCsvPath, + groupEpicsPath, hasBlockedIssuesFeature, hasIssuableHealthStatusFeature, hasIssues, @@ -121,6 +122,7 @@ export function mountIssuesListApp() { canBulkUpdate: parseBoolean(canBulkUpdate), emptyStateSvgPath, endpoint, + groupEpicsPath, hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature), hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), hasIssues: parseBoolean(hasIssues), diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue index 101c7150c55f34de473b4342ae45f38de584049f..1450807b11dc13189beea9e47334f59fb5fb5d29 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue @@ -1,15 +1,18 @@ @@ -115,17 +89,25 @@ export default { @input="searchEpics" > diff --git a/ee/app/assets/javascripts/roadmap/mixins/filtered_search_mixin.js b/ee/app/assets/javascripts/roadmap/mixins/filtered_search_mixin.js index 97bc2365657f8c8556c7f8e7146a155c4d07cc33..343894320baa65a1fac470203752aedd1ae088d6 100644 --- a/ee/app/assets/javascripts/roadmap/mixins/filtered_search_mixin.js +++ b/ee/app/assets/javascripts/roadmap/mixins/filtered_search_mixin.js @@ -2,6 +2,7 @@ import { GlFilteredSearchToken } from '@gitlab/ui'; import Api from '~/api'; import axios from '~/lib/utils/axios_utils'; +import { joinPaths } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; @@ -120,13 +121,13 @@ export default { symbol: '&', token: EpicToken, operators: FilterTokenOperators, + idProperty: 'iid', + defaultEpics: [], fetchEpics: (search = '') => { - return axios.get(this.listEpicsPath, { params: { search } }).then(({ data }) => { - return { data }; - }); - }, - fetchSingleEpic: (iid) => { - return axios.get(`${this.listEpicsPath}/${iid}`).then(({ data }) => ({ data })); + const number = Number(search); + return !search || Number.isNaN(number) + ? axios.get(this.listEpicsPath, { params: { search } }) + : axios.get(joinPaths(this.listEpicsPath, search)).then(({ data }) => [data]); }, }, ]; @@ -242,7 +243,7 @@ export default { filterParams.myReactionEmoji = filter.value.data; break; case 'epic_iid': - filterParams.epicIid = Number(filter.value.data.split('::&')[1]); + filterParams.epicIid = filter.value.data; break; case 'filtered-search-term': if (filter.value.data) plainText.push(filter.value.data); diff --git a/ee/app/helpers/ee/issues_helper.rb b/ee/app/helpers/ee/issues_helper.rb index 4ef4b4618fde46d725372fb1bdc33f22cc9714ec..016df5815093076ce502d3fc4d27ae6847176eda 100644 --- a/ee/app/helpers/ee/issues_helper.rb +++ b/ee/app/helpers/ee/issues_helper.rb @@ -77,6 +77,10 @@ def issues_list_data(project, current_user, finder) has_issue_weights_feature: project.feature_available?(:issue_weights).to_s ) + if project.feature_available?(:epics) && project.group + data[:group_epics_path] = group_epics_path(project.group, format: :json) + end + if project.feature_available?(:iterations) data[:project_iterations_path] = api_v4_projects_iterations_path(id: project.id) end diff --git a/ee/spec/frontend/roadmap/components/roadmap_filters_spec.js b/ee/spec/frontend/roadmap/components/roadmap_filters_spec.js index f18209e466f4f95043ca656632b56a99cb0db7b6..d241ec8690c1262254dc7f237564689509cde711 100644 --- a/ee/spec/frontend/roadmap/components/roadmap_filters_spec.js +++ b/ee/spec/frontend/roadmap/components/roadmap_filters_spec.js @@ -216,8 +216,9 @@ describe('RoadmapFilters', () => { symbol: '&', token: EpicToken, operators, + idProperty: 'iid', + defaultEpics: [], fetchEpics: expect.any(Function), - fetchSingleEpic: expect.any(Function), }, ]; diff --git a/ee/spec/helpers/ee/issues_helper_spec.rb b/ee/spec/helpers/ee/issues_helper_spec.rb index 149225120e634bce6329fb487164f966b7fb1f9d..34e540d9f4efce76f07fd20b0848daf39c2a6cf4 100644 --- a/ee/spec/helpers/ee/issues_helper_spec.rb +++ b/ee/spec/helpers/ee/issues_helper_spec.rb @@ -137,7 +137,7 @@ context 'when features are enabled' do before do - stub_licensed_features(iterations: true, issue_weights: true, issuable_health_status: true, blocked_issues: true) + stub_licensed_features(epics: true, iterations: true, issue_weights: true, issuable_health_status: true, blocked_issues: true) end it 'returns data with licensed features enabled' do @@ -145,16 +145,25 @@ has_blocked_issues_feature: 'true', has_issuable_health_status_feature: 'true', has_issue_weights_feature: 'true', + group_epics_path: group_epics_path(project.group, format: :json), project_iterations_path: api_v4_projects_iterations_path(id: project.id) } expect(helper.issues_list_data(project, current_user, finder)).to include(expected) end + + context 'when project does not have group' do + let(:project_with_no_group) { create :project } + + it 'does not return group_epics_path' do + expect(helper.issues_list_data(project_with_no_group, current_user, finder)).not_to include(:group_epics_path) + end + end end context 'when features are disabled' do before do - stub_licensed_features(iterations: false, issue_weights: false, issuable_health_status: false, blocked_issues: false) + stub_licensed_features(epics: false, iterations: false, issue_weights: false, issuable_health_status: false, blocked_issues: false) end it 'returns data with licensed features disabled' do @@ -166,6 +175,7 @@ result = helper.issues_list_data(project, current_user, finder) expect(result).to include(expected) + expect(result).not_to include(:group_epics_path) expect(result).not_to include(:project_iterations_path) end end diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js index eede806c42ff54a9cd718d10864fccecc9a442d7..d6a23c4dcffcd52e96c3586e024a3ad96f6f65c9 100644 --- a/spec/frontend/issues_list/mock_data.js +++ b/spec/frontend/issues_list/mock_data.js @@ -16,6 +16,8 @@ export const locationSearch = [ 'confidential=no', 'iteration_title=season:+%234', 'not[iteration_title]=season:+%2320', + 'epic_id=12', + 'not[epic_id]=34', 'weight=1', 'not[weight]=3', ].join('&'); @@ -24,6 +26,7 @@ export const locationSearchWithSpecialValues = [ 'assignee_id=None', 'my_reaction_emoji=None', 'iteration_id=Current', + 'epic_id=None', 'weight=None', ].join('&'); @@ -42,6 +45,8 @@ export const filteredTokens = [ { type: 'confidential', value: { data: 'no', operator: OPERATOR_IS } }, { type: 'iteration', value: { data: 'season: #4', operator: OPERATOR_IS } }, { type: 'iteration', value: { data: 'season: #20', operator: OPERATOR_IS_NOT } }, + { type: 'epic_id', value: { data: '12', operator: OPERATOR_IS } }, + { type: 'epic_id', value: { data: '34', operator: OPERATOR_IS_NOT } }, { type: 'weight', value: { data: '1', operator: OPERATOR_IS } }, { type: 'weight', value: { data: '3', operator: OPERATOR_IS_NOT } }, { type: 'filtered-search-term', value: { data: 'find' } }, @@ -52,6 +57,7 @@ export const filteredTokensWithSpecialValues = [ { type: 'assignee_username', value: { data: 'None', operator: OPERATOR_IS } }, { type: 'my_reaction_emoji', value: { data: 'None', operator: OPERATOR_IS } }, { type: 'iteration', value: { data: 'Current', operator: OPERATOR_IS } }, + { type: 'epic_id', value: { data: 'None', operator: OPERATOR_IS } }, { type: 'weight', value: { data: 'None', operator: OPERATOR_IS } }, ]; @@ -68,6 +74,8 @@ export const apiParams = { confidential: 'no', iteration_title: 'season: #4', 'not[iteration_title]': 'season: #20', + epic_id: '12', + 'not[epic_id]': '34', weight: '1', 'not[weight]': '3', }; @@ -76,6 +84,7 @@ export const apiParamsWithSpecialValues = { assignee_id: 'None', my_reaction_emoji: 'None', iteration_id: 'Current', + epic_id: 'None', weight: 'None', }; @@ -92,6 +101,8 @@ export const urlParams = { confidential: ['no'], iteration_title: ['season: #4'], 'not[iteration_title]': ['season: #20'], + epic_id: ['12'], + 'not[epic_id]': ['34'], weight: ['1'], 'not[weight]': ['3'], }; @@ -100,5 +111,6 @@ export const urlParamsWithSpecialValues = { assignee_id: ['None'], my_reaction_emoji: ['None'], iteration_id: ['Current'], + epic_id: ['None'], weight: ['None'], }; diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js index c49a1ab68b1e0f3ea6dc242aa09e1f984b74805a..3b5d0dba195cc9cf5e9118b4e72b04063f3922b1 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -139,8 +139,8 @@ export const mockEpicToken = { symbol: '&', token: EpicToken, operators: [{ value: '=', description: 'is', default: 'true' }], + idProperty: 'iid', fetchEpics: () => Promise.resolve({ data: mockEpics }), - fetchSingleEpic: () => Promise.resolve({ data: mockEpics[0] }), }; export const mockReactionEmojiToken = { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js index 0c3f9e1363f2a763bb4955289d975b1837445a42..addc058f65810fc2f4976a0407cf3980116bdd4d 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js @@ -68,21 +68,6 @@ describe('EpicToken', () => { await wrapper.vm.$nextTick(); }); - describe('currentValue', () => { - it.each` - data | id - ${`${mockEpics[0].title}::&${mockEpics[0].iid}`} | ${mockEpics[0].iid} - ${mockEpics[0].iid} | ${mockEpics[0].iid} - ${'foobar'} | ${'foobar'} - `('$data returns $id', async ({ data, id }) => { - wrapper.setProps({ value: { data } }); - - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.currentValue).toBe(id); - }); - }); - describe('activeEpic', () => { it('returns object for currently present `value.data`', async () => { wrapper.setProps({ @@ -140,20 +125,6 @@ describe('EpicToken', () => { expect(wrapper.vm.loading).toBe(false); }); }); - - describe('fetchSingleEpic', () => { - it('calls `config.fetchSingleEpic` with provided iid param', async () => { - jest.spyOn(wrapper.vm.config, 'fetchSingleEpic'); - - wrapper.vm.fetchSingleEpic(1); - - expect(wrapper.vm.config.fetchSingleEpic).toHaveBeenCalledWith(1); - - await waitForPromises(); - - expect(wrapper.vm.epics).toEqual([mockEpics[0]]); - }); - }); }); describe('template', () => {