Commit 974fd014 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'kp-add-base-token-component-with-localstorage-support' into 'master'

Add base token component with support for recent values storage

See merge request gitlab-org/gitlab!61662
parents 1bf415fc 1281cc50
import { __ } from '~/locale';
export const DEBOUNCE_DELAY = 200;
export const MAX_RECENT_TOKENS_SIZE = 3;
export const FILTER_NONE = 'None';
export const FILTER_ANY = 'Any';
......
import { isEmpty } from 'lodash';
import { isEmpty, uniqWith, isEqual } from 'lodash';
import AccessorUtilities from '~/lib/utils/accessor';
import { queryToObject } from '~/lib/utils/url_utility';
import { MAX_RECENT_TOKENS_SIZE } from './constants';
/**
* Strips enclosing quotations from a string if it has one.
*
......@@ -162,3 +165,38 @@ export function urlQueryToFilter(query = '') {
return { ...memo, [filterName]: { value, operator } };
}, {});
}
/**
* Returns array of token values from localStorage
* based on provided recentTokenValuesStorageKey
*
* @param {String} recentTokenValuesStorageKey
* @returns
*/
export function getRecentlyUsedTokenValues(recentTokenValuesStorageKey) {
let recentlyUsedTokenValues = [];
if (AccessorUtilities.isLocalStorageAccessSafe()) {
recentlyUsedTokenValues = JSON.parse(localStorage.getItem(recentTokenValuesStorageKey)) || [];
}
return recentlyUsedTokenValues;
}
/**
* Sets provided token value to recently used array
* within localStorage for provided recentTokenValuesStorageKey
*
* @param {String} recentTokenValuesStorageKey
* @param {Object} tokenValue
*/
export function setTokenValueToRecentlyUsed(recentTokenValuesStorageKey, tokenValue) {
const recentlyUsedTokenValues = getRecentlyUsedTokenValues(recentTokenValuesStorageKey);
recentlyUsedTokenValues.splice(0, 0, { ...tokenValue });
if (AccessorUtilities.isLocalStorageAccessSafe()) {
localStorage.setItem(
recentTokenValuesStorageKey,
JSON.stringify(uniqWith(recentlyUsedTokenValues, isEqual).slice(0, MAX_RECENT_TOKENS_SIZE)),
);
}
}
<script>
import {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlDropdownSectionHeader,
GlLoadingIcon,
} from '@gitlab/ui';
import { DEBOUNCE_DELAY } from '../constants';
import { getRecentlyUsedTokenValues, setTokenValueToRecentlyUsed } from '../filtered_search_utils';
export default {
components: {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlDropdownSectionHeader,
GlLoadingIcon,
},
props: {
tokenConfig: {
type: Object,
required: true,
},
tokenValue: {
type: Object,
required: true,
},
tokenActive: {
type: Boolean,
required: true,
},
tokensListLoading: {
type: Boolean,
required: true,
},
tokenValues: {
type: Array,
required: true,
},
fnActiveTokenValue: {
type: Function,
required: true,
},
defaultTokenValues: {
type: Array,
required: false,
default: () => [],
},
recentTokenValuesStorageKey: {
type: String,
required: false,
default: '',
},
valueIdentifier: {
type: String,
required: false,
default: 'id',
},
fnCurrentTokenValue: {
type: Function,
required: false,
default: null,
},
},
data() {
return {
searchKey: '',
recentTokenValues: this.recentTokenValuesStorageKey
? getRecentlyUsedTokenValues(this.recentTokenValuesStorageKey)
: [],
loading: false,
};
},
computed: {
isRecentTokenValuesEnabled() {
return Boolean(this.recentTokenValuesStorageKey);
},
recentTokenIds() {
return this.recentTokenValues.map((tokenValue) => tokenValue.id || tokenValue.name);
},
currentTokenValue() {
if (this.fnCurrentTokenValue) {
return this.fnCurrentTokenValue(this.tokenValue.data);
}
return this.tokenValue.data.toLowerCase();
},
activeTokenValue() {
return this.fnActiveTokenValue(this.tokenValues, this.currentTokenValue);
},
/**
* Return all the tokenValues when searchKey is present
* otherwise return only the tokenValues which aren't
* present in "Recently used"
*/
availableTokenValues() {
return this.searchKey
? this.tokenValues
: this.tokenValues.filter(
(tokenValue) => !this.recentTokenIds.includes(tokenValue[this.valueIdentifier]),
);
},
},
watch: {
tokenActive: {
immediate: true,
handler(newValue) {
if (!newValue && !this.tokenValues.length) {
this.$emit('fetch-token-values', this.tokenValue.data);
}
},
},
},
methods: {
handleInput({ data }) {
this.searchKey = data;
setTimeout(() => {
if (!this.tokensListLoading) this.$emit('fetch-token-values', data);
}, DEBOUNCE_DELAY);
},
handleTokenValueSelected(activeTokenValue) {
if (this.isRecentTokenValuesEnabled) {
setTokenValueToRecentlyUsed(this.recentTokenValuesStorageKey, activeTokenValue);
}
},
},
};
</script>
<template>
<gl-filtered-search-token
:config="tokenConfig"
v-bind="{ ...this.$parent.$props, ...this.$parent.$attrs }"
v-on="this.$parent.$listeners"
@input="handleInput"
@select="handleTokenValueSelected(activeTokenValue)"
>
<template #view-token="viewTokenProps">
<slot name="view-token" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot>
</template>
<template #view="viewTokenProps">
<slot name="view" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot>
</template>
<template #suggestions>
<template v-if="defaultTokenValues.length">
<gl-filtered-search-suggestion
v-for="token in defaultTokenValues"
:key="token.value"
:value="token.value"
>
{{ token.text }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider />
</template>
<template v-if="isRecentTokenValuesEnabled && recentTokenValues.length && !searchKey">
<gl-dropdown-section-header>{{ __('Recently used') }}</gl-dropdown-section-header>
<slot name="token-values-list" :token-values="recentTokenValues"></slot>
<gl-dropdown-divider />
</template>
<gl-loading-icon v-if="tokensListLoading" />
<template v-else>
<slot name="token-values-list" :token-values="availableTokenValues"></slot>
</template>
</template>
</gl-filtered-search-token>
</template>
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import AccessorUtilities from '~/lib/utils/accessor';
import {
stripQuotes,
uniqueTokens,
......@@ -5,6 +8,8 @@ import {
processFilters,
filterToQueryObject,
urlQueryToFilter,
getRecentlyUsedTokenValues,
setTokenValueToRecentlyUsed,
} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import {
......@@ -14,6 +19,12 @@ import {
tokenValuePlain,
} from './mock_data';
const mockStorageKey = 'recent-tokens';
function setLocalStorageAvailability(isAvailable) {
jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(isAvailable);
}
describe('Filtered Search Utils', () => {
describe('stripQuotes', () => {
it.each`
......@@ -249,3 +260,79 @@ describe('urlQueryToFilter', () => {
expect(res).toEqual(result);
});
});
describe('getRecentlyUsedTokenValues', () => {
useLocalStorageSpy();
beforeEach(() => {
localStorage.removeItem(mockStorageKey);
});
it('returns array containing recently used token values from provided recentTokenValuesStorageKey', () => {
setLocalStorageAvailability(true);
const mockExpectedArray = [{ foo: 'bar' }];
localStorage.setItem(mockStorageKey, JSON.stringify(mockExpectedArray));
expect(getRecentlyUsedTokenValues(mockStorageKey)).toEqual(mockExpectedArray);
});
it('returns empty array when provided recentTokenValuesStorageKey does not have anything in localStorage', () => {
setLocalStorageAvailability(true);
expect(getRecentlyUsedTokenValues(mockStorageKey)).toEqual([]);
});
it('returns empty array when when access to localStorage is not available', () => {
setLocalStorageAvailability(false);
expect(getRecentlyUsedTokenValues(mockStorageKey)).toEqual([]);
});
});
describe('setTokenValueToRecentlyUsed', () => {
const mockTokenValue1 = { foo: 'bar' };
const mockTokenValue2 = { bar: 'baz' };
useLocalStorageSpy();
beforeEach(() => {
localStorage.removeItem(mockStorageKey);
});
it('adds provided tokenValue to localStorage for recentTokenValuesStorageKey', () => {
setLocalStorageAvailability(true);
setTokenValueToRecentlyUsed(mockStorageKey, mockTokenValue1);
expect(JSON.parse(localStorage.getItem(mockStorageKey))).toEqual([mockTokenValue1]);
});
it('adds provided tokenValue to localStorage at the top of existing values (i.e. Stack order)', () => {
setLocalStorageAvailability(true);
setTokenValueToRecentlyUsed(mockStorageKey, mockTokenValue1);
setTokenValueToRecentlyUsed(mockStorageKey, mockTokenValue2);
expect(JSON.parse(localStorage.getItem(mockStorageKey))).toEqual([
mockTokenValue2,
mockTokenValue1,
]);
});
it('ensures that provided tokenValue is not added twice', () => {
setLocalStorageAvailability(true);
setTokenValueToRecentlyUsed(mockStorageKey, mockTokenValue1);
setTokenValueToRecentlyUsed(mockStorageKey, mockTokenValue1);
expect(JSON.parse(localStorage.getItem(mockStorageKey))).toEqual([mockTokenValue1]);
});
it('does not add any value when acess to localStorage is not available', () => {
setLocalStorageAvailability(false);
setTokenValueToRecentlyUsed(mockStorageKey, mockTokenValue1);
expect(JSON.parse(localStorage.getItem(mockStorageKey))).toBeNull();
});
});
import { GlFilteredSearchToken } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import {
mockRegularLabel,
mockLabels,
} from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data';
import { DEFAULT_LABELS } from '~/vue_shared/components/filtered_search_bar/constants';
import {
getRecentlyUsedTokenValues,
setTokenValueToRecentlyUsed,
} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { mockLabelToken } from '../mock_data';
jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils');
const mockStorageKey = 'recent-tokens-label_name';
const defaultStubs = {
Portal: true,
GlFilteredSearchToken: {
template: `
<div>
<slot name="view-token"></slot>
<slot name="view"></slot>
</div>
`,
},
GlFilteredSearchSuggestionList: {
template: '<div></div>',
methods: {
getValue: () => '=',
},
},
};
const defaultSlots = {
'view-token': `
<div class="js-view-token">${mockRegularLabel.title}</div>
`,
view: `
<div class="js-view">${mockRegularLabel.title}</div>
`,
};
const mockProps = {
tokenConfig: mockLabelToken,
tokenValue: { data: '' },
tokenActive: false,
tokensListLoading: false,
tokenValues: [],
fnActiveTokenValue: jest.fn(),
defaultTokenValues: DEFAULT_LABELS,
recentTokenValuesStorageKey: mockStorageKey,
fnCurrentTokenValue: jest.fn(),
};
function createComponent({
props = { ...mockProps },
stubs = defaultStubs,
slots = defaultSlots,
} = {}) {
return mount(BaseToken, {
propsData: {
...props,
},
provide: {
portalName: 'fake target',
alignSuggestions: jest.fn(),
suggestionsListClass: 'custom-class',
},
stubs,
slots,
});
}
describe('BaseToken', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent({
props: {
...mockProps,
tokenValue: { data: `"${mockRegularLabel.title}"` },
tokenValues: mockLabels,
},
});
});
afterEach(() => {
wrapper.destroy();
});
describe('data', () => {
it('calls `getRecentlyUsedTokenValues` to populate `recentTokenValues` when `recentTokenValuesStorageKey` is defined', () => {
expect(getRecentlyUsedTokenValues).toHaveBeenCalledWith(mockStorageKey);
});
});
describe('computed', () => {
describe('currentTokenValue', () => {
it('calls `fnCurrentTokenValue` when it is provided', () => {
// We're disabling lint to trigger computed prop execution for this test.
// eslint-disable-next-line no-unused-vars
const { currentTokenValue } = wrapper.vm;
expect(wrapper.vm.fnCurrentTokenValue).toHaveBeenCalledWith(`"${mockRegularLabel.title}"`);
});
});
describe('activeTokenValue', () => {
it('calls `fnActiveTokenValue` when it is provided', async () => {
wrapper.setProps({
fnCurrentTokenValue: undefined,
});
await wrapper.vm.$nextTick();
// We're disabling lint to trigger computed prop execution for this test.
// eslint-disable-next-line no-unused-vars
const { activeTokenValue } = wrapper.vm;
expect(wrapper.vm.fnActiveTokenValue).toHaveBeenCalledWith(
mockLabels,
`"${mockRegularLabel.title.toLowerCase()}"`,
);
});
});
});
describe('watch', () => {
describe('tokenActive', () => {
let wrapperWithTokenActive;
beforeEach(() => {
wrapperWithTokenActive = createComponent({
props: {
...mockProps,
tokenActive: true,
tokenValue: { data: `"${mockRegularLabel.title}"` },
},
});
});
afterEach(() => {
wrapperWithTokenActive.destroy();
});
it('emits `fetch-token-values` event on the component when value of this prop is changed to false and `tokenValues` array is empty', async () => {
wrapperWithTokenActive.setProps({
tokenActive: false,
});
await wrapperWithTokenActive.vm.$nextTick();
expect(wrapperWithTokenActive.emitted('fetch-token-values')).toBeTruthy();
expect(wrapperWithTokenActive.emitted('fetch-token-values')).toEqual([
[`"${mockRegularLabel.title}"`],
]);
});
});
});
describe('methods', () => {
describe('handleTokenValueSelected', () => {
it('calls `setTokenValueToRecentlyUsed` when `recentTokenValuesStorageKey` is defined', () => {
const mockTokenValue = {
id: 1,
title: 'Foo',
};
wrapper.vm.handleTokenValueSelected(mockTokenValue);
expect(setTokenValueToRecentlyUsed).toHaveBeenCalledWith(mockStorageKey, mockTokenValue);
});
});
});
describe('template', () => {
it('renders gl-filtered-search-token component', () => {
const wrapperWithNoStubs = createComponent({
stubs: {},
});
const glFilteredSearchToken = wrapperWithNoStubs.find(GlFilteredSearchToken);
expect(glFilteredSearchToken.exists()).toBe(true);
expect(glFilteredSearchToken.props('config')).toBe(mockLabelToken);
wrapperWithNoStubs.destroy();
});
it('renders `view-token` slot when present', () => {
expect(wrapper.find('.js-view-token').exists()).toBe(true);
});
it('renders `view` slot when present', () => {
expect(wrapper.find('.js-view').exists()).toBe(true);
});
describe('events', () => {
let wrapperWithNoStubs;
beforeEach(() => {
wrapperWithNoStubs = createComponent({
stubs: { Portal: true },
});
});
afterEach(() => {
wrapperWithNoStubs.destroy();
});
it('emits `fetch-token-values` event on component after a delay when component emits `input` event', async () => {
jest.useFakeTimers();
wrapperWithNoStubs.find(GlFilteredSearchToken).vm.$emit('input', { data: 'foo' });
await wrapperWithNoStubs.vm.$nextTick();
jest.runAllTimers();
expect(wrapperWithNoStubs.emitted('fetch-token-values')).toBeTruthy();
expect(wrapperWithNoStubs.emitted('fetch-token-values')[1]).toEqual(['foo']);
});
});
});
});
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