Merge branch 'ide-keep-right-pane-tabs-alive' into 'master'

Keep IDE RightPane views alive

See merge request gitlab-org/gitlab-ce!21980
This commit is contained in:
Phil Hughes 2018-10-01 13:00:08 +00:00
commit aac9d70a8c
24 changed files with 345 additions and 62 deletions

View File

@ -50,7 +50,9 @@ export default {
this.stopPipelinePolling(); this.stopPipelinePolling();
}, },
methods: { methods: {
...mapActions(['setRightPane']), ...mapActions('rightPane', {
openRightPane: 'open',
}),
...mapActions('pipelines', ['fetchLatestPipeline', 'stopPipelinePolling']), ...mapActions('pipelines', ['fetchLatestPipeline', 'stopPipelinePolling']),
startTimer() { startTimer() {
this.intervalId = setInterval(() => { this.intervalId = setInterval(() => {
@ -88,7 +90,7 @@ export default {
<button <button
type="button" type="button"
class="p-0 border-0 h-50" class="p-0 border-0 h-50"
@click="setRightPane($options.rightSidebarViews.pipelines)" @click="openRightPane($options.rightSidebarViews.pipelines)"
> >
<ci-icon <ci-icon
v-tooltip v-tooltip

View File

@ -1,5 +1,6 @@
<script> <script>
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import _ from 'underscore';
import { __ } from '~/locale'; import { __ } from '~/locale';
import tooltip from '../../../vue_shared/directives/tooltip'; import tooltip from '../../../vue_shared/directives/tooltip';
import Icon from '../../../vue_shared/components/icon.vue'; import Icon from '../../../vue_shared/components/icon.vue';
@ -30,14 +31,10 @@ export default {
}, },
}, },
computed: { computed: {
...mapState(['rightPane', 'currentMergeRequestId', 'clientsidePreviewEnabled']), ...mapState(['currentMergeRequestId', 'clientsidePreviewEnabled']),
...mapState('rightPane', ['isOpen', 'currentView']),
...mapGetters(['packageJson']), ...mapGetters(['packageJson']),
pipelinesActive() { ...mapGetters('rightPane', ['isActiveView', 'isAliveView']),
return (
this.rightPane === rightSidebarViews.pipelines ||
this.rightPane === rightSidebarViews.jobsDetail
);
},
showLivePreview() { showLivePreview() {
return this.packageJson && this.clientsidePreviewEnabled; return this.packageJson && this.clientsidePreviewEnabled;
}, },
@ -46,22 +43,26 @@ export default {
{ {
show: this.currentMergeRequestId, show: this.currentMergeRequestId,
title: __('Merge Request'), title: __('Merge Request'),
isActive: this.rightPane === rightSidebarViews.mergeRequestInfo, views: [
view: rightSidebarViews.mergeRequestInfo, rightSidebarViews.mergeRequestInfo,
],
icon: 'text-description', icon: 'text-description',
}, },
{ {
show: true, show: true,
title: __('Pipelines'), title: __('Pipelines'),
isActive: this.pipelinesActive, views: [
view: rightSidebarViews.pipelines, rightSidebarViews.pipelines,
rightSidebarViews.jobsDetail,
],
icon: 'rocket', icon: 'rocket',
}, },
{ {
show: this.showLivePreview, show: this.showLivePreview,
title: __('Live preview'), title: __('Live preview'),
isActive: this.rightPane === rightSidebarViews.clientSidePreview, views: [
view: rightSidebarViews.clientSidePreview, rightSidebarViews.clientSidePreview,
],
icon: 'live-preview', icon: 'live-preview',
}, },
]; ];
@ -71,13 +72,26 @@ export default {
.concat(this.extensionTabs) .concat(this.extensionTabs)
.filter(tab => tab.show); .filter(tab => tab.show);
}, },
tabViews() {
return _.flatten(this.tabs.map(tab => tab.views));
},
aliveTabViews() {
return this.tabViews.filter(view => this.isAliveView(view.name));
},
}, },
methods: { methods: {
...mapActions(['setRightPane']), ...mapActions('rightPane', ['toggleOpen', 'open']),
clickTab(e, view) { clickTab(e, tab) {
e.target.blur(); e.target.blur();
this.setRightPane(view); if (this.isActiveTab(tab)) {
this.toggleOpen();
} else {
this.open(tab.views[0]);
}
},
isActiveTab(tab) {
return tab.views.some(view => this.isActiveView(view.name));
}, },
}, },
}; };
@ -88,15 +102,22 @@ export default {
class="multi-file-commit-panel ide-right-sidebar" class="multi-file-commit-panel ide-right-sidebar"
> >
<resizable-panel <resizable-panel
v-if="rightPane" v-show="isOpen"
:collapsible="false" :collapsible="false"
:initial-width="350" :initial-width="350"
:min-size="350" :min-size="350"
:class="`ide-right-sidebar-${rightPane}`" :class="`ide-right-sidebar-${currentView}`"
side="right" side="right"
class="multi-file-commit-panel-inner" class="multi-file-commit-panel-inner"
> >
<component :is="rightPane" /> <div
v-for="tabView in aliveTabViews"
v-show="isActiveView(tabView.name)"
:key="tabView.name"
class="h-100"
>
<component :is="tabView.name" />
</div>
</resizable-panel> </resizable-panel>
<nav class="ide-activity-bar"> <nav class="ide-activity-bar">
<ul class="list-unstyled"> <ul class="list-unstyled">
@ -109,13 +130,13 @@ export default {
:title="tab.title" :title="tab.title"
:aria-label="tab.title" :aria-label="tab.title"
:class="{ :class="{
active: tab.isActive active: isActiveTab(tab) && isOpen
}" }"
data-container="body" data-container="body"
data-placement="left" data-placement="left"
class="ide-sidebar-link is-right" class="ide-sidebar-link is-right"
type="button" type="button"
@click="clickTab($event, tab.view)" @click="clickTab($event, tab)"
> >
<icon <icon
:size="16" :size="16"

View File

@ -22,12 +22,14 @@ export default {
}, },
}, },
computed: { computed: {
...mapState('rightPane', {
rightPaneIsOpen: 'isOpen',
}),
...mapState([ ...mapState([
'rightPanelCollapsed', 'rightPanelCollapsed',
'viewer', 'viewer',
'panelResizing', 'panelResizing',
'currentActivityView', 'currentActivityView',
'rightPane',
]), ]),
...mapGetters([ ...mapGetters([
'currentMergeRequest', 'currentMergeRequest',
@ -99,7 +101,7 @@ export default {
this.editor.updateDimensions(); this.editor.updateDimensions();
} }
}, },
rightPane() { rightPaneIsOpen() {
this.editor.updateDimensions(); this.editor.updateDimensions();
}, },
}, },

View File

@ -29,10 +29,10 @@ export const diffModes = {
}; };
export const rightSidebarViews = { export const rightSidebarViews = {
pipelines: 'pipelines-list', pipelines: { name: 'pipelines-list', keepAlive: true },
jobsDetail: 'jobs-detail', jobsDetail: { name: 'jobs-detail', keepAlive: false },
mergeRequestInfo: 'merge-request-info', mergeRequestInfo: { name: 'merge-request-info', keepAlive: true },
clientSidePreview: 'clientside', clientSidePreview: { name: 'clientside', keepAlive: false },
}; };
export const stageKeys = { export const stageKeys = {

View File

@ -184,10 +184,6 @@ export const burstUnusedSeal = ({ state, commit }) => {
} }
}; };
export const setRightPane = ({ commit }, view) => {
commit(types.SET_RIGHT_PANE, view);
};
export const setLinks = ({ commit }, links) => commit(types.SET_LINKS, links); export const setLinks = ({ commit }, links) => commit(types.SET_LINKS, links);
export const setErrorMessage = ({ commit }, errorMessage) => export const setErrorMessage = ({ commit }, errorMessage) =>

