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';
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);
},
......
......@@ -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]: {
......
......@@ -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),
......
<script>
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
import {
GlDropdownDivider,
GlFilteredSearchSuggestion,
GlFilteredSearchToken,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import createFlash from '~/flash';
import { isNumeric } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
import { DEBOUNCE_DELAY } from '../constants';
import { stripQuotes } from '../filtered_search_utils';
import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants';
export default {
components: {
GlDropdownDivider,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlLoadingIcon,
......@@ -32,29 +35,16 @@ export default {
},
computed: {
currentValue() {
/*
* When the URL contains the epic_iid, we'd get: '123'
*/
if (isNumeric(this.value.data)) {
return parseInt(this.value.data, 10);
}
/*
* 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;
return Number(this.value.data);
},
defaultEpics() {
return this.config.defaultEpics || DEFAULT_NONE_ANY;
},
idProperty() {
return this.config.idProperty || 'id';
},
activeEpic() {
const currentValueIsString = typeof this.currentValue === 'string';
return this.epics.find(
(epic) => epic[currentValueIsString ? 'title' : 'iid'] === this.currentValue,
);
return this.epics.find((epic) => epic[this.idProperty] === this.currentValue);
},
},
watch: {
......@@ -72,20 +62,8 @@ export default {
this.loading = true;
this.config
.fetchEpics(searchTerm)
.then(({ data }) => {
this.epics = 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];
.then((response) => {
this.epics = Array.isArray(response) ? response : response.data;
})
.catch(() => createFlash({ message: __('There was a problem fetching epics.') }))
.finally(() => {
......@@ -93,17 +71,13 @@ export default {
});
},
searchEpics: debounce(function debouncedSearch({ data }) {
if (isNumeric(data)) {
return this.fetchSingleEpic(data);
}
return this.fetchEpicsBySearchTerm(data);
this.fetchEpicsBySearchTerm(data);
}, DEBOUNCE_DELAY),
getEpicValue(epic) {
return `${epic.title}::&${epic.iid}`;
getEpicDisplayText(epic) {
return `${epic.title}::&${epic[this.idProperty]}`;
},
},
stripQuotes,
};
</script>
......@@ -115,17 +89,25 @@ export default {
@input="searchEpics"
>
<template #view="{ inputValue }">
<span>{{ activeEpic ? getEpicValue(activeEpic) : $options.stripQuotes(inputValue) }}</span>
{{ activeEpic ? getEpicDisplayText(activeEpic) : inputValue }}
</template>
<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" />
<template v-else>
<gl-filtered-search-suggestion
v-for="epic in epics"
:key="epic.id"
:value="getEpicValue(epic)"
:key="epic[idProperty]"
:value="String(epic[idProperty])"
>
<div>{{ epic.title }}</div>
{{ epic.title }}
</gl-filtered-search-suggestion>
</template>
</template>
......
......@@ -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);
......
......@@ -77,6 +77,10 @@ module EE
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
......
......@@ -216,8 +216,9 @@ describe('RoadmapFilters', () => {
symbol: '&',
token: EpicToken,
operators,
idProperty: 'iid',
defaultEpics: [],
fetchEpics: expect.any(Function),
fetchSingleEpic: expect.any(Function),
},
];
......
......@@ -137,7 +137,7 @@ RSpec.describe EE::IssuesHelper do
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 @@ RSpec.describe EE::IssuesHelper do
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 @@ RSpec.describe EE::IssuesHelper do
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
......
......@@ -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'],
};
......@@ -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 = {
......
......@@ -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', () => {
......
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