Commit edce7128 authored by Paul Slaughter's avatar Paul Slaughter

Refactor frequent items into vuex modules

- This will help support the top nav redesign
  since it needs to have both instances of app
  in the same Vue application.
parent 63990624
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import AccessorUtilities from '~/lib/utils/accessor';
import {
mapVuexModuleState,
mapVuexModuleActions,
mapVuexModuleGetters,
} from '~/lib/utils/vuex_module_mappers';
import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants';
import eventHub from '../event_hub';
import { isMobile, updateExistingFrequentItem, sanitizeItem } from '../utils';
......@@ -16,6 +20,7 @@ export default {
GlLoadingIcon,
},
mixins: [frequentItemsMixin],
inject: ['vuexModule'],
props: {
currentUserName: {
type: String,
......@@ -27,8 +32,13 @@ export default {
},
},
computed: {
...mapState(['searchQuery', 'isLoadingItems', 'isFetchFailed', 'items']),
...mapGetters(['hasSearchQuery']),
...mapVuexModuleState((vm) => vm.vuexModule, [
'searchQuery',
'isLoadingItems',
'isFetchFailed',
'items',
]),
...mapVuexModuleGetters((vm) => vm.vuexModule, ['hasSearchQuery']),
translations() {
return this.getTranslations(['loadingMessage', 'header']);
},
......@@ -56,7 +66,11 @@ export default {
eventHub.$off(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler);
},
methods: {
...mapActions(['setNamespace', 'setStorageKey', 'fetchFrequentItems']),
...mapVuexModuleActions((vm) => vm.vuexModule, [
'setNamespace',
'setStorageKey',
'fetchFrequentItems',
]),
dropdownOpenHandler() {
if (this.searchQuery === '' || isMobile()) {
this.fetchFrequentItems();
......@@ -101,14 +115,15 @@ export default {
<template>
<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
v-if="isLoadingItems"
:label="translations.loadingMessage"
size="lg"
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 }}
</div>
<frequent-items-list
......
<script>
/* eslint-disable vue/require-default-prop, vue/no-v-html */
import { mapState } from 'vuex';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
import { mapVuexModuleState } from '~/lib/utils/vuex_module_mappers';
import Tracking from '~/tracking';
import Identicon from '~/vue_shared/components/identicon.vue';
......@@ -13,6 +13,7 @@ export default {
Identicon,
},
mixins: [trackingMixin],
inject: ['vuexModule'],
props: {
matcher: {
type: String,
......@@ -42,7 +43,7 @@ export default {
},
},
computed: {
...mapState(['dropdownType']),
...mapVuexModuleState((vm) => vm.vuexModule, ['dropdownType']),
truncatedNamespace() {
return truncateNamespace(this.namespace);
},
......
<script>
import { GlSearchBoxByType } from '@gitlab/ui';
import { debounce } from 'lodash';
import { mapActions, mapState } from 'vuex';
import { mapVuexModuleActions, mapVuexModuleState } from '~/lib/utils/vuex_module_mappers';
import Tracking from '~/tracking';
import frequentItemsMixin from './frequent_items_mixin';
......@@ -12,13 +12,14 @@ export default {
GlSearchBoxByType,
},
mixins: [frequentItemsMixin, trackingMixin],
inject: ['vuexModule'],
data() {
return {
searchQuery: '',
};
},
computed: {
...mapState(['dropdownType']),
...mapVuexModuleState((vm) => vm.vuexModule, ['dropdownType']),
translations() {
return this.getTranslations(['searchInputPlaceholder']);
},
......@@ -32,7 +33,7 @@ export default {
}, 500),
},
methods: {
...mapActions(['setSearchQuery']),
...mapVuexModuleActions((vm) => vm.vuexModule, ['setSearchQuery']),
},
};
</script>
......
......@@ -36,3 +36,16 @@ export const TRANSLATION_KEYS = {
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 Vue from 'vue';
import Vuex from 'vuex';
import { createStore } from '~/frequent_items/store';
import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue';
import Translate from '~/vue_shared/translate';
import { FREQUENT_ITEMS_DROPDOWNS } from './constants';
import eventHub from './event_hub';
Vue.use(Vuex);
Vue.use(Translate);
const frequentItemDropdowns = [
{
namespace: 'projects',
key: 'project',
},
{
namespace: 'groups',
key: 'group',
},
];
export default function initFrequentItemDropdowns() {
frequentItemDropdowns.forEach((dropdown) => {
const { namespace, key } = dropdown;
const store = createStore();
FREQUENT_ITEMS_DROPDOWNS.forEach((dropdown) => {
const { namespace, key, vuexModule } = dropdown;
const el = document.getElementById(`js-${namespace}-dropdown`);
const navEl = document.getElementById(`nav-${namespace}-dropdown`);
......@@ -29,9 +24,6 @@ export default function initFrequentItemDropdowns() {
return;
}
const dropdownType = namespace;
const store = createStore({ dropdownType });
import('./components/app.vue')
.then(({ default: FrequentItems }) => {
// eslint-disable-next-line no-new
......@@ -55,13 +47,23 @@ export default function initFrequentItemDropdowns() {
};
},
render(createElement) {
return createElement(FrequentItems, {
props: {
namespace,
currentUserName: this.currentUserName,
currentItem: this.currentItem,
return createElement(
VuexModuleProvider,
{
props: {
vuexModule,
},
},
});
[
createElement(FrequentItems, {
props: {
namespace,
currentUserName: this.currentUserName,
currentItem: this.currentItem,
},
}),
],
);
},
});
})
......
import Vue from 'vue';
import Vuex from 'vuex';
import { FREQUENT_ITEMS_DROPDOWNS } from '../constants';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex);
export const createFrequentItemsModule = (initState = {}) => ({
namespaced: true,
actions,
getters,
mutations,
state: state(initState),
});
export const createStore = (initState = {}) => {
export const createStore = () => {
return new Vuex.Store({
actions,
getters,
mutations,
state: state(initState),
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 { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue';
import { createStore } from '~/frequent_items/store';
import { mockProject } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('FrequentItemsListItemComponent', () => {
let wrapper;
let trackingSpy;
let store = createStore();
let store;
const findTitle = () => wrapper.find({ ref: 'frequentItemsItemTitle' });
const findAvatar = () => wrapper.find({ ref: 'frequentItemsItemAvatar' });
......@@ -31,11 +35,15 @@ describe('FrequentItemsListItemComponent', () => {
avatarUrl: mockProject.avatarUrl,
...props,
},
provide: {
vuexModule: 'frequentProjects',
},
localVue,
});
};
beforeEach(() => {
store = createStore({ dropdownType: 'project' });
store = createStore();
trackingSpy = mockTracking('_category_', document, jest.spyOn);
trackingSpy.mockImplementation(() => {});
});
......@@ -119,7 +127,7 @@ describe('FrequentItemsListItemComponent', () => {
});
link.trigger('click');
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 frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue';
import { createStore } from '~/frequent_items/store';
import { mockFrequentProjects } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('FrequentItemsListComponent', () => {
let wrapper;
......@@ -18,6 +22,10 @@ describe('FrequentItemsListComponent', () => {
matcher: 'lab',
...props,
},
localVue,
provide: {
vuexModule: 'frequentProjects',
},
});
};
......
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 searchComponent from '~/frequent_items/components/frequent_items_search_input.vue';
import { createStore } from '~/frequent_items/store';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('FrequentItemsSearchInputComponent', () => {
let wrapper;
let trackingSpy;
......@@ -14,12 +18,16 @@ describe('FrequentItemsSearchInputComponent', () => {
shallowMount(searchComponent, {
store,
propsData: { namespace },
localVue,
provide: {
vuexModule: 'frequentProjects',
},
});
const findSearchBoxByType = () => wrapper.find(GlSearchBoxByType);
beforeEach(() => {
store = createStore({ dropdownType: 'project' });
store = createStore();
jest.spyOn(store, 'dispatch').mockImplementation(() => {});
trackingSpy = mockTracking('_category_', document, jest.spyOn);
......@@ -57,9 +65,9 @@ describe('FrequentItemsSearchInputComponent', () => {
await wrapper.vm.$nextTick();
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