Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
a0dd45d8c8
commit
1b668e02bd
7 changed files with 349 additions and 1 deletions
|
@ -0,0 +1,133 @@
|
|||
<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>
|
BIN
doc/user/group/roadmap/img/roadmap_filters_v13_11.png
Normal file
BIN
doc/user/group/roadmap/img/roadmap_filters_v13_11.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
Before Width: | Height: | Size: 20 KiB |
|
@ -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).
|
||||
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -0,0 +1,180 @@
|
|||
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}`);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue