Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-04-16 06:09:34 +00:00
parent a0dd45d8c8
commit 1b668e02bd
7 changed files with 349 additions and 1 deletions

View file

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View file

@ -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).

View file

@ -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 ""

View file

@ -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'],

View file

@ -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}`);
});
});
});