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"
>
- {{ activeEpic ? getEpicValue(activeEpic) : $options.stripQuotes(inputValue) }}
+ {{ activeEpic ? getEpicDisplayText(activeEpic) : inputValue }}
+
+ {{ epic.text }}
+
+
- {{ epic.title }}
+ {{ epic.title }}
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', () => {