Commit ad3db46a authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '301143-refactor-frequent-items-store' into 'master'

Refactor frequent items into vuex modules

See merge request gitlab-org/gitlab!60893
parents d780b8ba edce7128
<script> <script>
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import AccessorUtilities from '~/lib/utils/accessor'; import AccessorUtilities from '~/lib/utils/accessor';
import {
mapVuexModuleState,
mapVuexModuleActions,
mapVuexModuleGetters,
} from '~/lib/utils/vuex_module_mappers';
import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants'; import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import { isMobile, updateExistingFrequentItem, sanitizeItem } from '../utils'; import { isMobile, updateExistingFrequentItem, sanitizeItem } from '../utils';
...@@ -16,6 +20,7 @@ export default { ...@@ -16,6 +20,7 @@ export default {
GlLoadingIcon, GlLoadingIcon,
}, },
mixins: [frequentItemsMixin], mixins: [frequentItemsMixin],
inject: ['vuexModule'],
props: { props: {
currentUserName: { currentUserName: {
type: String, type: String,
...@@ -27,8 +32,13 @@ export default { ...@@ -27,8 +32,13 @@ export default {
}, },
}, },
computed: { computed: {
...mapState(['searchQuery', 'isLoadingItems', 'isFetchFailed', 'items']), ...mapVuexModuleState((vm) => vm.vuexModule, [
...mapGetters(['hasSearchQuery']), 'searchQuery',
'isLoadingItems',
'isFetchFailed',
'items',
]),
...mapVuexModuleGetters((vm) => vm.vuexModule, ['hasSearchQuery']),
translations() { translations() {
return this.getTranslations(['loadingMessage', 'header']); return this.getTranslations(['loadingMessage', 'header']);
}, },
...@@ -56,7 +66,11 @@ export default { ...@@ -56,7 +66,11 @@ export default {
eventHub.$off(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler); eventHub.$off(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler);
}, },
methods: { methods: {
...mapActions(['setNamespace', 'setStorageKey', 'fetchFrequentItems']), ...mapVuexModuleActions((vm) => vm.vuexModule, [
'setNamespace',
'setStorageKey',
'fetchFrequentItems',
]),
dropdownOpenHandler() { dropdownOpenHandler() {
if (this.searchQuery === '' || isMobile()) { if (this.searchQuery === '' || isMobile()) {
this.fetchFrequentItems(); this.fetchFrequentItems();
...@@ -101,14 +115,15 @@ export default { ...@@ -101,14 +115,15 @@ export default {
<template> <template>
<div class="gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch gl-h-full"> <div class="gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch gl-h-full">
<frequent-items-search-input :namespace="namespace" /> <frequent-items-search-input :namespace="namespace" data-testid="frequent-items-search-input" />
<gl-loading-icon <gl-loading-icon
v-if="isLoadingItems" v-if="isLoadingItems"
:label="translations.loadingMessage" :label="translations.loadingMessage"
size="lg" size="lg"
class="loading-animation prepend-top-20" class="loading-animation prepend-top-20"
data-testid="loading"
/> />
<div v-if="!isLoadingItems && !hasSearchQuery" class="section-header"> <div v-if="!isLoadingItems && !hasSearchQuery" class="section-header" data-testid="header">
{{ translations.header }} {{ translations.header }}
</div> </div>
<frequent-items-list <frequent-items-list
......
<script> <script>
/* eslint-disable vue/require-default-prop, vue/no-v-html */ /* eslint-disable vue/require-default-prop, vue/no-v-html */
import { mapState } from 'vuex';
import highlight from '~/lib/utils/highlight'; import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility'; import { truncateNamespace } from '~/lib/utils/text_utility';
import { mapVuexModuleState } from '~/lib/utils/vuex_module_mappers';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import Identicon from '~/vue_shared/components/identicon.vue'; import Identicon from '~/vue_shared/components/identicon.vue';
...@@ -13,6 +13,7 @@ export default { ...@@ -13,6 +13,7 @@ export default {
Identicon, Identicon,
}, },
mixins: [trackingMixin], mixins: [trackingMixin],
inject: ['vuexModule'],
props: { props: {
matcher: { matcher: {
type: String, type: String,
...@@ -42,7 +43,7 @@ export default { ...@@ -42,7 +43,7 @@ export default {
}, },
}, },
computed: { computed: {
...mapState(['dropdownType']), ...mapVuexModuleState((vm) => vm.vuexModule, ['dropdownType']),
truncatedNamespace() { truncatedNamespace() {
return truncateNamespace(this.namespace); return truncateNamespace(this.namespace);
}, },
......
<script> <script>
import { GlSearchBoxByType } from '@gitlab/ui'; import { GlSearchBoxByType } from '@gitlab/ui';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { mapActions, mapState } from 'vuex'; import { mapVuexModuleActions, mapVuexModuleState } from '~/lib/utils/vuex_module_mappers';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import frequentItemsMixin from './frequent_items_mixin'; import frequentItemsMixin from './frequent_items_mixin';
...@@ -12,13 +12,14 @@ export default { ...@@ -12,13 +12,14 @@ export default {
GlSearchBoxByType, GlSearchBoxByType,
}, },
mixins: [frequentItemsMixin, trackingMixin], mixins: [frequentItemsMixin, trackingMixin],
inject: ['vuexModule'],
data() { data() {
return { return {
searchQuery: '', searchQuery: '',
}; };
}, },
computed: { computed: {
...mapState(['dropdownType']), ...mapVuexModuleState((vm) => vm.vuexModule, ['dropdownType']),
translations() { translations() {
return this.getTranslations(['searchInputPlaceholder']); return this.getTranslations(['searchInputPlaceholder']);
}, },
...@@ -32,7 +33,7 @@ export default { ...@@ -32,7 +33,7 @@ export default {
}, 500), }, 500),
}, },
methods: { methods: {
...mapActions(['setSearchQuery']), ...mapVuexModuleActions((vm) => vm.vuexModule, ['setSearchQuery']),
}, },
}; };
</script> </script>
......
...@@ -36,3 +36,16 @@ export const TRANSLATION_KEYS = { ...@@ -36,3 +36,16 @@ export const TRANSLATION_KEYS = {
searchInputPlaceholder: s__('GroupsDropdown|Search your groups'), searchInputPlaceholder: s__('GroupsDropdown|Search your groups'),
}, },
}; };
export const FREQUENT_ITEMS_DROPDOWNS = [
{
namespace: 'projects',
key: 'project',
vuexModule: 'frequentProjects',
},
{
namespace: 'groups',
key: 'group',
vuexModule: 'frequentGroups',
},
];
import $ from 'jquery'; import $ from 'jquery';
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex';
import { createStore } from '~/frequent_items/store'; import { createStore } from '~/frequent_items/store';
import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue';
import Translate from '~/vue_shared/translate'; import Translate from '~/vue_shared/translate';
import { FREQUENT_ITEMS_DROPDOWNS } from './constants';
import eventHub from './event_hub'; import eventHub from './event_hub';
Vue.use(Vuex);
Vue.use(Translate); Vue.use(Translate);
const frequentItemDropdowns = [
{
namespace: 'projects',
key: 'project',
},
{
namespace: 'groups',
key: 'group',
},
];
export default function initFrequentItemDropdowns() { export default function initFrequentItemDropdowns() {
frequentItemDropdowns.forEach((dropdown) => { const store = createStore();
const { namespace, key } = dropdown;
FREQUENT_ITEMS_DROPDOWNS.forEach((dropdown) => {
const { namespace, key, vuexModule } = dropdown;
const el = document.getElementById(`js-${namespace}-dropdown`); const el = document.getElementById(`js-${namespace}-dropdown`);
const navEl = document.getElementById(`nav-${namespace}-dropdown`); const navEl = document.getElementById(`nav-${namespace}-dropdown`);
...@@ -29,9 +24,6 @@ export default function initFrequentItemDropdowns() { ...@@ -29,9 +24,6 @@ export default function initFrequentItemDropdowns() {
return; return;
} }
const dropdownType = namespace;
const store = createStore({ dropdownType });
import('./components/app.vue') import('./components/app.vue')
.then(({ default: FrequentItems }) => { .then(({ default: FrequentItems }) => {
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
...@@ -55,13 +47,23 @@ export default function initFrequentItemDropdowns() { ...@@ -55,13 +47,23 @@ export default function initFrequentItemDropdowns() {
}; };
}, },
render(createElement) { render(createElement) {
return createElement(FrequentItems, { return createElement(
VuexModuleProvider,
{
props: {
vuexModule,
},
},
[
createElement(FrequentItems, {
props: { props: {
namespace, namespace,
currentUserName: this.currentUserName, currentUserName: this.currentUserName,
currentItem: this.currentItem, currentItem: this.currentItem,
}, },
}); }),
],
);
}, },
}); });
}) })
......
import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { FREQUENT_ITEMS_DROPDOWNS } from '../constants';
import * as actions from './actions'; import * as actions from './actions';
import * as getters from './getters'; import * as getters from './getters';
import mutations from './mutations'; import mutations from './mutations';
import state from './state'; import state from './state';
Vue.use(Vuex); export const createFrequentItemsModule = (initState = {}) => ({
namespaced: true,
export const createStore = (initState = {}) => {
return new Vuex.Store({
actions, actions,
getters, getters,
mutations, mutations,
state: state(initState), state: state(initState),
});
export const createStore = () => {
return new Vuex.Store({
modules: FREQUENT_ITEMS_DROPDOWNS.reduce(
(acc, { namespace, vuexModule }) =>
Object.assign(acc, {
[vuexModule]: createFrequentItemsModule({ dropdownType: namespace }),
}),
{},
),
}); });
}; };
import { mapValues, isString } from 'lodash';
import { mapState, mapActions } from 'vuex';
export const REQUIRE_STRING_ERROR_MESSAGE =
'`vuex_module_mappers` can only be used with an array of strings, or an object with string values. Consider using the regular `vuex` map helpers instead.';
const normalizeFieldsToObject = (fields) => {
return Array.isArray(fields)
? fields.reduce((acc, key) => Object.assign(acc, { [key]: key }), {})
: fields;
};
const mapVuexModuleFields = ({ namespaceSelector, fields, vuexHelper, selector } = {}) => {
// The `vuexHelper` needs an object which maps keys to field selector functions.
const map = mapValues(normalizeFieldsToObject(fields), (value) => {
if (!isString(value)) {
throw new Error(REQUIRE_STRING_ERROR_MESSAGE);
}
// We need to use a good ol' function to capture the right "this".
return function mappedFieldSelector(...args) {
const namespace = namespaceSelector(this);
return selector(namespace, value, ...args);
};
});
return vuexHelper(map);
};
/**
* Like `mapState`, but takes a function in the first param for selecting a namespace.
*
* ```
* computed: {
* ...mapVuexModuleState(vm => vm.vuexModule, ['foo']),
* }
* ```
*
* @param {Function} namespaceSelector
* @param {Array|Object} fields
*/
export const mapVuexModuleState = (namespaceSelector, fields) =>
mapVuexModuleFields({
namespaceSelector,
fields,
vuexHelper: mapState,
selector: (namespace, value, state) => state[namespace][value],
});
/**
* Like `mapActions`, but takes a function in the first param for selecting a namespace.
*
* ```
* methods: {
* ...mapVuexModuleActions(vm => vm.vuexModule, ['fetchFoos']),
* }
* ```
*
* @param {Function} namespaceSelector
* @param {Array|Object} fields
*/
export const mapVuexModuleActions = (namespaceSelector, fields) =>
mapVuexModuleFields({
namespaceSelector,
fields,
vuexHelper: mapActions,
selector: (namespace, value, dispatch, ...args) => dispatch(`${namespace}/${value}`, ...args),
});
/**
* Like `mapGetters`, but takes a function in the first param for selecting a namespace.
*
* ```
* computed: {
* ...mapGetters(vm => vm.vuexModule, ['hasSearchInfo']),
* }
* ```
*
* @param {Function} namespaceSelector
* @param {Array|Object} fields
*/
export const mapVuexModuleGetters = (namespaceSelector, fields) =>
mapVuexModuleFields({
namespaceSelector,
fields,
// `mapGetters` does not let us pass an object which maps to functions. Thankfully `mapState` does
// and gives us access to the getters.
vuexHelper: mapState,
selector: (namespace, value, state, getters) => getters[`${namespace}/${value}`],
});
<script>
export default {
provide() {
return {
vuexModule: this.vuexModule,
};
},
props: {
vuexModule: {
type: String,
required: true,
},
},
render() {
return this.$slots.default;
},
};
</script>
import { shallowMount } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { trimText } from 'helpers/text_helper'; import { trimText } from 'helpers/text_helper';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue'; import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue';
import { createStore } from '~/frequent_items/store'; import { createStore } from '~/frequent_items/store';
import { mockProject } from '../mock_data'; import { mockProject } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('FrequentItemsListItemComponent', () => { describe('FrequentItemsListItemComponent', () => {
let wrapper; let wrapper;
let trackingSpy; let trackingSpy;
let store = createStore(); let store;
const findTitle = () => wrapper.find({ ref: 'frequentItemsItemTitle' }); const findTitle = () => wrapper.find({ ref: 'frequentItemsItemTitle' });
const findAvatar = () => wrapper.find({ ref: 'frequentItemsItemAvatar' }); const findAvatar = () => wrapper.find({ ref: 'frequentItemsItemAvatar' });
...@@ -31,11 +35,15 @@ describe('FrequentItemsListItemComponent', () => { ...@@ -31,11 +35,15 @@ describe('FrequentItemsListItemComponent', () => {
avatarUrl: mockProject.avatarUrl, avatarUrl: mockProject.avatarUrl,
...props, ...props,
}, },
provide: {
vuexModule: 'frequentProjects',
},
localVue,
}); });
}; };
beforeEach(() => { beforeEach(() => {
store = createStore({ dropdownType: 'project' }); store = createStore();
trackingSpy = mockTracking('_category_', document, jest.spyOn); trackingSpy = mockTracking('_category_', document, jest.spyOn);
trackingSpy.mockImplementation(() => {}); trackingSpy.mockImplementation(() => {});
}); });
...@@ -119,7 +127,7 @@ describe('FrequentItemsListItemComponent', () => { ...@@ -119,7 +127,7 @@ describe('FrequentItemsListItemComponent', () => {
}); });
link.trigger('click'); link.trigger('click');
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_link', { expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_link', {
label: 'project_dropdown_frequent_items_list_item', label: 'projects_dropdown_frequent_items_list_item',
}); });
}); });
}); });
......
import { mount } from '@vue/test-utils'; import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import frequentItemsListComponent from '~/frequent_items/components/frequent_items_list.vue'; import frequentItemsListComponent from '~/frequent_items/components/frequent_items_list.vue';
import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue'; import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue';
import { createStore } from '~/frequent_items/store'; import { createStore } from '~/frequent_items/store';
import { mockFrequentProjects } from '../mock_data'; import { mockFrequentProjects } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('FrequentItemsListComponent', () => { describe('FrequentItemsListComponent', () => {
let wrapper; let wrapper;
...@@ -18,6 +22,10 @@ describe('FrequentItemsListComponent', () => { ...@@ -18,6 +22,10 @@ describe('FrequentItemsListComponent', () => {
matcher: 'lab', matcher: 'lab',
...props, ...props,
}, },
localVue,
provide: {
vuexModule: 'frequentProjects',
},
}); });
}; };
......
import { GlSearchBoxByType } from '@gitlab/ui'; import { GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import searchComponent from '~/frequent_items/components/frequent_items_search_input.vue'; import searchComponent from '~/frequent_items/components/frequent_items_search_input.vue';
import { createStore } from '~/frequent_items/store'; import { createStore } from '~/frequent_items/store';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('FrequentItemsSearchInputComponent', () => { describe('FrequentItemsSearchInputComponent', () => {
let wrapper; let wrapper;
let trackingSpy; let trackingSpy;
...@@ -14,12 +18,16 @@ describe('FrequentItemsSearchInputComponent', () => { ...@@ -14,12 +18,16 @@ describe('FrequentItemsSearchInputComponent', () => {
shallowMount(searchComponent, { shallowMount(searchComponent, {
store, store,
propsData: { namespace }, propsData: { namespace },
localVue,
provide: {
vuexModule: 'frequentProjects',
},
}); });
const findSearchBoxByType = () => wrapper.find(GlSearchBoxByType); const findSearchBoxByType = () => wrapper.find(GlSearchBoxByType);
beforeEach(() => { beforeEach(() => {
store = createStore({ dropdownType: 'project' }); store = createStore();
jest.spyOn(store, 'dispatch').mockImplementation(() => {}); jest.spyOn(store, 'dispatch').mockImplementation(() => {});
trackingSpy = mockTracking('_category_', document, jest.spyOn); trackingSpy = mockTracking('_category_', document, jest.spyOn);
...@@ -57,9 +65,9 @@ describe('FrequentItemsSearchInputComponent', () => { ...@@ -57,9 +65,9 @@ describe('FrequentItemsSearchInputComponent', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'type_search_query', { expect(trackingSpy).toHaveBeenCalledWith(undefined, 'type_search_query', {
label: 'project_dropdown_frequent_items_search_input', label: 'projects_dropdown_frequent_items_search_input',
}); });
expect(store.dispatch).toHaveBeenCalledWith('setSearchQuery', value); expect(store.dispatch).toHaveBeenCalledWith('frequentProjects/setSearchQuery', value);
}); });
}); });
}); });
import { mount, createLocalVue } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import {
mapVuexModuleActions,
mapVuexModuleGetters,
mapVuexModuleState,
REQUIRE_STRING_ERROR_MESSAGE,
} from '~/lib/utils/vuex_module_mappers';
const TEST_MODULE_NAME = 'testModuleName';
const localVue = createLocalVue();
localVue.use(Vuex);
// setup test component and store ----------------------------------------------
//
// These are used to indirectly test `vuex_module_mappers`.
const TestComponent = Vue.extend({
props: {
vuexModule: {
type: String,
required: true,
},
},
computed: {
...mapVuexModuleState((vm) => vm.vuexModule, { name: 'name', value: 'count' }),
...mapVuexModuleGetters((vm) => vm.vuexModule, ['hasValue', 'hasName']),
stateJson() {
return JSON.stringify({
name: this.name,
value: this.value,
});
},
gettersJson() {
return JSON.stringify({
hasValue: this.hasValue,
hasName: this.hasName,
});
},
},
methods: {
...mapVuexModuleActions((vm) => vm.vuexModule, ['increment']),
},
template: `
<div>
<pre data-testid="state">{{ stateJson }}</pre>
<pre data-testid="getters">{{ gettersJson }}</pre>
</div>`,
});
const createTestStore = () => {
return new Vuex.Store({
modules: {
[TEST_MODULE_NAME]: {
namespaced: true,
state: {
name: 'Lorem',
count: 0,
},
mutations: {
INCREMENT: (state, amount) => {
state.count += amount;
},
},
actions: {
increment({ commit }, amount) {
commit('INCREMENT', amount);
},
},
getters: {
hasValue: (state) => state.count > 0,
hasName: (state) => Boolean(state.name.length),
},
},
},
});
};
describe('~/lib/utils/vuex_module_mappers', () => {
let store;
let wrapper;
const getJsonInTemplate = (testId) =>
JSON.parse(wrapper.find(`[data-testid="${testId}"]`).text());
const getMappedState = () => getJsonInTemplate('state');
const getMappedGetters = () => getJsonInTemplate('getters');
beforeEach(() => {
store = createTestStore();
wrapper = mount(TestComponent, {
propsData: {
vuexModule: TEST_MODULE_NAME,
},
store,
localVue,
});
});
afterEach(() => {
wrapper.destroy();
});
describe('from module defined by prop', () => {
it('maps state', () => {
expect(getMappedState()).toEqual({
name: store.state[TEST_MODULE_NAME].name,
value: store.state[TEST_MODULE_NAME].count,
});
});
it('maps getters', () => {
expect(getMappedGetters()).toEqual({
hasName: true,
hasValue: false,
});
});
it('maps action', () => {
jest.spyOn(store, 'dispatch');
expect(store.dispatch).not.toHaveBeenCalled();
wrapper.vm.increment(10);
expect(store.dispatch).toHaveBeenCalledWith(`${TEST_MODULE_NAME}/increment`, 10);
});
});
describe('with non-string object value', () => {
it('throws helpful error', () => {
expect(() => mapVuexModuleActions((vm) => vm.bogus, { foo: () => {} })).toThrowError(
REQUIRE_STRING_ERROR_MESSAGE,
);
});
});
});
import { mount } from '@vue/test-utils';
import Vue from 'vue';
import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue';
const TestComponent = Vue.extend({
inject: ['vuexModule'],
template: `<div data-testid="vuexModule">{{ vuexModule }}</div> `,
});
const TEST_VUEX_MODULE = 'testVuexModule';
describe('~/vue_shared/components/vuex_module_provider', () => {
let wrapper;
const findProvidedVuexModule = () => wrapper.find('[data-testid="vuexModule"]').text();
beforeEach(() => {
wrapper = mount(VuexModuleProvider, {
propsData: {
vuexModule: TEST_VUEX_MODULE,
},
slots: {
default: TestComponent,
},
});
});
it('provides "vuexModule" set from prop', () => {
expect(findProvidedVuexModule()).toBe(TEST_VUEX_MODULE);
});
});
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