Commit adbf6149 authored by Paul Slaughter's avatar Paul Slaughter

Keep IDE RightPane views alive

**Why?**
- This is needed for the Web Terminal feature.
  https://gitlab.com/gitlab-org/gitlab-ee/issues/5426

**Notes:**
- Introduces a `pane` Vuex module.
- Some views should not be kept alive (i.e. job details).
  This is why a `keepAlive` flag was introduced for views.
parent 1eefdf5d
......@@ -50,7 +50,9 @@ export default {
this.stopPipelinePolling();
},
methods: {
...mapActions(['setRightPane']),
...mapActions('rightPane', {
openRightPane: 'open',
}),
...mapActions('pipelines', ['fetchLatestPipeline', 'stopPipelinePolling']),
startTimer() {
this.intervalId = setInterval(() => {
......@@ -88,7 +90,7 @@ export default {
<button
type="button"
class="p-0 border-0 h-50"
@click="setRightPane($options.rightSidebarViews.pipelines)"
@click="openRightPane($options.rightSidebarViews.pipelines)"
>
<ci-icon
v-tooltip
......
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import _ from 'underscore';
import { __ } from '~/locale';
import tooltip from '../../../vue_shared/directives/tooltip';
import Icon from '../../../vue_shared/components/icon.vue';
......@@ -30,14 +31,10 @@ export default {
},
},
computed: {
...mapState(['rightPane', 'currentMergeRequestId', 'clientsidePreviewEnabled']),
...mapState(['currentMergeRequestId', 'clientsidePreviewEnabled']),
...mapState('rightPane', ['isOpen', 'currentView']),
...mapGetters(['packageJson']),
pipelinesActive() {
return (
this.rightPane === rightSidebarViews.pipelines ||
this.rightPane === rightSidebarViews.jobsDetail
);
},
...mapGetters('rightPane', ['isActiveView', 'isAliveView']),
showLivePreview() {
return this.packageJson && this.clientsidePreviewEnabled;
},
......@@ -46,22 +43,26 @@ export default {
{
show: this.currentMergeRequestId,
title: __('Merge Request'),
isActive: this.rightPane === rightSidebarViews.mergeRequestInfo,
view: rightSidebarViews.mergeRequestInfo,
views: [
rightSidebarViews.mergeRequestInfo,
],
icon: 'text-description',
},
{
show: true,
title: __('Pipelines'),
isActive: this.pipelinesActive,
view: rightSidebarViews.pipelines,
views: [
rightSidebarViews.pipelines,
rightSidebarViews.jobsDetail,
],
icon: 'rocket',
},
{
show: this.showLivePreview,
title: __('Live preview'),
isActive: this.rightPane === rightSidebarViews.clientSidePreview,
view: rightSidebarViews.clientSidePreview,
views: [
rightSidebarViews.clientSidePreview,
],
icon: 'live-preview',
},
];
......@@ -71,13 +72,26 @@ export default {
.concat(this.extensionTabs)
.filter(tab => tab.show);
},
tabViews() {
return _.flatten(this.tabs.map(tab => tab.views));
},
aliveTabViews() {
return this.tabViews.filter(view => this.isAliveView(view.name));
},
},
methods: {
...mapActions(['setRightPane']),
clickTab(e, view) {
...mapActions('rightPane', ['toggleOpen', 'open']),
clickTab(e, tab) {
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"
>
<resizable-panel
v-if="rightPane"
v-show="isOpen"
:collapsible="false"
:initial-width="350"
:min-size="350"
:class="`ide-right-sidebar-${rightPane}`"
:class="`ide-right-sidebar-${currentView}`"
side="right"
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>
<nav class="ide-activity-bar">
<ul class="list-unstyled">
......@@ -109,13 +130,13 @@ export default {
:title="tab.title"
:aria-label="tab.title"
:class="{
active: tab.isActive
active: isActiveTab(tab) && isOpen
}"
data-container="body"
data-placement="left"
class="ide-sidebar-link is-right"
type="button"
@click="clickTab($event, tab.view)"
@click="clickTab($event, tab)"
>
<icon
:size="16"
......
......@@ -22,12 +22,14 @@ export default {
},
},
computed: {
...mapState('rightPane', {
rightPaneIsOpen: 'isOpen',
}),
...mapState([
'rightPanelCollapsed',
'viewer',
'panelResizing',
'currentActivityView',
'rightPane',
]),
...mapGetters([
'currentMergeRequest',
......@@ -99,7 +101,7 @@ export default {
this.editor.updateDimensions();
}
},
rightPane() {
rightPaneIsOpen() {
this.editor.updateDimensions();
},
},
......
......@@ -29,10 +29,10 @@ export const diffModes = {
};
export const rightSidebarViews = {
pipelines: 'pipelines-list',
jobsDetail: 'jobs-detail',
mergeRequestInfo: 'merge-request-info',
clientSidePreview: 'clientside',
pipelines: { name: 'pipelines-list', keepAlive: true },
jobsDetail: { name: 'jobs-detail', keepAlive: false },
mergeRequestInfo: { name: 'merge-request-info', keepAlive: true },
clientSidePreview: { name: 'clientside', keepAlive: false },
};
export const stageKeys = {
......
......@@ -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 setErrorMessage = ({ commit }, errorMessage) =>
......
......@@ -9,6 +9,7 @@ import pipelines from './modules/pipelines';
import mergeRequests from './modules/merge_requests';
import branches from './modules/branches';
import fileTemplates from './modules/file_templates';
import paneModule from './modules/pane';
Vue.use(Vuex);
......@@ -24,6 +25,7 @@ export const createStore = () =>
mergeRequests,
branches,
fileTemplates: fileTemplates(),
rightPane: paneModule(),
},
});
......
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 () => {};
export const isActiveView = state => view => state.currentView === view;
export const isAliveView = (state, getters) => view =>
state.keepAliveViews[view] || (state.isOpen && getters.isActiveView(view));
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,
});
export const SET_OPEN = 'SET_OPEN';
export const SET_CURRENT_VIEW = 'SET_CURRENT_VIEW';
export const KEEP_ALIVE_VIEW = 'KEEP_ALIVE_VIEW';
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,
});
},
};
export default () => ({
isOpen: false,
currentView: null,
keepAliveViews: {},
});
......@@ -113,7 +113,7 @@ export const toggleStageCollapsed = ({ commit }, stageId) =>
export const setDetailJob = ({ commit, dispatch }, job) => {
commit(types.SET_DETAIL_JOB, job);
dispatch('setRightPane', job ? rightSidebarViews.jobsDetail : rightSidebarViews.pipelines, {
dispatch('rightPane/open', job ? rightSidebarViews.jobsDetail : rightSidebarViews.pipelines, {
root: true,
});
};
......
......@@ -68,8 +68,6 @@ export const UPDATE_TEMP_FLAG = 'UPDATE_TEMP_FLAG';
export const TOGGLE_FILE_FINDER = 'TOGGLE_FILE_FINDER';
export const BURST_UNUSED_SEAL = 'BURST_UNUSED_SEAL';
export const SET_RIGHT_PANE = 'SET_RIGHT_PANE';
export const CLEAR_PROJECTS = 'CLEAR_PROJECTS';
export const RESET_OPEN_FILES = 'RESET_OPEN_FILES';
......
......@@ -166,11 +166,6 @@ export default {
unusedSeal: false,
});
},
[types.SET_RIGHT_PANE](state, view) {
Object.assign(state, {
rightPane: state.rightPane === view ? null : view,
});
},
[types.SET_LINKS](state, links) {
Object.assign(state, { links });
},
......
......@@ -23,7 +23,6 @@ export default () => ({
currentActivityView: activityBarViews.edit,
unusedSeal: true,
fileFindVisible: false,
rightPane: null,
links: {},
errorMessage: null,
entryModal: {
......
import Vue from 'vue';
import store from '~/ide/stores';
import ideStatusBar from '~/ide/components/ide_status_bar.vue';
import { rightSidebarViews } from '~/ide/constants';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { resetStore } from '../helpers';
import { projectData } from '../mock_data';
......@@ -64,7 +65,7 @@ describe('ideStatusBar', () => {
describe('pipeline status', () => {
it('opens right sidebar on clicking icon', done => {
spyOn(vm, 'setRightPane');
spyOn(vm, 'openRightPane');
Vue.set(vm.$store.state.pipelines, 'latestPipeline', {
details: {
status: {
......@@ -80,7 +81,7 @@ describe('ideStatusBar', () => {
.then(() => {
vm.$el.querySelector('.ide-status-pipeline button').click();
expect(vm.setRightPane).toHaveBeenCalledWith('pipelines-list');
expect(vm.openRightPane).toHaveBeenCalledWith(rightSidebarViews.pipelines);
})
.then(done)
.catch(done.fail);
......
......@@ -25,7 +25,8 @@ describe('IDE right pane', () => {
describe('active', () => {
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.currentProjectId = 'gitlab-ce';
vm.$store.state.currentMergeRequestId = 1;
......@@ -41,20 +42,21 @@ describe('IDE right pane', () => {
},
};
vm.$nextTick(() => {
expect(vm.$el.querySelector('.ide-sidebar-link.active')).not.toBe(null);
expect(
vm.$el.querySelector('.ide-sidebar-link.active').getAttribute('data-original-title'),
).toBe('Merge Request');
done();
});
vm.$nextTick()
.then(() => {
expect(vm.$el.querySelector('.ide-sidebar-link.active')).not.toBe(null);
expect(
vm.$el.querySelector('.ide-sidebar-link.active').getAttribute('data-original-title'),
).toBe('Merge Request');
})
.then(done)
.catch(done.fail);
});
});
describe('click', () => {
beforeEach(() => {
spyOn(vm, 'setRightPane');
spyOn(vm, 'open');
});
it('sets view to merge request', done => {
......@@ -63,7 +65,7 @@ describe('IDE right pane', () => {
vm.$nextTick(() => {
vm.$el.querySelector('.ide-sidebar-link').click();
expect(vm.setRightPane).toHaveBeenCalledWith(rightSidebarViews.mergeRequestInfo);
expect(vm.open).toHaveBeenCalledWith(rightSidebarViews.mergeRequestInfo);
done();
});
......
......@@ -319,8 +319,8 @@ describe('RepoEditor', () => {
});
});
it('calls updateDimensions when rightPane is updated', done => {
vm.$store.state.rightPane = 'testing';
it('calls updateDimensions when rightPane is opened', done => {
vm.$store.state.rightPane.isOpen = true;
vm.$nextTick(() => {
expect(vm.editor.updateDimensions).toHaveBeenCalled();
......
......@@ -6,6 +6,7 @@ import mergeRequestsState from '~/ide/stores/modules/merge_requests/state';
import pipelinesState from '~/ide/stores/modules/pipelines/state';
import branchesState from '~/ide/stores/modules/branches/state';
import fileTemplatesState from '~/ide/stores/modules/file_templates/state';
import paneState from '~/ide/stores/modules/pane/state';
export const resetStore = store => {
const newState = {
......@@ -15,6 +16,7 @@ export const resetStore = store => {
pipelines: pipelinesState(),
branches: branchesState(),
fileTemplates: fileTemplatesState(),
rightPane: paneState(),
};
store.replaceState(newState);
};
......
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,
);
});
});
});
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);
});
});
});
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,
});
});
});
});
......@@ -315,29 +315,29 @@ describe('IDE pipelines actions', () => {
'job',
mockedState,
[{ type: types.SET_DETAIL_JOB, payload: 'job' }],
[{ type: 'setRightPane', payload: 'jobs-detail' }],
[{ type: 'rightPane/open', payload: rightSidebarViews.jobsDetail }],
done,
);
});
it('dispatches setRightPane as pipeline when job is null', done => {
it('dispatches rightPane/open as pipeline when job is null', done => {
testAction(
setDetailJob,
null,
mockedState,
[{ type: types.SET_DETAIL_JOB, payload: null }],
[{ type: 'setRightPane', payload: rightSidebarViews.pipelines }],
[{ type: 'rightPane/open', payload: rightSidebarViews.pipelines }],
done,
);
});
it('dispatches setRightPane as job', done => {
it('dispatches rightPane/open as job', done => {
testAction(
setDetailJob,
'job',
mockedState,
[{ type: types.SET_DETAIL_JOB, payload: 'job' }],
[{ type: 'setRightPane', payload: rightSidebarViews.jobsDetail }],
[{ type: 'rightPane/open', payload: rightSidebarViews.jobsDetail }],
done,
);
});
......
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