Commit 97552cee authored by Enrique Alcántara's avatar Enrique Alcántara

Merge branch '204821-step-2-extract-sidebar-nav-from-collapsible-panel' into 'master'

Step 2 - Extract sidebar_nav component from collapsible_panel

See merge request gitlab-org/gitlab!32465
parents 6ed38caf b7622a27
<script>
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { otherSide } from '../utils';
import { SIDE_RIGHT } from '../constants';
export default {
directives: {
tooltip: GlTooltipDirective,
},
components: {
GlIcon,
},
props: {
tabs: {
type: Array,
required: true,
},
side: {
type: String,
required: true,
},
currentView: {
type: String,
required: false,
default: '',
},
isOpen: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
otherSide() {
return otherSide(this.side);
},
},
methods: {
isActiveTab(tab) {
return this.isOpen && tab.views.some(view => view.name === this.currentView);
},
buttonClasses(tab) {
return [
{
'is-right': this.side === SIDE_RIGHT,
active: this.isActiveTab(tab),
},
...(tab.buttonClasses || []),
];
},
clickTab(e, tab) {
e.currentTarget.blur();
this.$root.$emit('bv::hide::tooltip');
if (this.isActiveTab(tab)) {
this.$emit('close');
} else {
this.$emit('open', tab.views[0]);
}
},
},
};
</script>
<template>
<nav class="ide-activity-bar">
<ul class="list-unstyled">
<li v-for="tab of tabs" :key="tab.title">
<button
v-tooltip="{ container: 'body', placement: otherSide }"
:title="tab.title"
:aria-label="tab.title"
:class="buttonClasses(tab)"
:data-qa-selector="`${tab.title.toLowerCase()}_tab_button`"
class="ide-sidebar-link"
type="button"
@click="clickTab($event, tab)"
>
<gl-icon :size="16" :name="tab.icon" />
</button>
</li>
</ul>
</nav>
</template>
...@@ -3,7 +3,7 @@ import { mapActions, mapState } from 'vuex'; ...@@ -3,7 +3,7 @@ import { mapActions, mapState } from 'vuex';
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';
import ResizablePanel from '../resizable_panel.vue'; import ResizablePanel from '../resizable_panel.vue';
import { GlSkeletonLoading } from '@gitlab/ui'; import IdeSidebarNav from '../ide_sidebar_nav.vue';
export default { export default {
name: 'CollapsibleSidebar', name: 'CollapsibleSidebar',
...@@ -13,7 +13,7 @@ export default { ...@@ -13,7 +13,7 @@ export default {
components: { components: {
Icon, Icon,
ResizablePanel, ResizablePanel,
GlSkeletonLoading, IdeSidebarNav,
}, },
props: { props: {
extensionTabs: { extensionTabs: {
...@@ -31,7 +31,6 @@ export default { ...@@ -31,7 +31,6 @@ export default {
}, },
}, },
computed: { computed: {
...mapState(['loading']),
...mapState({ ...mapState({
isOpen(state) { isOpen(state) {
return state[this.namespace].isOpen; return state[this.namespace].isOpen;
...@@ -39,9 +38,6 @@ export default { ...@@ -39,9 +38,6 @@ export default {
currentView(state) { currentView(state) {
return state[this.namespace].currentView; return state[this.namespace].currentView;
}, },
isActiveView(state, getters) {
return getters[`${this.namespace}/isActiveView`];
},
isAliveView(_state, getters) { isAliveView(_state, getters) {
return getters[`${this.namespace}/isAliveView`]; return getters[`${this.namespace}/isAliveView`];
}, },
...@@ -59,9 +55,6 @@ export default { ...@@ -59,9 +55,6 @@ export default {
aliveTabViews() { aliveTabViews() {
return this.tabViews.filter(view => this.isAliveView(view.name)); return this.tabViews.filter(view => this.isAliveView(view.name));
}, },
otherSide() {
return this.side === 'right' ? 'left' : 'right';
},
}, },
methods: { methods: {
...mapActions({ ...mapActions({
...@@ -72,25 +65,6 @@ export default { ...@@ -72,25 +65,6 @@ export default {
return dispatch(`${this.namespace}/open`, 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> </script>
...@@ -110,40 +84,23 @@ export default { ...@@ -110,40 +84,23 @@ export default {
class="multi-file-commit-panel-inner" class="multi-file-commit-panel-inner"
> >
<div class="h-100 d-flex flex-column align-items-stretch"> <div class="h-100 d-flex flex-column align-items-stretch">
<slot v-if="isOpen" name="header"></slot>
<div <div
v-for="tabView in aliveTabViews" v-for="tabView in aliveTabViews"
v-show="isActiveView(tabView.name)" v-show="tabView.name === currentView"
:key="tabView.name" :key="tabView.name"
class="flex-fill gl-overflow-hidden js-tab-view" class="flex-fill gl-overflow-hidden js-tab-view"
> >
<component :is="tabView.component" /> <component :is="tabView.component" />
</div> </div>
<slot name="footer"></slot>
</div> </div>
</resizable-panel> </resizable-panel>
<nav class="ide-activity-bar"> <ide-sidebar-nav
<ul class="list-unstyled"> :tabs="tabs"
<li> :side="side"
<slot name="header-icon"></slot> :current-view="currentView"
</li> :is-open="isOpen"
<li v-for="tab of tabs" :key="tab.title"> @open="open"
<button @close="toggleOpen"
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> </div>
</template> </template>
...@@ -92,3 +92,6 @@ export const commitActionTypes = { ...@@ -92,3 +92,6 @@ export const commitActionTypes = {
}; };
export const packageJsonPath = 'package.json'; export const packageJsonPath = 'package.json';
export const SIDE_LEFT = 'left';
export const SIDE_RIGHT = 'right';
export const isActiveView = state => view => state.currentView === view; // eslint-disable-next-line import/prefer-default-export
export const isAliveView = state => view =>
export const isAliveView = (state, getters) => view => state.keepAliveViews[view] || (state.isOpen && state.currentView === view);
state.keepAliveViews[view] || (state.isOpen && getters.isActiveView(view));
import { SIDE_LEFT, SIDE_RIGHT } from './constants';
import { languages } from 'monaco-editor'; import { languages } from 'monaco-editor';
import { flatten } from 'lodash'; import { flatten } from 'lodash';
...@@ -73,3 +74,5 @@ export function registerLanguages(def, ...defs) { ...@@ -73,3 +74,5 @@ export function registerLanguages(def, ...defs) {
languages.setMonarchTokensProvider(languageId, def.language); languages.setMonarchTokensProvider(languageId, def.language);
languages.setLanguageConfiguration(languageId, def.conf); languages.setLanguageConfiguration(languageId, def.conf);
} }
export const otherSide = side => (side === SIDE_RIGHT ? SIDE_LEFT : SIDE_RIGHT);
...@@ -14,6 +14,9 @@ module QA ...@@ -14,6 +14,9 @@ module QA
base.class_eval do base.class_eval do
view 'app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue' do 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 :ide_right_sidebar, %q(:data-qa-selector="`ide_${side}_sidebar`") # rubocop:disable QA/ElementWithPattern
end
view 'app/assets/javascripts/ide/components/ide_sidebar_nav.vue' do
element :terminal_tab_button, %q(:data-qa-selector="`${tab.title.toLowerCase()}_tab_button`") # rubocop:disable QA/ElementWithPattern element :terminal_tab_button, %q(:data-qa-selector="`${tab.title.toLowerCase()}_tab_button`") # rubocop:disable QA/ElementWithPattern
end end
......
export const getKey = name => `$_gl_jest_${name}`;
export const getBinding = (el, name) => el[getKey(name)];
export const createMockDirective = () => ({
bind(el, { name, value, arg, modifiers }) {
el[getKey(name)] = {
value,
arg,
modifiers,
};
},
unbind(el, { name }) {
delete el[getKey(name)];
},
});
import { shallowMount } from '@vue/test-utils';
import { GlIcon } from '@gitlab/ui';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import IdeSidebarNav from '~/ide/components/ide_sidebar_nav.vue';
import { SIDE_RIGHT, SIDE_LEFT } from '~/ide/constants';
const TEST_TABS = [
{
title: 'Lorem',
icon: 'angle-up',
views: [{ name: 'lorem-1' }, { name: 'lorem-2' }],
},
{
title: 'Ipsum',
icon: 'angle-down',
views: [{ name: 'ipsum-1' }, { name: 'ipsum-2' }],
},
];
const TEST_CURRENT_INDEX = 1;
const TEST_CURRENT_VIEW = TEST_TABS[TEST_CURRENT_INDEX].views[1].name;
const TEST_OPEN_VIEW = TEST_TABS[TEST_CURRENT_INDEX].views[0];
describe('~/ide/components/ide_sidebar_nav', () => {
let wrapper;
const createComponent = (props = {}) => {
if (wrapper) {
throw new Error('wrapper already exists');
}
wrapper = shallowMount(IdeSidebarNav, {
propsData: {
tabs: TEST_TABS,
currentView: TEST_CURRENT_VIEW,
isOpen: false,
...props,
},
directives: {
tooltip: createMockDirective(),
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findButtons = () => wrapper.findAll('li button');
const findButtonsData = () =>
findButtons().wrappers.map(button => {
return {
title: button.attributes('title'),
ariaLabel: button.attributes('aria-label'),
classes: button.classes(),
qaSelector: button.attributes('data-qa-selector'),
icon: button.find(GlIcon).props('name'),
tooltip: getBinding(button.element, 'tooltip').value,
};
});
const clickTab = () =>
findButtons()
.at(TEST_CURRENT_INDEX)
.trigger('click');
describe.each`
isOpen | side | otherSide | classes | classesObj | emitEvent | emitArg
${false} | ${SIDE_LEFT} | ${SIDE_RIGHT} | ${[]} | ${{}} | ${'open'} | ${[TEST_OPEN_VIEW]}
${false} | ${SIDE_RIGHT} | ${SIDE_LEFT} | ${['is-right']} | ${{}} | ${'open'} | ${[TEST_OPEN_VIEW]}
${true} | ${SIDE_RIGHT} | ${SIDE_LEFT} | ${['is-right']} | ${{ [TEST_CURRENT_INDEX]: ['active'] }} | ${'close'} | ${[]}
`(
'with side = $side, isOpen = $isOpen',
({ isOpen, side, otherSide, classes, classesObj, emitEvent, emitArg }) => {
let bsTooltipHide;
beforeEach(() => {
createComponent({ isOpen, side });
bsTooltipHide = jest.fn();
wrapper.vm.$root.$on('bv::hide::tooltip', bsTooltipHide);
});
it('renders buttons', () => {
expect(findButtonsData()).toEqual(
TEST_TABS.map((tab, index) => ({
title: tab.title,
ariaLabel: tab.title,
classes: ['ide-sidebar-link', ...classes, ...(classesObj[index] || [])],
qaSelector: `${tab.title.toLowerCase()}_tab_button`,
icon: tab.icon,
tooltip: {
container: 'body',
placement: otherSide,
},
})),
);
});
it('when tab clicked, emits event', () => {
expect(wrapper.emitted()).toEqual({});
clickTab();
expect(wrapper.emitted()).toEqual({
[emitEvent]: [emitArg],
});
});
it('when tab clicked, hides tooltip', () => {
expect(bsTooltipHide).not.toHaveBeenCalled();
clickTab();
expect(bsTooltipHide).toHaveBeenCalled();
});
},
);
});
...@@ -2,6 +2,7 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; ...@@ -2,6 +2,7 @@ import { createLocalVue, shallowMount } from '@vue/test-utils';
import { createStore } from '~/ide/stores'; import { createStore } from '~/ide/stores';
import paneModule from '~/ide/stores/modules/pane'; import paneModule from '~/ide/stores/modules/pane';
import CollapsibleSidebar from '~/ide/components/panes/collapsible_sidebar.vue'; import CollapsibleSidebar from '~/ide/components/panes/collapsible_sidebar.vue';
import IdeSidebarNav from '~/ide/components/ide_sidebar_nav.vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
const localVue = createLocalVue(); const localVue = createLocalVue();
...@@ -24,19 +25,15 @@ describe('ide/components/panes/collapsible_sidebar.vue', () => { ...@@ -24,19 +25,15 @@ describe('ide/components/panes/collapsible_sidebar.vue', () => {
width, width,
...props, ...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"]`); const findSidebarNav = () => wrapper.find(IdeSidebarNav);
beforeEach(() => { beforeEach(() => {
store = createStore(); store = createStore();
store.registerModule('leftPane', paneModule()); store.registerModule('leftPane', paneModule());
jest.spyOn(store, 'dispatch').mockImplementation();
}); });
afterEach(() => { afterEach(() => {
...@@ -75,92 +72,60 @@ describe('ide/components/panes/collapsible_sidebar.vue', () => { ...@@ -75,92 +72,60 @@ describe('ide/components/panes/collapsible_sidebar.vue', () => {
${'left'} ${'left'}
${'right'} ${'right'}
`('when side=$side', ({ side }) => { `('when side=$side', ({ side }) => {
it('correctly renders side specific attributes', () => { beforeEach(() => {
createComponent({ extensionTabs, side }); 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(() => { it('correctly renders side specific attributes', () => {
createComponent({ extensionTabs }); expect(wrapper.classes()).toContain('multi-file-commit-panel');
expect(wrapper.classes()).toContain(`ide-${side}-sidebar`);
button = findTabButton(); expect(wrapper.find('.multi-file-commit-panel-inner')).not.toBe(null);
expect(wrapper.find(`.ide-${side}-sidebar-${fakeComponentName}`)).not.toBe(null);
expect(findSidebarNav().props('side')).toBe(side);
}); });
it('correctly renders tab-specific classes', () => { it('nothing is dispatched', () => {
store.state.rightPane.currentView = fakeComponentName; expect(store.dispatch).not.toHaveBeenCalled();
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', () => { it('when sidebar emits open, dispatch open', () => {
store.state.rightPane.isOpen = true; const view = 'lorem-view';
store.state.rightPane.currentView = fakeComponentName;
return wrapper.vm.$nextTick().then(() => { findSidebarNav().vm.$emit('open', view);
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(store.dispatch).toHaveBeenCalledWith(`${side}Pane/open`, view);
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('when sidebar emits close, dispatch toggleOpen', () => {
it('opens view', () => { findSidebarNav().vm.$emit('close');
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.dispatch).toHaveBeenCalledWith(`${side}Pane/toggleOpen`);
expect(store.state.rightPane.isOpen).toBeFalsy();
});
}); });
});
it('shows header-icon', () => { describe.each`
expect(wrapper.find('.header-icon-slot')).not.toBeNull(); isOpen
${true}
${false}
`('when isOpen=$isOpen', ({ isOpen }) => {
beforeEach(() => {
store.state.rightPane.isOpen = isOpen;
store.state.rightPane.currentView = fakeComponentName;
createComponent({ extensionTabs });
}); });
it('shows header', () => { it(`tab view is shown=${isOpen}`, () => {
expect(wrapper.find('.header-slot')).not.toBeNull(); expect(wrapper.find('.js-tab-view').exists()).toBe(isOpen);
}); });
it('shows footer', () => { it('renders sidebar nav', () => {
expect(wrapper.find('.footer-slot')).not.toBeNull(); expect(findSidebarNav().props()).toEqual({
tabs: extensionTabs,
side: 'right',
currentView: fakeComponentName,
isOpen,
});
}); });
}); });
}); });
......
...@@ -7,20 +7,6 @@ describe('IDE pane module getters', () => { ...@@ -7,20 +7,6 @@ describe('IDE pane module getters', () => {
[TEST_VIEW]: true, [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', () => { describe('isAliveView', () => {
it('returns true if given view is in keepAliveViews', () => { it('returns true if given view is in keepAliveViews', () => {
const result = getters.isAliveView({ keepAliveViews: TEST_KEEP_ALIVE_VIEWS }, {})(TEST_VIEW); const result = getters.isAliveView({ keepAliveViews: TEST_KEEP_ALIVE_VIEWS }, {})(TEST_VIEW);
...@@ -29,25 +15,25 @@ describe('IDE pane module getters', () => { ...@@ -29,25 +15,25 @@ describe('IDE pane module getters', () => {
}); });
it('returns true if given view is active view and open', () => { it('returns true if given view is active view and open', () => {
const result = getters.isAliveView( const result = getters.isAliveView({ ...state(), isOpen: true, currentView: TEST_VIEW })(
{ ...state(), isOpen: true }, TEST_VIEW,
{ isActiveView: () => true }, );
)(TEST_VIEW);
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('returns false if given view is active view and closed', () => { it('returns false if given view is active view and closed', () => {
const result = getters.isAliveView(state(), { isActiveView: () => true })(TEST_VIEW); const result = getters.isAliveView({ ...state(), currentView: TEST_VIEW })(TEST_VIEW);
expect(result).toBe(false); expect(result).toBe(false);
}); });
it('returns false if given view is not activeView', () => { it('returns false if given view is not activeView', () => {
const result = getters.isAliveView( const result = getters.isAliveView({
{ ...state(), isOpen: true }, ...state(),
{ isActiveView: () => false }, isOpen: true,
)(TEST_VIEW); currentView: `${TEST_VIEW}_other`,
})(TEST_VIEW);
expect(result).toBe(false); expect(result).toBe(false);
}); });
......
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