Commit 0f1517b6 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'roadmap-filter-by-epic' into 'master'

Filter by epic in roadmap

See merge request gitlab-org/gitlab!58642
parents b17c41f4 dedbe7ba
<script>
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, 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';
export default {
components: {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlLoadingIcon,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
data() {
return {
epics: this.config.initialEpics || [],
loading: true,
};
},
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;
},
activeEpic() {
const currentValueIsString = typeof this.currentValue === 'string';
return this.epics.find(
(epic) => epic[currentValueIsString ? 'title' : 'iid'] === this.currentValue,
);
},
},
watch: {
active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.epics.length) {
this.searchEpics({ data: this.currentValue });
}
},
},
},
methods: {
fetchEpicsBySearchTerm(searchTerm = '') {
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];
})
.catch(() => createFlash({ message: __('There was a problem fetching epics.') }))
.finally(() => {
this.loading = false;
});
},
searchEpics: debounce(function debouncedSearch({ data }) {
if (isNumeric(data)) {
return this.fetchSingleEpic(data);
}
return this.fetchEpicsBySearchTerm(data);
}, DEBOUNCE_DELAY),
getEpicValue(epic) {
return `${epic.title}::&${epic.iid}`;
},
},
stripQuotes,
};
</script>
<template>
<gl-filtered-search-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
v-on="$listeners"
@input="searchEpics"
>
<template #view="{ inputValue }">
<span>{{ activeEpic ? getEpicValue(activeEpic) : $options.stripQuotes(inputValue) }}</span>
</template>
<template #suggestions>
<gl-loading-icon v-if="loading" />
<template v-else>
<gl-filtered-search-suggestion
v-for="epic in epics"
:key="epic.id"
:value="getEpicValue(epic)"
>
<div>{{ epic.title }}</div>
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</template>
......@@ -42,6 +42,7 @@ toggle the list of the milestone bars.
> - Filtering roadmaps by milestone is recommended for production use.
> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#enable-or-disable-filtering-roadmaps-by-milestone). **(PREMIUM SELF)**
> - Filtering by epic confidentiality [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/218624) in GitLab 13.9.
> - Filtering by epic [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/218623) in GitLab 13.11.
WARNING:
Filtering roadmaps by milestone might not be available to you. Check the **version history** note above for details.
......@@ -69,8 +70,10 @@ You can also filter epics in the Roadmap view by the epics':
- Label
- Milestone
- Confidentiality
- Epic
- Your Reaction
![roadmap date range in weeks](img/roadmap_filters_v13_8.png)
![roadmap date range in weeks](img/roadmap_filters_v13_11.png)
Roadmaps can also be [visualized inside an epic](../epics/index.md#roadmap-in-epics).
......
......@@ -3,7 +3,11 @@ import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui';
import { dateInWords } from '~/lib/utils/datetime_utility';
import { s__, sprintf } from '~/locale';
import { emptyStateDefault, emptyStateWithFilters } from '../constants';
import {
emptyStateDefault,
emptyStateWithFilters,
emptyStateWithEpicIidFiltered,
} from '../constants';
import CommonMixin from '../mixins/common_mixin';
export default {
......@@ -41,6 +45,11 @@ export default {
required: false,
default: false,
},
filterParams: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
timeframeRange() {
......@@ -100,6 +109,10 @@ export default {
);
}
if (this.hasFiltersApplied && Boolean(this.filterParams?.epicIid)) {
return emptyStateWithEpicIidFiltered;
}
if (this.hasFiltersApplied) {
return sprintf(emptyStateWithFilters, {
startDate: this.timeframeRange.startDate,
......
......@@ -60,6 +60,7 @@ export default {
'isChildEpics',
'hasFiltersApplied',
'milestonesFetchFailure',
'filterParams',
]),
showFilteredSearchbar() {
if (this.glFeatures.asyncFiltering) {
......@@ -176,6 +177,7 @@ export default {
:has-filters-applied="hasFiltersApplied"
:empty-state-illustration-path="emptyStateIllustrationPath"
:is-child-epics="isChildEpics"
:filter-params="filterParams"
/>
<roadmap-shell
v-else-if="!epicsFetchFailure"
......
......@@ -48,6 +48,10 @@ export const emptyStateWithFilters = s__(
'GroupRoadmap|To widen your search, change or remove filters; from %{startDate} to %{endDate}.',
);
export const emptyStateWithEpicIidFiltered = s__(
'GroupRoadmap|To make your epics appear in the roadmap, add start or due dates to them.',
);
export const PRESET_DEFAULTS = {
QUARTERS: {
TIMEFRAME_LENGTH: 21,
......
......@@ -6,18 +6,25 @@ import { __ } from '~/locale';
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 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 { FilterTokenOperators } from '../constants';
export default {
inject: ['groupFullPath', 'groupMilestonesPath'],
inject: ['groupFullPath', 'groupMilestonesPath', 'listEpicsPath'],
computed: {
urlParams() {
const { search, authorUsername, labelName, milestoneTitle, confidential, myReactionEmoji } =
this.filterParams || {};
const {
search,
authorUsername,
labelName,
milestoneTitle,
confidential,
myReactionEmoji,
epicIid,
} = this.filterParams || {};
return {
state: this.currentState || this.epicsState,
page: this.currentPage,
......@@ -29,6 +36,7 @@ export default {
milestone_title: milestoneTitle,
confidential,
my_reaction_emoji: myReactionEmoji,
epic_iid: epicIid && Number(epicIid),
search,
};
},
......@@ -104,6 +112,23 @@ export default {
{ icon: 'eye', value: false, title: __('No') },
],
},
{
type: 'epic_iid',
icon: 'epic',
title: __('Epic'),
unique: true,
symbol: '&',
token: EpicToken,
operators: FilterTokenOperators,
fetchEpics: (search = '') => {
return axios.get(this.listEpicsPath, { params: { search } }).then(({ data }) => {
return { data };
});
},
fetchSingleEpic: (iid) => {
return axios.get(`${this.listEpicsPath}/${iid}`).then(({ data }) => ({ data }));
},
},
];
if (gon.current_user_id) {
......@@ -133,8 +158,15 @@ export default {
return tokens;
},
getFilteredSearchValue() {
const { authorUsername, labelName, milestoneTitle, confidential, myReactionEmoji, search } =
this.filterParams || {};
const {
authorUsername,
labelName,
milestoneTitle,
confidential,
myReactionEmoji,
search,
epicIid,
} = this.filterParams || {};
const filteredSearchValue = [];
if (authorUsername) {
......@@ -174,6 +206,13 @@ export default {
});
}
if (epicIid) {
filteredSearchValue.push({
type: 'epic_iid',
value: { data: epicIid },
});
}
if (search) {
filteredSearchValue.push(search);
}
......@@ -202,6 +241,9 @@ export default {
case 'my_reaction_emoji':
filterParams.myReactionEmoji = filter.value.data;
break;
case 'epic_iid':
filterParams.epicIid = Number(filter.value.data.split('::&')[1]);
break;
case 'filtered-search-term':
if (filter.value.data) plainText.push(filter.value.data);
break;
......
......@@ -4,6 +4,7 @@ query groupEpics(
$fullPath: ID!
$state: EpicState
$sort: EpicSort
$iid: ID
$startDate: Time
$dueDate: Time
$labelName: [String!] = []
......@@ -18,6 +19,7 @@ query groupEpics(
id
name
epics(
iid: $iid
state: $state
sort: $sort
startDate: $startDate
......
......@@ -77,6 +77,10 @@ export default () => {
...(rawFilterParams.confidential && {
confidential: parseBoolean(rawFilterParams.confidential),
}),
...(rawFilterParams.epicIid && {
epicIid: parseInt(rawFilterParams.epicIid, 10),
}),
};
const timeframe = getTimeframeForPreset(
presetType,
......
......@@ -45,6 +45,10 @@ const fetchGroupEpics = (
...filterParams,
first: gon.roadmap_epics_limit + 1,
};
if (filterParams?.epicIid) {
variables.iid = filterParams.epicIid;
}
}
return epicUtils.gqClient
......
---
title: Filter by epic in roadmap
merge_request: 58642
author:
type: added
......@@ -54,6 +54,7 @@ const mockProvide = {
groupFullPath: 'gitlab-org',
groupLabelsPath: '/gitlab-org/-/labels.json',
groupMilestonesPath: '/gitlab-org/-/milestone.json',
listEpicsPath: '/gitlab-org/-/epics',
emptyStatePath: '/assets/illustrations/empty-state/epics.svg',
};
......
......@@ -47,6 +47,7 @@ describe('RoadmapApp', () => {
glFeatures: { asyncFiltering: true },
groupFullPath: 'gitlab-org',
groupMilestonesPath: '/groups/gitlab-org/-/milestones.json',
listEpicsPath: '/groups/gitlab-org/-/epics',
},
store,
});
......
......@@ -13,6 +13,7 @@ import { visitUrl, mergeUrlParams, updateHistory } from '~/lib/utils/url_utility
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.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 EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_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';
......@@ -28,6 +29,7 @@ const createComponent = ({
epicsState = EPICS_STATES.ALL,
sortedBy = mockSortedBy,
groupFullPath = 'gitlab-org',
listEpicsPath = '/groups/gitlab-org/-/epics',
groupMilestonesPath = '/groups/gitlab-org/-/milestones.json',
timeframe = getTimeframeForMonthsView(mockTimeframeInitialDate),
filterParams = {},
......@@ -51,6 +53,7 @@ const createComponent = ({
provide: {
groupFullPath,
groupMilestonesPath,
listEpicsPath,
},
});
};
......@@ -205,6 +208,17 @@ describe('RoadmapFilters', () => {
{ icon: 'eye', value: false, title: 'No' },
],
},
{
type: 'epic_iid',
icon: 'epic',
title: 'Epic',
unique: true,
symbol: '&',
token: EpicToken,
operators,
fetchEpics: expect.any(Function),
fetchSingleEpic: expect.any(Function),
},
];
beforeEach(() => {
......@@ -217,7 +231,7 @@ describe('RoadmapFilters', () => {
expect(filteredSearchBar.props('recentSearchesStorageKey')).toBe('epics');
});
it('includes `Author`, `Milestone`, `Confidential` and `Label` tokens when user is not logged in', () => {
it('includes `Author`, `Milestone`, `Confidential`, `Epic` and `Label` tokens when user is not logged in', () => {
expect(filteredSearchBar.props('tokens')).toEqual(filterTokens);
});
......
......@@ -15283,6 +15283,9 @@ msgstr ""
msgid "GroupRoadmap|The roadmap shows the progress of your epics along a timeline"
msgstr ""
msgid "GroupRoadmap|To make your epics appear in the roadmap, add start or due dates to them."
msgstr ""
msgid "GroupRoadmap|To view the roadmap, add a start or due date to one of the %{linkStart}child epics%{linkEnd}."
msgstr ""
......@@ -31551,6 +31554,9 @@ msgstr ""
msgid "There was a problem fetching emojis."
msgstr ""
msgid "There was a problem fetching epics."
msgstr ""
msgid "There was a problem fetching groups."
msgstr ""
......
......@@ -4,6 +4,7 @@ import Api from '~/api';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_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 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';
......@@ -60,6 +61,11 @@ export const mockMilestones = [
mockEscapedMilestone,
];
export const mockEpics = [
{ iid: 1, id: 1, title: 'Foo' },
{ iid: 2, id: 2, title: 'Bar' },
];
export const mockEmoji1 = {
name: 'thumbsup',
};
......@@ -114,6 +120,18 @@ export const mockMilestoneToken = {
fetchMilestones: () => Promise.resolve({ data: mockMilestones }),
};
export const mockEpicToken = {
type: 'epic_iid',
icon: 'clock',
title: 'Epic',
unique: true,
symbol: '&',
token: EpicToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchEpics: () => Promise.resolve({ data: mockEpics }),
fetchSingleEpic: () => Promise.resolve({ data: mockEpics[0] }),
};
export const mockReactionEmojiToken = {
type: 'my_reaction_emoji',
icon: 'thumb-up',
......@@ -189,6 +207,14 @@ export const tokenValuePlain = {
value: { data: 'foo' },
};
export const tokenValueEpic = {
type: 'epic_iid',
value: {
operator: '=',
data: '"foo"::&42',
},
};
export const mockHistoryItems = [
[tokenValueAuthor, tokenValueLabel, tokenValueMilestone, 'duo'],
[tokenValueAuthor, 'si'],
......
import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue';
import { mockEpicToken, mockEpics } from '../mock_data';
jest.mock('~/flash');
const defaultStubs = {
Portal: true,
GlFilteredSearchSuggestionList: {
template: '<div></div>',
methods: {
getValue: () => '=',
},
},
};
function createComponent(options = {}) {
const {
config = mockEpicToken,
value = { data: '' },
active = false,
stubs = defaultStubs,
} = options;
return mount(EpicToken, {
propsData: {
config,
value,
active,
},
provide: {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: 'custom-class',
},
stubs,
});
}
describe('EpicToken', () => {
let mock;
let wrapper;
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent();
});
afterEach(() => {
mock.restore();
wrapper.destroy();
});
describe('computed', () => {
beforeEach(async () => {
wrapper = createComponent({
data: {
epics: mockEpics,
},
});
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({
value: { data: `${mockEpics[0].iid}` },
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.activeEpic).toEqual(mockEpics[0]);
});
});
});
describe('methods', () => {
describe('fetchEpicsBySearchTerm', () => {
it('calls `config.fetchEpics` with provided searchTerm param', () => {
jest.spyOn(wrapper.vm.config, 'fetchEpics');
wrapper.vm.fetchEpicsBySearchTerm('foo');
expect(wrapper.vm.config.fetchEpics).toHaveBeenCalledWith('foo');
});
it('sets response to `epics` when request is successful', async () => {
jest.spyOn(wrapper.vm.config, 'fetchEpics').mockResolvedValue({
data: mockEpics,
});
wrapper.vm.fetchEpicsBySearchTerm();
await waitForPromises();
expect(wrapper.vm.epics).toEqual(mockEpics);
});
it('calls `createFlash` with flash error message when request fails', async () => {
jest.spyOn(wrapper.vm.config, 'fetchEpics').mockRejectedValue({});
wrapper.vm.fetchEpicsBySearchTerm('foo');
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: 'There was a problem fetching epics.',
});
});
it('sets `loading` to false when request completes', async () => {
jest.spyOn(wrapper.vm.config, 'fetchEpics').mockRejectedValue({});
wrapper.vm.fetchEpicsBySearchTerm('foo');
await waitForPromises();
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', () => {
beforeEach(async () => {
wrapper = createComponent({
value: { data: `${mockEpics[0].iid}` },
data: { epics: mockEpics },
});
await wrapper.vm.$nextTick();
});
it('renders gl-filtered-search-token component', () => {
expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true);
});
it('renders token item when value is selected', () => {
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
expect(tokenSegments).toHaveLength(3);
expect(tokenSegments.at(2).text()).toBe(`${mockEpics[0].title}::&${mockEpics[0].iid}`);
});
});
});
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