Commit 8fd46077 authored by Simon Knox's avatar Simon Knox

Merge branch '239384_03-global-search-inflate-recent-items' into 'master'

Global Search - Inflate Recent Items

See merge request gitlab-org/gitlab!65183
parents 036225c5 99e69f66
......@@ -4,7 +4,7 @@ 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';
import { loadDataFromLS, setFrequentItemToLS, mergeById } from './utils';
export const fetchGroups = ({ commit }, search) => {
commit(types.REQUEST_GROUPS);
......@@ -41,14 +41,30 @@ export const fetchProjects = ({ commit, state }, search) => {
}
};
export const loadFrequentGroups = ({ commit }) => {
export const loadFrequentGroups = async ({ commit }) => {
const data = loadDataFromLS(GROUPS_LOCAL_STORAGE_KEY);
commit(types.LOAD_FREQUENT_ITEMS, { key: GROUPS_LOCAL_STORAGE_KEY, data });
const promises = data.map((d) => Api.group(d.id));
try {
const inflatedData = mergeById(await Promise.all(promises), data);
commit(types.LOAD_FREQUENT_ITEMS, { key: GROUPS_LOCAL_STORAGE_KEY, data: inflatedData });
} catch {
createFlash({ message: __('There was a problem fetching recent groups.') });
}
};
export const loadFrequentProjects = ({ commit }) => {
export const loadFrequentProjects = async ({ commit }) => {
const data = loadDataFromLS(PROJECTS_LOCAL_STORAGE_KEY);
commit(types.LOAD_FREQUENT_ITEMS, { key: PROJECTS_LOCAL_STORAGE_KEY, data });
const promises = data.map((d) => Api.project(d.id).then((res) => res.data));
try {
const inflatedData = mergeById(await Promise.all(promises), data);
commit(types.LOAD_FREQUENT_ITEMS, { key: PROJECTS_LOCAL_STORAGE_KEY, data: inflatedData });
} catch {
createFlash({ message: __('There was a problem fetching recent projects.') });
}
};
export const setFrequentGroup = ({ state }, item) => {
......
import AccessorUtilities from '../../lib/utils/accessor';
import { MAX_FREQUENT_ITEMS, MAX_FREQUENCY } from './constants';
function extractKeys(object, keyList) {
return Object.fromEntries(keyList.map((key) => [key, object[key]]));
}
export const loadDataFromLS = (key) => {
if (!AccessorUtilities.isLocalStorageAccessSafe()) {
return [];
......@@ -15,13 +19,16 @@ export const loadDataFromLS = (key) => {
}
};
export const setFrequentItemToLS = (key, data, item) => {
export const setFrequentItemToLS = (key, data, itemData) => {
if (!AccessorUtilities.isLocalStorageAccessSafe()) {
return;
}
const keyList = ['id', 'avatar_url', 'name', 'full_name', 'name_with_namespace', 'frequency'];
try {
const frequentItems = data[key];
const frequentItems = data[key].map((obj) => extractKeys(obj, keyList));
const item = extractKeys(itemData, keyList);
const existingItemIndex = frequentItems.findIndex((i) => i.id === item.id);
if (existingItemIndex >= 0) {
......@@ -34,7 +41,7 @@ export const setFrequentItemToLS = (key, data, item) => {
frequentItems.pop();
}
frequentItems.push({ id: item.id, frequency: 1 });
frequentItems.push({ ...item, frequency: 1 });
}
// Sort by frequency
......@@ -48,3 +55,10 @@ export const setFrequentItemToLS = (key, data, item) => {
localStorage.removeItem(key);
}
};
export const mergeById = (inflatedData, storedData) => {
return inflatedData.map((data) => {
const stored = storedData?.find((d) => d.id === data.id) || {};
return { ...stored, ...data };
});
};
......@@ -23,9 +23,6 @@ export default {
return isEmpty(this.initialData) ? ANY_OPTION : this.initialData;
},
},
created() {
this.loadFrequentGroups();
},
methods: {
...mapActions(['fetchGroups', 'setFrequentGroup', 'loadFrequentGroups']),
handleGroupChange(group) {
......@@ -52,6 +49,7 @@ export default {
:loading="fetchingGroups"
:selected-item="selectedGroup"
:items="groups"
@first-open="loadFrequentGroups"
@search="fetchGroups"
@change="handleGroupChange"
/>
......
......@@ -22,9 +22,6 @@ export default {
return this.initialData ? this.initialData : ANY_OPTION;
},
},
created() {
this.loadFrequentProjects();
},
methods: {
...mapActions(['fetchProjects', 'setFrequentProject', 'loadFrequentProjects']),
handleProjectChange(project) {
......@@ -55,6 +52,7 @@ export default {
:loading="fetchingProjects"
:selected-item="selectedProject"
:items="projects"
@first-open="loadFrequentProjects"
@search="fetchProjects"
@change="handleProjectChange"
/>
......
......@@ -65,6 +65,7 @@ export default {
data() {
return {
searchText: '',
hasBeenOpened: false,
};
},
methods: {
......@@ -72,6 +73,11 @@ export default {
return selected.id === this.selectedItem.id;
},
openDropdown() {
if (!this.hasBeenOpened) {
this.hasBeenOpened = true;
this.$emit('first-open');
}
this.$emit('search', this.searchText);
},
resetDropdown() {
......
......@@ -33051,6 +33051,12 @@ msgstr ""
msgid "There was a problem fetching project users."
msgstr ""
msgid "There was a problem fetching recent groups."
msgstr ""
msgid "There was a problem fetching recent projects."
msgstr ""
msgid "There was a problem fetching the job token scope value"
msgstr ""
......
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from '~/search/store/constants';
import * as types from '~/search/store/mutation_types';
export const MOCK_QUERY = {
scope: 'issues',
state: 'all',
......@@ -6,45 +9,45 @@ export const MOCK_QUERY = {
};
export const MOCK_GROUP = {
id: 1,
name: 'test group',
full_name: 'full name / test group',
id: 1,
};
export const MOCK_GROUPS = [
{
id: 1,
avatar_url: null,
name: 'test group',
full_name: 'full name / test group',
id: 1,
},
{
id: 2,
avatar_url: 'https://avatar.com',
name: 'test group 2',
full_name: 'full name / test group 2',
id: 2,
},
];
export const MOCK_PROJECT = {
id: 1,
name: 'test project',
namespace: MOCK_GROUP,
nameWithNamespace: 'test group / test project',
id: 1,
};
export const MOCK_PROJECTS = [
{
id: 1,
name: 'test project',
namespace: MOCK_GROUP,
name_with_namespace: 'test group / test project',
id: 1,
},
{
id: 2,
name: 'test project 2',
namespace: MOCK_GROUP,
name_with_namespace: 'test group / test project 2',
id: 2,
},
];
......@@ -65,3 +68,39 @@ export const MOCK_SORT_OPTIONS = [
];
export const MOCK_LS_KEY = 'mock-ls-key';
export const MOCK_INFLATED_DATA = [
{ id: 1, name: 'test 1' },
{ id: 2, name: 'test 2' },
];
export const FRESH_STORED_DATA = [
{ id: 1, name: 'test 1', frequency: 1 },
{ id: 2, name: 'test 2', frequency: 2 },
];
export const STALE_STORED_DATA = [
{ id: 1, name: 'blah 1', frequency: 1 },
{ id: 2, name: 'blah 2', frequency: 2 },
];
export const MOCK_FRESH_DATA_RES = { name: 'fresh' };
export const PROMISE_ALL_EXPECTED_MUTATIONS = {
initGroups: {
type: types.LOAD_FREQUENT_ITEMS,
payload: { key: GROUPS_LOCAL_STORAGE_KEY, data: FRESH_STORED_DATA },
},
resGroups: {
type: types.LOAD_FREQUENT_ITEMS,
payload: { key: GROUPS_LOCAL_STORAGE_KEY, data: [MOCK_FRESH_DATA_RES, MOCK_FRESH_DATA_RES] },
},
initProjects: {
type: types.LOAD_FREQUENT_ITEMS,
payload: { key: PROJECTS_LOCAL_STORAGE_KEY, data: FRESH_STORED_DATA },
},
resProjects: {
type: types.LOAD_FREQUENT_ITEMS,
payload: { key: PROJECTS_LOCAL_STORAGE_KEY, data: [MOCK_FRESH_DATA_RES, MOCK_FRESH_DATA_RES] },
},
};
......@@ -9,7 +9,16 @@ import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from '~/search/s
import * as types from '~/search/store/mutation_types';
import createState from '~/search/store/state';
import * as storeUtils from '~/search/store/utils';
import { MOCK_QUERY, MOCK_GROUPS, MOCK_PROJECT, MOCK_PROJECTS, MOCK_GROUP } from '../mock_data';
import {
MOCK_QUERY,
MOCK_GROUPS,
MOCK_PROJECT,
MOCK_PROJECTS,
MOCK_GROUP,
FRESH_STORED_DATA,
MOCK_FRESH_DATA_RES,
PROMISE_ALL_EXPECTED_MUTATIONS,
} from '../mock_data';
jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility', () => ({
......@@ -58,6 +67,33 @@ describe('Global Search Store Actions', () => {
});
});
describe.each`
action | axiosMock | type | expectedMutations | flashCallCount | lsKey
${actions.loadFrequentGroups} | ${{ method: 'onGet', code: 200 }} | ${'success'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.initGroups, PROMISE_ALL_EXPECTED_MUTATIONS.resGroups]} | ${0} | ${GROUPS_LOCAL_STORAGE_KEY}
${actions.loadFrequentGroups} | ${{ method: 'onGet', code: 500 }} | ${'error'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.initGroups]} | ${1} | ${GROUPS_LOCAL_STORAGE_KEY}
${actions.loadFrequentProjects} | ${{ method: 'onGet', code: 200 }} | ${'success'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.initProjects, PROMISE_ALL_EXPECTED_MUTATIONS.resProjects]} | ${0} | ${PROJECTS_LOCAL_STORAGE_KEY}
${actions.loadFrequentProjects} | ${{ method: 'onGet', code: 500 }} | ${'error'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.initProjects]} | ${1} | ${PROJECTS_LOCAL_STORAGE_KEY}
`(
'Promise.all calls',
({ action, axiosMock, type, expectedMutations, flashCallCount, lsKey }) => {
describe(action.name, () => {
describe(`on ${type}`, () => {
beforeEach(() => {
storeUtils.loadDataFromLS = jest.fn().mockReturnValue(FRESH_STORED_DATA);
mock[axiosMock.method]().reply(axiosMock.code, MOCK_FRESH_DATA_RES);
});
it(`should dispatch the correct mutations`, () => {
return testAction({ action, state, expectedMutations }).then(() => {
expect(storeUtils.loadDataFromLS).toHaveBeenCalledWith(lsKey);
flashCallback(flashCallCount);
});
});
});
});
},
);
describe('getGroupsData', () => {
const mockCommit = () => {};
beforeEach(() => {
......@@ -144,48 +180,6 @@ 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();
......
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';
import { loadDataFromLS, setFrequentItemToLS, mergeById } from '~/search/store/utils';
import {
MOCK_LS_KEY,
MOCK_GROUPS,
MOCK_INFLATED_DATA,
FRESH_STORED_DATA,
STALE_STORED_DATA,
} from '../mock_data';
useLocalStorageSpy();
jest.mock('~/lib/utils/accessor', () => ({
......@@ -46,28 +52,28 @@ describe('Global Search Store Utils', () => {
describe('with existing data', () => {
describe(`when frequency is less than ${MAX_FREQUENCY}`, () => {
beforeEach(() => {
frequentItems[MOCK_LS_KEY] = [{ id: MOCK_GROUPS[0].id, frequency: 1 }];
frequentItems[MOCK_LS_KEY] = [{ ...MOCK_GROUPS[0], 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 }]),
JSON.stringify([{ ...MOCK_GROUPS[0], frequency: 2 }]),
);
});
});
describe(`when frequency is equal to ${MAX_FREQUENCY}`, () => {
beforeEach(() => {
frequentItems[MOCK_LS_KEY] = [{ id: MOCK_GROUPS[0].id, frequency: MAX_FREQUENCY }];
frequentItems[MOCK_LS_KEY] = [{ ...MOCK_GROUPS[0], 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 }]),
JSON.stringify([{ ...MOCK_GROUPS[0], frequency: MAX_FREQUENCY }]),
);
});
});
......@@ -82,7 +88,7 @@ describe('Global Search Store Utils', () => {
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 }]),
JSON.stringify([{ ...MOCK_GROUPS[0], frequency: 1 }]),
);
});
});
......@@ -90,8 +96,8 @@ describe('Global Search Store Utils', () => {
describe('with multiple entries', () => {
beforeEach(() => {
frequentItems[MOCK_LS_KEY] = [
{ id: MOCK_GROUPS[0].id, frequency: 1 },
{ id: MOCK_GROUPS[1].id, frequency: 1 },
{ ...MOCK_GROUPS[0], frequency: 1 },
{ ...MOCK_GROUPS[1], frequency: 1 },
];
setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[1]);
});
......@@ -100,8 +106,8 @@ describe('Global Search Store Utils', () => {
expect(localStorage.setItem).toHaveBeenCalledWith(
MOCK_LS_KEY,
JSON.stringify([
{ id: MOCK_GROUPS[1].id, frequency: 2 },
{ id: MOCK_GROUPS[0].id, frequency: 1 },
{ ...MOCK_GROUPS[1], frequency: 2 },
{ ...MOCK_GROUPS[0], frequency: 1 },
]),
);
});
......@@ -143,5 +149,40 @@ describe('Global Search Store Utils', () => {
expect(localStorage.removeItem).toHaveBeenCalledWith(MOCK_LS_KEY);
});
});
describe('with additional data', () => {
beforeEach(() => {
const MOCK_ADDITIONAL_DATA_GROUP = { ...MOCK_GROUPS[0], extraData: 'test' };
frequentItems[MOCK_LS_KEY] = [];
setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_ADDITIONAL_DATA_GROUP);
});
it('parses out extra data for LS', () => {
expect(localStorage.setItem).toHaveBeenCalledWith(
MOCK_LS_KEY,
JSON.stringify([{ ...MOCK_GROUPS[0], frequency: 1 }]),
);
});
});
});
describe.each`
description | inflatedData | storedData | response
${'identical'} | ${MOCK_INFLATED_DATA} | ${FRESH_STORED_DATA} | ${FRESH_STORED_DATA}
${'stale'} | ${MOCK_INFLATED_DATA} | ${STALE_STORED_DATA} | ${FRESH_STORED_DATA}
${'empty'} | ${MOCK_INFLATED_DATA} | ${[]} | ${MOCK_INFLATED_DATA}
${'null'} | ${MOCK_INFLATED_DATA} | ${null} | ${MOCK_INFLATED_DATA}
`('mergeById', ({ description, inflatedData, storedData, response }) => {
describe(`with ${description} storedData`, () => {
let res;
beforeEach(() => {
res = mergeById(inflatedData, storedData);
});
it('prioritizes inflatedData and preserves frequency count', () => {
expect(response).toStrictEqual(res);
});
});
});
});
......@@ -64,12 +64,14 @@ describe('GroupFilter', () => {
});
describe('events', () => {
beforeEach(() => {
createComponent();
});
describe('when @search is emitted', () => {
const search = 'test';
beforeEach(() => {
createComponent();
findSearchableDropdown().vm.$emit('search', search);
});
......@@ -81,8 +83,6 @@ describe('GroupFilter', () => {
describe('when @change is emitted with Any', () => {
beforeEach(() => {
createComponent();
findSearchableDropdown().vm.$emit('change', ANY_OPTION);
});
......@@ -102,8 +102,6 @@ describe('GroupFilter', () => {
describe('when @change is emitted with a group', () => {
beforeEach(() => {
createComponent();
findSearchableDropdown().vm.$emit('change', MOCK_GROUP);
});
......@@ -120,6 +118,16 @@ describe('GroupFilter', () => {
expect(actionSpies.setFrequentGroup).toHaveBeenCalledWith(expect.any(Object), MOCK_GROUP);
});
});
describe('when @first-open is emitted', () => {
beforeEach(() => {
findSearchableDropdown().vm.$emit('first-open');
});
it('calls loadFrequentGroups', () => {
expect(actionSpies.loadFrequentGroups).toHaveBeenCalledTimes(1);
});
});
});
describe('computed', () => {
......@@ -145,14 +153,4 @@ describe('GroupFilter', () => {
});
});
});
describe('onCreate', () => {
beforeEach(() => {
createComponent();
});
it('calls loadFrequentGroups', () => {
expect(actionSpies.loadFrequentGroups).toHaveBeenCalledTimes(1);
});
});
});
......@@ -119,6 +119,16 @@ describe('ProjectFilter', () => {
});
});
});
describe('when @first-open is emitted', () => {
beforeEach(() => {
findSearchableDropdown().vm.$emit('first-open');
});
it('calls loadFrequentProjects', () => {
expect(actionSpies.loadFrequentProjects).toHaveBeenCalledTimes(1);
});
});
});
describe('computed', () => {
......@@ -144,14 +154,4 @@ describe('ProjectFilter', () => {
});
});
});
describe('onCreate', () => {
beforeEach(() => {
createComponent();
});
it('calls loadFrequentProjects', () => {
expect(actionSpies.loadFrequentProjects).toHaveBeenCalledTimes(1);
});
});
});
......@@ -159,5 +159,30 @@ describe('Global Search Searchable Dropdown', () => {
expect(wrapper.emitted('change')[0]).toEqual([MOCK_GROUPS[0]]);
});
describe('opening the dropdown', () => {
describe('for the first time', () => {
beforeEach(() => {
findGlDropdown().vm.$emit('show');
});
it('$emits @search and @first-open', () => {
expect(wrapper.emitted('search')[0]).toStrictEqual([wrapper.vm.searchText]);
expect(wrapper.emitted('first-open')[0]).toStrictEqual([]);
});
});
describe('not for the first time', () => {
beforeEach(() => {
wrapper.setData({ hasBeenOpened: true });
findGlDropdown().vm.$emit('show');
});
it('$emits @search and not @first-open', () => {
expect(wrapper.emitted('search')[0]).toStrictEqual([wrapper.vm.searchText]);
expect(wrapper.emitted('first-open')).toBeUndefined();
});
});
});
});
});
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