View File

@ -9,6 +9,7 @@ import pipelines from './modules/pipelines';
import mergeRequests from './modules/merge_requests'; import mergeRequests from './modules/merge_requests';
import branches from './modules/branches'; import branches from './modules/branches';
import fileTemplates from './modules/file_templates'; import fileTemplates from './modules/file_templates';
import paneModule from './modules/pane';
Vue.use(Vuex); Vue.use(Vuex);
@ -24,6 +25,7 @@ export const createStore = () =>
mergeRequests, mergeRequests,
branches, branches,
fileTemplates: fileTemplates(), fileTemplates: fileTemplates(),
rightPane: paneModule(),
}, },
}); });

View File

@ -0,0 +1,30 @@
import * as types from './mutation_types';
export const toggleOpen = ({ dispatch, state }, view) => {
if (state.isOpen) {
dispatch('close');
} else {
dispatch('open', view);
}
};
export const open = ({ commit }, view) => {
commit(types.SET_OPEN, true);
if (view) {
const { name, keepAlive } = view;
commit(types.SET_CURRENT_VIEW, name);
if (keepAlive) {
commit(types.KEEP_ALIVE_VIEW, name);
}
}
};
export const close = ({ commit }) => {
commit(types.SET_OPEN, false);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};

View File

@ -0,0 +1,4 @@
export const isActiveView = state => view => state.currentView === view;
export const isAliveView = (state, getters) => view =>
state.keepAliveViews[view] || (state.isOpen && getters.isActiveView(view));

