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';
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';
import IdeSidebarNav from '../ide_sidebar_nav.vue';
export default {
name: 'CollapsibleSidebar',
......@@ -13,7 +13,7 @@ export default {
components: {
Icon,
ResizablePanel,
GlSkeletonLoading,
IdeSidebarNav,
},
props: {
extensionTabs: {
......@@ -31,7 +31,6 @@ export default {
},
},
computed: {
...mapState(['loading']),
...mapState({
isOpen(state) {
return state[this.namespace].isOpen;
......@@ -39,9 +38,6 @@ export default {
currentView(state) {
return state[this.namespace].currentView;
},
isActiveView(state, getters) {
return getters[`${this.namespace}/isActiveView`];
},
isAliveView(_state, getters) {
return getters[`${this.namespace}/isAliveView`];
},
......@@ -59,9 +55,6 @@ export default {
aliveTabViews() {
return this.tabViews.filter(view => this.isAliveView(view.name));
},
otherSide() {
return this.side === 'right' ? 'left' : 'right';
},
},
methods: {
...mapActions({
......@@ -72,25 +65,6 @@ export default {
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>
......@@ -110,40 +84,23 @@ export default {
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)"
v-show="tabView.name === currentView"
:key="tabView.name"
class="flex-fill gl-overflow-hidden 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>
<ide-sidebar-nav
:tabs="tabs"
:side="side"
:current-view="currentView"
:is-open="isOpen"
@open="open"
@close="toggleOpen"
/>
</div>
</template>
......@@ -92,3 +92,6 @@ export const commitActionTypes = {
};
export const packageJsonPath = 'package.json';
export const SIDE_LEFT = 'left';
export const SIDE_RIGHT = 'right';
export const isActiveView = state => view => state.currentView === view;
export const isAliveView = (state, getters) => view =>
state.keepAliveViews[view] || (state.isOpen && getters.isActiveView(view));
// eslint-disable-next-line import/prefer-default-export
export const isAliveView = state => view =>
state.keepAliveViews[view] || (state.isOpen && state.currentView === view);
import { SIDE_LEFT, SIDE_RIGHT } from './constants';
import { languages } from 'monaco-editor';
import { flatten } from 'lodash';
......@@ -73,3 +74,5 @@ export function registerLanguages(def, ...defs) {
languages.setMonarchTokensProvider(languageId, def.language);
languages.setLanguageConfiguration(languageId, def.conf);
}
export const otherSide = side => (side === SIDE_RIGHT ? SIDE_LEFT : SIDE_RIGHT);
......@@ -14,6 +14,9 @@ module QA
base.class_eval 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
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
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';
import { createStore } from '~/ide/stores';
import paneModule from '~/ide/stores/modules/pane';
import CollapsibleSidebar from '~/ide/components/panes/collapsible_sidebar.vue';
import IdeSidebarNav from '~/ide/components/ide_sidebar_nav.vue';
import Vuex from 'vuex';
const localVue = createLocalVue();
......@@ -24,19 +25,15 @@ describe('ide/components/panes/collapsible_sidebar.vue', () => {
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"]`);
const findSidebarNav = () => wrapper.find(IdeSidebarNav);
beforeEach(() => {
store = createStore();
store.registerModule('leftPane', paneModule());
jest.spyOn(store, 'dispatch').mockImplementation();
});
afterEach(() => {
......@@ -75,92 +72,60 @@ describe('ide/components/panes/collapsible_sidebar.vue', () => {
${'left'}
${'right'}
`('when side=$side', ({ side }) => {
it('correctly renders side specific attributes', () => {
beforeEach(() => {
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 side specific attributes', () => {
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(findSidebarNav().props('side')).toBe(side);
});
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('nothing is dispatched', () => {
expect(store.dispatch).not.toHaveBeenCalled();
});
it('can show an open pane tab with an active view', () => {
store.state.rightPane.isOpen = true;
store.state.rightPane.currentView = fakeComponentName;
it('when sidebar emits open, dispatch open', () => {
const view = 'lorem-view';
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;
findSidebarNav().vm.$emit('open', view);
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);
});
expect(store.dispatch).toHaveBeenCalledWith(`${side}Pane/open`, view);
});
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();
it('when sidebar emits close, dispatch toggleOpen', () => {
findSidebarNav().vm.$emit('close');
button.trigger('click');
expect(store.state.rightPane.isOpen).toBeFalsy();
});
expect(store.dispatch).toHaveBeenCalledWith(`${side}Pane/toggleOpen`);
});
});
it('shows header-icon', () => {
expect(wrapper.find('.header-icon-slot')).not.toBeNull();
describe.each`
isOpen
${true}
${false}
`('when isOpen=$isOpen', ({ isOpen }) => {
beforeEach(() => {
store.state.rightPane.isOpen = isOpen;
store.state.rightPane.currentView = fakeComponentName;
createComponent({ extensionTabs });
});
it('shows header', () => {
expect(wrapper.find('.header-slot')).not.toBeNull();
it(`tab view is shown=${isOpen}`, () => {
expect(wrapper.find('.js-tab-view').exists()).toBe(isOpen);
});
it('shows footer', () => {
expect(wrapper.find('.footer-slot')).not.toBeNull();
it('renders sidebar nav', () => {
expect(findSidebarNav().props()).toEqual({
tabs: extensionTabs,
side: 'right',
currentView: fakeComponentName,
isOpen,
});
});
});
});
......
......@@ -7,20 +7,6 @@ describe('IDE pane module getters', () => {
[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);
......@@ -29,25 +15,25 @@ describe('IDE pane module getters', () => {
});
it('returns true if given view is active view and open', () => {
const result = getters.isAliveView(
{ ...state(), isOpen: true },
{ isActiveView: () => true },
)(TEST_VIEW);
const result = getters.isAliveView({ ...state(), isOpen: true, currentView: TEST_VIEW })(
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);
const result = getters.isAliveView({ ...state(), currentView: TEST_VIEW })(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);
const result = getters.isAliveView({
...state(),
isOpen: true,
currentView: `${TEST_VIEW}_other`,
})(TEST_VIEW);
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