Commit 8b9fb322 authored by Chad Woolley's avatar Chad Woolley Committed by Paul Slaughter

Refactor Web IDE collapsible sidebar logic

Make it reusable for left sidebar as well as right.

This supports merge request https://gitlab.com/gitlab-org/gitlab/merge_requests/21010
parent 0e09920a
<script>
import Vue from 'vue';
import { mapActions, mapState, mapGetters } from 'vuex';
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import FindFile from '~/vue_shared/components/file_finder/index.vue';
......
<script>
import { mapActions, mapState } from 'vuex';
import _ from 'underscore';
import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import ResizablePanel from '../resizable_panel.vue';
import { GlSkeletonLoading } from '@gitlab/ui';
export default {
name: 'CollapsibleSidebar',
directives: {
tooltip,
},
components: {
Icon,
ResizablePanel,
GlSkeletonLoading,
},
props: {
extensionTabs: {
type: Array,
required: false,
default: () => [],
},
side: {
type: String,
required: true,
},
width: {
type: Number,
required: true,
},
},
computed: {
...mapState(['loading']),
...mapState({
isOpen(state) {
return state[this.namespace].isOpen;
},
currentView(state) {
return state[this.namespace].currentView;
},
isActiveView(state, getters) {
return getters[`${this.namespace}/isActiveView`];
},
isAliveView(_state, getters) {
return getters[`${this.namespace}/isAliveView`];
},
}),
namespace() {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return `${this.side}Pane`;
},
tabs() {
return 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));
},
otherSide() {
return this.side === 'right' ? 'left' : 'right';
},
},
methods: {
...mapActions({
toggleOpen(dispatch) {
return dispatch(`${this.namespace}/toggleOpen`);
},
open(dispatch, view) {
return dispatch(`${this.namespace}/open`, view);
},
}),
clickTab(e, tab) {
e.target.blur();
if (this.isActiveTab(tab)) {
this.toggleOpen();
} else {
this.open(tab.views[0]);
}
},
isActiveTab(tab) {
return tab.views.some(view => this.isActiveView(view.name));
},
buttonClasses(tab) {
return [
this.side === 'right' ? 'is-right' : '',
this.isActiveTab(tab) && this.isOpen ? 'active' : '',
...(tab.buttonClasses || []),
];
},
},
};
</script>
<template>
<div
:class="`ide-${side}-sidebar`"
:data-qa-selector="`ide_${side}_sidebar`"
class="multi-file-commit-panel ide-sidebar"
>
<resizable-panel
v-show="isOpen"
:collapsible="false"
:initial-width="width"
:min-size="width"
:class="`ide-${side}-sidebar-${currentView}`"
:side="side"
class="multi-file-commit-panel-inner"
>
<div class="h-100 d-flex flex-column align-items-stretch">
<slot v-if="isOpen" name="header"></slot>
<div
v-for="tabView in aliveTabViews"
v-show="isActiveView(tabView.name)"
:key="tabView.name"
class="flex-fill js-tab-view"
>
<component :is="tabView.component" />
</div>
<slot name="footer"></slot>
</div>
</resizable-panel>
<nav class="ide-activity-bar">
<ul class="list-unstyled">
<li>
<slot name="header-icon"></slot>
</li>
<li v-for="tab of tabs" :key="tab.title">
<button
v-tooltip
:title="tab.title"
:aria-label="tab.title"
:class="buttonClasses(tab)"
data-container="body"
:data-placement="otherSide"
:data-qa-selector="`${tab.title.toLowerCase()}_tab_button`"
class="ide-sidebar-link"
type="button"
@click="clickTab($event, tab)"
>
<icon :size="16" :name="tab.icon" />
</button>
</li>
</ul>
</nav>
</div>
</template>
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import _ from 'underscore';
import { mapGetters, mapState } from 'vuex';
import { __ } from '~/locale';
import tooltip from '../../../vue_shared/directives/tooltip';
import Icon from '../../../vue_shared/components/icon.vue';
import CollapsibleSidebar from './collapsible_sidebar.vue';
import { rightSidebarViews } from '../../constants';
import MergeRequestInfo from '../merge_requests/info.vue';
import PipelinesList from '../pipelines/list.vue';
import JobsDetail from '../jobs/detail.vue';
import MergeRequestInfo from '../merge_requests/info.vue';
import ResizablePanel from '../resizable_panel.vue';
import Clientside from '../preview/clientside.vue';
export default {
directives: {
tooltip,
},
name: 'RightPane',
components: {
Icon,
PipelinesList,
JobsDetail,
ResizablePanel,
MergeRequestInfo,
Clientside,
CollapsibleSidebar,
},
props: {
extensionTabs: {
......@@ -32,103 +22,40 @@ export default {
},
computed: {
...mapState(['currentMergeRequestId', 'clientsidePreviewEnabled']),
...mapState('rightPane', ['isOpen', 'currentView']),
...mapGetters(['packageJson']),
...mapGetters('rightPane', ['isActiveView', 'isAliveView']),
showLivePreview() {
return this.packageJson && this.clientsidePreviewEnabled;
},
defaultTabs() {
rightExtensionTabs() {
return [
{
show: this.currentMergeRequestId,
show: Boolean(this.currentMergeRequestId),
title: __('Merge Request'),
views: [rightSidebarViews.mergeRequestInfo],
views: [{ component: MergeRequestInfo, ...rightSidebarViews.mergeRequestInfo }],
icon: 'text-description',
},
{
show: true,
title: __('Pipelines'),
views: [rightSidebarViews.pipelines, rightSidebarViews.jobsDetail],
views: [
{ component: PipelinesList, ...rightSidebarViews.pipelines },
{ component: JobsDetail, ...rightSidebarViews.jobsDetail },
],
icon: 'rocket',
},
{
show: this.showLivePreview,
title: __('Live preview'),
views: [rightSidebarViews.clientSidePreview],
views: [{ component: Clientside, ...rightSidebarViews.clientSidePreview }],
icon: 'live-preview',
},
...this.extensionTabs,
];
},
tabs() {
return this.defaultTabs.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('rightPane', ['toggleOpen', 'open']),
clickTab(e, tab) {
e.target.blur();
if (this.isActiveTab(tab)) {
this.toggleOpen();
} else {
this.open(tab.views[0]);
}
},
isActiveTab(tab) {
return tab.views.some(view => this.isActiveView(view.name));
},
},
};
</script>
<template>
<div class="multi-file-commit-panel ide-right-sidebar" data-qa-selector="ide_right_sidebar">
<resizable-panel
v-show="isOpen"
:collapsible="false"
:initial-width="350"
:min-size="350"
:class="`ide-right-sidebar-${currentView}`"
side="right"
class="multi-file-commit-panel-inner"
>
<div
v-for="tabView in aliveTabViews"
v-show="isActiveView(tabView.name)"
:key="tabView.name"
class="h-100"
>
<component :is="tabView.component || tabView.name" />
</div>
</resizable-panel>
<nav class="ide-activity-bar">
<ul class="list-unstyled">
<li v-for="tab of tabs" :key="tab.title">
<button
v-tooltip
:title="tab.title"
:aria-label="tab.title"
:class="{
active: isActiveTab(tab) && isOpen,
}"
data-container="body"
data-placement="left"
:data-qa-selector="`${tab.title.toLowerCase()}_tab_button`"
class="ide-sidebar-link is-right"
type="button"
@click="clickTab($event, tab)"
>
<icon :size="16" :name="tab.icon" />
</button>
</li>
</ul>
</nav>
</div>
<collapsible-sidebar :extension-tabs="rightExtensionTabs" side="right" :width="350" />
</template>
......@@ -33,19 +33,6 @@ export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => {
}
};
export const toggleRightPanelCollapsed = ({ dispatch, state }, e = undefined) => {
if (e) {
$(e.currentTarget)
.tooltip('hide')
.blur();
}
dispatch('setPanelCollapsedStatus', {
side: 'right',
collapsed: !state.rightPanelCollapsed,
});
};
export const setResizingStatus = ({ commit }, resizing) => {
commit(types.SET_RESIZING_STATUS, resizing);
};
......
import * as types from './mutation_types';
export const toggleOpen = ({ dispatch, state }, view) => {
export const toggleOpen = ({ dispatch, state }) => {
if (state.isOpen) {
dispatch('close');
} else {
dispatch('open', view);
dispatch('open');
}
};
export const open = ({ commit }, view) => {
export const open = ({ state, commit }, view) => {
commit(types.SET_OPEN, true);
if (view) {
if (view && view.name !== state.currentView) {
const { name, keepAlive } = view;
commit(types.SET_CURRENT_VIEW, name);
......
......@@ -11,7 +11,6 @@ export const SET_LINKS = 'SET_LINKS';
// Project Mutation Types
export const SET_PROJECT = 'SET_PROJECT';
export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT';
export const TOGGLE_PROJECT_OPEN = 'TOGGLE_PROJECT_OPEN';
export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE';
// Merge Request Mutation Types
......
......@@ -740,6 +740,7 @@ $ide-commit-header-height: 48px;
.ide-sidebar-link {
display: flex;
align-items: center;
justify-content: center;
position: relative;
height: 60px;
width: 100%;
......@@ -1076,10 +1077,12 @@ $ide-commit-header-height: 48px;
}
}
.ide-right-sidebar {
.ide-sidebar {
width: auto;
min-width: 60px;
}
.ide-right-sidebar {
.ide-activity-bar {
border-left: 1px solid $white-dark;
}
......
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import EERightPane from 'ee/ide/components/panes/right.vue';
import RightPane from '~/ide/components/panes/right.vue';
......@@ -29,6 +29,7 @@ describe('IDE EERightPane', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('adds terminal tab', () => {
......
......@@ -8,8 +8,8 @@ module QA
module WebTerminalPanel
def self.prepended(page)
page.module_eval do
view 'app/assets/javascripts/ide/components/panes/right.vue' do
element :ide_right_sidebar
view 'app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue' do
element :ide_right_sidebar, %q(:data-qa-selector="`ide_${side}_sidebar`") # rubocop:disable QA/ElementWithPattern
element :terminal_tab_button, %q(:data-qa-selector="`${tab.title.toLowerCase()}_tab_button`") # rubocop:disable QA/ElementWithPattern
end
......
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { createStore } from '~/ide/stores';
import paneModule from '~/ide/stores/modules/pane';
import CollapsibleSidebar from '~/ide/components/panes/collapsible_sidebar.vue';
import Vuex from 'vuex';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('ide/components/panes/collapsible_sidebar.vue', () => {
let wrapper;
let store;
const width = 350;
const fakeComponentName = 'fake-component';
const createComponent = props => {
wrapper = shallowMount(CollapsibleSidebar, {
localVue,
store,
propsData: {
extensionTabs: [],
side: 'right',
width,
...props,
},
slots: {
'header-icon': '<div class=".header-icon-slot">SLOT ICON</div>',
header: '<div class=".header-slot"/>',
footer: '<div class=".footer-slot"/>',
},
});
};
const findTabButton = () => wrapper.find(`[data-qa-selector="${fakeComponentName}_tab_button"]`);
beforeEach(() => {
store = createStore();
store.registerModule('leftPane', paneModule());
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('with a tab', () => {
let fakeView;
let extensionTabs;
beforeEach(() => {
const FakeComponent = localVue.component(fakeComponentName, {
render: () => {},
});
fakeView = {
name: fakeComponentName,
keepAlive: true,
component: FakeComponent,
};
extensionTabs = [
{
show: true,
title: fakeComponentName,
views: [fakeView],
icon: 'text-description',
buttonClasses: ['button-class-1', 'button-class-2'],
},
];
});
describe.each`
side
${'left'}
${'right'}
`('when side=$side', ({ side }) => {
it('correctly renders side specific attributes', () => {
createComponent({ extensionTabs, side });
const button = findTabButton();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.classes()).toContain('multi-file-commit-panel');
expect(wrapper.classes()).toContain(`ide-${side}-sidebar`);
expect(wrapper.find('.multi-file-commit-panel-inner')).not.toBe(null);
expect(wrapper.find(`.ide-${side}-sidebar-${fakeComponentName}`)).not.toBe(null);
expect(button.attributes('data-placement')).toEqual(side === 'left' ? 'right' : 'left');
if (side === 'right') {
// this class is only needed on the right side; there is no 'is-left'
expect(button.classes()).toContain('is-right');
} else {
expect(button.classes()).not.toContain('is-right');
}
});
});
});
describe('when default side', () => {
let button;
beforeEach(() => {
createComponent({ extensionTabs });
button = findTabButton();
});
it('correctly renders tab-specific classes', () => {
store.state.rightPane.currentView = fakeComponentName;
return wrapper.vm.$nextTick().then(() => {
expect(button.classes()).toContain('button-class-1');
expect(button.classes()).toContain('button-class-2');
});
});
it('can show an open pane tab with an active view', () => {
store.state.rightPane.isOpen = true;
store.state.rightPane.currentView = fakeComponentName;
return wrapper.vm.$nextTick().then(() => {
expect(button.classes()).toEqual(expect.arrayContaining(['ide-sidebar-link', 'active']));
expect(button.attributes('data-original-title')).toEqual(fakeComponentName);
expect(wrapper.find('.js-tab-view').exists()).toBe(true);
});
});
it('does not show a pane which is not open', () => {
store.state.rightPane.isOpen = false;
store.state.rightPane.currentView = fakeComponentName;
return wrapper.vm.$nextTick().then(() => {
expect(button.classes()).not.toEqual(
expect.arrayContaining(['ide-sidebar-link', 'active']),
);
expect(wrapper.find('.js-tab-view').exists()).toBe(false);
});
});
describe('when button is clicked', () => {
it('opens view', () => {
button.trigger('click');
expect(store.state.rightPane.isOpen).toBeTruthy();
});
it('toggles open view if tab is currently active', () => {
button.trigger('click');
expect(store.state.rightPane.isOpen).toBeTruthy();
button.trigger('click');
expect(store.state.rightPane.isOpen).toBeFalsy();
});
});
it('shows header-icon', () => {
expect(wrapper.find('.header-icon-slot')).not.toBeNull();
});
it('shows header', () => {
expect(wrapper.find('.header-slot')).not.toBeNull();
});
it('shows footer', () => {
expect(wrapper.find('.footer-slot')).not.toBeNull();
});
});
});
});
import Vue from 'vue';
import '~/behaviors/markdown/render_gfm';
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { createStore } from '~/ide/stores';
import RightPane from '~/ide/components/panes/right.vue';
import CollapsibleSidebar from '~/ide/components/panes/collapsible_sidebar.vue';
import { rightSidebarViews } from '~/ide/constants';
describe('IDE right pane', () => {
let Component;
let vm;
const localVue = createLocalVue();
localVue.use(Vuex);
beforeAll(() => {
Component = Vue.extend(RightPane);
});
describe('ide/components/panes/right.vue', () => {
let wrapper;
let store;
beforeEach(() => {
const store = createStore();
const createComponent = props => {
wrapper = shallowMount(RightPane, {
localVue,
store,
propsData: {
...props,
},
});
};
vm = createComponentWithStore(Component, store).$mount();
beforeEach(() => {
store = createStore();
});
afterEach(() => {
vm.$destroy();
wrapper.destroy();
wrapper = null;
});
describe('active', () => {
it('renders merge request button as active', done => {
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;
vm.$store.state.projects['gitlab-ce'] = {
mergeRequests: {
1: {
iid: 1,
title: 'Testing',
title_html: '<span class="title-html">Testing</span>',
description: 'Description',
description_html: '<p class="description-html">Description HTML</p>',
},
it('allows tabs to be added via extensionTabs prop', () => {
createComponent({
extensionTabs: [
{
show: true,
title: 'FakeTab',
},
};
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);
],
});
expect(wrapper.find(CollapsibleSidebar).props('extensionTabs')).toEqual(
expect.arrayContaining([
expect.objectContaining({
show: true,
title: 'FakeTab',
}),
]),
);
});
describe('click', () => {
beforeEach(() => {
jest.spyOn(vm, 'open').mockReturnValue();
});
describe('pipelines tab', () => {
it('is always shown', () => {
createComponent();
it('sets view to merge request', done => {
vm.$store.state.currentMergeRequestId = '123';
expect(wrapper.find(CollapsibleSidebar).props('extensionTabs')).toEqual(
expect.arrayContaining([
expect.objectContaining({
show: true,
title: 'Pipelines',
views: expect.arrayContaining([
expect.objectContaining({
name: rightSidebarViews.pipelines.name,
}),
expect.objectContaining({
name: rightSidebarViews.jobsDetail.name,
}),
]),
}),
]),
);
});
});
vm.$nextTick(() => {
vm.$el.querySelector('.ide-sidebar-link').click();
describe('merge request tab', () => {
it('is shown if there is a currentMergeRequestId', () => {
store.state.currentMergeRequestId = 1;
expect(vm.open).toHaveBeenCalledWith(rightSidebarViews.mergeRequestInfo);
createComponent();
done();
});
expect(wrapper.find(CollapsibleSidebar).props('extensionTabs')).toEqual(
expect.arrayContaining([
expect.objectContaining({
show: true,
title: 'Merge Request',
views: expect.arrayContaining([
expect.objectContaining({
name: rightSidebarViews.mergeRequestInfo.name,
}),
]),
}),
]),
);
});
});
describe('live preview', () => {
it('renders live preview button', done => {
Vue.set(vm.$store.state.entries, 'package.json', {
describe('clientside live preview tab', () => {
it('is shown if there is a packageJson and clientsidePreviewEnabled', () => {
Vue.set(store.state.entries, 'package.json', {
name: 'package.json',
});
vm.$store.state.clientsidePreviewEnabled = true;
store.state.clientsidePreviewEnabled = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('button[aria-label="Live preview"]')).not.toBeNull();
createComponent();
done();
});
expect(wrapper.find(CollapsibleSidebar).props('extensionTabs')).toEqual(
expect.arrayContaining([
expect.objectContaining({
show: true,
title: 'Live preview',
views: expect.arrayContaining([
expect.objectContaining({
name: rightSidebarViews.clientSidePreview.name,
}),
]),
}),
]),
);
});
});
});
......@@ -8,14 +8,7 @@ describe('IDE pane module actions', () => {
describe('toggleOpen', () => {
it('dispatches open if closed', done => {
testAction(
actions.toggleOpen,
TEST_VIEW,
{ isOpen: false },
[],
[{ type: 'open', payload: TEST_VIEW }],
done,
);
testAction(actions.toggleOpen, TEST_VIEW, { isOpen: false }, [], [{ type: 'open' }], done);
});
it('dispatches close if opened', done => {
......@@ -24,37 +17,48 @@ describe('IDE pane module actions', () => {
});
describe('open', () => {
it('commits SET_OPEN', done => {
testAction(actions.open, null, {}, [{ type: types.SET_OPEN, payload: true }], [], done);
});
describe('with a view specified', () => {
it('commits SET_OPEN and SET_CURRENT_VIEW', done => {
testAction(
actions.open,
TEST_VIEW,
{},
[
{ type: types.SET_OPEN, payload: true },
{ type: types.SET_CURRENT_VIEW, payload: TEST_VIEW.name },
],
[],
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,
);
});
});
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('without a view specified', () => {
it('commits SET_OPEN', done => {
testAction(
actions.open,
undefined,
{},
[{ type: types.SET_OPEN, payload: true }],
[],
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