View File

@ -0,0 +1,12 @@
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
export default () => ({
namespaced: true,
state: state(),
actions,
getters,
mutations,
});

View File

@ -0,0 +1,3 @@
export const SET_OPEN = 'SET_OPEN';
export const SET_CURRENT_VIEW = 'SET_CURRENT_VIEW';
export const KEEP_ALIVE_VIEW = 'KEEP_ALIVE_VIEW';

View File

@ -0,0 +1,19 @@
import * as types from './mutation_types';
export default {
[types.SET_OPEN](state, isOpen) {
Object.assign(state, {
isOpen,
});
},
[types.SET_CURRENT_VIEW](state, currentView) {
Object.assign(state, {
currentView,
});
},
[types.KEEP_ALIVE_VIEW](state, viewName) {
Object.assign(state.keepAliveViews, {
[viewName]: true,
});
},
};

View File

@ -0,0 +1,5 @@
export default () => ({
isOpen: false,
currentView: null,
keepAliveViews: {},
});

View File

@ -113,7 +113,7 @@ export const toggleStageCollapsed = ({ commit }, stageId) =>
export const setDetailJob = ({ commit, dispatch }, job) => { export const setDetailJob = ({ commit, dispatch }, job) => {
commit(types.SET_DETAIL_JOB, job); commit(types.SET_DETAIL_JOB, job);
dispatch('setRightPane', job ? rightSidebarViews.jobsDetail : rightSidebarViews.pipelines, { dispatch('rightPane/open', job ? rightSidebarViews.jobsDetail : rightSidebarViews.pipelines, {
root: true, root: true,
}); });
}; };

View File

@ -68,8 +68,6 @@ export const UPDATE_TEMP_FLAG = 'UPDATE_TEMP_FLAG';
export const TOGGLE_FILE_FINDER = 'TOGGLE_FILE_FINDER'; export const TOGGLE_FILE_FINDER = 'TOGGLE_FILE_FINDER';
export const BURST_UNUSED_SEAL = 'BURST_UNUSED_SEAL'; export const BURST_UNUSED_SEAL = 'BURST_UNUSED_SEAL';
export const SET_RIGHT_PANE = 'SET_RIGHT_PANE';
export const CLEAR_PROJECTS = 'CLEAR_PROJECTS'; export const CLEAR_PROJECTS = 'CLEAR_PROJECTS';
export const RESET_OPEN_FILES = 'RESET_OPEN_FILES'; export const RESET_OPEN_FILES = 'RESET_OPEN_FILES';

View File

