From 0567141bc414a8f2bb98e04b63e943046fa84259 Mon Sep 17 00:00:00 2001 From: Daniel Grajeda Date: Tue, 3 Aug 2021 23:52:27 -0600 Subject: [PATCH] Notebook Views Actions (#16207) This adds the actions currently needed by the views --- .eslintrc.json | 1 + ThirdPartyNotices.txt | 27 ++ package.json | 5 +- remote/package.json | 1 + remote/web/package.json | 1 + remote/web/yarn.lock | 5 + remote/yarn.lock | 5 + .../notebook/browser/cellViews/codeActions.ts | 4 +- .../browser/cellViews/textCell.component.ts | 4 + .../notebookViews/insertCellsModal.css | 12 + .../browser/notebookViews/insertCellsModal.ts | 298 ++++++++++++++++++ .../notebookViews/notebookViews.component.ts | 31 +- .../notebookViews/notebookViewsActions.ts | 242 ++++++++++++++ .../notebookViewsCard.component.ts | 40 ++- .../browser/notebookViews/viewOptionsModal.ts | 149 +++++++++ .../test/browser/notebookViewsActions.test.ts | 203 ++++++++++++ .../code/browser/workbench/workbench-dev.html | 1 + src/vs/code/browser/workbench/workbench.html | 1 + test/unit/electron/renderer.js | 1 + yarn.lock | 5 + 20 files changed, 1027 insertions(+), 9 deletions(-) create mode 100644 src/sql/workbench/contrib/notebook/browser/notebookViews/insertCellsModal.css create mode 100644 src/sql/workbench/contrib/notebook/browser/notebookViews/insertCellsModal.ts create mode 100644 src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsActions.ts create mode 100644 src/sql/workbench/contrib/notebook/browser/notebookViews/viewOptionsModal.ts create mode 100644 src/sql/workbench/contrib/notebook/test/browser/notebookViewsActions.test.ts diff --git a/.eslintrc.json b/.eslintrc.json index d3bf56f40f..c4948922ff 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -742,6 +742,7 @@ "chart.js", "plotly.js", "angular2-grid", + "html-to-image", "html-query-plan", "turndown", "gridstack", diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index 218692880c..2103e310af 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -30,6 +30,7 @@ expressly granted herein, whether by implication, estoppel or otherwise. getmac: https://github.com/bevry/getmac graceful-fs: https://github.com/isaacs/node-graceful-fs gridstack: https://github.com/gridstack/gridstack.js + html-to-image: https://github.com/bubkoo/html-to-image html-query-plan: https://github.com/JustinPealing/html-query-plan http-proxy-agent: https://github.com/TooTallNate/node-https-proxy-agent https-proxy-agent: https://github.com/TooTallNate/node-https-proxy-agent @@ -520,6 +521,32 @@ SOFTWARE. ========================================= END OF gridstack NOTICES AND INFORMATION +%% html-to-image NOTICES AND INFORMATION BEGIN HERE +========================================= +MIT License + +Copyright (c) 2017 W.Y. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +========================================= +END OF html-to-image NOTICES AND INFORMATION + %% html-query-plan NOTICES AND INFORMATION BEGIN HERE ========================================= The MIT License (MIT) diff --git a/package.json b/package.json index 92a2be0b2e..163410b194 100644 --- a/package.json +++ b/package.json @@ -78,13 +78,14 @@ "graceful-fs": "4.2.3", "gridstack": "^3.1.3", "html-query-plan": "git://github.com/kburtram/html-query-plan.git#2.6", + "html-to-image": "^1.6.2", "http-proxy-agent": "^2.1.0", "https-proxy-agent": "^2.2.3", "iconv-lite-umd": "0.6.8", "jquery": "3.5.0", "jschardet": "2.3.0", - "mark.js": "^8.11.1", "keytar": "7.2.0", + "mark.js": "^8.11.1", "minimist": "^1.2.5", "native-is-elevated": "0.4.3", "native-keymap": "2.2.1", @@ -223,8 +224,8 @@ "style-loader": "^1.0.0", "temp-write": "^3.4.0", "ts-loader": "^6.2.1", - "typemoq": "^0.3.2", "tsec": "0.1.4", + "typemoq": "^0.3.2", "typescript": "^4.3.0-dev.20210426", "typescript-formatter": "7.1.0", "underscore": "^1.12.1", diff --git a/remote/package.json b/remote/package.json index b068531847..d1f0057916 100644 --- a/remote/package.json +++ b/remote/package.json @@ -20,6 +20,7 @@ "graceful-fs": "4.2.3", "gridstack": "^3.1.3", "html-query-plan": "git://github.com/kburtram/html-query-plan.git#2.6", + "html-to-image": "^1.6.2", "http-proxy-agent": "^2.1.0", "https-proxy-agent": "^2.2.3", "iconv-lite-umd": "0.6.8", diff --git a/remote/web/package.json b/remote/web/package.json index 56222b4f8b..7548cfae23 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -16,6 +16,7 @@ "chart.js": "^2.9.4", "gridstack": "^3.1.3", "html-query-plan": "git://github.com/kburtram/html-query-plan.git#2.6", + "html-to-image": "^1.6.2", "iconv-lite-umd": "0.6.8", "jquery": "3.5.0", "jschardet": "2.3.0", diff --git a/remote/web/yarn.lock b/remote/web/yarn.lock index 941da2deb6..1aeedde813 100644 --- a/remote/web/yarn.lock +++ b/remote/web/yarn.lock @@ -180,6 +180,11 @@ has-flag@^3.0.0: version "2.5.0" resolved "git://github.com/kburtram/html-query-plan.git#c524feb824e4960897ad875a37af068376a2b4a3" +html-to-image@^1.6.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/html-to-image/-/html-to-image-1.6.2.tgz#9d444424cc638df13db5c7be810ac0d2962f5edd" + integrity sha512-X6X7wJW2KQ+AGqMeBITdntCcQnxBgZY62MdGOi042Y70+0SMe8/iJCzUv8RNaUoXqUjWw5FPyoTDmdGoapTQIQ== + htmlparser2@^3.9.0: version "3.10.1" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" diff --git a/remote/yarn.lock b/remote/yarn.lock index a53ed530d6..791f194f9f 100644 --- a/remote/yarn.lock +++ b/remote/yarn.lock @@ -402,6 +402,11 @@ has-flag@^3.0.0: version "2.5.0" resolved "git://github.com/kburtram/html-query-plan.git#c524feb824e4960897ad875a37af068376a2b4a3" +html-to-image@^1.6.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/html-to-image/-/html-to-image-1.6.2.tgz#9d444424cc638df13db5c7be810ac0d2962f5edd" + integrity sha512-X6X7wJW2KQ+AGqMeBITdntCcQnxBgZY62MdGOi042Y70+0SMe8/iJCzUv8RNaUoXqUjWw5FPyoTDmdGoapTQIQ== + htmlparser2@^3.9.0: version "3.10.1" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" diff --git a/src/sql/workbench/contrib/notebook/browser/cellViews/codeActions.ts b/src/sql/workbench/contrib/notebook/browser/cellViews/codeActions.ts index 1fd61f86d9..e16b63c2ea 100644 --- a/src/sql/workbench/contrib/notebook/browser/cellViews/codeActions.ts +++ b/src/sql/workbench/contrib/notebook/browser/cellViews/codeActions.ts @@ -70,7 +70,7 @@ interface IActionStateData { commandId?: string; } -class IMultiStateData { +export class IMultiStateData { private _stateMap = new Map(); constructor(mappings: { key: T, value: IActionStateData }[], private _state: T, private _baseClass?: string) { if (mappings) { @@ -120,7 +120,7 @@ class IMultiStateData { } } -abstract class MultiStateAction extends Action { +export abstract class MultiStateAction extends Action { constructor( id: string, protected states: IMultiStateData, diff --git a/src/sql/workbench/contrib/notebook/browser/cellViews/textCell.component.ts b/src/sql/workbench/contrib/notebook/browser/cellViews/textCell.component.ts index b09c675779..0da6e27a2b 100644 --- a/src/sql/workbench/contrib/notebook/browser/cellViews/textCell.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/cellViews/textCell.component.ts @@ -166,6 +166,10 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges { return this._activeCellId; } + get outputRef(): ElementRef { + return this.output; + } + private setLoading(isLoading: boolean): void { this.cellModel.loaded = !isLoading; this._changeRef.detectChanges(); diff --git a/src/sql/workbench/contrib/notebook/browser/notebookViews/insertCellsModal.css b/src/sql/workbench/contrib/notebook/browser/notebookViews/insertCellsModal.css new file mode 100644 index 0000000000..5537c448f4 --- /dev/null +++ b/src/sql/workbench/contrib/notebook/browser/notebookViews/insertCellsModal.css @@ -0,0 +1,12 @@ +#insert-dialog-cell-grid .loading-spinner-container { + flex: 1; + align-self: center; +} +#insert-dialog-cell-grid .loading-spinner { + margin: auto; +} +#insert-dialog-cell-grid input[type="checkbox"] { + display: flex; + -webkit-appearance: none; + outline: none !important; +} diff --git a/src/sql/workbench/contrib/notebook/browser/notebookViews/insertCellsModal.ts b/src/sql/workbench/contrib/notebook/browser/notebookViews/insertCellsModal.ts new file mode 100644 index 0000000000..5c0f6c9740 --- /dev/null +++ b/src/sql/workbench/contrib/notebook/browser/notebookViews/insertCellsModal.ts @@ -0,0 +1,298 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import 'vs/css!./insertCellsModal'; +import { Checkbox } from 'sql/base/browser/ui/checkbox/checkbox'; +import { Button } from 'sql/base/browser/ui/button/button'; +import { IClipboardService } from 'sql/platform/clipboard/common/clipboardService'; +import { IAdsTelemetryService } from 'sql/platform/telemetry/common/telemetry'; +import { Modal } from 'sql/workbench/browser/modal/modal'; +import { attachModalDialogStyler } from 'sql/workbench/common/styler'; +import { ITextResourcePropertiesService } from 'vs/editor/common/services/textResourceConfigurationService'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; +import { ILogService } from 'vs/platform/log/common/log'; +import { IColorTheme, ICssStyleCollector, IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; +import * as DOM from 'vs/base/browser/dom'; +import { attachCheckboxStyler } from 'sql/platform/theme/common/styler'; +import { ServiceOptionType } from 'sql/platform/connection/common/interfaces'; +import { ServiceOption } from 'azdata'; +import * as DialogHelper from 'sql/workbench/browser/modal/dialogHelper'; +import { TextCellComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/textCell.component'; +import { ComponentFactoryResolver, ViewContainerRef } from '@angular/core'; +import { ICellModel } from 'sql/workbench/services/notebook/browser/models/modelInterfaces'; +import { NotebookModel } from 'sql/workbench/services/notebook/browser/models/notebookModel'; +import { inputBorder, inputValidationInfoBorder } from 'vs/platform/theme/common/colorRegistry'; +import { localize } from 'vs/nls'; +import { NotebookViewsExtension } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension'; +import { attachButtonStyler } from 'vs/platform/theme/common/styler'; +import { toJpeg } from 'html-to-image'; +import { truncate } from 'vs/base/common/strings'; + +type CellOption = { + optionMetadata: ServiceOption, + defaultValue: string, + currentValue: boolean +}; + +export class CellOptionsModel { + private _optionsMap: { [name: string]: CellOption } = {}; + + constructor( + optionsMetadata: ServiceOption[], + private onInsert: (cell: ICellModel) => void, + private _context: NotebookViewsExtension, + ) { + optionsMetadata.forEach(optionMetadata => { + let defaultValue = this.getDisplayValue(optionMetadata, optionMetadata.defaultValue); + this._optionsMap[optionMetadata.name] = { + optionMetadata: optionMetadata, + defaultValue: optionMetadata.defaultValue, + currentValue: defaultValue + }; + }); + } + + private getDisplayValue(optionMetadata: ServiceOption, optionValue: string): boolean { + let displayValue: boolean = false; + switch (optionMetadata.valueType) { + case ServiceOptionType.boolean: + displayValue = DialogHelper.getBooleanValueFromStringOrBoolean(optionValue); + break; + } + return displayValue; + } + + restoreCells(): void { + for (let key in this._optionsMap) { + let optionElement = this._optionsMap[key]; + if (optionElement.currentValue === true) { + const activeView = this._context.getActiveView(); + const cellToInsert = activeView.getCell(optionElement.optionMetadata.name); + if (cellToInsert) { + this.onInsert(cellToInsert); + } + } + } + } + + public setOptionValue(optionName: string, value: boolean): void { + if (this._optionsMap[optionName] !== undefined) { + this._optionsMap[optionName].currentValue = value; + } + } + + public getOptionValue(optionName: string): boolean | undefined { + return this._optionsMap[optionName]?.currentValue; + } +} + +export class InsertCellsModal extends Modal { + public viewModel: CellOptionsModel; + + private _submitButton: Button; + private _cancelButton: Button; + private _optionsMap: { [name: string]: Checkbox } = {}; + private _maxTitleLength: number = 20; + + constructor( + private onInsert: (cell: ICellModel) => void, + private _context: NotebookViewsExtension, + private _containerRef: ViewContainerRef, + private _componentFactoryResolver: ComponentFactoryResolver, + @ILogService logService: ILogService, + @IThemeService themeService: IThemeService, + @ILayoutService layoutService: ILayoutService, + @IClipboardService clipboardService: IClipboardService, + @IContextKeyService contextKeyService: IContextKeyService, + @IAdsTelemetryService telemetryService: IAdsTelemetryService, + @ITextResourcePropertiesService textResourcePropertiesService: ITextResourcePropertiesService, + ) { + super( + localize("insertCellsModal.title", "Insert cells"), + 'InsertCellsModal', + telemetryService, + layoutService, + clipboardService, + themeService, + logService, + textResourcePropertiesService, + contextKeyService, + { hasErrors: true, hasSpinner: true } + ); + + const options = this.getOptions(); + this.viewModel = new CellOptionsModel(options, this.onInsert, this._context); + } + + protected renderBody(container: HTMLElement): void { + const grid = DOM.$('div#insert-dialog-cell-grid'); + + grid.style.display = 'grid'; + grid.style.gridTemplateColumns = '1fr 1fr'; + grid.style.gap = '10px'; + grid.style.padding = '10px'; + grid.style.overflowY = 'auto'; + grid.style.maxHeight = 'calc(100% - 40px)'; + + const gridTitle = DOM.$('h2.grid-title'); + gridTitle.title = localize("insertCellsModal.selectCells", "Select cell sources"); + + DOM.append(container, grid); + + this.createOptions(grid) + .catch((e) => { this.setError(localize("insertCellsModal.thumbnailError", "Error: Unable to generate thumbnails.")); }); + } + + protected layout(height: number): void { + // No-op for now. No need to relayout. + } + + private async createOptions(container: HTMLElement): Promise { + const activeView = this._context.getActiveView(); + const cellsAvailableToInsert = activeView.hiddenCells; + + cellsAvailableToInsert.forEach(async (cell) => { + const optionWidget = this.createCheckBoxHelper( + container, + '
', + false, + () => this.onOptionChecked(cell.cellGuid) + ); + + const img = await this.generateScreenshot(cell); + const wrapper = DOM.$('div.thumnail-wrapper'); + const thumbnail = DOM.$('img.thumbnail'); + + thumbnail.src = img; + thumbnail.style.maxWidth = '100%'; + DOM.append(wrapper, thumbnail); + optionWidget.label = wrapper.outerHTML; + + this._optionsMap[cell.cellGuid] = optionWidget; + }); + } + + private createCheckBoxHelper(container: HTMLElement, label: string, isChecked: boolean, onCheck: (viaKeyboard: boolean) => void): Checkbox { + const checkbox = new Checkbox(DOM.append(container, DOM.$('.dialog-input-section')), { + label: label, + checked: isChecked, + onChange: onCheck, + ariaLabel: label + }); + this._register(attachCheckboxStyler(checkbox, this._themeService)); + return checkbox; + } + + public onOptionChecked(optionName: string) { + this.viewModel.setOptionValue(optionName, (this._optionsMap[optionName]).checked); + this.validate(); + } + + private getOptions(): ServiceOption[] { + const activeView = this._context.getActiveView(); + const cellsAvailableToInsert = activeView.hiddenCells; + return cellsAvailableToInsert.map((cell) => ({ + name: cell.cellGuid, + displayName: truncate(cell.renderedOutputTextContent[0] ?? '', this._maxTitleLength) || localize("insertCellsModal.untitled", "Untitled Cell : {0}", cell.cellGuid), + description: '', + groupName: undefined, + valueType: ServiceOptionType.boolean, + defaultValue: '', + objectType: undefined, + categoryValues: [], + isRequired: false, + isArray: undefined, + })); + } + + public override render() { + super.render(); + + this._submitButton = this.addFooterButton(localize('insertCellsModal.Insert', "Insert"), () => this.onSubmitHandler()); + this._cancelButton = this.addFooterButton(localize('insertCellsModal.Cancel', "Cancel"), () => this.onCancelHandler(), 'right', true); + + this._register(attachButtonStyler(this._submitButton, this._themeService)); + this._register(attachButtonStyler(this._cancelButton, this._themeService)); + + attachModalDialogStyler(this, this._themeService); + this.validate(); + } + + private validate() { + if (Object.keys(this._optionsMap).length) { + this._submitButton.enabled = true; + } else { + this._submitButton.enabled = false; + } + } + + private onSubmitHandler() { + this.viewModel.restoreCells(); + this.close(); + } + + private onCancelHandler() { + this.close(); + } + + public close(): void { + return this.hide(); + } + + public async open(): Promise { + this.show(); + } + + public override dispose(): void { + super.dispose(); + for (let key in this._optionsMap) { + let widget = this._optionsMap[key]; + widget.dispose(); + delete this._optionsMap[key]; + } + } + + public async generateScreenshot(cell: ICellModel, screenshotWidth: number = 300, screenshowHeight: number = 300, backgroundColor: string = '#ffffff'): Promise { + let componentFactory = this._componentFactoryResolver.resolveComponentFactory(TextCellComponent); + let component = this._containerRef.createComponent(componentFactory); + + component.instance.model = this._context.notebook as NotebookModel; + component.instance.cellModel = cell; + + component.instance.handleContentChanged(); + + const element: HTMLElement = component.instance.outputRef.nativeElement; + + const scale = element.clientWidth / screenshotWidth; + const canvasWidth = element.clientWidth / scale; + const canvasHeight = element.clientHeight / scale; + + return toJpeg(component.instance.outputRef.nativeElement, { quality: .6, canvasWidth, canvasHeight, backgroundColor }); + } +} + +registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => { + const inputBorderColor = theme.getColor(inputBorder); + if (inputBorderColor) { + collector.addRule(` + #insert-dialog-cell-grid input[type="checkbox"] + label { + border: 2px solid; + border-color: ${inputBorderColor.toString()}; + display: flex; + height: 125px; + overflow: hidden; + } + `); + } + + const inputActiveOptionBorderColor = theme.getColor(inputValidationInfoBorder); + if (inputActiveOptionBorderColor) { + collector.addRule(` + #insert-dialog-cell-grid input[type="checkbox"]:checked + label { + border-color: ${inputActiveOptionBorderColor.toString()}; + } + `); + } +}); diff --git a/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViews.component.ts b/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViews.component.ts index d755c9e437..00e3ca366b 100644 --- a/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViews.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViews.component.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Component, Input, ViewChildren, QueryList, ChangeDetectorRef, forwardRef, Inject, ViewChild, ElementRef } from '@angular/core'; +import { Component, Input, ViewChildren, QueryList, ChangeDetectorRef, forwardRef, Inject, ViewChild, ElementRef, ViewContainerRef, ComponentFactoryResolver } from '@angular/core'; import { ICellModel, INotebookModel, ISingleNotebookEditOperation } from 'sql/workbench/services/notebook/browser/models/modelInterfaces'; import { CodeCellComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/codeCell.component'; import { TextCellComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/textCell.component'; @@ -26,8 +26,11 @@ import { CellType, CellTypes } from 'sql/workbench/services/notebook/common/cont import { isUndefinedOrNull } from 'vs/base/common/types'; import { IConnectionManagementService } from 'sql/platform/connection/common/connectionManagement'; import { NotebookViewsExtension } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension'; -import { INotebookView } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews'; +import { INotebookView, INotebookViewMetadata } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews'; import { NotebookViewsGridComponent } from 'sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsGrid.component'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { DeleteViewAction, InsertCellAction, ViewSettingsAction } from 'sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsActions'; +import { RunAllCellsAction } from 'sql/workbench/contrib/notebook/browser/notebookActions'; import { IActionViewItem } from 'vs/base/browser/ui/actionbar/actionbar'; export const NOTEBOOKVIEWS_SELECTOR: string = 'notebook-view-component'; @@ -41,6 +44,7 @@ export class NotebookViewComponent extends AngularDisposable implements INoteboo @Input() model: NotebookModel; @Input() activeView: INotebookView; @Input() views: NotebookViewsExtension; + @Input() notebookMeta: INotebookViewMetadata; @ViewChild('container', { read: ElementRef }) private _container: ElementRef; @ViewChild('viewsToolbar', { read: ElementRef }) private _viewsToolbar: ElementRef; @@ -51,18 +55,21 @@ export class NotebookViewComponent extends AngularDisposable implements INoteboo protected _actionBar: Taskbar; public previewFeaturesEnabled: boolean = false; private _modelReadyDeferred = new Deferred(); - + private _runAllCellsAction: RunAllCellsAction; private _scrollTop: number; constructor( @Inject(IBootstrapParams) private _notebookParams: INotebookParams, @Inject(forwardRef(() => ChangeDetectorRef)) private _changeRef: ChangeDetectorRef, + @Inject(IInstantiationService) private _instantiationService: IInstantiationService, @Inject(IKeybindingService) private _keybindingService: IKeybindingService, @Inject(INotificationService) private _notificationService: INotificationService, @Inject(INotebookService) private _notebookService: INotebookService, @Inject(IConnectionManagementService) private _connectionManagementService: IConnectionManagementService, @Inject(IConfigurationService) private _configurationService: IConfigurationService, - @Inject(IEditorService) private _editorService: IEditorService + @Inject(IEditorService) private _editorService: IEditorService, + @Inject(ViewContainerRef) private _containerRef: ViewContainerRef, + @Inject(ComponentFactoryResolver) private _componentFactoryResolver: ComponentFactoryResolver, ) { super(); this._register(this._configurationService.onDidChangeConfiguration(e => { @@ -235,9 +242,25 @@ export class NotebookViewComponent extends AngularDisposable implements INoteboo titleElement.style.marginRight = '25px'; titleElement.style.minHeight = '25px'; + let insertCellsAction = this._instantiationService.createInstance(InsertCellAction, this.insertCell.bind(this), this.views, this._containerRef, this._componentFactoryResolver); + + this._runAllCellsAction = this._instantiationService.createInstance(RunAllCellsAction, 'notebook.runAllCells', localize('runAllPreview', "Run all"), 'notebook-button masked-pseudo start-outline'); + + let spacerElement = document.createElement('li'); + spacerElement.style.marginLeft = 'auto'; + + let viewOptions = this._instantiationService.createInstance(ViewSettingsAction, this.views); + + let deleteView = this._instantiationService.createInstance(DeleteViewAction, this.views); + this._actionBar.setContent([ { element: titleElement }, { element: Taskbar.createTaskbarSeparator() }, + { action: insertCellsAction }, + { action: this._runAllCellsAction }, + { element: spacerElement }, + { action: viewOptions }, + { action: deleteView } ]); } diff --git a/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsActions.ts b/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsActions.ts new file mode 100644 index 0000000000..1d46bf825d --- /dev/null +++ b/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsActions.ts @@ -0,0 +1,242 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Action } from 'vs/base/common/actions'; +import { ViewOptionsModal } from 'sql/workbench/contrib/notebook/browser/notebookViews/viewOptionsModal'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { localize } from 'vs/nls'; +import { InsertCellsModal } from 'sql/workbench/contrib/notebook/browser/notebookViews/insertCellsModal'; +import { ComponentFactoryResolver, ViewContainerRef } from '@angular/core'; +import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { IDisposable } from 'vs/base/common/lifecycle'; +import { CellExecutionState, ICellModel, ViewMode } from 'sql/workbench/services/notebook/browser/models/modelInterfaces'; +import { CellActionBase, CellContext, IMultiStateData, MultiStateAction } from 'sql/workbench/contrib/notebook/browser/cellViews/codeActions'; +import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; +import { IConnectionManagementService } from 'sql/platform/connection/common/connectionManagement'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { ILogService } from 'vs/platform/log/common/log'; +import { getErrorMessage } from 'vs/base/common/errors'; +import * as types from 'vs/base/common/types'; +import { ActionBar, ActionsOrientation } from 'vs/base/browser/ui/actionbar/actionbar'; +import { Separator } from 'sql/base/browser/ui/separator/separator'; +import { ToggleMoreActions } from 'sql/workbench/contrib/notebook/browser/cellToolbarActions'; +import { NotebookViewsExtension } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension'; +import { INotebookView } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews'; + +export class ViewSettingsAction extends Action { + private static readonly ID = 'notebookView.viewSettings'; + private static readonly LABEL = undefined; + private static readonly ICON = 'notebook-button settings masked-icon'; + + constructor( + private _context: NotebookViewsExtension, + @IInstantiationService private _instantiationService: IInstantiationService, + ) { + super(ViewSettingsAction.ID, ViewSettingsAction.LABEL, ViewSettingsAction.ICON); + } + + override async run(): Promise { + const optionsModal = this._instantiationService.createInstance(ViewOptionsModal, this._context.getActiveView()); + optionsModal.render(); + optionsModal.open(); + } +} + +export class DeleteViewAction extends Action { + private static readonly ID = 'notebookView.deleteView'; + private static readonly LABEL = undefined; + private static readonly ICON = 'notebook-button delete masked-icon'; + + constructor( + private _notebookViews: NotebookViewsExtension, + @IDialogService private readonly dialogService: IDialogService, + @INotificationService private readonly notificationService: INotificationService + ) { + super(DeleteViewAction.ID, DeleteViewAction.LABEL, DeleteViewAction.ICON); + } + + override async run(): Promise { + const activeView = this._notebookViews.getActiveView(); + if (activeView) { + const confirmDelete = await this.confirmDelete(activeView); + if (confirmDelete) { + this._notebookViews.removeView(activeView.guid); + this._notebookViews.notebook.viewMode = ViewMode.Notebook; + } + } else { + this.notificationService.error(localize('viewsUnableToRemove', "Unable to remove view")); + } + } + + private async confirmDelete(view: INotebookView): Promise { + const result = await this.dialogService.confirm({ + message: localize('confirmDelete', "Are you sure you want to delete view \"{0}\"?", view.name), + primaryButton: localize('delete', "&&Delete"), + type: 'question' + }); + + if (result.confirmed) { + return true; + } + return false; + } +} + +export class InsertCellAction extends Action { + private static readonly ID = 'notebookView.insertCell'; + private static readonly LABEL = localize('insertCells', "Insert Cells"); + private static readonly ICON = 'notebook-button masked-pseudo add-new'; + + constructor( + private onInsert: (cell: ICellModel) => void, + private _context: NotebookViewsExtension, + private _containerRef: ViewContainerRef, + private _componentFactoryResolver: ComponentFactoryResolver, + @IInstantiationService private _instantiationService: IInstantiationService, + ) { + super(InsertCellAction.ID, InsertCellAction.LABEL, InsertCellAction.ICON); + } + + override async run(): Promise { + const optionsModal = this._instantiationService.createInstance(InsertCellsModal, this.onInsert, this._context, this._containerRef, this._componentFactoryResolver); + optionsModal.render(); + optionsModal.open(); + } +} + +export class RunCellAction extends MultiStateAction { + public static ID = 'notebookView.runCell'; + public static LABEL = localize('runCell', "Run cell"); + private _executionChangedDisposable: IDisposable; + private _context: CellContext; + constructor(context: CellContext, @INotificationService private notificationService: INotificationService, + @IConnectionManagementService private connectionManagementService: IConnectionManagementService, + @IKeybindingService keybindingService: IKeybindingService, + @ILogService logService: ILogService + ) { + super(RunCellAction.ID, new IMultiStateData([ + { key: CellExecutionState.Hidden, value: { label: '', className: '', tooltip: '', hideIcon: true } }, + { key: CellExecutionState.Stopped, value: { label: '', className: 'action-label notebook-button masked-pseudo start-outline masked-icon', tooltip: localize('runCell', "Run cell"), commandId: 'notebook.command.runactivecell' } }, + { key: CellExecutionState.Running, value: { label: '', className: 'action-label codicon notebook-button toolbarIconStop', tooltip: localize('stopCell', "Cancel execution") } }, + { key: CellExecutionState.Error, value: { label: '', className: 'toolbarIconRunError', tooltip: localize('errorRunCell', "Error on last run. Click to run again") } }, + ], CellExecutionState.Hidden), keybindingService, logService); + this.ensureContextIsUpdated(context); + } + + public override run(): Promise { + return this.doRun(); + } + + public async doRun(): Promise { + if (!this._context) { + // TODO should we error? + return; + } + try { + await this._context.cell.runCell(this.notificationService, this.connectionManagementService); + } catch (error) { + let message = getErrorMessage(error); + this.notificationService.error(message); + } + } + + private ensureContextIsUpdated(context: CellContext) { + if (context && context !== this._context) { + if (this._executionChangedDisposable) { + this._executionChangedDisposable.dispose(); + } + this._context = context; + this.updateStateAndExecutionCount(context.cell.executionState); + this._executionChangedDisposable = this._context.cell.onExecutionStateChange((state) => { + this.updateStateAndExecutionCount(state); + }); + } + } + + private updateStateAndExecutionCount(state: CellExecutionState) { + let label = ''; + let className = ''; + if (!types.isUndefinedOrNull(this._context.cell.executionCount)) { + label = `[${this._context.cell.executionCount}]`; + // Heuristic to try and align correctly independent of execution count length. Moving left margin + // back by a few px seems to make things "work" OK, but isn't a super clean solution + if (label.length === 4) { + className = 'execCountTen'; + } else if (label.length > 4) { + className = 'execCountHundred'; + } + } + this.states.updateStateData(CellExecutionState.Hidden, (data) => { + data.label = label; + data.className = className; + }); + this.updateState(state); + } +} + +export class HideCellAction extends Action { + private static readonly ID = 'notebookView.hideCell'; + private static readonly LABEL = undefined; + private static readonly ICON = 'notebook-button delete masked-icon'; + + constructor( + private hideFn: () => void, + private context: any + ) { + super(HideCellAction.ID, HideCellAction.LABEL, HideCellAction.ICON); + } + + override async run(): Promise { + this.hideFn.apply(this.context); + } +} + +export class ViewCellInNotebook extends CellActionBase { + constructor(id: string, label: string, + @INotificationService notificationService: INotificationService + ) { + super(id, label, undefined, notificationService); + } + + doRun(context: CellContext): Promise { + try { + context?.model?.updateActiveCell(context.cell); + context.model.viewMode = ViewMode.Notebook; + } catch (error) { + let message = localize('unableToNavigateToCell', "Unable to navigate to notebook cell."); + + this.notificationService.notify({ + severity: Severity.Error, + message: message + }); + } + return Promise.resolve(); + } +} + +export class ViewCellToggleMoreActions { + private _actions: (Action | CellActionBase)[] = []; + private _moreActions: ActionBar; + private _moreActionsElement: HTMLElement; + constructor( + @IInstantiationService private instantiationService: IInstantiationService + ) { + this._actions.push( + instantiationService.createInstance(ViewCellInNotebook, 'viewCellInNotebook', localize('viewCellInNotebook', "View Cell In Notebook")), + ); + } + + public onInit(elementRef: HTMLElement, context: CellContext) { + this._moreActionsElement = elementRef; + this._moreActionsElement.setAttribute('aria-haspopup', 'menu'); + if (this._moreActionsElement.childNodes.length > 0) { + this._moreActionsElement.removeChild(this._moreActionsElement.childNodes[0]); + } + this._moreActions = new ActionBar(this._moreActionsElement, { orientation: ActionsOrientation.VERTICAL, ariaLabel: localize('moreActionsLabel', "More") }); + this._moreActions.context = { target: this._moreActionsElement }; + let validActions = this._actions.filter(a => a instanceof Separator || a instanceof CellActionBase && a.canRun(context)); + this._moreActions.push(this.instantiationService.createInstance(ToggleMoreActions, validActions, context), { icon: true, label: false }); + } +} diff --git a/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsCard.component.ts b/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsCard.component.ts index cbe5e2d86c..c0286bf981 100644 --- a/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsCard.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsCard.component.ts @@ -3,18 +3,27 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import 'vs/css!./cellToolbar'; +import * as DOM from 'vs/base/browser/dom'; import { Component, OnInit, Input, ViewChild, TemplateRef, ElementRef, Inject, Output, EventEmitter, ChangeDetectorRef, forwardRef } from '@angular/core'; import { ICellModel } from 'sql/workbench/services/notebook/browser/models/modelInterfaces'; import { NotebookModel } from 'sql/workbench/services/notebook/browser/models/notebookModel'; import { DEFAULT_VIEW_CARD_HEIGHT, DEFAULT_VIEW_CARD_WIDTH } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewModel'; import { NotebookViewsExtension } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension'; import { CellChangeEventType, INotebookView, INotebookViewCellMetadata } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews'; +import { ITaskbarContent, Taskbar } from 'sql/base/browser/ui/taskbar/taskbar'; +import { CellContext } from 'sql/workbench/contrib/notebook/browser/cellViews/codeActions'; +import { RunCellAction, HideCellAction, ViewCellToggleMoreActions } from 'sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsActions'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { CellTypes } from 'sql/workbench/services/notebook/common/contracts'; @Component({ selector: 'view-card-component', templateUrl: decodeURI(require.toUrl('./notebookViewsCard.component.html')) }) export class NotebookViewsCardComponent implements OnInit { + public _cellToggleMoreActions: ViewCellToggleMoreActions; + + private _actionbar: Taskbar; private _metadata: INotebookViewCellMetadata; private _activeView: INotebookView; @@ -26,12 +35,16 @@ export class NotebookViewsCardComponent implements OnInit { @ViewChild('templateRef') templateRef: TemplateRef; @ViewChild('item', { read: ElementRef }) private _item: ElementRef; + @ViewChild('actionbar', { read: ElementRef }) private _actionbarRef: ElementRef; constructor( @Inject(forwardRef(() => ChangeDetectorRef)) private _changeRef: ChangeDetectorRef, + @Inject(IInstantiationService) private _instantiationService: IInstantiationService ) { } - ngOnInit() { } + ngOnInit() { + this.initActionBar(); + } ngOnChanges() { if (this.views) { @@ -51,6 +64,31 @@ export class NotebookViewsCardComponent implements OnInit { this.detectChanges(); } + initActionBar() { + if (this._actionbarRef) { + let taskbarContent: ITaskbarContent[] = []; + let context = new CellContext(this.model, this.cell); + + this._actionbar = new Taskbar(this._actionbarRef.nativeElement); + this._actionbar.context = { target: this._actionbarRef.nativeElement }; + + if (this.cell.cellType === CellTypes.Code) { + let runCellAction = this._instantiationService.createInstance(RunCellAction, context); + taskbarContent.push({ action: runCellAction }); + } + + let hideButton = new HideCellAction(this.hide, this); + taskbarContent.push({ action: hideButton }); + + let moreActionsContainer = DOM.$('li.action-item'); + this._cellToggleMoreActions = this._instantiationService.createInstance(ViewCellToggleMoreActions); + this._cellToggleMoreActions.onInit(moreActionsContainer, context); + taskbarContent.push({ element: moreActionsContainer }); + + this._actionbar.setContent(taskbarContent); + } + } + get elementRef(): ElementRef { return this._item; } diff --git a/src/sql/workbench/contrib/notebook/browser/notebookViews/viewOptionsModal.ts b/src/sql/workbench/contrib/notebook/browser/notebookViews/viewOptionsModal.ts new file mode 100644 index 0000000000..6650494843 --- /dev/null +++ b/src/sql/workbench/contrib/notebook/browser/notebookViews/viewOptionsModal.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Checkbox } from 'sql/base/browser/ui/checkbox/checkbox'; +import { Button } from 'sql/base/browser/ui/button/button'; +import { IClipboardService } from 'sql/platform/clipboard/common/clipboardService'; +import { IAdsTelemetryService } from 'sql/platform/telemetry/common/telemetry'; +import { Modal } from 'sql/workbench/browser/modal/modal'; +import { attachModalDialogStyler } from 'sql/workbench/common/styler'; +import { ITextResourcePropertiesService } from 'vs/editor/common/services/textResourceConfigurationService'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; +import { ILogService } from 'vs/platform/log/common/log'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import * as DOM from 'vs/base/browser/dom'; +import { attachInputBoxStyler } from 'sql/platform/theme/common/styler'; +import { attachButtonStyler } from 'vs/platform/theme/common/styler'; +import { localize } from 'vs/nls'; +import { IInputOptions, MessageType } from 'vs/base/browser/ui/inputbox/inputBox'; +import { InputBox } from 'sql/base/browser/ui/inputBox/inputBox'; +import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import { INotebookView } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews'; + +export class ViewOptionsModal extends Modal { + private _submitButton: Button; + private _cancelButton: Button; + private _optionsMap: { [name: string]: InputBox | Checkbox } = {}; + private _viewNameInput: InputBox; + + constructor( + private _view: INotebookView, + @ILogService logService: ILogService, + @IThemeService themeService: IThemeService, + @ILayoutService layoutService: ILayoutService, + @IClipboardService clipboardService: IClipboardService, + @IContextKeyService contextKeyService: IContextKeyService, + @IAdsTelemetryService telemetryService: IAdsTelemetryService, + @IContextViewService private _contextViewService: IContextViewService, + @ITextResourcePropertiesService textResourcePropertiesService: ITextResourcePropertiesService, + ) { + super( + localize("viewOptionsModal.title", "Configure View"), + 'ViewOptionsModal', + telemetryService, + layoutService, + clipboardService, + themeService, + logService, + textResourcePropertiesService, + contextKeyService, + { hasErrors: true, hasSpinner: true } + ); + } + + protected renderBody(container: HTMLElement): void { + const formWrapper = DOM.$('div#view-options-form'); + formWrapper.style.padding = '10px'; + + DOM.append(container, formWrapper); + + this._viewNameInput = this.createNameInput(formWrapper); + + } + + protected layout(height: number): void { + + } + + protected createNameInput(container: HTMLElement): InputBox { + return this.createInputBoxHelper(container, localize('viewOptionsModal.name', "View Name"), this._view.name, { + validationOptions: { + validation: (value: string) => { + if (!value) { + return ({ type: MessageType.ERROR, content: localize('viewOptionsModal.missingRequireField', "This field is required.") }); + } + if (this._view.name !== value && !this._view.nameAvailable(value)) { + return ({ type: MessageType.ERROR, content: localize('viewOptionsModal.nameTaken', "This view name has already been taken.") }); + } + return undefined; + } + }, + ariaLabel: localize('viewOptionsModal.name', "View Name") + }); + } + + private createInputBoxHelper(container: HTMLElement, label: string, defaultValue: string = '', options?: IInputOptions): InputBox { + const inputContainer = DOM.append(container, DOM.$('.dialog-input-section')); + DOM.append(inputContainer, DOM.$('.dialog-label')).innerText = label; + const input = new InputBox(DOM.append(inputContainer, DOM.$('.dialog-input')), this._contextViewService, options); + input.value = defaultValue; + return input; + } + + override render() { + super.render(); + + this._submitButton = this.addFooterButton(localize('save', "Save"), () => this.onSubmitHandler()); + this._cancelButton = this.addFooterButton(localize('cancel', "Cancel"), () => this.onCancelHandler(), 'right', true); + + this._register(attachInputBoxStyler(this._viewNameInput!, this._themeService)); + this._register(attachButtonStyler(this._submitButton, this._themeService)); + this._register(attachButtonStyler(this._cancelButton, this._themeService)); + + this._register(this._viewNameInput.onDidChange(v => this.validate())); + + attachModalDialogStyler(this, this._themeService); + this.validate(); + } + + private validate() { + let valid = true; + + if (this._viewNameInput.validate()) { + valid = false; + } + + this._submitButton.enabled = valid; + } + + private onSubmitHandler() { + this._view.name = this._viewNameInput.value; + this._view.save(); + + this.close(); + } + + private onCancelHandler() { + this.close(); + } + + public close(): void { + return this.hide(); + } + + public open(): void { + this.show(); + } + + public override dispose(): void { + super.dispose(); + for (let key in this._optionsMap) { + let widget = this._optionsMap[key]; + widget.dispose(); + delete this._optionsMap[key]; + } + } +} diff --git a/src/sql/workbench/contrib/notebook/test/browser/notebookViewsActions.test.ts b/src/sql/workbench/contrib/notebook/test/browser/notebookViewsActions.test.ts new file mode 100644 index 0000000000..d4906eb4b8 --- /dev/null +++ b/src/sql/workbench/contrib/notebook/test/browser/notebookViewsActions.test.ts @@ -0,0 +1,203 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { nb } from 'azdata'; +import * as assert from 'assert'; +import { ICapabilitiesService } from 'sql/platform/capabilities/common/capabilitiesService'; +import { TestCapabilitiesService } from 'sql/platform/capabilities/test/common/testCapabilitiesService'; +import { TestConnectionManagementService } from 'sql/platform/connection/test/common/testConnectionManagementService'; +import { NullAdsTelemetryService } from 'sql/platform/telemetry/common/adsTelemetryService'; +import { NotebookEditorContentManager } from 'sql/workbench/contrib/notebook/browser/models/notebookInput'; +import { DeleteViewAction, InsertCellAction } from 'sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsActions'; +import { SessionManager } from 'sql/workbench/contrib/notebook/test/emptySessionClasses'; +import { NotebookManagerStub } from 'sql/workbench/contrib/notebook/test/stubs'; +import { ModelFactory } from 'sql/workbench/services/notebook/browser/models/modelFactory'; +import { ICellModel, INotebookModelOptions, ViewMode } from 'sql/workbench/services/notebook/browser/models/modelInterfaces'; +import { NotebookModel } from 'sql/workbench/services/notebook/browser/models/notebookModel'; +import { NotebookViewsExtension } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension'; +import { CellTypes } from 'sql/workbench/services/notebook/common/contracts'; +import TypeMoq = require('typemoq'); +import { URI } from 'vs/base/common/uri'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; +import { TestDialogService } from 'vs/platform/dialogs/test/common/testDialogService'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { InstantiationService } from 'vs/platform/instantiation/common/instantiationService'; +import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; +import { NullLogService } from 'vs/platform/log/common/log'; +import { INotificationService } from 'vs/platform/notification/common/notification'; +import { TestNotificationService } from 'vs/platform/notification/test/common/testNotificationService'; +import { Memento } from 'vs/workbench/common/memento'; +import { TestStorageService } from 'vs/workbench/test/common/workbenchTestServices'; +import sinon = require('sinon'); +import { InsertCellsModal } from 'sql/workbench/contrib/notebook/browser/notebookViews/insertCellsModal'; +import { MockContextKeyService } from 'vs/platform/keybinding/test/common/mockKeybindingService'; + +let initialNotebookContent: nb.INotebookContents = { + cells: [{ + cell_type: CellTypes.Code, + source: ['insert into t1 values (c1, c2)'], + metadata: { language: 'python' }, + execution_count: 1 + }, { + cell_type: CellTypes.Markdown, + source: ['I am *markdown*'], + metadata: { language: 'python' }, + execution_count: 1 + }], + metadata: { + kernelspec: { + name: 'mssql', + language: 'sql' + }, + }, + nbformat: 4, + nbformat_minor: 5 +}; + +suite('Notebook Views Actions', function (): void { + let defaultViewName = 'Default New View'; + let notebookManagers = [new NotebookManagerStub()]; + let mockSessionManager: TypeMoq.Mock; + let memento: TypeMoq.Mock; + let queryConnectionService: TypeMoq.Mock; + let defaultModelOptions: INotebookModelOptions; + const logService = new NullLogService(); + + let defaultUri = URI.file('/some/path.ipynb'); + let notificationService: TypeMoq.Mock; + let capabilitiesService: TypeMoq.Mock; + let instantiationService: IInstantiationService; + let configurationService: IConfigurationService; + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.sandbox.create(); + setupServices(); + }); + + teardown(() => { + sandbox.restore(); + }); + + test('delete view action accept', async function (): Promise { + const dialogService = new TestDialogService(); + const notificationService = new TestNotificationService(); + const notebookViews = await initializeNotebookViewsExtension(initialNotebookContent); + + const newView = notebookViews.createNewView(defaultViewName); + + assert.strictEqual(notebookViews.getViews().length, 1, 'View not created'); + + notebookViews.setActiveView(newView); + + assert.deepStrictEqual(notebookViews.getActiveView(), newView, 'Active view not set properly'); + + const deleteAction = new DeleteViewAction(notebookViews, dialogService, notificationService); + sandbox.stub(deleteAction, 'confirmDelete').withArgs(newView).returns(Promise.resolve(true)); + await deleteAction.run(); + + assert.strictEqual(notebookViews.getViews().length, 0, 'View not deleted'); + assert.strictEqual(notebookViews.notebook.viewMode, ViewMode.Notebook, 'View mode was note set to notebook'); + }); + + test('delete view action decline', async function (): Promise { + const dialogService = new TestDialogService(); + const notificationService = new TestNotificationService(); + const notebookViews = await initializeNotebookViewsExtension(initialNotebookContent); + + const newView = notebookViews.createNewView(defaultViewName); + + assert.strictEqual(notebookViews.getViews().length, 1, 'View not created'); + + notebookViews.setActiveView(newView); + + assert.strictEqual(notebookViews.getActiveView(), newView, 'Active view not set properly'); + + const deleteAction = new DeleteViewAction(notebookViews, dialogService, notificationService); + sandbox.stub(deleteAction, 'confirmDelete').withArgs(newView).returns(Promise.resolve(false)); + await deleteAction.run(); + + assert.strictEqual(notebookViews.getViews().length, 1, 'View should not have deleted'); + }); + + test('show insertcellmodal', async function (): Promise { + let opened = false; + let rendered = false; + const notebookViews = await initializeNotebookViewsExtension(initialNotebookContent); + const newView = notebookViews.createNewView(defaultViewName); + + notebookViews.setActiveView(newView); + + let insertCellsModal = TypeMoq.Mock.ofType(InsertCellsModal, TypeMoq.MockBehavior.Strict, + (cell: ICellModel) => { }, // onInsert + notebookViews, // _context + undefined, // _containerRef + undefined, // _componentFactoryResolver + undefined, // logService + undefined, // themeService + undefined, // layoutService + undefined, // clipboardService + new MockContextKeyService(), // contextkeyservice + undefined, // telemetryService + undefined, // textResourcePropertiesService + ); + + insertCellsModal.setup(x => x.render()).callback(() => { + rendered = true; + }); + + insertCellsModal.setup(x => x.open()).callback(() => { + opened = true; + }); + + const instantiationService = new InstantiationService(); + sinon.stub(instantiationService, 'createInstance').withArgs(InsertCellsModal, sinon.match.any, sinon.match.any, sinon.match.any, sinon.match.any).returns(insertCellsModal.object); + + const insertCellAction = new InsertCellAction((cell: ICellModel) => { }, notebookViews, undefined, undefined, instantiationService); + await insertCellAction.run(); + + assert.ok(rendered); + assert.ok(opened); + }); + + function setupServices() { + mockSessionManager = TypeMoq.Mock.ofType(SessionManager); + notebookManagers[0].sessionManager = mockSessionManager.object; + notificationService = TypeMoq.Mock.ofType(TestNotificationService, TypeMoq.MockBehavior.Loose); + capabilitiesService = TypeMoq.Mock.ofType(TestCapabilitiesService); + memento = TypeMoq.Mock.ofType(Memento, TypeMoq.MockBehavior.Loose, ''); + memento.setup(x => x.getMemento(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => void 0); + queryConnectionService = TypeMoq.Mock.ofType(TestConnectionManagementService, TypeMoq.MockBehavior.Loose, memento.object, undefined, new TestStorageService()); + queryConnectionService.callBase = true; + let serviceCollection = new ServiceCollection(); + instantiationService = new InstantiationService(serviceCollection, true); + configurationService = new TestConfigurationService(); + defaultModelOptions = { + notebookUri: defaultUri, + factory: new ModelFactory(instantiationService), + notebookManagers, + contentManager: undefined, + notificationService: notificationService.object, + connectionService: queryConnectionService.object, + providerId: 'SQL', + cellMagicMapper: undefined, + defaultKernel: undefined, + layoutChanged: undefined, + capabilitiesService: capabilitiesService.object + }; + } + + async function initializeNotebookViewsExtension(contents: nb.INotebookContents): Promise { + let mockContentManager = TypeMoq.Mock.ofType(NotebookEditorContentManager); + mockContentManager.setup(c => c.loadContent()).returns(() => Promise.resolve(contents)); + defaultModelOptions.contentManager = mockContentManager.object; + + let model = new NotebookModel(defaultModelOptions, undefined, logService, undefined, new NullAdsTelemetryService(), queryConnectionService.object, configurationService); + await model.loadContents(); + await model.requestModelLoad(); + + return new NotebookViewsExtension(model); + } +}); diff --git a/src/vs/code/browser/workbench/workbench-dev.html b/src/vs/code/browser/workbench/workbench-dev.html index efc17dd0fb..4b7ad5d09b 100644 --- a/src/vs/code/browser/workbench/workbench-dev.html +++ b/src/vs/code/browser/workbench/workbench-dev.html @@ -60,6 +60,7 @@ 'angular2-slickgrid': `${window.location.origin}/static/remote/web/node_modules/angular2-slickgrid/out/bundles/angular2-slickgrid.umd.js`, 'chart.js': `${window.location.origin}/static/remote/web/node_modules/chart.js/dist/Chart.bundle.min.js`, 'html-query-plan': `${window.location.origin}/static/remote/web/node_modules/html-query-plan/dist/index.min.js`, + 'html-to-image': `${window.location.origin}/static/web/node_modules/html-to-image/dist/html-to-image.js`, 'ng2-charts': `${window.location.origin}/static/remote/web/node_modules/ng2-charts/bundles/ng2-charts.umd.js`, 'rxjs/Observable': `${window.location.origin}/static/remote/web/node_modules/rxjs/bundles/Rx.min.js?0`, 'rxjs/observable/merge': `${window.location.origin}/static/remote/web/node_modules/rxjs/bundles/Rx.min.js?1`, diff --git a/src/vs/code/browser/workbench/workbench.html b/src/vs/code/browser/workbench/workbench.html index 8f6c1d6ac8..3ac59f47d9 100644 --- a/src/vs/code/browser/workbench/workbench.html +++ b/src/vs/code/browser/workbench/workbench.html @@ -60,6 +60,7 @@ 'angular2-slickgrid': `${window.location.origin}/static/node_modules/angular2-slickgrid/out/bundles/angular2-slickgrid.umd.js`, 'chart.js': `${window.location.origin}/static/node_modules/chart.js/dist/Chart.bundle.min.js`, 'html-query-plan': `${window.location.origin}/static/node_modules/html-query-plan/dist/index.min.js`, + 'html-to-image': `${window.location.origin}/static/web/node_modules/html-to-image/dist/html-to-image.js`, 'ng2-charts': `${window.location.origin}/static/node_modules/ng2-charts/bundles/ng2-charts.umd.js`, 'rxjs/Observable': `${window.location.origin}/static/node_modules/rxjs/bundles/Rx.min.js?0`, 'rxjs/observable/merge': `${window.location.origin}/static/node_modules/rxjs/bundles/Rx.min.js?1`, diff --git a/test/unit/electron/renderer.js b/test/unit/electron/renderer.js index 7433bb0b21..f45e6d5780 100644 --- a/test/unit/electron/renderer.js +++ b/test/unit/electron/renderer.js @@ -103,6 +103,7 @@ function initLoader(opts) { '@angular/platform-browser-dynamic', '@angular/router', 'angular2-grid', + 'html-to-image', 'gridstack/dist/h5/gridstack-dd-native', 'ng2-charts', 'rxjs/add/observable/of', diff --git a/yarn.lock b/yarn.lock index 4ae20091cc..ebe5d0e875 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5284,6 +5284,11 @@ html-escaper@^2.0.0: version "2.5.0" resolved "git://github.com/kburtram/html-query-plan.git#c524feb824e4960897ad875a37af068376a2b4a3" +html-to-image@^1.6.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/html-to-image/-/html-to-image-1.6.2.tgz#9d444424cc638df13db5c7be810ac0d2962f5edd" + integrity sha512-X6X7wJW2KQ+AGqMeBITdntCcQnxBgZY62MdGOi042Y70+0SMe8/iJCzUv8RNaUoXqUjWw5FPyoTDmdGoapTQIQ== + "htmlparser2@>= 3.7.3 < 4.0.0": version "3.9.2" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.9.2.tgz#1bdf87acca0f3f9e53fa4fcceb0f4b4cbb00b338"