Commit 8dd8e6ad authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch '217943-vsa-fetch-new-suggestsions-when-typing' into 'master'

VSA filter bar update suggestions while typing

See merge request gitlab-org/gitlab!35603
parents e235c8fd 47be8411
......@@ -34,6 +34,7 @@ export default {
unique: true,
symbol: '%',
isLoading: this.milestonesLoading,
fetchData: this.fetchMilestones,
},
{
icon: 'labels',
......@@ -44,6 +45,7 @@ export default {
unique: false,
symbol: '~',
isLoading: this.labelsLoading,
fetchData: this.fetchLabels,
},
];
},
......
......@@ -9,11 +9,11 @@ export const setMilestonesEndpoint = ({ commit }, milestonesEndpoint) =>
export const setLabelsEndpoint = ({ commit }, labelsEndpoint) =>
commit(types.SET_LABELS_ENDPOINT, labelsEndpoint);
export const fetchMilestones = ({ commit, state }) => {
export const fetchMilestones = ({ commit, state }, search_title = '') => {
commit(types.REQUEST_MILESTONES);
return axios
.get(state.milestonesEndpoint)
.get(state.milestonesEndpoint, { params: { search_title } })
.then(({ data }) => {
commit(types.RECEIVE_MILESTONES_SUCCESS, data);
})
......@@ -24,11 +24,11 @@ export const fetchMilestones = ({ commit, state }) => {
});
};
export const fetchLabels = ({ commit, state }) => {
export const fetchLabels = ({ commit, state }, search = '') => {
commit(types.REQUEST_LABELS);
return axios
.get(state.labelsEndpoint)
.get(state.labelsEndpoint, { params: { search } })
.then(({ data }) => {
commit(types.RECEIVE_LABELS_SUCCESS, data);
})
......
......@@ -65,6 +65,7 @@ export default {
symbol: '%',
isLoading: this.milestonesLoading,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchData: this.fetchMilestones,
},
{
icon: 'labels',
......@@ -76,6 +77,7 @@ export default {
symbol: '~',
isLoading: this.labelsLoading,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchData: this.fetchLabels,
},
{
icon: 'pencil',
......@@ -86,6 +88,7 @@ export default {
unique: true,
isLoading: this.authorsLoading,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchData: this.fetchAuthors,
},
{
icon: 'user',
......@@ -96,15 +99,23 @@ export default {
unique: false,
isLoading: this.assigneesLoading,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchData: this.fetchAssignees,
},
];
},
},
mounted() {
this.initializeTokens();
},
methods: {
...mapActions('filters', ['setFilters', 'fetchTokenData']),
...mapActions('filters', [
'setFilters',
'fetchMilestones',
'fetchLabels',
'fetchAuthors',
'fetchAssignees',
]),
initializeTokens() {
const {
selectedMilestone: milestone = null,
......
......@@ -15,21 +15,12 @@ export const setPaths = ({ commit }, { groupPath = '', milestonesPath = '', labe
commit(types.SET_LABELS_PATH, appendExtension(ls));
};
export const fetchTokenData = ({ dispatch }) => {
return Promise.all([
dispatch('fetchLabels'),
dispatch('fetchMilestones'),
dispatch('fetchAuthors'),
dispatch('fetchAssignees'),
]);
};
export const fetchMilestones = ({ commit, state }) => {
export const fetchMilestones = ({ commit, state }, search_title = '') => {
commit(types.REQUEST_MILESTONES);
const { milestonesPath } = state;
return axios
.get(milestonesPath)
.get(milestonesPath, { params: { search_title } })
.then(({ data }) => commit(types.RECEIVE_MILESTONES_SUCCESS, data))
.catch(({ response }) => {
const { status } = response;
......@@ -38,11 +29,11 @@ export const fetchMilestones = ({ commit, state }) => {
});
};
export const fetchLabels = ({ commit, state }) => {
export const fetchLabels = ({ commit, state }, search = '') => {
commit(types.REQUEST_LABELS);
return axios
.get(state.labelsPath)
.get(state.labelsPath, { params: { search } })
.then(({ data }) => commit(types.RECEIVE_LABELS_SUCCESS, data))
.catch(({ response }) => {
const { status } = response;
......@@ -90,7 +81,5 @@ export const setFilters = ({ dispatch }, nextFilters) =>
export const initialize = ({ dispatch, commit }, initialFilters) => {
commit(types.INITIALIZE, initialFilters);
return dispatch('setPaths', initialFilters)
.then(() => dispatch('setFilters', initialFilters))
.then(() => dispatch('fetchTokenData'));
return dispatch('setPaths', initialFilters).then(() => dispatch('setFilters', initialFilters));
};
......@@ -6,6 +6,8 @@ import {
GlLoadingIcon,
} from '@gitlab/ui';
import { __ } from '~/locale';
import { debounce } from 'lodash';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
export default {
components: {
......@@ -29,38 +31,14 @@ export default {
labels() {
return this.config.labels;
},
filteredLabels() {
const labelsList = this.labels.map(label => ({
...label,
value: this.getEscapedText(label.title),
}));
return this.value?.data
? labelsList.filter(
label => label.title.toLowerCase().indexOf(this.value.data?.toLowerCase()) !== -1,
)
: labelsList;
},
},
created() {
this.searchLabels(this.value);
},
methods: {
getEscapedText(text) {
let escapedText = text;
const hasSpace = text.indexOf(' ') !== -1;
const hasDoubleQuote = text.indexOf('"') !== -1;
// Encapsulate value with quotes if it has spaces
// Known side effect: values's with both single and double quotes
// won't escape properly
if (hasSpace) {
if (hasDoubleQuote) {
escapedText = `'${text}'`;
} else {
// Encapsulate singleQuotes or if it hasSpace
escapedText = `"${text}"`;
}
}
return escapedText;
},
searchLabels: debounce(function debouncedSearch({ data = '' }) {
this.config.fetchData(data);
}, DEBOUNCE_DELAY),
},
defaultSuggestions: [
// eslint-disable-next-line @gitlab/require-i18n-strings
......@@ -72,7 +50,12 @@ export default {
</script>
<template>
<gl-filtered-search-token :config="config" v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<gl-filtered-search-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
v-on="$listeners"
@input="searchLabels"
>
<template #view="{ inputValue }">
<template v-if="config.symbol">{{ config.symbol }}</template
>{{ inputValue }}
......@@ -86,12 +69,12 @@ export default {
:value="suggestion.value"
>{{ suggestion.text }}</gl-filtered-search-suggestion
>
<gl-dropdown-divider v-if="config.isLoading || filteredLabels.length" />
<gl-dropdown-divider v-if="config.isLoading || labels.length" />
<gl-filtered-search-suggestion
v-for="label in filteredLabels"
v-for="label in labels"
ref="labelItem"
:key="label.id"
:value="label.value"
:value="label.title"
>
<div class="d-flex">
<span
......
......@@ -6,6 +6,8 @@ import {
GlLoadingIcon,
} from '@gitlab/ui';
import { __ } from '~/locale';
import { debounce } from 'lodash';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
export default {
components: {
......@@ -29,14 +31,9 @@ export default {
milestones() {
return this.config.milestones;
},
filteredMilestones() {
return this.value?.data
? this.milestones.filter(
milestone =>
milestone.title.toLowerCase().indexOf(this.value.data?.toLowerCase()) !== -1,
)
: this.milestones;
},
},
created() {
this.searchMilestones(this.value);
},
methods: {
getEscapedText(text) {
......@@ -58,6 +55,9 @@ export default {
return escapedText;
},
searchMilestones: debounce(function debouncedSearch({ data = '' }) {
this.config.fetchData(data);
}, DEBOUNCE_DELAY),
},
defaultSuggestions: [
// eslint-disable-next-line @gitlab/require-i18n-strings
......@@ -73,7 +73,12 @@ export default {
</script>
<template>
<gl-filtered-search-token :config="config" v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<gl-filtered-search-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
v-on="$listeners"
@input="searchMilestones"
>
<template #view="{ inputValue }">
<template v-if="config.symbol">{{ config.symbol }}</template
>{{ inputValue }}
......@@ -85,11 +90,11 @@ export default {
:value="suggestion.value"
>{{ suggestion.text }}</gl-filtered-search-suggestion
>
<gl-dropdown-divider v-if="config.isLoading || filteredMilestones.length" />
<gl-dropdown-divider v-if="config.isLoading || milestones.length" />
<gl-loading-icon v-if="config.isLoading" />
<template v-else>
<gl-filtered-search-suggestion
v-for="milestone in filteredMilestones"
v-for="milestone in milestones"
ref="milestoneItem"
:key="milestone.id"
:value="getEscapedText(milestone.title)"
......
......@@ -5,6 +5,8 @@ import {
GlFilteredSearchSuggestion,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
export default {
components: {
......@@ -25,19 +27,29 @@ export default {
},
},
computed: {
users() {
return this.config.users;
},
selectedUser() {
return this.value?.data
? this.config.users.find(({ username }) => username === this.value.data)
: {};
},
},
created() {
this.searchUsers(this.value);
},
methods: {
searchUsers: debounce(function debouncedSearch({ data = '' }) {
this.config.fetchData(data);
}, DEBOUNCE_DELAY),
},
};
</script>
<template>
<gl-filtered-search-token :config="config" v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<gl-filtered-search-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
v-on="$listeners"
@input="searchUsers"
>
<template #view="{ inputValue }">
<div v-if="selectedUser" data-testid="selected-user">
<gl-avatar :size="16" :src="selectedUser.avatar_url" />
......@@ -48,7 +60,7 @@ export default {
<gl-loading-icon v-if="config.isLoading" />
<template v-else>
<gl-filtered-search-suggestion
v-for="user in users"
v-for="user in config.users"
:key="user.username"
:value="user.username"
data-testid="user-item"
......
......@@ -39,7 +39,7 @@ describe('Filters actions', () => {
selectedMilestone: 'NEXT',
};
it('dispatches setPaths, setFilters and fetchTokenData', () => {
it('dispatches setPaths, setFilters', () => {
return actions
.initialize(
{
......@@ -50,10 +50,9 @@ describe('Filters actions', () => {
initialData,
)
.then(() => {
expect(mockDispatch).toHaveBeenCalledTimes(3);
expect(mockDispatch).toHaveBeenCalledTimes(2);
expect(mockDispatch).toHaveBeenCalledWith('setPaths', initialData);
expect(mockDispatch).toHaveBeenCalledWith('setFilters', initialData);
expect(mockDispatch).toHaveBeenCalledWith('fetchTokenData');
});
});
......@@ -128,23 +127,6 @@ describe('Filters actions', () => {
});
});
describe('fetchTokenData', () => {
it('dispatches requests for token data', () => {
return testAction(
actions.fetchTokenData,
{ milestonesPath, labelsPath },
state,
[],
[
{ type: 'fetchLabels' },
{ type: 'fetchMilestones' },
{ type: 'fetchAuthors' },
{ type: 'fetchAssignees' },
],
);
});
});
describe('fetchAuthors', () => {
describe('success', () => {
beforeEach(() => {
......
import { shallowMount } from '@vue/test-utils';
import { GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
import { GlFilteredSearchSuggestion, GlLoadingIcon, GlFilteredSearchToken } from '@gitlab/ui';
import LabelToken from 'ee/analytics/shared/components/tokens/label_token.vue';
import { mockLabels } from './mock_data';
......@@ -14,6 +14,7 @@ describe('LabelToken', () => {
unique: false,
symbol: '~',
isLoading: false,
fetchData: jest.fn(),
};
const stubs = {
GlFilteredSearchToken: {
......@@ -21,7 +22,7 @@ describe('LabelToken', () => {
},
};
const createComponent = (props = {}, options) => {
const createComponent = (props = {}, options = { stubs }) => {
wrapper = shallowMount(LabelToken, {
propsData: {
config: defaultConfig,
......@@ -34,41 +35,69 @@ describe('LabelToken', () => {
const findFilteredSearchSuggestion = index =>
wrapper.findAll(GlFilteredSearchSuggestion).at(index);
const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
const findAllLabelSuggestions = () => wrapper.findAll({ ref: 'labelItem' });
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
it('renders a loading icon', () => {
createComponent({ config: { isLoading: true }, value: {} }, { stubs });
createComponent({ config: { ...defaultConfig, isLoading: true }, value: {} }, { stubs });
expect(findLoadingIcon().exists()).toBe(true);
});
describe('suggestions', () => {
it('renders a suggestion for each item', () => {
createComponent();
const res = findAllLabelSuggestions();
expect(res).toHaveLength(mockLabels.length);
mockLabels.forEach((m, index) => {
expect(res.at(index).html()).toContain(m.title);
});
});
describe('default suggestions', () => {
it.each`
text | dropdownIndex
${'None'} | ${0}
${'Any'} | ${1}
`('renders the "$text" suggestion', ({ text, dropdownIndex }) => {
createComponent(null, { stubs });
createComponent(null);
expect(findFilteredSearchSuggestion(dropdownIndex).text()).toEqual(text);
});
});
});
describe('search', () => {
describe('when no search term is given', () => {
it('renders two label suggestions', () => {
createComponent(null, { stubs });
it('calls `fetchData` with an empty search term', () => {
createComponent({
value: defaultValue,
});
expect(defaultConfig.fetchData).toHaveBeenCalledWith('');
});
});
describe('when the search term "Peaches castle" is given', () => {
const data = "Peach's castle";
it('calls `fetchData` with the search term', () => {
createComponent({ value: { data } });
expect(findAllLabelSuggestions()).toHaveLength(2);
expect(defaultConfig.fetchData).toHaveBeenCalledWith(data);
});
});
describe('when the search term "Alero" is given', () => {
it('renders one label suggestion that matches the search term', () => {
createComponent({ value: { data: 'Alero' } }, { stubs });
describe('when the input changes', () => {
const data = 'Moo moo farm';
it('calls `fetchData` with the updated search term', () => {
createComponent({ value: defaultValue }, { stubs: { GlFilteredSearchToken } });
expect(defaultConfig.fetchData).not.toHaveBeenCalledWith(data);
expect(findAllLabelSuggestions()).toHaveLength(1);
findFilteredSearchToken().vm.$emit('input', { data });
expect(defaultConfig.fetchData).toHaveBeenCalledWith(data);
});
});
});
......
import { shallowMount } from '@vue/test-utils';
import { GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
import { GlFilteredSearchSuggestion, GlLoadingIcon, GlFilteredSearchToken } from '@gitlab/ui';
import MilestoneToken from 'ee/analytics/shared/components/tokens/milestone_token.vue';
import { mockMilestones } from './mock_data';
......@@ -18,6 +18,7 @@ describe('MilestoneToken', () => {
const findFilteredSearchSuggestion = index =>
wrapper.findAll(GlFilteredSearchSuggestion).at(index);
const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
const findAllMilestoneSuggestions = () => wrapper.findAll({ ref: 'milestoneItem' });
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
......@@ -31,6 +32,7 @@ describe('MilestoneToken', () => {
unique: true,
symbol: '%',
isLoading: false,
fetchData: jest.fn(),
};
stubs = {
GlFilteredSearchToken: {
......@@ -72,21 +74,46 @@ describe('MilestoneToken', () => {
);
});
it('renders a suggestion for each item', () => {
createComponent({ config, value }, { stubs });
const res = findAllMilestoneSuggestions();
expect(res).toHaveLength(mockMilestones.length);
mockMilestones.forEach((m, index) => {
expect(res.at(index).html()).toContain(m.title);
});
});
});
describe('search', () => {
describe('when no search term is given', () => {
it('renders two milestone suggestions', () => {
it('calls `fetchData` with an empty search term', () => {
createComponent({ config, value }, { stubs });
expect(findAllMilestoneSuggestions()).toHaveLength(2);
expect(config.fetchData).toHaveBeenCalledWith('');
});
});
describe('when the search term "v4" is given', () => {
it('renders one milestone suggestion that matches the search term', () => {
value.data = 'v4';
const query = 'v4';
it('calls `fetchData` with the search term', () => {
value.data = query;
createComponent({ config, value }, { stubs });
expect(findAllMilestoneSuggestions()).toHaveLength(1);
expect(config.fetchData).toHaveBeenCalledWith(query);
});
});
describe('when the input changes', () => {
const data = 'v4';
it('calls `fetchData` with the updated search term', () => {
createComponent({ config, value }, { stubs: { GlFilteredSearchToken } });
expect(config.fetchData).not.toHaveBeenCalledWith(data);
findFilteredSearchToken().vm.$emit('input', { data });
expect(config.fetchData).toHaveBeenCalledWith(data);
});
});
});
......
import { shallowMount } from '@vue/test-utils';
import { GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
import { GlFilteredSearchSuggestion, GlLoadingIcon, GlFilteredSearchToken } from '@gitlab/ui';
import UserToken from 'ee/analytics/shared/components/tokens/user_token.vue';
import { mockUsers } from './mock_data';
......@@ -8,6 +8,7 @@ describe('UserToken', () => {
let value;
let config;
let stubs;
const defaultValue = { data: '' };
const createComponent = (props = {}, options) => {
wrapper = shallowMount(UserToken, {
......@@ -18,14 +19,16 @@ describe('UserToken', () => {
const findFilteredSearchSuggestion = index =>
wrapper.findAll(GlFilteredSearchSuggestion).at(index);
const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
const findAllUserSuggestions = () => wrapper.findAll('[data-testid="user-item"]');
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
beforeEach(() => {
value = { data: '' };
value = defaultValue;
config = {
users: mockUsers,
isLoading: false,
fetchData: jest.fn(),
};
stubs = {
GlFilteredSearchToken: {
......@@ -73,4 +76,37 @@ describe('UserToken', () => {
expect(findAllUserSuggestions()).toHaveLength(3);
});
});
describe('search', () => {
describe('when no search term is given', () => {
it('calls `fetchData` with an empty search term', () => {
createComponent({
config,
value,
});
expect(config.fetchData).toHaveBeenCalledWith('');
});
});
describe('when the search term "Diddy Kong" is given', () => {
const data = 'Diddy Kong';
it('calls `fetchData` with the search term', () => {
createComponent({ config, value: { data } });
expect(config.fetchData).toHaveBeenCalledWith(data);
});
});
describe('when the input changes', () => {
const data = 'Donkey Kong';
it('calls `fetchData` with the updated search term', () => {
createComponent({ config, value: defaultValue }, { stubs: { GlFilteredSearchToken } });
expect(config.fetchData).not.toHaveBeenCalledWith(data);
findFilteredSearchToken().vm.$emit('input', { data });
expect(config.fetchData).toHaveBeenCalledWith(data);
});
});
});
});
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