Commit 939ae5ad authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '208800-step-1-1-create-sync-store-and-router' into 'master'

Step 1.1 - Create modules to help break store and router cyclical dependency

See merge request gitlab-org/gitlab!33787
parents ac46d494 fc03d3c2
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
/* eslint-disable @gitlab/vue-require-i18n-strings */ /* eslint-disable @gitlab/vue-require-i18n-strings */
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
import router from '../../ide_router';
export default { export default {
components: { components: {
...@@ -26,7 +25,7 @@ export default { ...@@ -26,7 +25,7 @@ export default {
}, },
computed: { computed: {
branchHref() { branchHref() {
return router.resolve(`/project/${this.projectId}/edit/${this.item.name}`).href; return this.$router.resolve(`/project/${this.projectId}/edit/${this.item.name}`).href;
}, },
}, },
}; };
......
<script> <script>
import Icon from '../../../vue_shared/components/icon.vue'; import Icon from '../../../vue_shared/components/icon.vue';
import router from '../../ide_router';
export default { export default {
components: { components: {
...@@ -33,7 +32,7 @@ export default { ...@@ -33,7 +32,7 @@ export default {
mergeRequestHref() { mergeRequestHref() {
const path = `/project/${this.item.projectPathWithNamespace}/merge_requests/${this.item.iid}`; const path = `/project/${this.item.projectPathWithNamespace}/merge_requests/${this.item.iid}`;
return router.resolve(path).href; return this.$router.resolve(path).href;
}, },
}, },
}; };
......
<script> <script>
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import RepoTab from './repo_tab.vue'; import RepoTab from './repo_tab.vue';
import router from '../ide_router';
export default { export default {
components: { components: {
...@@ -28,7 +27,7 @@ export default { ...@@ -28,7 +27,7 @@ export default {
if (this.activeFile.pending) { if (this.activeFile.pending) {
return this.removePendingTab(this.activeFile).then(() => { return this.removePendingTab(this.activeFile).then(() => {
router.push(`/project${this.activeFile.url}`); this.$router.push(`/project${this.activeFile.url}`);
}); });
} }
......
import * as types from './mutation_types';
// eslint-disable-next-line import/prefer-default-export
export const push = ({ commit }, fullPath) => {
commit(types.PUSH, fullPath);
};
import state from './state';
import mutations from './mutations';
import * as actions from './actions';
export default {
namespaced: true,
state,
mutations,
actions,
};
// eslint-disable-next-line import/prefer-default-export
export const PUSH = 'PUSH';
import * as types from './mutation_types';
export default {
[types.PUSH](state, fullPath) {
state.fullPath = fullPath;
},
};
export default () => ({
fullPath: '',
});
/* eslint-disable import/prefer-default-export */
/**
* This method adds listeners to the given router and store and syncs their state with eachother
*
* ### Why?
*
* Previously the IDE had a circular dependency between a singleton router and a singleton store.
* This causes some integration testing headaches...
*
* At the time, the most effecient way to break this ciruclar dependency was to:
*
* - Replace the router with a factory function that receives a store reference
* - Have the store write to a certain state that can be watched by the router
*
* Hence... This helper function...
*/
export const syncRouterAndStore = (router, store) => {
const disposables = [];
let currentPath = '';
// sync store to router
disposables.push(
store.watch(
state => state.router.fullPath,
fullPath => {
if (currentPath === fullPath) {
return;
}
currentPath = fullPath;
router.push(fullPath);
},
),
);
// sync router to store
disposables.push(
router.afterEach(to => {
if (currentPath === to.fullPath) {
return;
}
currentPath = to.fullPath;
store.dispatch('router/push', currentPath, { root: true });
}),
);
const unsync = () => {
disposables.forEach(fn => fn());
};
return unsync;
};
...@@ -22,6 +22,7 @@ describe('IDE branch item', () => { ...@@ -22,6 +22,7 @@ describe('IDE branch item', () => {
isActive: false, isActive: false,
...props, ...props,
}, },
router,
}); });
} }
......
import Vue from 'vue'; import { mount } from '@vue/test-utils';
import router from '~/ide/ide_router'; import router from '~/ide/ide_router';
import Item from '~/ide/components/merge_requests/item.vue'; import Item from '~/ide/components/merge_requests/item.vue';
import mountCompontent from '../../../helpers/vue_mount_component_helper';
const TEST_ITEM = {
iid: 1,
projectPathWithNamespace: 'gitlab-org/gitlab-ce',
title: 'Merge request title',
};
describe('IDE merge request item', () => { describe('IDE merge request item', () => {
const Component = Vue.extend(Item); let wrapper;
let vm;
const createComponent = (props = {}) => {
beforeEach(() => { wrapper = mount(Item, {
vm = mountCompontent(Component, { propsData: {
item: { item: {
iid: 1, ...TEST_ITEM,
projectPathWithNamespace: 'gitlab-org/gitlab-ce', },
title: 'Merge request title', currentId: `${TEST_ITEM.iid}`,
currentProjectId: TEST_ITEM.projectPathWithNamespace,
...props,
}, },
currentId: '1', router,
currentProjectId: 'gitlab-org/gitlab-ce',
}); });
}); };
const findIcon = () => wrapper.find('.ic-mobile-issue-close');
afterEach(() => { afterEach(() => {
vm.$destroy(); wrapper.destroy();
wrapper = null;
}); });
it('renders merge requests data', () => { describe('default', () => {
expect(vm.$el.textContent).toContain('Merge request title'); beforeEach(() => {
expect(vm.$el.textContent).toContain('gitlab-org/gitlab-ce!1'); createComponent();
}); });
it('renders link with href', () => { it('renders merge requests data', () => {
const expectedHref = router.resolve( expect(wrapper.text()).toContain('Merge request title');
`/project/${vm.item.projectPathWithNamespace}/merge_requests/${vm.item.iid}`, expect(wrapper.text()).toContain('gitlab-org/gitlab-ce!1');
).href; });
expect(vm.$el.tagName.toLowerCase()).toBe('a'); it('renders link with href', () => {
expect(vm.$el).toHaveAttr('href', expectedHref); const expectedHref = router.resolve(
}); `/project/${TEST_ITEM.projectPathWithNamespace}/merge_requests/${TEST_ITEM.iid}`,
).href;
it('renders icon if ID matches currentId', () => { expect(wrapper.element.tagName.toLowerCase()).toBe('a');
expect(vm.$el.querySelector('.ic-mobile-issue-close')).not.toBe(null); expect(wrapper.attributes('href')).toBe(expectedHref);
}); });
it('does not render icon if ID does not match currentId', done => { it('renders icon if ID matches currentId', () => {
vm.currentId = '2'; expect(findIcon().exists()).toBe(true);
});
});
vm.$nextTick(() => { describe('with different currentId', () => {
expect(vm.$el.querySelector('.ic-mobile-issue-close')).toBe(null); beforeEach(() => {
createComponent({ currentId: `${TEST_ITEM.iid + 1}` });
});
done(); it('does not render icon', () => {
expect(findIcon().exists()).toBe(false);
}); });
}); });
it('does not render icon if project ID does not match', done => { describe('with different project ID', () => {
vm.currentProjectId = 'test/test'; beforeEach(() => {
createComponent({ currentProjectId: 'test/test' });
vm.$nextTick(() => { });
expect(vm.$el.querySelector('.ic-mobile-issue-close')).toBe(null);
done(); it('does not render icon', () => {
expect(findIcon().exists()).toBe(false);
}); });
}); });
}); });
import * as actions from '~/ide/stores/modules/router/actions';
import * as types from '~/ide/stores/modules/router/mutation_types';
import testAction from 'helpers/vuex_action_helper';
const TEST_PATH = 'test/path/abc';
describe('ide/stores/modules/router/actions', () => {
describe('push', () => {
it('commits mutation', () => {
return testAction(
actions.push,
TEST_PATH,
{},
[{ type: types.PUSH, payload: TEST_PATH }],
[],
);
});
});
});
import mutations from '~/ide/stores/modules/router/mutations';
import * as types from '~/ide/stores/modules/router/mutation_types';
import createState from '~/ide/stores/modules/router/state';
const TEST_PATH = 'test/path/abc';
describe('ide/stores/modules/router/mutations', () => {
let state;
beforeEach(() => {
state = createState();
});
describe(types.PUSH, () => {
it('updates state', () => {
expect(state.fullPath).toBe('');
mutations[types.PUSH](state, TEST_PATH);
expect(state.fullPath).toBe(TEST_PATH);
});
});
});
import Vue from 'vue';
import VueRouter from 'vue-router';
import Vuex from 'vuex';
import routerModule from '~/ide/stores/modules/router';
import { syncRouterAndStore } from '~/ide/sync_router_and_store';
import waitForPromises from 'helpers/wait_for_promises';
const TEST_ROUTE = '/test/lorem/ipsum';
Vue.use(Vuex);
describe('~/ide/sync_router_and_store', () => {
let unsync;
let router;
let store;
let onRouterChange;
const createSync = () => {
unsync = syncRouterAndStore(router, store);
};
const getRouterCurrentPath = () => router.currentRoute.fullPath;
const getStoreCurrentPath = () => store.state.router.fullPath;
const updateRouter = path => {
router.push(path);
return waitForPromises();
};
const updateStore = path => {
store.dispatch('router/push', path);
return waitForPromises();
};
beforeEach(() => {
router = new VueRouter();
store = new Vuex.Store({
modules: {
router: routerModule,
},
});
jest.spyOn(store, 'dispatch');
onRouterChange = jest.fn();
router.beforeEach((to, from, next) => {
onRouterChange(to, from);
next();
});
});
afterEach(() => {
unsync();
unsync = null;
});
it('keeps store and router in sync', async () => {
createSync();
await updateRouter('/test/test');
await updateRouter('/test/test');
await updateStore('123/abc');
await updateRouter('def');
// Even though we pused relative paths, the store and router kept track of the resulting fullPath
expect(getRouterCurrentPath()).toBe('/test/123/def');
expect(getStoreCurrentPath()).toBe('/test/123/def');
});
describe('default', () => {
beforeEach(() => {
createSync();
});
it('store is default', () => {
expect(store.dispatch).not.toHaveBeenCalled();
expect(getStoreCurrentPath()).toBe('');
});
it('router is default', () => {
expect(onRouterChange).not.toHaveBeenCalled();
expect(getRouterCurrentPath()).toBe('/');
});
describe('when store changes', () => {
beforeEach(() => {
updateStore(TEST_ROUTE);
});
it('store is updated', () => {
// let's make sure the action isn't dispatched more than necessary
expect(store.dispatch).toHaveBeenCalledTimes(1);
expect(getStoreCurrentPath()).toBe(TEST_ROUTE);
});
it('router is updated', () => {
expect(onRouterChange).toHaveBeenCalledTimes(1);
expect(getRouterCurrentPath()).toBe(TEST_ROUTE);
});
describe('when store changes again to the same thing', () => {
beforeEach(() => {
onRouterChange.mockClear();
updateStore(TEST_ROUTE);
});
it('doesnt change router again', () => {
expect(onRouterChange).not.toHaveBeenCalled();
});
});
});
describe('when router changes', () => {
beforeEach(() => {
updateRouter(TEST_ROUTE);
});
it('store is updated', () => {
expect(store.dispatch).toHaveBeenCalledTimes(1);
expect(getStoreCurrentPath()).toBe(TEST_ROUTE);
});
it('router is updated', () => {
// let's make sure the router change isn't triggered more than necessary
expect(onRouterChange).toHaveBeenCalledTimes(1);
expect(getRouterCurrentPath()).toBe(TEST_ROUTE);
});
describe('when router changes again to the same thing', () => {
beforeEach(() => {
store.dispatch.mockClear();
updateRouter(TEST_ROUTE);
});
it('doesnt change store again', () => {
expect(store.dispatch).not.toHaveBeenCalled();
});
});
});
describe('when disposed', () => {
beforeEach(() => {
unsync();
});
it('a store change does not trigger a router change', () => {
updateStore(TEST_ROUTE);
expect(getRouterCurrentPath()).toBe('/');
expect(onRouterChange).not.toHaveBeenCalled();
});
it('a router change does not trigger a store change', () => {
updateRouter(TEST_ROUTE);
expect(getStoreCurrentPath()).toBe('');
expect(store.dispatch).not.toHaveBeenCalled();
});
});
});
});
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