Commit 69118225 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'kp-use-base-token-for-author-token' into 'master'

Refactor AuthorToken to use BaseToken

See merge request gitlab-org/gitlab!63030
parents d83a9726 3ceee229
<script> <script>
import { import { GlAvatar, GlFilteredSearchSuggestion } from '@gitlab/ui';
GlFilteredSearchToken,
GlAvatar,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { DEFAULT_LABEL_ANY, DEBOUNCE_DELAY } from '../constants'; import { DEFAULT_LABEL_ANY } from '../constants';
import BaseToken from './base_token.vue';
export default { export default {
components: { components: {
GlFilteredSearchToken, BaseToken,
GlAvatar, GlAvatar,
GlFilteredSearchSuggestion, GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
}, },
props: { props: {
config: { config: {
...@@ -30,45 +23,35 @@ export default { ...@@ -30,45 +23,35 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
active: {
type: Boolean,
required: true,
},
}, },
data() { data() {
return { return {
authors: this.config.initialAuthors || [], authors: this.config.initialAuthors || [],
defaultAuthors: this.config.defaultAuthors || [DEFAULT_LABEL_ANY], defaultAuthors: this.config.defaultAuthors || [DEFAULT_LABEL_ANY],
loading: true, preloadedAuthors: [
{
id: gon.current_user_id,
name: gon.current_user_fullname,
username: gon.current_username,
avatar_url: gon.current_user_avatar_url,
},
],
loading: false,
}; };
}, },
computed: { methods: {
currentUser() { getActiveAuthor(authors, currentValue) {
return { return authors.find((author) => author.username.toLowerCase() === currentValue);
id: gon.current_user_id,
name: gon.current_user_fullname,
username: gon.current_username,
avatar_url: gon.current_user_avatar_url,
};
},
currentValue() {
return this.value.data.toLowerCase();
},
activeAuthor() {
return this.authors.find((author) => author.username.toLowerCase() === this.currentValue);
},
activeAuthorAvatar() {
return this.avatarUrl(this.activeAuthor);
}, },
}, getAvatarUrl(author) {
watch: { return author.avatarUrl || author.avatar_url;
active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.authors.length) {
this.fetchAuthorBySearchTerm(this.value.data);
}
},
}, },
},
methods: {
fetchAuthorBySearchTerm(searchTerm) { fetchAuthorBySearchTerm(searchTerm) {
this.loading = true;
const fetchPromise = this.config.fetchPath const fetchPromise = this.config.fetchPath
? this.config.fetchAuthors(this.config.fetchPath, searchTerm) ? this.config.fetchAuthors(this.config.fetchPath, searchTerm)
: this.config.fetchAuthors(searchTerm); : this.config.fetchAuthors(searchTerm);
...@@ -89,69 +72,47 @@ export default { ...@@ -89,69 +72,47 @@ export default {
this.loading = false; this.loading = false;
}); });
}, },
avatarUrl(author) {
return author.avatarUrl || author.avatar_url;
},
searchAuthors: debounce(function debouncedSearch({ data }) {
this.fetchAuthorBySearchTerm(data);
}, DEBOUNCE_DELAY),
}, },
}; };
</script> </script>
<template> <template>
<gl-filtered-search-token <base-token
:config="config" :token-config="config"
v-bind="{ ...$props, ...$attrs }" :token-value="value"
v-on="$listeners" :token-active="active"
@input="searchAuthors" :tokens-list-loading="loading"
:token-values="authors"
:fn-active-token-value="getActiveAuthor"
:default-token-values="defaultAuthors"
:preloaded-token-values="preloadedAuthors"
:recent-token-values-storage-key="config.recentTokenValuesStorageKey"
@fetch-token-values="fetchAuthorBySearchTerm"
> >
<template #view="{ inputValue }"> <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
<gl-avatar <gl-avatar
v-if="activeAuthor" v-if="activeTokenValue"
:size="16" :size="16"
:src="activeAuthorAvatar" :src="getAvatarUrl(activeTokenValue)"
shape="circle" shape="circle"
class="gl-mr-2" class="gl-mr-2"
/> />
<span>{{ activeAuthor ? activeAuthor.name : inputValue }}</span> <span>{{ activeTokenValue ? activeTokenValue.name : inputValue }}</span>
</template> </template>
<template #suggestions> <template #token-values-list="{ tokenValues }">
<gl-filtered-search-suggestion <gl-filtered-search-suggestion
v-for="author in defaultAuthors" v-for="author in tokenValues"
:key="author.value" :key="author.username"
:value="author.value" :value="author.username"
> >
{{ author.text }} <div class="gl-display-flex">
</gl-filtered-search-suggestion> <gl-avatar :size="32" :src="getAvatarUrl(author)" />
<gl-dropdown-divider v-if="defaultAuthors.length" /> <div>
<template v-if="loading"> <div>{{ author.name }}</div>
<gl-filtered-search-suggestion v-if="currentUser.id" :value="currentUser.username"> <div>@{{ author.username }}</div>
<div class="gl-display-flex">
<gl-avatar :size="32" :src="avatarUrl(currentUser)" />
<div>
<div>{{ currentUser.name }}</div>
<div>@{{ currentUser.username }}</div>
</div>
</div> </div>
</gl-filtered-search-suggestion> </div>
<gl-loading-icon class="gl-mt-3" /> </gl-filtered-search-suggestion>
</template>
<template v-else>
<gl-filtered-search-suggestion
v-for="author in authors"
:key="author.username"
:value="author.username"
>
<div class="d-flex">
<gl-avatar :size="32" :src="avatarUrl(author)" />
<div>
<div>{{ author.name }}</div>
<div>@{{ author.username }}</div>
</div>
</div>
</gl-filtered-search-suggestion>
</template>
</template> </template>
</gl-filtered-search-token> </base-token>
</template> </template>
...@@ -48,6 +48,11 @@ export default { ...@@ -48,6 +48,11 @@ export default {
required: false, required: false,
default: () => [], default: () => [],
}, },
preloadedTokenValues: {
type: Array,
required: false,
default: () => [],
},
recentTokenValuesStorageKey: { recentTokenValuesStorageKey: {
type: String, type: String,
required: false, required: false,
...@@ -158,6 +163,11 @@ export default { ...@@ -158,6 +163,11 @@ export default {
<slot name="token-values-list" :token-values="recentTokenValues"></slot> <slot name="token-values-list" :token-values="recentTokenValues"></slot>
<gl-dropdown-divider /> <gl-dropdown-divider />
</template> </template>
<slot
v-if="preloadedTokenValues.length"
name="token-values-list"
:token-values="preloadedTokenValues"
></slot>
<gl-loading-icon v-if="tokensListLoading" /> <gl-loading-icon v-if="tokensListLoading" />
<template v-else> <template v-else>
<slot name="token-values-list" :token-values="availableTokenValues"></slot> <slot name="token-values-list" :token-values="availableTokenValues"></slot>
......
...@@ -52,6 +52,7 @@ export default { ...@@ -52,6 +52,7 @@ export default {
symbol: '@', symbol: '@',
token: AuthorToken, token: AuthorToken,
operators: OPERATOR_IS_ONLY, operators: OPERATOR_IS_ONLY,
recentTokenValuesStorageKey: `${this.groupFullPath}-epics-recent-tokens-author_username`,
fetchAuthors: Api.users.bind(Api), fetchAuthors: Api.users.bind(Api),
}, },
{ {
......
...@@ -175,6 +175,7 @@ describe('RoadmapFilters', () => { ...@@ -175,6 +175,7 @@ describe('RoadmapFilters', () => {
symbol: '@', symbol: '@',
token: AuthorToken, token: AuthorToken,
operators, operators,
recentTokenValuesStorageKey: 'gitlab-org-epics-recent-tokens-author_username',
fetchAuthors: expect.any(Function), fetchAuthors: expect.any(Function),
}, },
{ {
......
import { import {
GlFilteredSearchToken,
GlFilteredSearchTokenSegment, GlFilteredSearchTokenSegment,
GlFilteredSearchSuggestion, GlFilteredSearchSuggestion,
GlDropdownDivider, GlDropdownDivider,
GlLoadingIcon, GlAvatar,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
...@@ -16,6 +15,7 @@ import { ...@@ -16,6 +15,7 @@ import {
DEFAULT_NONE_ANY, DEFAULT_NONE_ANY,
} from '~/vue_shared/components/filtered_search_bar/constants'; } 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 BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { mockAuthorToken, mockAuthors } from '../mock_data'; import { mockAuthorToken, mockAuthors } from '../mock_data';
...@@ -62,6 +62,8 @@ describe('AuthorToken', () => { ...@@ -62,6 +62,8 @@ describe('AuthorToken', () => {
let mock; let mock;
let wrapper; let wrapper;
const getBaseToken = () => wrapper.findComponent(BaseToken);
beforeEach(() => { beforeEach(() => {
window.gon = { window.gon = {
...originalGon, ...originalGon,
...@@ -79,102 +81,125 @@ describe('AuthorToken', () => { ...@@ -79,102 +81,125 @@ describe('AuthorToken', () => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('computed', () => { describe('methods', () => {
describe('currentValue', () => { describe('fetchAuthorBySearchTerm', () => {
it('returns lowercase string for `value.data`', () => { beforeEach(() => {
wrapper = createComponent({ value: { data: 'FOO' } }); wrapper = createComponent();
expect(wrapper.vm.currentValue).toBe('foo');
}); });
});
describe('activeAuthor', () => {
it('returns object for currently present `value.data`', async () => {
wrapper = createComponent({ value: { data: mockAuthors[0].username } });
wrapper.setData({ it('calls `config.fetchAuthors` with provided searchTerm param', () => {
authors: mockAuthors, jest.spyOn(wrapper.vm.config, 'fetchAuthors');
});
await wrapper.vm.$nextTick(); getBaseToken().vm.$emit('fetch-token-values', mockAuthors[0].username);
expect(wrapper.vm.activeAuthor).toEqual(mockAuthors[0]); expect(wrapper.vm.config.fetchAuthors).toHaveBeenCalledWith(
mockAuthorToken.fetchPath,
mockAuthors[0].username,
);
}); });
});
});
describe('fetchAuthorBySearchTerm', () => {
it('calls `config.fetchAuthors` with provided searchTerm param', () => {
wrapper = createComponent();
jest.spyOn(wrapper.vm.config, 'fetchAuthors');
wrapper.vm.fetchAuthorBySearchTerm(mockAuthors[0].username);
expect(wrapper.vm.config.fetchAuthors).toHaveBeenCalledWith( it('sets response to `authors` when request is succesful', () => {
mockAuthorToken.fetchPath, jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockResolvedValue(mockAuthors);
mockAuthors[0].username,
);
});
it('sets response to `authors` when request is succesful', () => {
wrapper = createComponent();
jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockResolvedValue(mockAuthors);
wrapper.vm.fetchAuthorBySearchTerm('root'); getBaseToken().vm.$emit('fetch-token-values', 'root');
return waitForPromises().then(() => { return waitForPromises().then(() => {
expect(wrapper.vm.authors).toEqual(mockAuthors); expect(getBaseToken().props('tokenValues')).toEqual(mockAuthors);
});
}); });
});
it('calls `createFlash` with flash error message when request fails', () => {
wrapper = createComponent();
jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); it('calls `createFlash` with flash error message when request fails', () => {
jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({});
wrapper.vm.fetchAuthorBySearchTerm('root'); getBaseToken().vm.$emit('fetch-token-values', 'root');
return waitForPromises().then(() => { return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalledWith({ expect(createFlash).toHaveBeenCalledWith({
message: 'There was a problem fetching users.', message: 'There was a problem fetching users.',
});
}); });
}); });
});
it('sets `loading` to false when request completes', () => { it('sets `loading` to false when request completes', async () => {
wrapper = createComponent(); jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({});
jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); getBaseToken().vm.$emit('fetch-token-values', 'root');
wrapper.vm.fetchAuthorBySearchTerm('root'); await waitForPromises();
return waitForPromises().then(() => { expect(getBaseToken().props('tokensListLoading')).toBe(false);
expect(wrapper.vm.loading).toBe(false);
}); });
}); });
}); });
describe('template', () => { describe('template', () => {
it('renders gl-filtered-search-token component', () => { it('renders base-token component', () => {
wrapper = createComponent({ data: { authors: mockAuthors } }); wrapper = createComponent({
value: { data: mockAuthors[0].username },
data: { authors: mockAuthors },
});
expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true); const baseTokenEl = getBaseToken();
expect(baseTokenEl.exists()).toBe(true);
expect(baseTokenEl.props()).toMatchObject({
tokenValues: mockAuthors,
fnActiveTokenValue: wrapper.vm.getActiveAuthor,
});
}); });
it('renders token item when value is selected', () => { it('renders token item when value is selected', () => {
wrapper = createComponent({ wrapper = createComponent({
value: { data: mockAuthors[0].username }, value: { data: mockAuthors[0].username },
data: { authors: mockAuthors }, data: { authors: mockAuthors },
stubs: { Portal: true },
}); });
return wrapper.vm.$nextTick(() => { return wrapper.vm.$nextTick(() => {
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
expect(tokenSegments).toHaveLength(3); // Author, =, "Administrator" expect(tokenSegments).toHaveLength(3); // Author, =, "Administrator"
expect(tokenSegments.at(2).text()).toBe(mockAuthors[0].name); // "Administrator"
const tokenValue = tokenSegments.at(2);
expect(tokenValue.findComponent(GlAvatar).props('src')).toBe(mockAuthors[0].avatar_url);
expect(tokenValue.text()).toBe(mockAuthors[0].name); // "Administrator"
});
});
it('renders token value with correct avatarUrl from author object', async () => {
const getAvatarEl = () =>
wrapper.findAll(GlFilteredSearchTokenSegment).at(2).findComponent(GlAvatar);
wrapper = createComponent({
value: { data: mockAuthors[0].username },
data: {
authors: [
{
...mockAuthors[0],
},
],
},
stubs: { Portal: true },
});
await wrapper.vm.$nextTick();
expect(getAvatarEl().props('src')).toBe(mockAuthors[0].avatar_url);
wrapper.setData({
authors: [
{
...mockAuthors[0],
avatarUrl: mockAuthors[0].avatar_url,
avatar_url: undefined,
},
],
}); });
await wrapper.vm.$nextTick();
expect(getAvatarEl().props('src')).toBe(mockAuthors[0].avatar_url);
}); });
it('renders provided defaultAuthors as suggestions', async () => { it('renders provided defaultAuthors as suggestions', async () => {
...@@ -237,10 +262,6 @@ describe('AuthorToken', () => { ...@@ -237,10 +262,6 @@ describe('AuthorToken', () => {
}); });
}); });
it('shows loading icon', () => {
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
it('shows current user', () => { it('shows current user', () => {
const firstSuggestion = wrapper.findComponent(GlFilteredSearchSuggestion).text(); const firstSuggestion = wrapper.findComponent(GlFilteredSearchSuggestion).text();
expect(firstSuggestion).toContain('Administrator'); expect(firstSuggestion).toContain('Administrator');
......
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