@ -166,11 +166,6 @@ export default {
unusedSeal: false, unusedSeal: false,
}); });
}, },
[types.SET_RIGHT_PANE](state, view) {
Object.assign(state, {
rightPane: state.rightPane === view ? null : view,
});
},
[types.SET_LINKS](state, links) { [types.SET_LINKS](state, links) {
Object.assign(state, { links }); Object.assign(state, { links });
}, },

View File

@ -23,7 +23,6 @@ export default () => ({
currentActivityView: activityBarViews.edit, currentActivityView: activityBarViews.edit,
unusedSeal: true, unusedSeal: true,
fileFindVisible: false, fileFindVisible: false,
rightPane: null,
links: {}, links: {},
errorMessage: null, errorMessage: null,
entryModal: { entryModal: {

View File

@ -1,6 +1,7 @@
import Vue from 'vue'; import Vue from 'vue';
import store from '~/ide/stores'; import store from '~/ide/stores';
import ideStatusBar from '~/ide/components/ide_status_bar.vue'; import ideStatusBar from '~/ide/components/ide_status_bar.vue';
import { rightSidebarViews } from '~/ide/constants';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { resetStore } from '../helpers'; import { resetStore } from '../helpers';
import { projectData } from '../mock_data'; import { projectData } from '../mock_data';
@ -64,7 +65,7 @@ describe('ideStatusBar', () => {
describe('pipeline status', () => { describe('pipeline status', () => {
it('opens right sidebar on clicking icon', done => { it('opens right sidebar on clicking icon', done => {
spyOn(vm, 'setRightPane'); spyOn(vm, 'openRightPane');
Vue.set(vm.$store.state.pipelines, 'latestPipeline', { Vue.set(vm.$store.state.pipelines, 'latestPipeline', {
details: { details: {
status: { status: {
@ -80,7 +81,7 @@ describe('ideStatusBar', () => {
.then(() => { .then(() => {
vm.$el.querySelector('.ide-status-pipeline button').click(); vm.$el.querySelector('.ide-status-pipeline button').click();
expect(vm.setRightPane).toHaveBeenCalledWith('pipelines-list'); expect(vm.openRightPane).toHaveBeenCalledWith(rightSidebarViews.pipelines);
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);

View File

@ -25,7 +25,8 @@ describe('IDE right pane', () => {
describe('active', () => { describe('active', () => {
it('renders merge request button as active', done => { it('renders merge request button as active', done => {
vm.$store.state.rightPane = rightSidebarViews.mergeRequestInfo; vm.$store.state.rightPane.isOpen = true;
vm.$store.state.rightPane.currentView = rightSidebarViews.mergeRequestInfo.name;
vm.$store.state.currentMergeRequestId = '123'; vm.$store.state.currentMergeRequestId = '123';
vm.$store.state.currentProjectId = 'gitlab-ce'; vm.$store.state.currentProjectId = 'gitlab-ce';
vm.$store.state.currentMergeRequestId = 1; vm.$store.state.currentMergeRequestId = 1;
@ -41,20 +42,21 @@ describe('IDE right pane', () => {
}, },
}; };
vm.$nextTick(() => { vm.$nextTick()
.then(() => {
expect(vm.$el.querySelector('.ide-sidebar-link.active')).not.toBe(null); expect(vm.$el.querySelector('.ide-sidebar-link.active')).not.toBe(null);
expect( expect(
vm.$el.querySelector('.ide-sidebar-link.active').getAttribute('data-original-title'), vm.$el.querySelector('.ide-sidebar-link.active').getAttribute('data-original-title'),
).toBe('Merge Request'); ).toBe('Merge Request');
})
done(); .then(done)
}); .catch(done.fail);
}); });
}); });
describe('click', () => { describe('click', () => {
beforeEach(() => { beforeEach(() => {
spyOn(vm, 'setRightPane'); spyOn(vm, 'open');
}); });
it('sets view to merge request', done => { it('sets view to merge request', done => {
@ -63,7 +65,7 @@ describe('IDE right pane', () => {
vm.$nextTick(() => { vm.$nextTick(() => {
vm.$el.querySelector('.ide-sidebar-link').click(); vm.$el.querySelector('.ide-sidebar-link').click();
expect(vm.setRightPane).toHaveBeenCalledWith(rightSidebarViews.mergeRequestInfo); expect(vm.open).toHaveBeenCalledWith(rightSidebarViews.mergeRequestInfo);
done(); done();
}); });

View File

@ -319,8 +319,8 @@ describe('RepoEditor', () => {
}); });
}); });
it('calls updateDimensions when rightPane is updated', done => { it('calls updateDimensions when rightPane is opened', done => {
vm.$store.state.rightPane = 'testing'; vm.$store.state.rightPane.isOpen = true;
vm.$nextTick(() => { vm.$nextTick(() => {
expect(vm.editor.updateDimensions).toHaveBeenCalled(); expect(vm.editor.updateDimensions).toHaveBeenCalled();

View File

@ -6,6 +6,7 @@ import mergeRequestsState from '~/ide/stores/modules/merge_requests/state';
import pipelinesState from '~/ide/stores/modules/pipelines/state'; import pipelinesState from '~/ide/stores/modules/pipelines/state';
import branchesState from '~/ide/stores/modules/branches/state'; import branchesState from '~/ide/stores/modules/branches/state';
import fileTemplatesState from '~/ide/stores/modules/file_templates/state'; import fileTemplatesState from '~/ide/stores/modules/file_templates/state';
import paneState from '~/ide/stores/modules/pane/state';
export const resetStore = store => { export const resetStore = store => {
const newState = { const newState = {
@ -15,6 +16,7 @@ export const resetStore = store => {
pipelines: pipelinesState(), pipelines: pipelinesState(),
branches: branchesState(), branches: branchesState(),
fileTemplates: fileTemplatesState(), fileTemplates: fileTemplatesState(),
rightPane: paneState(),
}; };
store.replaceState(newState); store.replaceState(newState);
}; };

View File

@ -0,0 +1,87 @@
import * as actions from '~/ide/stores/modules/pane/actions';
import * as types from '~/ide/stores/modules/pane/mutation_types';
import testAction from 'spec/helpers/vuex_action_helper';
describe('IDE pane module actions', () => {
const TEST_VIEW = { name: 'test' };
const TEST_VIEW_KEEP_ALIVE = { name: 'test-keep-alive', keepAlive: true };
describe('toggleOpen', () => {
it('dispatches open if closed', done => {
testAction(
actions.toggleOpen,
TEST_VIEW,
{ isOpen: false },
[],
[{ type: 'open', payload: TEST_VIEW }],
done,
);
});
it('dispatches close if opened', done => {
testAction(
actions.toggleOpen,
TEST_VIEW,
{ isOpen: true },
[],
[{ type: 'close' }],
done,
);
});
});
describe('open', () => {
it('commits SET_OPEN', done => {
testAction(
actions.open,
null,
{},
[{ type: types.SET_OPEN, payload: true }],
[],
done,
);
});
it('commits SET_CURRENT_VIEW if view is given', done => {
testAction(
actions.open,
TEST_VIEW,
{},
[
{ type: types.SET_OPEN, payload: true },
{ type: types.SET_CURRENT_VIEW, payload: TEST_VIEW.name },
],
[],
done,
);
});
it('commits KEEP_ALIVE_VIEW if keepAlive is true', done => {
testAction(
actions.open,
TEST_VIEW_KEEP_ALIVE,
{},
[
{ type: types.SET_OPEN, payload: true },
{ type: types.SET_CURRENT_VIEW, payload: TEST_VIEW_KEEP_ALIVE.name },
{ type: types.KEEP_ALIVE_VIEW, payload: TEST_VIEW_KEEP_ALIVE.name },
],
[],
done,
);
});
});
describe('close', () => {
it('commits SET_OPEN', done => {
testAction(
actions.close,
null,
{},
[{ type: types.SET_OPEN, payload: false }],
[],
done,
);
});
});
});

View File

@ -0,0 +1,61 @@
import * as getters from '~/ide/stores/modules/pane/getters';
import state from '~/ide/stores/modules/pane/state';
describe('IDE pane module getters', () => {
const TEST_VIEW = 'test-view';
const TEST_KEEP_ALIVE_VIEWS = {
[TEST_VIEW]: true,
};
describe('isActiveView', () => {
it('returns true if given view matches currentView', () => {
const result = getters.isActiveView({ currentView: 'A' })('A');
expect(result).toBe(true);
});
it('returns false if given view does not match currentView', () => {
const result = getters.isActiveView({ currentView: 'A' })('B');
expect(result).toBe(false);
});
});
describe('isAliveView', () => {
it('returns true if given view is in keepAliveViews', () => {
const result = getters.isAliveView(
{ keepAliveViews: TEST_KEEP_ALIVE_VIEWS },
{},
)(TEST_VIEW);
expect(result).toBe(true);
});
it('returns true if given view is active view and open', () => {
const result = getters.isAliveView(
{ ...state(), isOpen: true },
{ isActiveView: () => true },
)(TEST_VIEW);
expect(result).toBe(true);
});
it('returns false if given view is active view and closed', () => {
const result = getters.isAliveView(
state(),
{ isActiveView: () => true },
)(TEST_VIEW);
expect(result).toBe(false);
});
it('returns false if given view is not activeView', () => {
const result = getters.isAliveView(
{ ...state(), isOpen: true },
{ isActiveView: () => false },
)(TEST_VIEW);
expect(result).toBe(false);
});
});
});

View File

@ -0,0 +1,42 @@
import state from '~/ide/stores/modules/pane/state';
import mutations from '~/ide/stores/modules/pane/mutations';
import * as types from '~/ide/stores/modules/pane/mutation_types';
describe('IDE pane module mutations', () => {
const TEST_VIEW = 'test-view';
let mockedState;
beforeEach(() => {
mockedState = state();
});
describe('SET_OPEN', () => {
it('sets isOpen', () => {
mockedState.isOpen = false;
mutations[types.SET_OPEN](mockedState, true);
expect(mockedState.isOpen).toBe(true);
});
});
describe('SET_CURRENT_VIEW', () => {
it('sets currentView', () => {
mockedState.currentView = null;
mutations[types.SET_CURRENT_VIEW](mockedState, TEST_VIEW);
expect(mockedState.currentView).toEqual(TEST_VIEW);
});
});
describe('KEEP_ALIVE_VIEW', () => {
it('adds entry to keepAliveViews', () => {
mutations[types.KEEP_ALIVE_VIEW](mockedState, TEST_VIEW);
expect(mockedState.keepAliveViews).toEqual({
[TEST_VIEW]: true,
});
});
});
});

View File

@ -315,29 +315,29 @@ describe('IDE pipelines actions', () => {
'job', 'job',
mockedState, mockedState,
[{ type: types.SET_DETAIL_JOB, payload: 'job' }], [{ type: types.SET_DETAIL_JOB, payload: 'job' }],
[{ type: 'setRightPane', payload: 'jobs-detail' }], [{ type: 'rightPane/open', payload: rightSidebarViews.jobsDetail }],
done, done,
); );
}); });
it('dispatches setRightPane as pipeline when job is null', done => { it('dispatches rightPane/open as pipeline when job is null', done => {
testAction( testAction(
setDetailJob, setDetailJob,
null, null,
mockedState, mockedState,
[{ type: types.SET_DETAIL_JOB, payload: null }], [{ type: types.SET_DETAIL_JOB, payload: null }],
[{ type: 'setRightPane', payload: rightSidebarViews.pipelines }], [{ type: 'rightPane/open', payload: rightSidebarViews.pipelines }],
done, done,
); );
}); });
it('dispatches setRightPane as job', done => { it('dispatches rightPane/open as job', done => {
testAction( testAction(
setDetailJob, setDetailJob,
'job', 'job',
mockedState, mockedState,
[{ type: types.SET_DETAIL_JOB, payload: 'job' }], [{ type: types.SET_DETAIL_JOB, payload: 'job' }],
[{ type: 'setRightPane', payload: rightSidebarViews.jobsDetail }], [{ type: 'rightPane/open', payload: rightSidebarViews.jobsDetail }],
done, done,
); );
}); });