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, {
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 createStore = (initState = {}) => {
return new Vuex.Store({
export const createFrequentItemsModule = (initState = {}) => ({
namespaced: true,
actions,
getters,
mutations,
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 MockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
import { useRealDate } from 'helpers/fake_date';
import Vuex from 'vuex';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import appComponent from '~/frequent_items/components/app.vue';
import App from '~/frequent_items/components/app.vue';
import FrequentItemsList from '~/frequent_items/components/frequent_items_list.vue';
import { FREQUENT_ITEMS, HOUR_IN_MS } from '~/frequent_items/constants';
import eventHub from '~/frequent_items/event_hub';
import { createStore } from '~/frequent_items/store';
......@@ -12,246 +13,230 @@ import { getTopFrequentItems } from '~/frequent_items/utils';
import axios from '~/lib/utils/axios_utils';
import { currentSession, mockFrequentProjects, mockSearchedProjects } from '../mock_data';
Vue.use(Vuex);
useLocalStorageSpy();
let session;
const createComponentWithStore = (namespace = 'projects') => {
session = currentSession[namespace];
const TEST_NAMESPACE = 'projects';
const TEST_VUEX_MODULE = 'frequentProjects';
const TEST_PROJECT = currentSession[TEST_NAMESPACE].project;
const TEST_STORAGE_KEY = currentSession[TEST_NAMESPACE].storageKey;
describe('Frequent Items App Component', () => {
let wrapper;
let mock;
let store;
const createComponent = ({ currentItem = null } = {}) => {
const session = currentSession[TEST_NAMESPACE];
gon.api_version = session.apiVersion;
const Component = Vue.extend(appComponent);
const store = createStore();
return mountComponentWithStore(Component, {
wrapper = mountExtended(App, {
store,
props: {
namespace,
propsData: {
namespace: TEST_NAMESPACE,
currentUserName: session.username,
currentItem: session.project || session.group,
currentItem: currentItem || session.project,
},
provide: {
vuexModule: TEST_VUEX_MODULE,
},
});
};
};
describe('Frequent Items App Component', () => {
let vm;
let mock;
const triggerDropdownOpen = () => eventHub.$emit(`${TEST_NAMESPACE}-dropdownOpen`);
const getStoredProjects = () => JSON.parse(localStorage.getItem(TEST_STORAGE_KEY));
const findSearchInput = () => wrapper.findByTestId('frequent-items-search-input');
const findLoading = () => wrapper.findByTestId('loading');
const findSectionHeader = () => wrapper.findByTestId('header');
const findFrequentItemsList = () => wrapper.findComponent(FrequentItemsList);
const findFrequentItems = () => findFrequentItemsList().findAll('li');
const setSearch = (search) => {
const searchInput = wrapper.find('input');
searchInput.setValue(search);
};
beforeEach(() => {
mock = new MockAdapter(axios);
vm = createComponentWithStore();
store = createStore();
});
afterEach(() => {
mock.restore();
vm.$destroy();
wrapper.destroy();
});
describe('methods', () => {
describe('dropdownOpenHandler', () => {
it('should fetch frequent items when no search has been previously made on desktop', () => {
jest.spyOn(vm, 'fetchFrequentItems').mockImplementation(() => {});
vm.dropdownOpenHandler();
expect(vm.fetchFrequentItems).toHaveBeenCalledWith();
});
});
describe('logItemAccess', () => {
let storage;
describe('default', () => {
beforeEach(() => {
storage = {};
jest.spyOn(store, 'dispatch');
localStorage.setItem.mockImplementation((storageKey, value) => {
storage[storageKey] = value;
createComponent();
});
localStorage.getItem.mockImplementation((storageKey) => {
if (storage[storageKey]) {
return storage[storageKey];
}
it('should fetch frequent items', () => {
triggerDropdownOpen();
return null;
expect(store.dispatch).toHaveBeenCalledWith(`${TEST_VUEX_MODULE}/fetchFrequentItems`);
});
});
it('should create a project store if it does not exist and adds a project', () => {
vm.logItemAccess(session.storageKey, session.project);
const projects = JSON.parse(storage[session.storageKey]);
it('should not fetch frequent items if detroyed', () => {
wrapper.destroy();
triggerDropdownOpen();
expect(projects.length).toBe(1);
expect(projects[0].frequency).toBe(1);
expect(projects[0].lastAccessedOn).toBeDefined();
expect(store.dispatch).not.toHaveBeenCalledWith(`${TEST_VUEX_MODULE}/fetchFrequentItems`);
});
it('should prevent inserting same report multiple times into store', () => {
vm.logItemAccess(session.storageKey, session.project);
vm.logItemAccess(session.storageKey, session.project);
const projects = JSON.parse(storage[session.storageKey]);
expect(projects.length).toBe(1);
it('should render search input', () => {
expect(findSearchInput().exists()).toBe(true);
});
describe('with real date', () => {
useRealDate();
it('should increase frequency of report if it was logged multiple times over the course of an hour', () => {
let projects;
const newTimestamp = Date.now() + HOUR_IN_MS + 1;
vm.logItemAccess(session.storageKey, session.project);
projects = JSON.parse(storage[session.storageKey]);
it('should render loading animation', async () => {
triggerDropdownOpen();
store.state[TEST_VUEX_MODULE].isLoadingItems = true;
expect(projects[0].frequency).toBe(1);
await wrapper.vm.$nextTick();
vm.logItemAccess(session.storageKey, {
...session.project,
lastAccessedOn: newTimestamp,
});
projects = JSON.parse(storage[session.storageKey]);
const loading = findLoading();
expect(projects[0].frequency).toBe(2);
expect(projects[0].lastAccessedOn).not.toBe(session.project.lastAccessedOn);
});
expect(loading.exists()).toBe(true);
expect(loading.find('[aria-label="Loading projects"]').exists()).toBe(true);
});
it('should always update project metadata', () => {
let projects;
const oldProject = {
...session.project,
};
it('should render frequent projects list header', () => {
const sectionHeader = findSectionHeader();
const newProject = {
...session.project,
name: 'New Name',
avatarUrl: 'new/avatar.png',
namespace: 'New / Namespace',
webUrl: 'http://localhost/new/web/url',
};
expect(sectionHeader.exists()).toBe(true);
expect(sectionHeader.text()).toBe('Frequently visited');
});
vm.logItemAccess(session.storageKey, oldProject);
projects = JSON.parse(storage[session.storageKey]);
it('should render frequent projects list', async () => {
const expectedResult = getTopFrequentItems(mockFrequentProjects);
localStorage.setItem(TEST_STORAGE_KEY, JSON.stringify(mockFrequentProjects));
expect(projects[0].name).toBe(oldProject.name);
expect(projects[0].avatarUrl).toBe(oldProject.avatarUrl);
expect(projects[0].namespace).toBe(oldProject.namespace);
expect(projects[0].webUrl).toBe(oldProject.webUrl);
expect(findFrequentItems().length).toBe(1);
vm.logItemAccess(session.storageKey, newProject);
projects = JSON.parse(storage[session.storageKey]);
triggerDropdownOpen();
await wrapper.vm.$nextTick();
expect(projects[0].name).toBe(newProject.name);
expect(projects[0].avatarUrl).toBe(newProject.avatarUrl);
expect(projects[0].namespace).toBe(newProject.namespace);
expect(projects[0].webUrl).toBe(newProject.webUrl);
expect(findFrequentItems().length).toBe(expectedResult.length);
expect(findFrequentItemsList().props()).toEqual({
items: expectedResult,
namespace: TEST_NAMESPACE,
hasSearchQuery: false,
isFetchFailed: false,
matcher: '',
});
});
it('should not add more than 20 projects in store', () => {
for (let id = 0; id < FREQUENT_ITEMS.MAX_COUNT; id += 1) {
const project = {
...session.project,
id,
};
vm.logItemAccess(session.storageKey, project);
}
const projects = JSON.parse(storage[session.storageKey]);
it('should render searched projects list', async () => {
mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects.data);
expect(projects.length).toBe(FREQUENT_ITEMS.MAX_COUNT);
});
});
});
setSearch('gitlab');
await wrapper.vm.$nextTick();
describe('created', () => {
it('should bind event listeners on eventHub', (done) => {
jest.spyOn(eventHub, '$on').mockImplementation(() => {});
expect(findLoading().exists()).toBe(true);
createComponentWithStore().$mount();
await waitForPromises();
Vue.nextTick(() => {
expect(eventHub.$on).toHaveBeenCalledWith('projects-dropdownOpen', expect.any(Function));
done();
});
expect(findFrequentItems().length).toBe(mockSearchedProjects.data.length);
expect(findFrequentItemsList().props()).toEqual(
expect.objectContaining({
items: mockSearchedProjects.data.map(
({ avatar_url, web_url, name_with_namespace, ...item }) => ({
...item,
avatarUrl: avatar_url,
webUrl: web_url,
namespace: name_with_namespace,
}),
),
namespace: TEST_NAMESPACE,
hasSearchQuery: true,
isFetchFailed: false,
matcher: 'gitlab',
}),
);
});
});
describe('beforeDestroy', () => {
it('should unbind event listeners on eventHub', (done) => {
jest.spyOn(eventHub, '$off').mockImplementation(() => {});
vm.$mount();
vm.$destroy();
describe('logging', () => {
it('when created, it should create a project storage entry and adds a project', () => {
createComponent();
Vue.nextTick(() => {
expect(eventHub.$off).toHaveBeenCalledWith('projects-dropdownOpen', expect.any(Function));
done();
});
expect(getStoredProjects()).toEqual([
expect.objectContaining({
frequency: 1,
lastAccessedOn: Date.now(),
}),
]);
});
describe('when created multiple times', () => {
beforeEach(() => {
createComponent();
wrapper.destroy();
createComponent();
wrapper.destroy();
});
describe('template', () => {
it('should render search input', () => {
expect(vm.$el.querySelector('.search-input-container')).toBeDefined();
it('should only log once', () => {
expect(getStoredProjects()).toEqual([
expect.objectContaining({
lastAccessedOn: Date.now(),
frequency: 1,
}),
]);
});
it('should render loading animation', (done) => {
vm.$store.dispatch('fetchSearchedItems');
it('should increase frequency, when created an hour later', () => {
const hourLater = Date.now() + HOUR_IN_MS + 1;
Vue.nextTick(() => {
const loadingEl = vm.$el.querySelector('.loading-animation');
jest.spyOn(Date, 'now').mockReturnValue(hourLater);
createComponent({ currentItem: { ...TEST_PROJECT, lastAccessedOn: hourLater } });
expect(loadingEl).toBeDefined();
expect(loadingEl.classList.contains('prepend-top-20')).toBe(true);
expect(loadingEl.querySelector('span').getAttribute('aria-label')).toBe('Loading projects');
done();
expect(getStoredProjects()).toEqual([
expect.objectContaining({
lastAccessedOn: hourLater,
frequency: 2,
}),
]);
});
});
it('should render frequent projects list header', (done) => {
Vue.nextTick(() => {
const sectionHeaderEl = vm.$el.querySelector('.section-header');
it('should always update project metadata', () => {
const oldProject = {
...TEST_PROJECT,
};
expect(sectionHeaderEl).toBeDefined();
expect(sectionHeaderEl.innerText.trim()).toBe('Frequently visited');
done();
});
});
const newProject = {
...oldProject,
name: 'New Name',
avatarUrl: 'new/avatar.png',
namespace: 'New / Namespace',
webUrl: 'http://localhost/new/web/url',
};
it('should render frequent projects list', (done) => {
const expectedResult = getTopFrequentItems(mockFrequentProjects);
localStorage.getItem.mockImplementation(() => JSON.stringify(mockFrequentProjects));
createComponent({ currentItem: oldProject });
wrapper.destroy();
expect(getStoredProjects()).toEqual([expect.objectContaining(oldProject)]);
expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(1);
createComponent({ currentItem: newProject });
wrapper.destroy();
vm.fetchFrequentItems();
Vue.nextTick(() => {
expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(
expectedResult.length,
);
done();
});
expect(getStoredProjects()).toEqual([expect.objectContaining(newProject)]);
});
it('should render searched projects list', (done) => {
mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects);
expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(1);
it('should not add more than 20 projects in store', () => {
for (let id = 0; id < FREQUENT_ITEMS.MAX_COUNT + 10; id += 1) {
const project = {
...TEST_PROJECT,
id,
};
createComponent({ currentItem: project });
wrapper.destroy();
}
vm.$store.dispatch('setSearchQuery', 'gitlab');
vm.$nextTick()
.then(() => {
expect(vm.$el.querySelector('.loading-animation')).toBeDefined();
})
.then(waitForPromises)
.then(() => {
expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(
mockSearchedProjects.data.length,
);
})
.then(done)
.catch(done.fail);
expect(getStoredProjects().length).toBe(FREQUENT_ITEMS.MAX_COUNT);
});
});
});
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