diff --git a/src/sql/media/icons/common-icons.css b/src/sql/media/icons/common-icons.css index fa92380141..e908539152 100644 --- a/src/sql/media/icons/common-icons.css +++ b/src/sql/media/icons/common-icons.css @@ -442,6 +442,15 @@ Includes non-masked style declarations. */ mask-image: url("clear.svg"); } +.codicon:not(.masked-icon).icon-run-with-parameters { + background-image: url("run-with-parameters.svg"); +} + +.codicon.masked-icon.icon-run-with-parameters:before { + -webkit-mask-image: url("run-with-parameters.svg"); + mask-image: url("run-with-parameters.svg"); +} + .codicon:not(.masked-icon).icon-shield { background-image: url("shield.svg"); } diff --git a/src/sql/media/icons/run-with-parameters.svg b/src/sql/media/icons/run-with-parameters.svg new file mode 100644 index 0000000000..93405cadd9 --- /dev/null +++ b/src/sql/media/icons/run-with-parameters.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/sql/workbench/contrib/notebook/browser/notebook.component.ts b/src/sql/workbench/contrib/notebook/browser/notebook.component.ts index a174d946d8..ec31e8fea8 100644 --- a/src/sql/workbench/contrib/notebook/browser/notebook.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/notebook.component.ts @@ -28,7 +28,7 @@ import { INotebookService, INotebookParams, INotebookEditor, INotebookSection, I import { NotebookModel } from 'sql/workbench/services/notebook/browser/models/notebookModel'; import { Deferred } from 'sql/base/common/promise'; import { Taskbar } from 'sql/base/browser/ui/taskbar/taskbar'; -import { AddCellAction, KernelsDropdown, AttachToDropdown, TrustedAction, RunAllCellsAction, ClearAllOutputsAction, CollapseCellsAction } from 'sql/workbench/contrib/notebook/browser/notebookActions'; +import { AddCellAction, KernelsDropdown, AttachToDropdown, TrustedAction, RunAllCellsAction, ClearAllOutputsAction, CollapseCellsAction, RunParametersAction } from 'sql/workbench/contrib/notebook/browser/notebookActions'; import { DropdownMenuActionViewItem } from 'sql/base/browser/ui/buttonMenu/buttonMenu'; import { ISingleNotebookEditOperation } from 'sql/workbench/api/common/sqlExtHostTypes'; import { IConnectionDialogService } from 'sql/workbench/services/connection/common/connectionDialogService'; @@ -129,7 +129,6 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe this.notebookService.removeNotebookEditor(this); } } - public get model(): NotebookModel | null { return this._model; } @@ -387,6 +386,8 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe this._trustedAction = this.instantiationService.createInstance(TrustedAction, 'notebook.Trusted', true); this._trustedAction.enabled = false; + let runParametersAction = this.instantiationService.createInstance(RunParametersAction, 'notebook.runParameters', true, this._notebookParams.notebookUri); + let taskbar = this.toolbar.nativeElement; this._actionBar = new Taskbar(taskbar, { actionViewItemProvider: action => this.actionItemProvider(action as Action) }); this._actionBar.context = this._notebookParams.notebookUri; @@ -418,6 +419,7 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe { action: collapseCellsAction }, { action: clearResultsButton }, { action: this._trustedAction }, + { action: runParametersAction }, ]); } else { let kernelContainer = document.createElement('div'); @@ -458,10 +460,9 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe { action: this._trustedAction }, { action: this._runAllCellsAction }, { action: clearResultsButton }, - { action: collapseCellsAction } + { action: collapseCellsAction }, ]); } - } protected initNavSection(): void { diff --git a/src/sql/workbench/contrib/notebook/browser/notebook.css b/src/sql/workbench/contrib/notebook/browser/notebook.css index 6161bd268c..320db99939 100644 --- a/src/sql/workbench/contrib/notebook/browser/notebook.css +++ b/src/sql/workbench/contrib/notebook/browser/notebook.css @@ -79,6 +79,16 @@ padding-left: 18px; width: 16px; } + +.notebookEditor + .in-preview + .actions-container + .action-item + .codicon.icon-run-with-parameters:before { + padding-left: 20px; + width: 18px; +} + .notebookEditor .in-preview .actions-container .action-item:last-child { margin-right: 14px; } diff --git a/src/sql/workbench/contrib/notebook/browser/notebookActions.ts b/src/sql/workbench/contrib/notebook/browser/notebookActions.ts index a7d0f5f59b..d7011e6079 100644 --- a/src/sql/workbench/contrib/notebook/browser/notebookActions.ts +++ b/src/sql/workbench/contrib/notebook/browser/notebookActions.ts @@ -28,6 +28,7 @@ import { INotebookService } from 'sql/workbench/services/notebook/browser/notebo import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { CellContext } from 'sql/workbench/contrib/notebook/browser/cellViews/codeActions'; import { URI } from 'vs/base/common/uri'; +import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; const msgLoading = localize('loading', "Loading kernels..."); export const msgChanging = localize('changing', "Changing kernel..."); @@ -39,6 +40,8 @@ const msgSelectConnection = localize('selectConnection', "Select Connection"); const msgLocalHost = localize('localhost', "localhost"); export const noKernel: string = localize('noKernel', "No Kernel"); +const baseIconClass = 'codicon'; +const maskedIconClass = 'masked-icon'; // Action to add a cell to notebook based on cell type(code/markdown). export class AddCellAction extends Action { @@ -101,17 +104,15 @@ export abstract class TooltipFromLabelAction extends Action { // Action to clear outputs of all code cells. export class ClearAllOutputsAction extends TooltipFromLabelAction { private static readonly label = localize('clearResults', "Clear Results"); - private static readonly baseClass = 'codicon'; private static readonly iconClass = 'icon-clear-results'; - private static readonly maskedIconClass = 'masked-icon'; constructor(id: string, toggleTooltip: boolean, @INotebookService private _notebookService: INotebookService) { super(id, { label: ClearAllOutputsAction.label, - baseClass: ClearAllOutputsAction.baseClass, + baseClass: baseIconClass, iconClass: ClearAllOutputsAction.iconClass, - maskedIconClass: ClearAllOutputsAction.maskedIconClass, + maskedIconClass: maskedIconClass, shouldToggleTooltip: toggleTooltip }); } @@ -170,24 +171,22 @@ export class TrustedAction extends ToggleableAction { // Constants private static readonly trustedLabel = localize('trustLabel', "Trusted"); private static readonly notTrustedLabel = localize('untrustLabel', "Not Trusted"); - private static readonly baseClass = 'codicon'; private static readonly previewTrustedCssClass = 'icon-shield'; private static readonly trustedCssClass = 'icon-trusted'; private static readonly previewNotTrustedCssClass = 'icon-shield-x'; private static readonly notTrustedCssClass = 'icon-notTrusted'; - private static readonly maskedIconClass = 'masked-icon'; constructor( id: string, toggleTooltip: boolean, @INotebookService private _notebookService: INotebookService ) { super(id, { - baseClass: TrustedAction.baseClass, + baseClass: baseIconClass, toggleOnLabel: TrustedAction.trustedLabel, toggleOnClass: toggleTooltip === true ? TrustedAction.previewTrustedCssClass : TrustedAction.trustedCssClass, toggleOffLabel: TrustedAction.notTrustedLabel, toggleOffClass: toggleTooltip === true ? TrustedAction.previewNotTrustedCssClass : TrustedAction.notTrustedCssClass, - maskedIconClass: TrustedAction.maskedIconClass, + maskedIconClass: maskedIconClass, shouldToggleTooltip: toggleTooltip, isOn: false }); @@ -232,22 +231,20 @@ export class RunAllCellsAction extends Action { export class CollapseCellsAction extends ToggleableAction { private static readonly collapseCells = localize('collapseAllCells', "Collapse Cells"); private static readonly expandCells = localize('expandAllCells', "Expand Cells"); - private static readonly baseClass = 'codicon'; private static readonly previewCollapseCssClass = 'icon-collapse-cells'; private static readonly collapseCssClass = 'icon-hide-cells'; private static readonly previewExpandCssClass = 'icon-expand-cells'; private static readonly expandCssClass = 'icon-show-cells'; - private static readonly maskedIconClass = 'masked-icon'; constructor(id: string, toggleTooltip: boolean, @INotebookService private _notebookService: INotebookService) { super(id, { - baseClass: CollapseCellsAction.baseClass, + baseClass: baseIconClass, toggleOnLabel: CollapseCellsAction.expandCells, toggleOnClass: toggleTooltip === true ? CollapseCellsAction.previewExpandCssClass : CollapseCellsAction.expandCssClass, toggleOffLabel: CollapseCellsAction.collapseCells, toggleOffClass: toggleTooltip === true ? CollapseCellsAction.previewCollapseCssClass : CollapseCellsAction.collapseCssClass, - maskedIconClass: CollapseCellsAction.maskedIconClass, + maskedIconClass: maskedIconClass, shouldToggleTooltip: toggleTooltip, isOn: false }); @@ -272,6 +269,101 @@ export class CollapseCellsAction extends ToggleableAction { } } +export class RunParametersAction extends TooltipFromLabelAction { + private static readonly label = localize('runParameters', "Run with Parameters"); + private static readonly iconClass = 'icon-run-with-parameters'; + + constructor(id: string, + toggleTooltip: boolean, + context: URI, + @IQuickInputService private quickInputService: IQuickInputService, + @INotebookService private _notebookService: INotebookService, + @INotificationService private notificationService: INotificationService, + ) { + super(id, { + label: RunParametersAction.label, + baseClass: baseIconClass, + iconClass: RunParametersAction.iconClass, + maskedIconClass: maskedIconClass, + shouldToggleTooltip: toggleTooltip + }); + } + + /** + * Gets Default Parameters in Notebook from Parameter Cell + * Uses that as Placeholder values for user to inject new values for + * Once user enters all values it will open the new parameterized notebook + * with injected parameters value from the QuickInput + */ + public async run(context: URI): Promise { + const editor = this._notebookService.findNotebookEditor(context); + // Set defaultParameters to the parameter values in parameter cell + let defaultParameters = new Map(); + editor.cells.forEach(cell => { + if (cell.isParameter) { + for (let parameter of cell.source) { + let param = parameter.split('=', 2); + defaultParameters.set(param[0].trim(), param[1].trim()); + } + } + }); + + // Store new parameters values the user inputs + let inputParameters = new Map(); + let uriParams = new URLSearchParams(); + // Store new parameter values to map based off defaultParameters + if (defaultParameters.size === 0) { + // If there is no parameter cell indicate to user to create one + this.notificationService.notify({ + severity: Severity.Info, + message: localize('noParametersCell', "This notebook cannot run with parameters until a parameter cell is added. [Learn more](https://docs.microsoft.com/sql/azure-data-studio/notebooks/notebooks-parameterization)."), + }); + return; + } else { + for (let key of defaultParameters.keys()) { + let newParameterValue = await this.quickInputService.input({ prompt: key, value: defaultParameters.get(key), ignoreFocusLost: true }); + // If user cancels or escapes then it stops the action entirely + if (newParameterValue === undefined) { + return; + } + inputParameters.set(key, newParameterValue); + } + // Format the new parameters to be append to the URI + for (let key of inputParameters.keys()) { + // Will only add new injected parameters when the value is not the same as the defaultParameters values + if (inputParameters.get(key) !== defaultParameters.get(key)) { + // For empty strings we need to escape the value + // so that it is kept when adding uriParams.toString() to filePath + if (inputParameters.get(key) === '') { + uriParams.append(key, '\'\''); + } else { + uriParams.append(key, inputParameters.get(key)); + } + } + } + let stringParams = unescape(uriParams.toString()); + context = context.with({ query: stringParams }); + return this.openParameterizedNotebook(context); + } + } + + /** + * This function will be used once the showNotebookDocument can be used + * TODO - Call Extensibility API for ShowNotebook + * (showNotebookDocument to be utilized in Notebook Service) + **/ + public async openParameterizedNotebook(uri: URI): Promise { + // const editor = this._notebookService.findNotebookEditor(uri); + // let modelContents = editor.model.toJSON(); + // let basename = path.basename(uri.fsPath); + // let untitledUri = uri.with({ authority: '', scheme: 'untitled', path: basename }); + // this._notebookService.showNotebookDocument(untitledUri, { + // initialContent: modelContents, + // preserveFocus: true + // }); + } +} + const showAllKernelsConfigName = 'notebook.showAllKernels'; const workbenchPreviewConfigName = 'workbench.enablePreviewFeatures'; export const noKernelName = localize('noKernel', "No Kernel"); diff --git a/src/sql/workbench/contrib/notebook/test/browser/notebookActions.test.ts b/src/sql/workbench/contrib/notebook/test/browser/notebookActions.test.ts index 0925db416c..9329c7c631 100644 --- a/src/sql/workbench/contrib/notebook/test/browser/notebookActions.test.ts +++ b/src/sql/workbench/contrib/notebook/test/browser/notebookActions.test.ts @@ -7,7 +7,7 @@ import * as assert from 'assert'; import * as azdata from 'azdata'; import * as sinon from 'sinon'; import { TestConfigurationService } from 'sql/platform/connection/test/common/testConfigurationService'; -import { AddCellAction, ClearAllOutputsAction, CollapseCellsAction, KernelsDropdown, msgChanging, NewNotebookAction, noKernelName, RunAllCellsAction, TrustedAction } from 'sql/workbench/contrib/notebook/browser/notebookActions'; +import { AddCellAction, ClearAllOutputsAction, CollapseCellsAction, KernelsDropdown, msgChanging, NewNotebookAction, noKernelName, RunAllCellsAction, RunParametersAction, TrustedAction } from 'sql/workbench/contrib/notebook/browser/notebookActions'; import { ClientSessionStub, ContextViewProviderStub, NotebookComponentStub, NotebookModelStub, NotebookServiceStub } from 'sql/workbench/contrib/notebook/test/stubs'; import { NotebookEditorStub } from 'sql/workbench/contrib/notebook/test/testCommon'; import { ICellModel, INotebookModel } from 'sql/workbench/services/notebook/browser/models/modelInterfaces'; @@ -24,6 +24,7 @@ import { INotificationService } from 'vs/platform/notification/common/notificati import { TestNotificationService } from 'vs/platform/notification/test/common/testNotificationService'; import { workbenchInstantiationService } from 'vs/workbench/test/browser/workbenchTestServices'; import { URI } from 'vs/base/common/uri'; +import { MockQuickInputService } from 'sql/workbench/contrib/notebook/test/common/quickInputServiceMock'; class TestClientSession extends ClientSessionStub { private _errorState: boolean = false; @@ -257,6 +258,24 @@ suite('Notebook Actions', function (): void { assert.strictEqual(actualCmdId, NewNotebookAction.INTERNAL_NEW_NOTEBOOK_CMD_ID); }); + test.skip('Run with Parameters Action', async function (): Promise { + let mockNotification = TypeMoq.Mock.ofType(TestNotificationService); + mockNotification.setup(n => n.notify(TypeMoq.It.isAny())); + let quickInputService = new MockQuickInputService; + + let action = new RunParametersAction('TestId', true, testUri, quickInputService, mockNotebookService.object, mockNotification.object); + + // Normal use case + const testCells = [{ + isParameter: true, + source: ['x=2.0\n', 'y=5.0'] + }]; + + mockNotebookEditor.setup(x => x.cells).returns(() => testCells); + + assert.doesNotThrow(() => action.run(testUri)); + }); + suite('Kernels dropdown', async () => { let kernelsDropdown: KernelsDropdown; let contextViewProvider: ContextViewProviderStub; diff --git a/src/sql/workbench/contrib/notebook/test/common/quickInputServiceMock.ts b/src/sql/workbench/contrib/notebook/test/common/quickInputServiceMock.ts new file mode 100644 index 0000000000..b2193e9e08 --- /dev/null +++ b/src/sql/workbench/contrib/notebook/test/common/quickInputServiceMock.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as Types from 'vs/base/common/types'; + +import { IInputBox, IInputOptions, IPickOptions, IQuickInputButton, IQuickInputService, IQuickNavigateConfiguration, IQuickPick, IQuickPickItem, QuickPickInput } from 'vs/platform/quickinput/common/quickInput'; +import { Event } from 'vs/base/common/event'; +import { CancellationToken } from 'vs/base/common/cancellation'; + + +export class MockQuickInputService implements IQuickInputService { + declare readonly _serviceBrand: undefined; + + readonly onShow = Event.None; + readonly onHide = Event.None; + + readonly quickAccess = undefined!; + + public pick(picks: Promise[]> | QuickPickInput[], options?: IPickOptions & { canPickMany: true }, token?: CancellationToken): Promise; + public pick(picks: Promise[]> | QuickPickInput[], options?: IPickOptions & { canPickMany: false }, token?: CancellationToken): Promise; + public pick(picks: Promise[]> | QuickPickInput[], options?: Omit, 'canPickMany'>, token?: CancellationToken): Promise { + if (Types.isArray(picks)) { + return Promise.resolve({ label: 'selectedPick', description: 'pick description', value: 'selectedPick' }); + } else { + return Promise.resolve(undefined); + } + } + + public input(options?: IInputOptions, token?: CancellationToken): Promise { + return Promise.resolve(options ? 'resolved' + options.prompt : 'resolved'); + } + + backButton!: IQuickInputButton; + + createQuickPick(): IQuickPick { + throw new Error('not implemented.'); + } + + createInputBox(): IInputBox { + throw new Error('not implemented.'); + } + + focus(): void { + throw new Error('not implemented.'); + } + + toggle(): void { + throw new Error('not implemented.'); + } + + navigate(next: boolean, quickNavigate?: IQuickNavigateConfiguration): void { + throw new Error('not implemented.'); + } + + accept(): Promise { + throw new Error('not implemented.'); + } + + back(): Promise { + throw new Error('not implemented.'); + } + + cancel(): Promise { + throw new Error('not implemented.'); + } +}