Commit f56e0d11 authored by Simon Knox's avatar Simon Knox

Merge branch '239384_02-global-search-fetch-store-frequent-items' into 'master'

Global Search - Store Recent Item Data

See merge request gitlab-org/gitlab!63904
parents 9a819341 36004ac8
......@@ -2,7 +2,9 @@ import Api from '~/api';
import createFlash from '~/flash';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants';
import * as types from './mutation_types';
import { loadDataFromLS, setFrequentItemToLS } from './utils';
export const fetchGroups = ({ commit }, search) => {
commit(types.REQUEST_GROUPS);
......@@ -39,6 +41,24 @@ export const fetchProjects = ({ commit, state }, search) => {
}
};
export const loadFrequentGroups = ({ commit }) => {
const data = loadDataFromLS(GROUPS_LOCAL_STORAGE_KEY);
commit(types.LOAD_FREQUENT_ITEMS, { key: GROUPS_LOCAL_STORAGE_KEY, data });
};
export const loadFrequentProjects = ({ commit }) => {
const data = loadDataFromLS(PROJECTS_LOCAL_STORAGE_KEY);
commit(types.LOAD_FREQUENT_ITEMS, { key: PROJECTS_LOCAL_STORAGE_KEY, data });
};
export const setFrequentGroup = ({ state }, item) => {
setFrequentItemToLS(GROUPS_LOCAL_STORAGE_KEY, state.frequentItems, item);
};
export const setFrequentProject = ({ state }, item) => {
setFrequentItemToLS(PROJECTS_LOCAL_STORAGE_KEY, state.frequentItems, item);
};
export const setQuery = ({ commit }, { key, value }) => {
commit(types.SET_QUERY, { key, value });
};
......
export const MAX_FREQUENT_ITEMS = 5;
export const MAX_FREQUENCY = 5;
export const GROUPS_LOCAL_STORAGE_KEY = 'global-search-frequent-groups';
export const PROJECTS_LOCAL_STORAGE_KEY = 'global-search-frequent-projects';
......@@ -7,3 +7,5 @@ export const RECEIVE_PROJECTS_SUCCESS = 'RECEIVE_PROJECTS_SUCCESS';
export const RECEIVE_PROJECTS_ERROR = 'RECEIVE_PROJECTS_ERROR';
export const SET_QUERY = 'SET_QUERY';
export const LOAD_FREQUENT_ITEMS = 'LOAD_FREQUENT_ITEMS';
......@@ -26,4 +26,7 @@ export default {
[types.SET_QUERY](state, { key, value }) {
state.query[key] = value;
},
[types.LOAD_FREQUENT_ITEMS](state, { key, data }) {
state.frequentItems[key] = data;
},
};
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants';
const createState = ({ query }) => ({
query,
groups: [],
fetchingGroups: false,
projects: [],
fetchingProjects: false,
frequentItems: {
[GROUPS_LOCAL_STORAGE_KEY]: [],
[PROJECTS_LOCAL_STORAGE_KEY]: [],
},
});
export default createState;
import AccessorUtilities from '../../lib/utils/accessor';
import { MAX_FREQUENT_ITEMS, MAX_FREQUENCY } from './constants';
export const loadDataFromLS = (key) => {
if (!AccessorUtilities.isLocalStorageAccessSafe()) {
return [];
}
try {
return JSON.parse(localStorage.getItem(key)) || [];
} catch {
// The LS got in a bad state, let's wipe it
localStorage.removeItem(key);
return [];
}
};
export const setFrequentItemToLS = (key, data, item) => {
if (!AccessorUtilities.isLocalStorageAccessSafe()) {
return;
}
try {
const frequentItems = data[key];
const existingItemIndex = frequentItems.findIndex((i) => i.id === item.id);
if (existingItemIndex >= 0) {
// Up the frequency (Max 5)
const currentFrequency = frequentItems[existingItemIndex].frequency;
frequentItems[existingItemIndex].frequency = Math.min(currentFrequency + 1, MAX_FREQUENCY);
} else {
// Only store a max of 5 items
if (frequentItems.length >= MAX_FREQUENT_ITEMS) {
frequentItems.pop();
}
frequentItems.push({ id: item.id, frequency: 1 });
}
// Sort by frequency
frequentItems.sort((a, b) => b.frequency - a.frequency);
// Note we do not need to commit a mutation here as immediately after this we refresh the page to
// update the search results.
localStorage.setItem(key, JSON.stringify(frequentItems));
} catch {
// The LS got in a bad state, let's wipe it
localStorage.removeItem(key);
}
};
......@@ -23,9 +23,17 @@ export default {
return isEmpty(this.initialData) ? ANY_OPTION : this.initialData;
},
},
created() {
this.loadFrequentGroups();
},
methods: {
...mapActions(['fetchGroups']),
...mapActions(['fetchGroups', 'setFrequentGroup', 'loadFrequentGroups']),
handleGroupChange(group) {
// If group.id is null we are clearing the filter and don't need to store that in LS.
if (group.id) {
this.setFrequentGroup(group);
}
visitUrl(
setUrlParams({ [GROUP_DATA.queryParam]: group.id, [PROJECT_DATA.queryParam]: null }),
);
......
......@@ -22,9 +22,17 @@ export default {
return this.initialData ? this.initialData : ANY_OPTION;
},
},
created() {
this.loadFrequentProjects();
},
methods: {
...mapActions(['fetchProjects']),
...mapActions(['fetchProjects', 'setFrequentProject', 'loadFrequentProjects']),
handleProjectChange(project) {
// If project.id is null we are clearing the filter and don't need to store that in LS.
if (project.id) {
this.setFrequentProject(project);
}
// This determines if we need to update the group filter or not
const queryParams = {
...(project.namespace?.id && { [GROUP_DATA.queryParam]: project.namespace.id }),
......
......@@ -63,3 +63,5 @@ export const MOCK_SORT_OPTIONS = [
},
},
];
export const MOCK_LS_KEY = 'mock-ls-key';
......@@ -5,9 +5,11 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import * as urlUtils from '~/lib/utils/url_utility';
import * as actions from '~/search/store/actions';
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from '~/search/store/constants';
import * as types from '~/search/store/mutation_types';
import createState from '~/search/store/state';
import { MOCK_QUERY, MOCK_GROUPS, MOCK_PROJECT, MOCK_PROJECTS } from '../mock_data';
import * as storeUtils from '~/search/store/utils';
import { MOCK_QUERY, MOCK_GROUPS, MOCK_PROJECT, MOCK_PROJECTS, MOCK_GROUP } from '../mock_data';
jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility', () => ({
......@@ -141,4 +143,86 @@ describe('Global Search Store Actions', () => {
});
});
});
describe('loadFrequentGroups', () => {
beforeEach(() => {
storeUtils.loadDataFromLS = jest.fn().mockReturnValue(MOCK_GROUPS);
});
it(`calls loadDataFromLS with ${GROUPS_LOCAL_STORAGE_KEY} and LOAD_FREQUENT_ITEMS mutation`, async () => {
await testAction({
action: actions.loadFrequentGroups,
state,
expectedMutations: [
{
type: types.LOAD_FREQUENT_ITEMS,
payload: { key: GROUPS_LOCAL_STORAGE_KEY, data: MOCK_GROUPS },
},
],
});
expect(storeUtils.loadDataFromLS).toHaveBeenCalledWith(GROUPS_LOCAL_STORAGE_KEY);
});
});
describe('loadFrequentProjects', () => {
beforeEach(() => {
storeUtils.loadDataFromLS = jest.fn().mockReturnValue(MOCK_PROJECTS);
});
it(`calls loadDataFromLS with ${PROJECTS_LOCAL_STORAGE_KEY} and LOAD_FREQUENT_ITEMS mutation`, async () => {
await testAction({
action: actions.loadFrequentProjects,
state,
expectedMutations: [
{
type: types.LOAD_FREQUENT_ITEMS,
payload: { key: PROJECTS_LOCAL_STORAGE_KEY, data: MOCK_PROJECTS },
},
],
});
expect(storeUtils.loadDataFromLS).toHaveBeenCalledWith(PROJECTS_LOCAL_STORAGE_KEY);
});
});
describe('setFrequentGroup', () => {
beforeEach(() => {
storeUtils.setFrequentItemToLS = jest.fn();
});
it(`calls setFrequentItemToLS with ${GROUPS_LOCAL_STORAGE_KEY} and item data`, async () => {
await testAction({
action: actions.setFrequentGroup,
payload: MOCK_GROUP,
state,
});
expect(storeUtils.setFrequentItemToLS).toHaveBeenCalledWith(
GROUPS_LOCAL_STORAGE_KEY,
state.frequentItems,
MOCK_GROUP,
);
});
});
describe('setFrequentProject', () => {
beforeEach(() => {
storeUtils.setFrequentItemToLS = jest.fn();
});
it(`calls setFrequentItemToLS with ${PROJECTS_LOCAL_STORAGE_KEY} and item data`, async () => {
await testAction({
action: actions.setFrequentProject,
payload: MOCK_PROJECT,
state,
});
expect(storeUtils.setFrequentItemToLS).toHaveBeenCalledWith(
PROJECTS_LOCAL_STORAGE_KEY,
state.frequentItems,
MOCK_PROJECT,
);
});
});
});
......@@ -71,4 +71,13 @@ describe('Global Search Store Mutations', () => {
expect(state.query[payload.key]).toBe(payload.value);
});
});
describe('LOAD_FREQUENT_ITEMS', () => {
it('sets frequentItems[key] to data', () => {
const payload = { key: 'test-key', data: [1, 2, 3] };
mutations[types.LOAD_FREQUENT_ITEMS](state, payload);
expect(state.frequentItems[payload.key]).toStrictEqual(payload.data);
});
});
});
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { MAX_FREQUENCY } from '~/search/store/constants';
import { loadDataFromLS, setFrequentItemToLS } from '~/search/store/utils';
import { MOCK_LS_KEY, MOCK_GROUPS } from '../mock_data';
useLocalStorageSpy();
jest.mock('~/lib/utils/accessor', () => ({
isLocalStorageAccessSafe: jest.fn().mockReturnValue(true),
}));
describe('Global Search Store Utils', () => {
afterEach(() => {
localStorage.clear();
});
describe('loadDataFromLS', () => {
let res;
describe('with valid data', () => {
beforeEach(() => {
localStorage.setItem(MOCK_LS_KEY, JSON.stringify(MOCK_GROUPS));
res = loadDataFromLS(MOCK_LS_KEY);
});
it('returns parsed array', () => {
expect(res).toStrictEqual(MOCK_GROUPS);
});
});
describe('with invalid data', () => {
beforeEach(() => {
localStorage.setItem(MOCK_LS_KEY, '[}');
res = loadDataFromLS(MOCK_LS_KEY);
});
it('wipes local storage and returns an empty array', () => {
expect(localStorage.removeItem).toHaveBeenCalledWith(MOCK_LS_KEY);
expect(res).toStrictEqual([]);
});
});
});
describe('setFrequentItemToLS', () => {
const frequentItems = {};
describe('with existing data', () => {
describe(`when frequency is less than ${MAX_FREQUENCY}`, () => {
beforeEach(() => {
frequentItems[MOCK_LS_KEY] = [{ id: MOCK_GROUPS[0].id, frequency: 1 }];
setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]);
});
it('adds 1 to the frequency and calls localStorage.setItem', () => {
expect(localStorage.setItem).toHaveBeenCalledWith(
MOCK_LS_KEY,
JSON.stringify([{ id: MOCK_GROUPS[0].id, frequency: 2 }]),
);
});
});
describe(`when frequency is equal to ${MAX_FREQUENCY}`, () => {
beforeEach(() => {
frequentItems[MOCK_LS_KEY] = [{ id: MOCK_GROUPS[0].id, frequency: MAX_FREQUENCY }];
setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]);
});
it(`does not further increase frequency past ${MAX_FREQUENCY} and calls localStorage.setItem`, () => {
expect(localStorage.setItem).toHaveBeenCalledWith(
MOCK_LS_KEY,
JSON.stringify([{ id: MOCK_GROUPS[0].id, frequency: MAX_FREQUENCY }]),
);
});
});
});
describe('with no existing data', () => {
beforeEach(() => {
frequentItems[MOCK_LS_KEY] = [];
setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]);
});
it('adds a new entry with frequency 1 and calls localStorage.setItem', () => {
expect(localStorage.setItem).toHaveBeenCalledWith(
MOCK_LS_KEY,
JSON.stringify([{ id: MOCK_GROUPS[0].id, frequency: 1 }]),
);
});
});
describe('with multiple entries', () => {
beforeEach(() => {
frequentItems[MOCK_LS_KEY] = [
{ id: MOCK_GROUPS[0].id, frequency: 1 },
{ id: MOCK_GROUPS[1].id, frequency: 1 },
];
setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[1]);
});
it('sorts the array by most frequent', () => {
expect(localStorage.setItem).toHaveBeenCalledWith(
MOCK_LS_KEY,
JSON.stringify([
{ id: MOCK_GROUPS[1].id, frequency: 2 },
{ id: MOCK_GROUPS[0].id, frequency: 1 },
]),
);
});
});
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 },
];
setFrequentItemToLS(MOCK_LS_KEY, frequentItems, { id: 6 });
});
it('removes the least frequent', () => {
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 },
]),
);
});
});
describe('with null data loaded in', () => {
beforeEach(() => {
frequentItems[MOCK_LS_KEY] = null;
setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]);
});
it('wipes local storage', () => {
expect(localStorage.removeItem).toHaveBeenCalledWith(MOCK_LS_KEY);
});
});
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import { MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { GROUPS_LOCAL_STORAGE_KEY } from '~/search/store/constants';
import GroupFilter from '~/search/topbar/components/group_filter.vue';
import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue';
import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '~/search/topbar/constants';
const localVue = createLocalVue();
localVue.use(Vuex);
Vue.use(Vuex);
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
......@@ -19,6 +20,8 @@ describe('GroupFilter', () => {
const actionSpies = {
fetchGroups: jest.fn(),
setFrequentGroup: jest.fn(),
loadFrequentGroups: jest.fn(),
};
const defaultProps = {
......@@ -35,7 +38,6 @@ describe('GroupFilter', () => {
});
wrapper = shallowMount(GroupFilter, {
localVue,
store,
propsData: {
...defaultProps,
......@@ -77,14 +79,35 @@ describe('GroupFilter', () => {
});
});
describe('when @change is emitted', () => {
describe('when @change is emitted with Any', () => {
beforeEach(() => {
createComponent();
findSearchableDropdown().vm.$emit('change', ANY_OPTION);
});
it('calls setUrlParams with group null, project id null, and then calls visitUrl', () => {
expect(setUrlParams).toHaveBeenCalledWith({
[GROUP_DATA.queryParam]: null,
[PROJECT_DATA.queryParam]: null,
});
expect(visitUrl).toHaveBeenCalled();
});
it('does not call setFrequentGroup', () => {
expect(actionSpies.setFrequentGroup).not.toHaveBeenCalled();
});
});
describe('when @change is emitted with a group', () => {
beforeEach(() => {
createComponent();
findSearchableDropdown().vm.$emit('change', MOCK_GROUP);
});
it('calls calls setUrlParams with group id, project id null, and visitUrl', () => {
it('calls setUrlParams with group id, project id null, and then calls visitUrl', () => {
expect(setUrlParams).toHaveBeenCalledWith({
[GROUP_DATA.queryParam]: MOCK_GROUP.id,
[PROJECT_DATA.queryParam]: null,
......@@ -92,6 +115,10 @@ describe('GroupFilter', () => {
expect(visitUrl).toHaveBeenCalled();
});
it(`calls setFrequentGroup with the group and ${GROUPS_LOCAL_STORAGE_KEY}`, () => {
expect(actionSpies.setFrequentGroup).toHaveBeenCalledWith(expect.any(Object), MOCK_GROUP);
});
});
});
......@@ -118,4 +145,14 @@ describe('GroupFilter', () => {
});
});
});
describe('onCreate', () => {
beforeEach(() => {
createComponent();
});
it('calls loadFrequentGroups', () => {
expect(actionSpies.loadFrequentGroups).toHaveBeenCalledTimes(1);
});
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import { MOCK_PROJECT, MOCK_QUERY } from 'jest/search/mock_data';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { PROJECTS_LOCAL_STORAGE_KEY } from '~/search/store/constants';
import ProjectFilter from '~/search/topbar/components/project_filter.vue';
import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue';
import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '~/search/topbar/constants';
const localVue = createLocalVue();
localVue.use(Vuex);
Vue.use(Vuex);
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
......@@ -19,6 +20,8 @@ describe('ProjectFilter', () => {
const actionSpies = {
fetchProjects: jest.fn(),
setFrequentProject: jest.fn(),
loadFrequentProjects: jest.fn(),
};
const defaultProps = {
......@@ -35,7 +38,6 @@ describe('ProjectFilter', () => {
});
wrapper = shallowMount(ProjectFilter, {
localVue,
store,
propsData: {
...defaultProps,
......@@ -84,12 +86,16 @@ describe('ProjectFilter', () => {
findSearchableDropdown().vm.$emit('change', ANY_OPTION);
});
it('calls setUrlParams with project id, not group id, then calls visitUrl', () => {
it('calls setUrlParams with null, no group id, then calls visitUrl', () => {
expect(setUrlParams).toHaveBeenCalledWith({
[PROJECT_DATA.queryParam]: ANY_OPTION.id,
[PROJECT_DATA.queryParam]: null,
});
expect(visitUrl).toHaveBeenCalled();
});
it('does not call setFrequentProject', () => {
expect(actionSpies.setFrequentProject).not.toHaveBeenCalled();
});
});
describe('with a Project', () => {
......@@ -104,6 +110,13 @@ describe('ProjectFilter', () => {
});
expect(visitUrl).toHaveBeenCalled();
});
it(`calls setFrequentProject with the group and ${PROJECTS_LOCAL_STORAGE_KEY}`, () => {
expect(actionSpies.setFrequentProject).toHaveBeenCalledWith(
expect.any(Object),
MOCK_PROJECT,
);
});
});
});
});
......@@ -131,4 +144,14 @@ describe('ProjectFilter', () => {
});
});
});
describe('onCreate', () => {
beforeEach(() => {
createComponent();
});
it('calls loadFrequentProjects', () => {
expect(actionSpies.loadFrequentProjects).toHaveBeenCalledTimes(1);
});
});
});
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