diff --git a/.gitignore b/.gitignore index 3f31a959..e94a791f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ node_modules/ .ipynb_checkpoints *.tsbuildinfo +*/yarn-error.log + # Created by https://www.gitignore.io/api/python # Edit at https://www.gitignore.io/?templates=python diff --git a/packages/application-extension/src/index.ts b/packages/application-extension/src/index.ts index 4e091259..36461add 100644 --- a/packages/application-extension/src/index.ts +++ b/packages/application-extension/src/index.ts @@ -39,7 +39,7 @@ import { PromiseDelegate } from '@lumino/coreutils'; import { DisposableDelegate, DisposableSet } from '@lumino/disposable'; -import { Widget } from '@lumino/widgets'; +import { Menu, Widget } from '@lumino/widgets'; /** * The default notebook factory. @@ -70,6 +70,11 @@ namespace CommandIDs { */ export const toggleTop = 'application:toggle-top'; + /** + * Toggle sidebar visibility + */ + export const togglePanel = 'application:toggle-panel'; + /** * Toggle the Zen mode */ @@ -96,6 +101,13 @@ namespace CommandIDs { export const resolveTree = 'application:resolve-tree'; } +/** + * Are the left and right panels available on the current page? + */ +const sidePanelsEnabled: () => boolean = () => { + return PageConfig.getOption('retroPage') === 'notebooks'; +}; + /** * Check if the application is dirty before closing the browser tab. */ @@ -581,6 +593,135 @@ const topVisibility: JupyterFrontEndPlugin = { autoStart: true }; +/** + * Plugin to toggle the left or right sidebar's visibility. + */ +const sidebarVisibility: JupyterFrontEndPlugin = { + id: '@retrolab/application-extension:sidebar', + requires: [IRetroShell, ITranslator], + optional: [IMainMenu, ISettingRegistry], + activate: ( + app: JupyterFrontEnd, + retroShell: IRetroShell, + translator: ITranslator, + menu: IMainMenu | null, + settingRegistry: ISettingRegistry | null + ) => { + if (!sidePanelsEnabled()) { + return; + } + + const trans = translator.load('retrolab'); + + /* Arguments for togglePanel command: + * side, left or right area + * title, widget title to show in the menu + * id, widget ID to activate in the sidebar + */ + app.commands.addCommand(CommandIDs.togglePanel, { + label: args => args['title'] as string, + caption: args => { + // We do not substitute the parameter into the string because the parameter is not + // localized (e.g., it is always 'left') even though the string is localized. + if (args['side'] === 'left') { + return trans.__( + 'Show %1 in the left sidebar', + args['title'] as string + ); + } else if (args['side'] === 'right') { + return trans.__( + 'Show %1 in the right sidebar', + args['title'] as string + ); + } + return trans.__('Show %1 in the sidebar', args['title'] as string); + }, + execute: args => { + switch (args['side'] as string) { + case 'left': + if (retroShell.leftCollapsed) { + retroShell.activateById(args['id'] as string); + retroShell.expandLeft(); + } else { + retroShell.collapseLeft(); + if (retroShell.currentWidget) { + retroShell.activateById(retroShell.currentWidget.id); + } + } + break; + case 'right': + if (retroShell.rightCollapsed) { + retroShell.activateById(args['id'] as string); + retroShell.expandRight(); + } else { + retroShell.collapseRight(); + if (retroShell.currentWidget) { + retroShell.activateById(retroShell.currentWidget.id); + } + } + break; + } + }, + isToggled: args => { + if (retroShell.leftCollapsed) { + return false; + } + const currentWidget = retroShell.leftHandler.current; + if (!currentWidget) { + return false; + } + + return currentWidget.id === (args['id'] as string); + } + }); + + const leftSidebarMenu = new Menu({ commands: app.commands }); + leftSidebarMenu.title.label = trans.__('Show Left Sidebar'); + + const rightSidebarMenu = new Menu({ commands: app.commands }); + rightSidebarMenu.title.label = trans.__('Show Right Sidebar'); + + app.restored.then(() => { + const leftWidgets = retroShell.widgetsList('left'); + leftWidgets.forEach(widget => { + leftSidebarMenu.addItem({ + command: CommandIDs.togglePanel, + args: { + side: 'left', + title: widget.title.caption, + id: widget.id + } + }); + }); + + const rightWidgets = retroShell.widgetsList('right'); + rightWidgets.forEach(widget => { + rightSidebarMenu.addItem({ + command: CommandIDs.togglePanel, + args: { + side: 'right', + title: widget.title.caption, + id: widget.id + } + }); + }); + + const menuItemsToAdd: Menu.IItemOptions[] = []; + if (leftWidgets.length > 0) { + menuItemsToAdd.push({ type: 'submenu', submenu: leftSidebarMenu }); + } + if (rightWidgets.length > 0) { + menuItemsToAdd.push({ type: 'submenu', submenu: rightSidebarMenu }); + } + + if (menu && menuItemsToAdd) { + menu.viewMenu.addGroup(menuItemsToAdd, 2); + } + }); + }, + autoStart: true +}; + /** * The default tree route resolver plugin. */ @@ -740,6 +881,7 @@ const plugins: JupyterFrontEndPlugin[] = [ router, sessionDialogs, shell, + sidebarVisibility, spacer, status, tabTitle, diff --git a/packages/application/src/shell.ts b/packages/application/src/shell.ts index 70dc9d3b..b41e6cd9 100644 --- a/packages/application/src/shell.ts +++ b/packages/application/src/shell.ts @@ -2,18 +2,23 @@ // Distributed under the terms of the Modified BSD License. import { JupyterFrontEnd } from '@jupyterlab/application'; - +import { PageConfig } from '@jupyterlab/coreutils'; import { DocumentRegistry } from '@jupyterlab/docregistry'; import { ArrayExt, find, IIterator, iter } from '@lumino/algorithm'; - import { Token } from '@lumino/coreutils'; - import { Message, MessageLoop, IMessageHandler } from '@lumino/messaging'; - +import { Debouncer } from '@lumino/polling'; import { ISignal, Signal } from '@lumino/signaling'; -import { Panel, Widget, BoxLayout } from '@lumino/widgets'; +import { + BoxLayout, + Layout, + Panel, + SplitPanel, + StackedPanel, + Widget +} from '@lumino/widgets'; /** * The RetroLab application shell token. @@ -40,39 +45,99 @@ export class RetroShell extends Widget implements JupyterFrontEnd.IShell { super(); this.id = 'main'; - const rootLayout = new BoxLayout(); - this._topHandler = new Private.PanelHandler(); this._menuHandler = new Private.PanelHandler(); + this._leftHandler = new Private.SideBarHandler(); + this._rightHandler = new Private.SideBarHandler(); this._main = new Panel(); + const topWrapper = (this._topWrapper = new Panel()); + const menuWrapper = (this._menuWrapper = new Panel()); this._topHandler.panel.id = 'top-panel'; this._menuHandler.panel.id = 'menu-panel'; this._main.id = 'main-panel'; + this._spacer = new Widget(); + this._spacer.id = 'spacer-widget'; + // create wrappers around the top and menu areas - const topWrapper = (this._topWrapper = new Panel()); topWrapper.id = 'top-panel-wrapper'; topWrapper.addWidget(this._topHandler.panel); - const menuWrapper = (this._menuWrapper = new Panel()); menuWrapper.id = 'menu-panel-wrapper'; menuWrapper.addWidget(this._menuHandler.panel); - BoxLayout.setStretch(topWrapper, 0); - BoxLayout.setStretch(menuWrapper, 0); + BoxLayout.setStretch(this._topWrapper, 0); + BoxLayout.setStretch(this._menuWrapper, 0); + + if (this.sidePanelsVisible()) { + this.layout = this.initLayoutWithSidePanels(); + } else { + this.layout = this.initLayoutWithoutSidePanels(); + } + } + + initLayoutWithoutSidePanels(): Layout { + const rootLayout = new BoxLayout(); + BoxLayout.setStretch(this._main, 1); this._spacer = new Widget(); this._spacer.id = 'spacer-widget'; rootLayout.spacing = 0; - rootLayout.addWidget(topWrapper); - rootLayout.addWidget(menuWrapper); + rootLayout.addWidget(this._topWrapper); + rootLayout.addWidget(this._menuWrapper); rootLayout.addWidget(this._spacer); rootLayout.addWidget(this._main); - this.layout = rootLayout; + return rootLayout; + } + + initLayoutWithSidePanels(): Layout { + const rootLayout = new BoxLayout(); + const leftHandler = this._leftHandler; + const rightHandler = this._rightHandler; + const mainPanel = this._main; + + this.leftPanel.id = 'jp-left-stack'; + this.rightPanel.id = 'jp-right-stack'; + + // Hide the side panels by default. + leftHandler.hide(); + rightHandler.hide(); + + // TODO: Consider storing this as an attribute this._hsplitPanel if saving/restoring layout needed + const hsplitPanel = new SplitPanel(); + hsplitPanel.id = 'main-split-panel'; + hsplitPanel.spacing = 1; + + // Catch current changed events on the side handlers. + leftHandler.updated.connect(this._onLayoutModified, this); + rightHandler.updated.connect(this._onLayoutModified, this); + + BoxLayout.setStretch(hsplitPanel, 1); + + SplitPanel.setStretch(leftHandler.stackedPanel, 0); + SplitPanel.setStretch(rightHandler.stackedPanel, 0); + SplitPanel.setStretch(mainPanel, 1); + + hsplitPanel.addWidget(leftHandler.stackedPanel); + hsplitPanel.addWidget(mainPanel); + hsplitPanel.addWidget(rightHandler.stackedPanel); + + // Use relative sizing to set the width of the side panels. + // This will still respect the min-size of children widget in the stacked + // panel. + hsplitPanel.setRelativeSizes([1, 2.5, 1]); + + rootLayout.spacing = 0; + rootLayout.addWidget(this._topWrapper); + rootLayout.addWidget(this._menuWrapper); + rootLayout.addWidget(this._spacer); + rootLayout.addWidget(hsplitPanel); + + return rootLayout; } /** @@ -103,13 +168,62 @@ export class RetroShell extends Widget implements JupyterFrontEnd.IShell { return this._menuWrapper; } + /** + * Get the left area handler + */ + get leftHandler(): Private.SideBarHandler { + return this._leftHandler; + } + + /** + * Get the right area handler + */ + get rightHandler(): Private.SideBarHandler { + return this._rightHandler; + } + + /** + * Shortcut to get the left area handler's stacked panel + */ + get leftPanel(): StackedPanel { + return this._leftHandler.stackedPanel; + } + + /** + * Shortcut to get the right area handler's stacked panel + */ + get rightPanel(): StackedPanel { + return this._rightHandler.stackedPanel; + } + + /** + * Is the left sidebar visible? + */ + get leftCollapsed(): boolean { + return !(this._leftHandler.isVisible && this.leftPanel.isVisible); + } + + /** + * Is the right sidebar visible? + */ + get rightCollapsed(): boolean { + return !(this._rightHandler.isVisible && this.rightPanel.isVisible); + } + /** * Activate a widget in its area. */ activateById(id: string): void { - const widget = find(this.widgets('main'), w => w.id === id); - if (widget) { - widget.activate(); + // Search all areas that can have widgets for this widget, starting with main. + for (const area of ['main', 'top', 'left', 'right', 'menu']) { + if ((area === 'left' || area === 'right') && !this.sidePanelsVisible()) { + continue; + } + + const widget = find(this.widgets(area), w => w.id === id); + if (widget) { + widget.activate(); + } } } @@ -126,24 +240,37 @@ export class RetroShell extends Widget implements JupyterFrontEnd.IShell { */ add( widget: Widget, - area?: Shell.Area, + area?: string, options?: DocumentRegistry.IOpenOptions ): void { const rank = options?.rank ?? DEFAULT_RANK; - if (area === 'top') { - return this._topHandler.addWidget(widget, rank); - } - if (area === 'menu') { - return this._menuHandler.addWidget(widget, rank); - } - if (area === 'main' || area === undefined) { - if (this._main.widgets.length > 0) { - // do not add the widget if there is already one - return; - } - this._main.addWidget(widget); - this._main.update(); - this._currentChanged.emit(void 0); + switch (area) { + case 'top': + return this._topHandler.addWidget(widget, rank); + case 'menu': + return this._menuHandler.addWidget(widget, rank); + case 'main': + case undefined: + if (this._main.widgets.length > 0) { + // do not add the widget if there is already one + return; + } + this._main.addWidget(widget); + this._main.update(); + this._currentChanged.emit(void 0); + break; + case 'left': + if (this.sidePanelsVisible()) { + return this._leftHandler.addWidget(widget, rank); + } + throw new Error(`${area} area is not available on this page`); + case 'right': + if (this.sidePanelsVisible()) { + return this._rightHandler.addWidget(widget, rank); + } + throw new Error(`${area} area is not available on this page`); + default: + throw new Error(`Cannot add widget to area: ${area}`); } } @@ -164,27 +291,121 @@ export class RetroShell extends Widget implements JupyterFrontEnd.IShell { } /** - * Return the list of widgets for the given area. - * - * @param area The area + * Expand the left panel to show the sidebar with its widget. */ - widgets(area: Shell.Area): IIterator { + expandLeft(): void { + if (!this.sidePanelsVisible()) { + throw new Error('Left panel is not available on this page'); + } + this.leftPanel.show(); + this._leftHandler.expand(); // Show the current widget, if any + this._onLayoutModified(); + } + + /** + * Collapse the left panel + */ + collapseLeft(): void { + if (!this.sidePanelsVisible()) { + throw new Error('Left panel is not available on this page'); + } + this._leftHandler.collapse(); + this.leftPanel.hide(); + this._onLayoutModified(); + } + + /** + * Expand the right panel to show the sidebar with its widget. + */ + expandRight(): void { + if (!this.sidePanelsVisible()) { + throw new Error('Right panel is not available on this page'); + } + this.rightPanel.show(); + this._rightHandler.expand(); // Show the current widget, if any + this._onLayoutModified(); + } + + /** + * Collapse the right panel + */ + collapseRight(): void { + if (!this.sidePanelsVisible()) { + throw new Error('Right panel is not available on this page'); + } + this._rightHandler.collapse(); + this.rightPanel.hide(); + this._onLayoutModified(); + } + + widgetsList(area?: string): readonly Widget[] { switch (area ?? 'main') { case 'top': - return iter(this._topHandler.panel.widgets); + return this._topHandler.panel.widgets; case 'menu': - return iter(this._menuHandler.panel.widgets); + return this._menuHandler.panel.widgets; case 'main': - return iter(this._main.widgets); + return this._main.widgets; + case 'left': + if (this.sidePanelsVisible()) { + return this._leftHandler.stackedPanel.widgets; + } + throw new Error(`Invalid area: ${area}`); + case 'right': + if (this.sidePanelsVisible()) { + return this._rightHandler.stackedPanel.widgets; + } + throw new Error(`Invalid area: ${area}`); default: throw new Error(`Invalid area: ${area}`); } } + /** + * Return the list of widgets for the given area. + * + * @param area The area + */ + widgets(area?: string): IIterator { + return iter(this.widgetsList(area)); + } + + /** + * Is a particular area empty (no widgets)? + * + * @param area Named area in the application + * @returns true if area has no widgets, false if at least one widget is present + */ + isEmpty(area: Shell.Area): boolean { + return this.widgetsList(area).length === 0; + } + + /** + * Can the shell display a left or right panel? + * + * @returns True if the left and right side panels could be shown, false otherwise + */ + sidePanelsVisible(): boolean { + return PageConfig.getOption('retroPage') === 'notebooks'; + } + + /** + * Handle a change to the layout. + */ + private _onLayoutModified(): void { + void this._layoutDebouncer.invoke(); + } + + private _layoutModified = new Signal(this); + private _layoutDebouncer = new Debouncer(() => { + this._layoutModified.emit(undefined); + }, 0); private _topWrapper: Panel; private _topHandler: Private.PanelHandler; private _menuWrapper: Panel; private _menuHandler: Private.PanelHandler; + private _leftHandler: Private.SideBarHandler; + private _rightHandler: Private.SideBarHandler; private _spacer: Widget; private _main: Panel; private _currentChanged = new Signal(this); @@ -197,7 +418,7 @@ export namespace Shell { /** * The areas of the application shell where widgets can reside. */ - export type Area = 'main' | 'top' | 'menu'; + export type Area = 'main' | 'top' | 'left' | 'right' | 'menu'; } /** @@ -289,4 +510,175 @@ namespace Private { private _items = new Array(); private _panel = new Panel(); } + + /** + * A class which manages a side bar that can show at most one widget at a time. + */ + export class SideBarHandler { + /** + * Construct a new side bar handler. + */ + constructor() { + this._stackedPanel = new StackedPanel(); + this._stackedPanel.hide(); + this._current = null; + this._lastCurrent = null; + this._stackedPanel.widgetRemoved.connect(this._onWidgetRemoved, this); + } + + get current(): Widget | null { + return ( + this._current || + this._lastCurrent || + (this._items.length > 0 ? this._items[0].widget : null) + ); + } + + /** + * Whether the panel is visible + */ + get isVisible(): boolean { + return this._stackedPanel.isVisible; + } + + /** + * Get the stacked panel managed by the handler + */ + get stackedPanel(): StackedPanel { + return this._stackedPanel; + } + + /** + * Signal fires when the stacked panel changes + */ + get updated(): ISignal { + return this._updated; + } + + /** + * Expand the sidebar. + * + * #### Notes + * This will open the most recently used widget, or the first widget + * if there is no most recently used. + */ + expand(): void { + const visibleWidget = this.current; + if (visibleWidget) { + this._current = visibleWidget; + this.activate(visibleWidget.id); + } + } + + /** + * Activate a widget residing in the stacked panel by ID. + * + * @param id - The widget's unique ID. + */ + activate(id: string): void { + const widget = this._findWidgetByID(id); + if (widget) { + this._current = widget; + widget.show(); + widget.activate(); + } + } + + /** + * Test whether the sidebar has the given widget by id. + */ + has(id: string): boolean { + return this._findWidgetByID(id) !== null; + } + + /** + * Collapse the sidebar so no items are expanded. + */ + collapse(): void { + this._current = null; + } + + /** + * Add a widget and its title to the stacked panel. + * + * If the widget is already added, it will be moved. + */ + addWidget(widget: Widget, rank: number): void { + widget.parent = null; + widget.hide(); + const item = { widget, rank }; + const index = this._findInsertIndex(item); + ArrayExt.insert(this._items, index, item); + this._stackedPanel.insertWidget(index, widget); + + // TODO: Update menu to include widget in appropriate position + + this._refreshVisibility(); + } + + /** + * Hide the side panel + */ + hide(): void { + this._isHiddenByUser = true; + this._refreshVisibility(); + } + + /** + * Show the side panel + */ + show(): void { + this._isHiddenByUser = false; + this._refreshVisibility(); + } + + /** + * Find the insertion index for a rank item. + */ + private _findInsertIndex(item: Private.IRankItem): number { + return ArrayExt.upperBound(this._items, item, Private.itemCmp); + } + + /** + * Find the index of the item with the given widget, or `-1`. + */ + private _findWidgetIndex(widget: Widget): number { + return ArrayExt.findFirstIndex(this._items, i => i.widget === widget); + } + + /** + * Find the widget with the given id, or `null`. + */ + private _findWidgetByID(id: string): Widget | null { + const item = find(this._items, value => value.widget.id === id); + return item ? item.widget : null; + } + + /** + * Refresh the visibility of the stacked panel. + */ + private _refreshVisibility(): void { + this._stackedPanel.setHidden(this._isHiddenByUser); + this._updated.emit(); + } + + /* + * Handle the `widgetRemoved` signal from the panel. + */ + private _onWidgetRemoved(sender: StackedPanel, widget: Widget): void { + if (widget === this._lastCurrent) { + this._lastCurrent = null; + } + ArrayExt.removeAt(this._items, this._findWidgetIndex(widget)); + // TODO: Remove the widget from the menu + this._refreshVisibility(); + } + + private _isHiddenByUser = false; + private _items = new Array(); + private _stackedPanel: StackedPanel; + private _current: Widget | null; + private _lastCurrent: Widget | null; + private _updated: Signal = new Signal(this); + } } diff --git a/packages/application/style/base.css b/packages/application/style/base.css index 491a4f17..442b3195 100644 --- a/packages/application/style/base.css +++ b/packages/application/style/base.css @@ -80,3 +80,6 @@ body[data-retro='notebooks'] #main-panel { body[data-retro='notebooks'] #spacer-widget { min-height: unset; } + +/* Sibling imports */ +@import './sidepanel.css'; diff --git a/packages/application/style/sidepanel.css b/packages/application/style/sidepanel.css new file mode 100644 index 00000000..58c56d63 --- /dev/null +++ b/packages/application/style/sidepanel.css @@ -0,0 +1,37 @@ +/*----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| Distributed under the terms of the Modified BSD License. +| +| Adapted from JupyterLab's packages/application/style/sidepanel.css. +|----------------------------------------------------------------------------*/ + +/*----------------------------------------------------------------------------- +| Variables +|----------------------------------------------------------------------------*/ + +:root { + --jp-private-sidebar-tab-width: 32px; +} + +/*----------------------------------------------------------------------------- +| SideBar +|----------------------------------------------------------------------------*/ + +/* Left */ + +/* Right */ + +/* Stack panels */ + +#jp-left-stack > .lm-Widget, +#jp-right-stack > .lm-Widget { + min-width: var(--jp-sidebar-min-width); +} + +#jp-right-stack { + border-left: var(--jp-border-width) solid var(--jp-border-color1); +} + +#jp-left-stack { + border-right: var(--jp-border-width) solid var(--jp-border-color1); +} diff --git a/packages/application/test/shell.spec.ts b/packages/application/test/shell.spec.ts index e0910a1b..7a28d37d 100644 --- a/packages/application/test/shell.spec.ts +++ b/packages/application/test/shell.spec.ts @@ -1,23 +1,29 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. -import { RetroShell, IRetroShell } from '@retrolab/application'; +import { IRetroShell, RetroShell, Shell } from '@retrolab/application'; import { JupyterFrontEnd } from '@jupyterlab/application'; import { toArray } from '@lumino/algorithm'; - import { Widget } from '@lumino/widgets'; -describe('Shell', () => { +describe('Shell for notebooks', () => { let shell: IRetroShell; + let sidePanelsVisibleSpy: jest.SpyInstance; beforeEach(() => { shell = new RetroShell(); + sidePanelsVisibleSpy = jest + .spyOn(shell, 'sidePanelsVisible') + .mockImplementation(() => { + return true; + }); Widget.attach(shell, document.body); }); afterEach(() => { + sidePanelsVisibleSpy.mockRestore(); shell.dispose(); }); @@ -25,10 +31,16 @@ describe('Shell', () => { it('should create a LabShell instance', () => { expect(shell).toBeInstanceOf(RetroShell); }); + + it('should make all areas empty initially', () => { + ['main', 'top', 'left', 'right', 'menu'].forEach(area => + expect(shell.isEmpty(area as Shell.Area)).toBe(true) + ); + }); }); describe('#widgets()', () => { - it('should add widgets to existing areas', () => { + it('should add widgets to main area', () => { const widget = new Widget(); shell.add(widget, 'main'); const widgets = toArray(shell.widgets('main')); @@ -38,8 +50,8 @@ describe('Shell', () => { it('should throw an exception if the area does not exist', () => { const jupyterFrontEndShell = shell as JupyterFrontEnd.IShell; expect(() => { - jupyterFrontEndShell.widgets('left'); - }).toThrow('Invalid area: left'); + jupyterFrontEndShell.widgets('fake'); + }).toThrow('Invalid area: fake'); }); }); @@ -62,16 +74,14 @@ describe('Shell', () => { const widget = new Widget(); widget.id = 'foo'; shell.add(widget, 'top'); - const widgets = toArray(shell.widgets('top')); - expect(widgets.length).toBeGreaterThan(0); + expect(shell.isEmpty('top')).toBe(false); }); it('should accept options', () => { const widget = new Widget(); widget.id = 'foo'; shell.add(widget, 'top', { rank: 10 }); - const widgets = toArray(shell.widgets('top')); - expect(widgets.length).toBeGreaterThan(0); + expect(shell.isEmpty('top')).toBe(false); }); }); @@ -80,8 +90,107 @@ describe('Shell', () => { const widget = new Widget(); widget.id = 'foo'; shell.add(widget, 'main'); + expect(shell.isEmpty('main')).toBe(false); + }); + }); + + describe('#add(widget, "left")', () => { + it('should add a widget to the left area', () => { + const widget = new Widget(); + widget.id = 'foo'; + shell.add(widget, 'left'); + expect(shell.isEmpty('left')).toBe(false); + }); + }); + + describe('#add(widget, "right")', () => { + it('should add a widget to the right area', () => { + const widget = new Widget(); + widget.id = 'foo'; + shell.add(widget, 'right'); + expect(shell.isEmpty('right')).toBe(false); + }); + }); +}); + +describe('Shell for tree view', () => { + let shell: IRetroShell; + let sidePanelsVisibleSpy: jest.SpyInstance; + + beforeEach(() => { + shell = new RetroShell(); + sidePanelsVisibleSpy = jest + .spyOn(shell, 'sidePanelsVisible') + .mockImplementation(() => { + return false; + }); + Widget.attach(shell, document.body); + }); + + afterEach(() => { + sidePanelsVisibleSpy.mockRestore(); + shell.dispose(); + }); + + describe('#constructor()', () => { + it('should create a LabShell instance', () => { + expect(shell).toBeInstanceOf(RetroShell); + }); + + it('should make all areas empty initially', () => { + ['main', 'top', 'menu'].forEach(area => + expect(shell.isEmpty(area as Shell.Area)).toBe(true) + ); + }); + }); + + describe('#widgets()', () => { + it('should add widgets to existing areas', () => { + const widget = new Widget(); + shell.add(widget, 'main'); const widgets = toArray(shell.widgets('main')); - expect(widgets.length).toBeGreaterThan(0); + expect(widgets).toEqual([widget]); + }); + + it('should throw an exception if a fake area does not exist', () => { + const jupyterFrontEndShell = shell as JupyterFrontEnd.IShell; + expect(() => { + jupyterFrontEndShell.widgets('fake'); + }).toThrow('Invalid area: fake'); + }); + + it('should throw an exception if the left area does not exist', () => { + const jupyterFrontEndShell = shell as JupyterFrontEnd.IShell; + expect(() => { + jupyterFrontEndShell.widgets('left'); + }).toThrow('Invalid area: left'); + }); + + it('should throw an exception if the right area does not exist', () => { + const jupyterFrontEndShell = shell as JupyterFrontEnd.IShell; + expect(() => { + jupyterFrontEndShell.widgets('right'); + }).toThrow('Invalid area: right'); + }); + }); + + describe('#add(widget, "left")', () => { + it('should fail to add a widget to the left area', () => { + const widget = new Widget(); + widget.id = 'foo'; + expect(() => { + shell.add(widget, 'left'); + }).toThrow('left area is not available on this page'); + }); + }); + + describe('#add(widget, "right")', () => { + it('should fail to add a widget to the right area', () => { + const widget = new Widget(); + widget.id = 'foo'; + expect(() => { + shell.add(widget, 'right'); + }).toThrow('right area is not available on this page'); }); }); }); diff --git a/ui-tests/test/settings.spec.ts-snapshots/top-hidden-darwin.png b/ui-tests/test/settings.spec.ts-snapshots/top-hidden-darwin.png new file mode 100644 index 00000000..34ed79f4 Binary files /dev/null and b/ui-tests/test/settings.spec.ts-snapshots/top-hidden-darwin.png differ