Commit 3ceee229 authored by Kushal Pandya's avatar Kushal Pandya

Refactor AuthorToken to use BaseToken

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