Commit ab2bbee4 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '239384_04-global-search-recent-items-ui' into 'master'

Global Search - Recent Groups/Projects

See merge request gitlab-org/gitlab!65881
parents 0b012330 cb98fe72
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants';
export const frequentGroups = (state) => {
return state.frequentItems[GROUPS_LOCAL_STORAGE_KEY];
};
export const frequentProjects = (state) => {
return state.frequentItems[PROJECTS_LOCAL_STORAGE_KEY];
};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import createState from './state';
......@@ -8,6 +9,7 @@ Vue.use(Vuex);
export const getStoreConfig = ({ query }) => ({
actions,
getters,
mutations,
state: createState({ query }),
});
......
......@@ -24,7 +24,15 @@ export const setFrequentItemToLS = (key, data, itemData) => {
return;
}
const keyList = ['id', 'avatar_url', 'name', 'full_name', 'name_with_namespace', 'frequency'];
const keyList = [
'id',
'avatar_url',
'name',
'full_name',
'name_with_namespace',
'frequency',
'lastUsed',
];
try {
const frequentItems = data[key].map((obj) => extractKeys(obj, keyList));
......@@ -35,17 +43,25 @@ export const setFrequentItemToLS = (key, data, itemData) => {
// Up the frequency (Max 5)
const currentFrequency = frequentItems[existingItemIndex].frequency;
frequentItems[existingItemIndex].frequency = Math.min(currentFrequency + 1, MAX_FREQUENCY);
frequentItems[existingItemIndex].lastUsed = new Date().getTime();
} else {
// Only store a max of 5 items
if (frequentItems.length >= MAX_FREQUENT_ITEMS) {
frequentItems.pop();
}
frequentItems.push({ ...item, frequency: 1 });
frequentItems.push({ ...item, frequency: 1, lastUsed: new Date().getTime() });
}
// Sort by frequency
frequentItems.sort((a, b) => b.frequency - a.frequency);
// Sort by frequency and lastUsed
frequentItems.sort((a, b) => {
if (a.frequency > b.frequency) {
return -1;
} else if (a.frequency < b.frequency) {
return 1;
}
return b.lastUsed - a.lastUsed;
});
// Note we do not need to commit a mutation here as immediately after this we refresh the page to
// update the search results.
......
<script>
import { isEmpty } from 'lodash';
import { mapState, mapActions } from 'vuex';
import { mapState, mapActions, mapGetters } from 'vuex';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants';
import SearchableDropdown from './searchable_dropdown.vue';
......@@ -19,6 +19,7 @@ export default {
},
computed: {
...mapState(['groups', 'fetchingGroups']),
...mapGetters(['frequentGroups']),
selectedGroup() {
return isEmpty(this.initialData) ? ANY_OPTION : this.initialData;
},
......@@ -49,6 +50,7 @@ export default {
:loading="fetchingGroups"
:selected-item="selectedGroup"
:items="groups"
:frequent-items="frequentGroups"
@first-open="loadFrequentGroups"
@search="fetchGroups"
@change="handleGroupChange"
......
<script>
import { mapState, mapActions } from 'vuex';
import { mapState, mapActions, mapGetters } from 'vuex';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants';
import SearchableDropdown from './searchable_dropdown.vue';
......@@ -18,6 +18,7 @@ export default {
},
computed: {
...mapState(['projects', 'fetchingProjects']),
...mapGetters(['frequentProjects']),
selectedProject() {
return this.initialData ? this.initialData : ANY_OPTION;
},
......@@ -52,6 +53,7 @@ export default {
:loading="fetchingProjects"
:selected-item="selectedProject"
:items="projects"
:frequent-items="frequentProjects"
@first-open="loadFrequentProjects"
@search="fetchProjects"
@change="handleProjectChange"
......
......@@ -2,6 +2,7 @@
import {
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType,
GlLoadingIcon,
GlIcon,
......@@ -16,11 +17,13 @@ import SearchableDropdownItem from './searchable_dropdown_item.vue';
export default {
i18n: {
clearLabel: __('Clear'),
frequentlySearched: __('Frequently searched'),
},
name: 'SearchableDropdown',
components: {
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType,
GlLoadingIcon,
GlIcon,
......@@ -61,6 +64,11 @@ export default {
required: false,
default: () => [],
},
frequentItems: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
......@@ -68,6 +76,11 @@ export default {
hasBeenOpened: false,
};
},
computed: {
showFrequentItems() {
return !this.searchText && this.frequentItems.length > 0;
},
},
methods: {
isSelected(selected) {
return selected.id === this.selectedItem.id;
......@@ -139,6 +152,25 @@ export default {
<span data-testid="item-title">{{ $options.ANY_OPTION.name }}</span>
</gl-dropdown-item>
</div>
<div
v-if="showFrequentItems"
class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2 gl-mb-2"
>
<gl-dropdown-section-header>{{
$options.i18n.frequentlySearched
}}</gl-dropdown-section-header>
<searchable-dropdown-item
v-for="item in frequentItems"
:key="item.id"
:item="item"
:selected-item="selectedItem"
:search-text="searchText"
:name="name"
:full-name="fullName"
data-testid="frequent-items"
@change="updateDropdown"
/>
</div>
<div v-if="!loading">
<searchable-dropdown-item
v-for="item in items"
......@@ -148,6 +180,7 @@ export default {
:search-text="searchText"
:name="name"
:full-name="fullName"
data-testid="searchable-items"
@change="updateDropdown"
/>
</div>
......
......@@ -14359,6 +14359,9 @@ msgstr ""
msgid "Frequency"
msgstr ""
msgid "Frequently searched"
msgstr ""
msgid "Friday"
msgstr ""
......
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from '~/search/store/constants';
import * as getters from '~/search/store/getters';
import createState from '~/search/store/state';
import { MOCK_QUERY, MOCK_GROUPS, MOCK_PROJECTS } from '../mock_data';
describe('Global Search Store Getters', () => {
let state;
beforeEach(() => {
state = createState({ query: MOCK_QUERY });
});
describe('frequentGroups', () => {
beforeEach(() => {
state.frequentItems[GROUPS_LOCAL_STORAGE_KEY] = MOCK_GROUPS;
});
it('returns the correct data', () => {
expect(getters.frequentGroups(state)).toStrictEqual(MOCK_GROUPS);
});
});
describe('frequentProjects', () => {
beforeEach(() => {
state.frequentItems[PROJECTS_LOCAL_STORAGE_KEY] = MOCK_PROJECTS;
});
it('returns the correct data', () => {
expect(getters.frequentProjects(state)).toStrictEqual(MOCK_PROJECTS);
});
});
});
......@@ -9,6 +9,9 @@ import {
STALE_STORED_DATA,
} from '../mock_data';
const PREV_TIME = new Date().getTime() - 1;
const CURRENT_TIME = new Date().getTime();
useLocalStorageSpy();
jest.mock('~/lib/utils/accessor', () => ({
isLocalStorageAccessSafe: jest.fn().mockReturnValue(true),
......@@ -52,28 +55,32 @@ describe('Global Search Store Utils', () => {
describe('with existing data', () => {
describe(`when frequency is less than ${MAX_FREQUENCY}`, () => {
beforeEach(() => {
frequentItems[MOCK_LS_KEY] = [{ ...MOCK_GROUPS[0], frequency: 1 }];
frequentItems[MOCK_LS_KEY] = [{ ...MOCK_GROUPS[0], frequency: 1, lastUsed: PREV_TIME }];
setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]);
});
it('adds 1 to the frequency and calls localStorage.setItem', () => {
it('adds 1 to the frequency, tracks lastUsed, and calls localStorage.setItem', () => {
expect(localStorage.setItem).toHaveBeenCalledWith(
MOCK_LS_KEY,
JSON.stringify([{ ...MOCK_GROUPS[0], frequency: 2 }]),
JSON.stringify([{ ...MOCK_GROUPS[0], frequency: 2, lastUsed: CURRENT_TIME }]),
);
});
});
describe(`when frequency is equal to ${MAX_FREQUENCY}`, () => {
beforeEach(() => {
frequentItems[MOCK_LS_KEY] = [{ ...MOCK_GROUPS[0], frequency: MAX_FREQUENCY }];
frequentItems[MOCK_LS_KEY] = [
{ ...MOCK_GROUPS[0], frequency: MAX_FREQUENCY, lastUsed: PREV_TIME },
];
setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]);
});
it(`does not further increase frequency past ${MAX_FREQUENCY} and calls localStorage.setItem`, () => {
it(`does not further increase frequency past ${MAX_FREQUENCY}, tracks lastUsed, and calls localStorage.setItem`, () => {
expect(localStorage.setItem).toHaveBeenCalledWith(
MOCK_LS_KEY,
JSON.stringify([{ ...MOCK_GROUPS[0], frequency: MAX_FREQUENCY }]),
JSON.stringify([
{ ...MOCK_GROUPS[0], frequency: MAX_FREQUENCY, lastUsed: CURRENT_TIME },
]),
);
});
});
......@@ -85,10 +92,10 @@ describe('Global Search Store Utils', () => {
setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]);
});
it('adds a new entry with frequency 1 and calls localStorage.setItem', () => {
it('adds a new entry with frequency 1, tracks lastUsed, and calls localStorage.setItem', () => {
expect(localStorage.setItem).toHaveBeenCalledWith(
MOCK_LS_KEY,
JSON.stringify([{ ...MOCK_GROUPS[0], frequency: 1 }]),
JSON.stringify([{ ...MOCK_GROUPS[0], frequency: 1, lastUsed: CURRENT_TIME }]),
);
});
});
......@@ -96,18 +103,20 @@ describe('Global Search Store Utils', () => {
describe('with multiple entries', () => {
beforeEach(() => {
frequentItems[MOCK_LS_KEY] = [
{ ...MOCK_GROUPS[0], frequency: 1 },
{ ...MOCK_GROUPS[1], frequency: 1 },
{ id: 1, frequency: 2, lastUsed: PREV_TIME },
{ id: 2, frequency: 1, lastUsed: PREV_TIME },
{ id: 3, frequency: 1, lastUsed: PREV_TIME },
];
setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[1]);
setFrequentItemToLS(MOCK_LS_KEY, frequentItems, { id: 3 });
});
it('sorts the array by most frequent', () => {
it('sorts the array by most frequent and lastUsed', () => {
expect(localStorage.setItem).toHaveBeenCalledWith(
MOCK_LS_KEY,
JSON.stringify([
{ ...MOCK_GROUPS[1], frequency: 2 },
{ ...MOCK_GROUPS[0], frequency: 1 },
{ id: 3, frequency: 2, lastUsed: CURRENT_TIME },
{ id: 1, frequency: 2, lastUsed: PREV_TIME },
{ id: 2, frequency: 1, lastUsed: PREV_TIME },
]),
);
});
......@@ -116,24 +125,24 @@ describe('Global Search Store Utils', () => {
describe('with max entries', () => {
beforeEach(() => {
frequentItems[MOCK_LS_KEY] = [
{ id: 1, frequency: 5 },
{ id: 2, frequency: 4 },
{ id: 3, frequency: 3 },
{ id: 4, frequency: 2 },
{ id: 5, frequency: 1 },
{ id: 1, frequency: 5, lastUsed: PREV_TIME },
{ id: 2, frequency: 4, lastUsed: PREV_TIME },
{ id: 3, frequency: 3, lastUsed: PREV_TIME },
{ id: 4, frequency: 2, lastUsed: PREV_TIME },
{ id: 5, frequency: 1, lastUsed: PREV_TIME },
];
setFrequentItemToLS(MOCK_LS_KEY, frequentItems, { id: 6 });
});
it('removes the least frequent', () => {
it('removes the last item in the array', () => {
expect(localStorage.setItem).toHaveBeenCalledWith(
MOCK_LS_KEY,
JSON.stringify([
{ id: 1, frequency: 5 },
{ id: 2, frequency: 4 },
{ id: 3, frequency: 3 },
{ id: 4, frequency: 2 },
{ id: 6, frequency: 1 },
{ id: 1, frequency: 5, lastUsed: PREV_TIME },
{ id: 2, frequency: 4, lastUsed: PREV_TIME },
{ id: 3, frequency: 3, lastUsed: PREV_TIME },
{ id: 4, frequency: 2, lastUsed: PREV_TIME },
{ id: 6, frequency: 1, lastUsed: CURRENT_TIME },
]),
);
});
......@@ -160,7 +169,7 @@ describe('Global Search Store Utils', () => {
it('parses out extra data for LS', () => {
expect(localStorage.setItem).toHaveBeenCalledWith(
MOCK_LS_KEY,
JSON.stringify([{ ...MOCK_GROUPS[0], frequency: 1 }]),
JSON.stringify([{ ...MOCK_GROUPS[0], frequency: 1, lastUsed: CURRENT_TIME }]),
);
});
});
......
......@@ -35,6 +35,9 @@ describe('GroupFilter', () => {
...initialState,
},
actions: actionSpies,
getters: {
frequentGroups: () => [],
},
});
wrapper = shallowMount(GroupFilter, {
......
......@@ -35,6 +35,9 @@ describe('ProjectFilter', () => {
...initialState,
},
actions: actionSpies,
getters: {
frequentProjects: () => [],
},
});
wrapper = shallowMount(ProjectFilter, {
......
......@@ -2,9 +2,9 @@ import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlSkeletonLoader } from
import { shallowMount, mount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data';
import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue';
import SearchableDropdownItem from '~/search/topbar/components/searchable_dropdown_item.vue';
import { ANY_OPTION, GROUP_DATA } from '~/search/topbar/constants';
Vue.use(Vuex);
......@@ -29,13 +29,15 @@ describe('Global Search Searchable Dropdown', () => {
},
});
wrapper = mountFn(SearchableDropdown, {
store,
propsData: {
...defaultProps,
...props,
},
});
wrapper = extendedWrapper(
mountFn(SearchableDropdown, {
store,
propsData: {
...defaultProps,
...props,
},
}),
);
};
afterEach(() => {
......@@ -45,10 +47,11 @@ describe('Global Search Searchable Dropdown', () => {
const findGlDropdown = () => wrapper.findComponent(GlDropdown);
const findGlDropdownSearch = () => findGlDropdown().findComponent(GlSearchBoxByType);
const findDropdownText = () => findGlDropdown().find('.dropdown-toggle-text');
const findSearchableDropdownItems = () =>
findGlDropdown().findAllComponents(SearchableDropdownItem);
const findSearchableDropdownItems = () => wrapper.findAllByTestId('searchable-items');
const findFrequentDropdownItems = () => wrapper.findAllByTestId('frequent-items');
const findAnyDropdownItem = () => findGlDropdown().findComponent(GlDropdownItem);
const findFirstGroupDropdownItem = () => findSearchableDropdownItems().at(0);
const findFirstSearchableDropdownItem = () => findSearchableDropdownItems().at(0);
const findFirstFrequentDropdownItem = () => findFrequentDropdownItems().at(0);
const findLoader = () => wrapper.findComponent(GlSkeletonLoader);
describe('template', () => {
......@@ -82,7 +85,7 @@ describe('Global Search Searchable Dropdown', () => {
});
});
describe('findDropdownItems', () => {
describe('Searchable Dropdown Items', () => {
describe('when loading is false', () => {
beforeEach(() => {
createComponent({}, { items: MOCK_GROUPS });
......@@ -96,7 +99,7 @@ describe('Global Search Searchable Dropdown', () => {
expect(findAnyDropdownItem().exists()).toBe(true);
});
it('renders SearchableDropdownItem for each item', () => {
it('renders searchable dropdown item for each item', () => {
expect(findSearchableDropdownItems()).toHaveLength(MOCK_GROUPS.length);
});
});
......@@ -114,12 +117,31 @@ describe('Global Search Searchable Dropdown', () => {
expect(findAnyDropdownItem().exists()).toBe(true);
});
it('does not render SearchableDropdownItem', () => {
it('does not render searchable dropdown items', () => {
expect(findSearchableDropdownItems()).toHaveLength(0);
});
});
});
describe.each`
searchText | frequentItems | length
${''} | ${[]} | ${0}
${''} | ${MOCK_GROUPS} | ${MOCK_GROUPS.length}
${'test'} | ${[]} | ${0}
${'test'} | ${MOCK_GROUPS} | ${0}
`('Frequent Dropdown Items', ({ searchText, frequentItems, length }) => {
describe(`when search is ${searchText} and frequentItems length is ${frequentItems.length}`, () => {
beforeEach(() => {
createComponent({}, { frequentItems });
wrapper.setData({ searchText });
});
it(`should${length ? '' : ' not'} render frequent dropdown items`, () => {
expect(findFrequentDropdownItems()).toHaveLength(length);
});
});
});
describe('Dropdown Text', () => {
describe('when selectedItem is any', () => {
beforeEach(() => {
......@@ -145,7 +167,7 @@ describe('Global Search Searchable Dropdown', () => {
describe('actions', () => {
beforeEach(() => {
createComponent({}, { items: MOCK_GROUPS });
createComponent({}, { items: MOCK_GROUPS, frequentItems: MOCK_GROUPS });
});
it('clicking "Any" dropdown item $emits @change with ANY_OPTION', () => {
......@@ -154,8 +176,14 @@ describe('Global Search Searchable Dropdown', () => {
expect(wrapper.emitted('change')[0]).toEqual([ANY_OPTION]);
});
it('on SearchableDropdownItem @change, the wrapper $emits change with the item', () => {
findFirstGroupDropdownItem().vm.$emit('change', MOCK_GROUPS[0]);
it('on searchable item @change, the wrapper $emits change with the item', () => {
findFirstSearchableDropdownItem().vm.$emit('change', MOCK_GROUPS[0]);
expect(wrapper.emitted('change')[0]).toEqual([MOCK_GROUPS[0]]);
});
it('on frequent item @change, the wrapper $emits change with the item', () => {
findFirstFrequentDropdownItem().vm.$emit('change', MOCK_GROUPS[0]);
expect(wrapper.emitted('change')[0]).toEqual([MOCK_GROUPS[0]]);
});
......
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