Commit 3e056352 authored by Bob Van Landuyt's avatar Bob Van Landuyt

Merge branch '322755-add-epic-token-to-issues-page-refactor' into 'master'

Add epic filter token to issues page refactor

See merge request gitlab-org/gitlab!61054
parents eea0e99d ce4472e7
...@@ -37,6 +37,7 @@ import { __ } from '~/locale'; ...@@ -37,6 +37,7 @@ import { __ } from '~/locale';
import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; 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 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 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 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 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'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
...@@ -87,6 +88,9 @@ export default { ...@@ -87,6 +88,9 @@ export default {
exportCsvPath: { exportCsvPath: {
default: '', default: '',
}, },
groupEpicsPath: {
default: '',
},
hasBlockedIssuesFeature: { hasBlockedIssuesFeature: {
default: false, default: false,
}, },
...@@ -241,6 +245,17 @@ export default { ...@@ -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) { if (this.hasIssueWeightsFeature) {
tokens.push({ tokens.push({
type: 'weight', type: 'weight',
...@@ -306,6 +321,16 @@ export default { ...@@ -306,6 +321,16 @@ export default {
fetchEmojis(search) { fetchEmojis(search) {
return this.fetchWithCache(this.autocompleteAwardEmojisPath, 'emojis', 'name', 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) { fetchLabels(search) {
return this.fetchWithCache(this.projectLabelsPath, 'labels', 'title', search); return this.fetchWithCache(this.projectLabelsPath, 'labels', 'title', search);
}, },
......
...@@ -324,6 +324,26 @@ export const filters = { ...@@ -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: { weight: {
apiParam: { apiParam: {
[OPERATOR_IS]: { [OPERATOR_IS]: {
......
...@@ -85,6 +85,7 @@ export function mountIssuesListApp() { ...@@ -85,6 +85,7 @@ export function mountIssuesListApp() {
emptyStateSvgPath, emptyStateSvgPath,
endpoint, endpoint,
exportCsvPath, exportCsvPath,
groupEpicsPath,
hasBlockedIssuesFeature, hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature, hasIssuableHealthStatusFeature,
hasIssues, hasIssues,
...@@ -121,6 +122,7 @@ export function mountIssuesListApp() { ...@@ -121,6 +122,7 @@ export function mountIssuesListApp() {
canBulkUpdate: parseBoolean(canBulkUpdate), canBulkUpdate: parseBoolean(canBulkUpdate),
emptyStateSvgPath, emptyStateSvgPath,
endpoint, endpoint,
groupEpicsPath,
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature), hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
hasIssues: parseBoolean(hasIssues), hasIssues: parseBoolean(hasIssues),
......
<script> <script>
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import {
GlDropdownDivider,
GlFilteredSearchSuggestion,
GlFilteredSearchToken,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { isNumeric } from '~/lib/utils/number_utils';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { DEBOUNCE_DELAY } from '../constants'; import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants';
import { stripQuotes } from '../filtered_search_utils';
export default { export default {
components: { components: {
GlDropdownDivider,
GlFilteredSearchToken, GlFilteredSearchToken,
GlFilteredSearchSuggestion, GlFilteredSearchSuggestion,
GlLoadingIcon, GlLoadingIcon,
...@@ -32,29 +35,16 @@ export default { ...@@ -32,29 +35,16 @@ export default {
}, },
computed: { computed: {
currentValue() { currentValue() {
/* return Number(this.value.data);
* When the URL contains the epic_iid, we'd get: '123' },
*/ defaultEpics() {
if (isNumeric(this.value.data)) { return this.config.defaultEpics || DEFAULT_NONE_ANY;
return parseInt(this.value.data, 10); },
} idProperty() {
return this.config.idProperty || 'id';
/*
* When the token is added in current session it'd be: 'Foo::&123'
*/
const id = this.value.data.split('::&')[1];
if (id) {
return parseInt(id, 10);
}
return this.value.data;
}, },
activeEpic() { activeEpic() {
const currentValueIsString = typeof this.currentValue === 'string'; return this.epics.find((epic) => epic[this.idProperty] === this.currentValue);
return this.epics.find(
(epic) => epic[currentValueIsString ? 'title' : 'iid'] === this.currentValue,
);
}, },
}, },
watch: { watch: {
...@@ -72,20 +62,8 @@ export default { ...@@ -72,20 +62,8 @@ export default {
this.loading = true; this.loading = true;
this.config this.config
.fetchEpics(searchTerm) .fetchEpics(searchTerm)
.then(({ data }) => { .then((response) => {
this.epics = data; this.epics = Array.isArray(response) ? response : response.data;
})
.catch(() => createFlash({ message: __('There was a problem fetching epics.') }))
.finally(() => {
this.loading = false;
});
},
fetchSingleEpic(iid) {
this.loading = true;
this.config
.fetchSingleEpic(iid)
.then(({ data }) => {
this.epics = [data];
}) })
.catch(() => createFlash({ message: __('There was a problem fetching epics.') })) .catch(() => createFlash({ message: __('There was a problem fetching epics.') }))
.finally(() => { .finally(() => {
...@@ -93,17 +71,13 @@ export default { ...@@ -93,17 +71,13 @@ export default {
}); });
}, },
searchEpics: debounce(function debouncedSearch({ data }) { searchEpics: debounce(function debouncedSearch({ data }) {
if (isNumeric(data)) { this.fetchEpicsBySearchTerm(data);
return this.fetchSingleEpic(data);
}
return this.fetchEpicsBySearchTerm(data);
}, DEBOUNCE_DELAY), }, DEBOUNCE_DELAY),
getEpicValue(epic) { getEpicDisplayText(epic) {
return `${epic.title}::&${epic.iid}`; return `${epic.title}::&${epic[this.idProperty]}`;
}, },
}, },
stripQuotes,
}; };
</script> </script>
...@@ -115,17 +89,25 @@ export default { ...@@ -115,17 +89,25 @@ export default {
@input="searchEpics" @input="searchEpics"
> >
<template #view="{ inputValue }"> <template #view="{ inputValue }">
<span>{{ activeEpic ? getEpicValue(activeEpic) : $options.stripQuotes(inputValue) }}</span> {{ activeEpic ? getEpicDisplayText(activeEpic) : inputValue }}
</template> </template>
<template #suggestions> <template #suggestions>
<gl-filtered-search-suggestion
v-for="epic in defaultEpics"
:key="epic.value"
:value="epic.value"
>
{{ epic.text }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider v-if="defaultEpics.length" />
<gl-loading-icon v-if="loading" /> <gl-loading-icon v-if="loading" />
<template v-else> <template v-else>
<gl-filtered-search-suggestion <gl-filtered-search-suggestion
v-for="epic in epics" v-for="epic in epics"
:key="epic.id" :key="epic[idProperty]"
:value="getEpicValue(epic)" :value="String(epic[idProperty])"
> >
<div>{{ epic.title }}</div> {{ epic.title }}
</gl-filtered-search-suggestion> </gl-filtered-search-suggestion>
</template> </template>
</template> </template>
......
...@@ -2,6 +2,7 @@ import { GlFilteredSearchToken } from '@gitlab/ui'; ...@@ -2,6 +2,7 @@ import { GlFilteredSearchToken } from '@gitlab/ui';
import Api from '~/api'; import Api from '~/api';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
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';
...@@ -120,13 +121,13 @@ export default { ...@@ -120,13 +121,13 @@ export default {
symbol: '&', symbol: '&',
token: EpicToken, token: EpicToken,
operators: FilterTokenOperators, operators: FilterTokenOperators,
idProperty: 'iid',
defaultEpics: [],
fetchEpics: (search = '') => { fetchEpics: (search = '') => {
return axios.get(this.listEpicsPath, { params: { search } }).then(({ data }) => { const number = Number(search);
return { data }; return !search || Number.isNaN(number)
}); ? axios.get(this.listEpicsPath, { params: { search } })
}, : axios.get(joinPaths(this.listEpicsPath, search)).then(({ data }) => [data]);
fetchSingleEpic: (iid) => {
return axios.get(`${this.listEpicsPath}/${iid}`).then(({ data }) => ({ data }));
}, },
}, },
]; ];
...@@ -242,7 +243,7 @@ export default { ...@@ -242,7 +243,7 @@ export default {
filterParams.myReactionEmoji = filter.value.data; filterParams.myReactionEmoji = filter.value.data;
break; break;
case 'epic_iid': case 'epic_iid':
filterParams.epicIid = Number(filter.value.data.split('::&')[1]); filterParams.epicIid = filter.value.data;
break; break;
case 'filtered-search-term': case 'filtered-search-term':
if (filter.value.data) plainText.push(filter.value.data); if (filter.value.data) plainText.push(filter.value.data);
......
...@@ -77,6 +77,10 @@ module EE ...@@ -77,6 +77,10 @@ module EE
has_issue_weights_feature: project.feature_available?(:issue_weights).to_s 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) if project.feature_available?(:iterations)
data[:project_iterations_path] = api_v4_projects_iterations_path(id: project.id) data[:project_iterations_path] = api_v4_projects_iterations_path(id: project.id)
end end
......
...@@ -216,8 +216,9 @@ describe('RoadmapFilters', () => { ...@@ -216,8 +216,9 @@ describe('RoadmapFilters', () => {
symbol: '&', symbol: '&',
token: EpicToken, token: EpicToken,
operators, operators,
idProperty: 'iid',
defaultEpics: [],
fetchEpics: expect.any(Function), fetchEpics: expect.any(Function),
fetchSingleEpic: expect.any(Function),
}, },
]; ];
......
...@@ -137,7 +137,7 @@ RSpec.describe EE::IssuesHelper do ...@@ -137,7 +137,7 @@ RSpec.describe EE::IssuesHelper do
context 'when features are enabled' do context 'when features are enabled' do
before 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 end
it 'returns data with licensed features enabled' do it 'returns data with licensed features enabled' do
...@@ -145,16 +145,25 @@ RSpec.describe EE::IssuesHelper do ...@@ -145,16 +145,25 @@ RSpec.describe EE::IssuesHelper do
has_blocked_issues_feature: 'true', has_blocked_issues_feature: 'true',
has_issuable_health_status_feature: 'true', has_issuable_health_status_feature: 'true',
has_issue_weights_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) project_iterations_path: api_v4_projects_iterations_path(id: project.id)
} }
expect(helper.issues_list_data(project, current_user, finder)).to include(expected) expect(helper.issues_list_data(project, current_user, finder)).to include(expected)
end 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 end
context 'when features are disabled' do context 'when features are disabled' do
before 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 end
it 'returns data with licensed features disabled' do it 'returns data with licensed features disabled' do
...@@ -166,6 +175,7 @@ RSpec.describe EE::IssuesHelper do ...@@ -166,6 +175,7 @@ RSpec.describe EE::IssuesHelper do
result = helper.issues_list_data(project, current_user, finder) result = helper.issues_list_data(project, current_user, finder)
expect(result).to include(expected) expect(result).to include(expected)
expect(result).not_to include(:group_epics_path)
expect(result).not_to include(:project_iterations_path) expect(result).not_to include(:project_iterations_path)
end end
end end
......
...@@ -16,6 +16,8 @@ export const locationSearch = [ ...@@ -16,6 +16,8 @@ export const locationSearch = [
'confidential=no', 'confidential=no',
'iteration_title=season:+%234', 'iteration_title=season:+%234',
'not[iteration_title]=season:+%2320', 'not[iteration_title]=season:+%2320',
'epic_id=12',
'not[epic_id]=34',
'weight=1', 'weight=1',
'not[weight]=3', 'not[weight]=3',
].join('&'); ].join('&');
...@@ -24,6 +26,7 @@ export const locationSearchWithSpecialValues = [ ...@@ -24,6 +26,7 @@ export const locationSearchWithSpecialValues = [
'assignee_id=None', 'assignee_id=None',
'my_reaction_emoji=None', 'my_reaction_emoji=None',
'iteration_id=Current', 'iteration_id=Current',
'epic_id=None',
'weight=None', 'weight=None',
].join('&'); ].join('&');
...@@ -42,6 +45,8 @@ export const filteredTokens = [ ...@@ -42,6 +45,8 @@ export const filteredTokens = [
{ type: 'confidential', value: { data: 'no', operator: OPERATOR_IS } }, { type: 'confidential', value: { data: 'no', operator: OPERATOR_IS } },
{ type: 'iteration', value: { data: 'season: #4', operator: OPERATOR_IS } }, { type: 'iteration', value: { data: 'season: #4', operator: OPERATOR_IS } },
{ type: 'iteration', value: { data: 'season: #20', operator: OPERATOR_IS_NOT } }, { 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: '1', operator: OPERATOR_IS } },
{ type: 'weight', value: { data: '3', operator: OPERATOR_IS_NOT } }, { type: 'weight', value: { data: '3', operator: OPERATOR_IS_NOT } },
{ type: 'filtered-search-term', value: { data: 'find' } }, { type: 'filtered-search-term', value: { data: 'find' } },
...@@ -52,6 +57,7 @@ export const filteredTokensWithSpecialValues = [ ...@@ -52,6 +57,7 @@ export const filteredTokensWithSpecialValues = [
{ type: 'assignee_username', value: { data: 'None', operator: OPERATOR_IS } }, { type: 'assignee_username', value: { data: 'None', operator: OPERATOR_IS } },
{ type: 'my_reaction_emoji', 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: 'iteration', value: { data: 'Current', operator: OPERATOR_IS } },
{ type: 'epic_id', value: { data: 'None', operator: OPERATOR_IS } },
{ type: 'weight', value: { data: 'None', operator: OPERATOR_IS } }, { type: 'weight', value: { data: 'None', operator: OPERATOR_IS } },
]; ];
...@@ -68,6 +74,8 @@ export const apiParams = { ...@@ -68,6 +74,8 @@ export const apiParams = {
confidential: 'no', confidential: 'no',
iteration_title: 'season: #4', iteration_title: 'season: #4',
'not[iteration_title]': 'season: #20', 'not[iteration_title]': 'season: #20',
epic_id: '12',
'not[epic_id]': '34',
weight: '1', weight: '1',
'not[weight]': '3', 'not[weight]': '3',
}; };
...@@ -76,6 +84,7 @@ export const apiParamsWithSpecialValues = { ...@@ -76,6 +84,7 @@ export const apiParamsWithSpecialValues = {
assignee_id: 'None', assignee_id: 'None',
my_reaction_emoji: 'None', my_reaction_emoji: 'None',
iteration_id: 'Current', iteration_id: 'Current',
epic_id: 'None',
weight: 'None', weight: 'None',
}; };
...@@ -92,6 +101,8 @@ export const urlParams = { ...@@ -92,6 +101,8 @@ export const urlParams = {
confidential: ['no'], confidential: ['no'],
iteration_title: ['season: #4'], iteration_title: ['season: #4'],
'not[iteration_title]': ['season: #20'], 'not[iteration_title]': ['season: #20'],
epic_id: ['12'],
'not[epic_id]': ['34'],
weight: ['1'], weight: ['1'],
'not[weight]': ['3'], 'not[weight]': ['3'],
}; };
...@@ -100,5 +111,6 @@ export const urlParamsWithSpecialValues = { ...@@ -100,5 +111,6 @@ export const urlParamsWithSpecialValues = {
assignee_id: ['None'], assignee_id: ['None'],
my_reaction_emoji: ['None'], my_reaction_emoji: ['None'],
iteration_id: ['Current'], iteration_id: ['Current'],
epic_id: ['None'],
weight: ['None'], weight: ['None'],
}; };
...@@ -139,8 +139,8 @@ export const mockEpicToken = { ...@@ -139,8 +139,8 @@ export const mockEpicToken = {
symbol: '&', symbol: '&',
token: EpicToken, token: EpicToken,
operators: [{ value: '=', description: 'is', default: 'true' }], operators: [{ value: '=', description: 'is', default: 'true' }],
idProperty: 'iid',
fetchEpics: () => Promise.resolve({ data: mockEpics }), fetchEpics: () => Promise.resolve({ data: mockEpics }),
fetchSingleEpic: () => Promise.resolve({ data: mockEpics[0] }),
}; };
export const mockReactionEmojiToken = { export const mockReactionEmojiToken = {
......
...@@ -68,21 +68,6 @@ describe('EpicToken', () => { ...@@ -68,21 +68,6 @@ describe('EpicToken', () => {
await wrapper.vm.$nextTick(); 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', () => { describe('activeEpic', () => {
it('returns object for currently present `value.data`', async () => { it('returns object for currently present `value.data`', async () => {
wrapper.setProps({ wrapper.setProps({
...@@ -140,20 +125,6 @@ describe('EpicToken', () => { ...@@ -140,20 +125,6 @@ describe('EpicToken', () => {
expect(wrapper.vm.loading).toBe(false); 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', () => { describe('template', () => {
......
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