diff --git a/src/sql/base/browser/ui/designer/designer.ts b/src/sql/base/browser/ui/designer/designer.ts index c0fb916ccf..432bc8704a 100644 --- a/src/sql/base/browser/ui/designer/designer.ts +++ b/src/sql/base/browser/ui/designer/designer.ts @@ -27,6 +27,7 @@ import { Button, IButtonStyles } from 'sql/base/browser/ui/button/button'; import { ButtonColumn } from 'sql/base/browser/ui/table/plugins/buttonColumn.plugin'; import { Codicon } from 'vs/base/common/codicons'; import { Color } from 'vs/base/common/color'; +import { LoadingSpinner } from 'sql/base/browser/ui/loadingSpinner/loadingSpinner'; export interface IDesignerStyle { tabbedPanelStyles?: ITabbedPanelStyles; @@ -44,6 +45,7 @@ export type CreateComponentFunc = (container: HTMLElement, component: DesignerDa export type SetComponentValueFunc = (definition: DesignerDataPropertyInfo, component: DesignerUIComponent, data: DesignerViewModel) => void; export class Designer extends Disposable implements IThemable { + private _loadingSpinner: LoadingSpinner; private _horizontalSplitViewContainer: HTMLElement; private _verticalSplitViewContainer: HTMLElement; private _tabbedPanelContainer: HTMLElement; @@ -89,6 +91,7 @@ export class Designer extends Disposable implements IThemable { } }, this._contextViewProvider ); + this._loadingSpinner = new LoadingSpinner(this._container, { showText: true, fullSize: true }); this._verticalSplitViewContainer = DOM.$('.designer-component'); this._horizontalSplitViewContainer = DOM.$('.container'); this._contentContainer = DOM.$('.content-container'); @@ -201,7 +204,10 @@ export class Designer extends Disposable implements IThemable { private async initializeDesignerView(): Promise { this._propertiesPane.clear(); DOM.clearNode(this._topContentContainer); + // For initialization, we would want to show the loading indicator immediately. + const handle = this.startLoading(localize('designer.loadingDesigner', "Loading designer..."), 0); const view = await this._input.getView(); + this.stopLoading(handle, localize('designer.loadingDesignerCompleted', "Loading designer completed")); if (view.components) { view.components.forEach(component => { this.createComponent(this._topContentContainer, component, component.propertyName, true, true); @@ -268,7 +274,9 @@ export class Designer extends Disposable implements IThemable { return; } await this.applyEdit(edit); + const handle = this.startLoading(localize('designer.processingChanges', "Processing changes...")); const result = await this._input.processEdit(edit); + this.stopLoading(handle, localize('designer.processingChangesCompleted', "Processing changes completed")); if (result.isValid) { this._supressEditProcessing = true; await this.updateComponentValues(); @@ -415,8 +423,10 @@ export class Designer extends Disposable implements IThemable { ariaLabel: inputProperties.title, type: inputProperties.inputType, }); - input.onDidChange(async (newValue) => { - await this.handleEdit({ type: DesignerEditType.Update, property: editIdentifier, value: newValue }); + input.onLoseFocus(async (args) => { + if (args.hasChanged) { + await this.handleEdit({ type: DesignerEditType.Update, property: editIdentifier, value: args.value }); + } }); if (setWidth && inputProperties.width !== undefined) { input.width = inputProperties.width as number; @@ -567,4 +577,22 @@ export class Designer extends Disposable implements IThemable { this.styleComponent(component); return component; } + + private startLoading(message: string, timeout: number = 500): any { + // To make the experience smoother, only show the loading indicator if the request is not returning in 500ms(default value). + return setTimeout(() => { + this._loadingSpinner.loadingMessage = message; + this._loadingSpinner.loading = true; + this._container.removeChild(this._verticalSplitViewContainer); + }, timeout); + } + + private stopLoading(handle: any, message: string): void { + clearTimeout(handle); + if (this._loadingSpinner.loading) { + this._loadingSpinner.loadingCompletedMessage = message; + this._loadingSpinner.loading = false; + this._container.appendChild(this._verticalSplitViewContainer); + } + } } diff --git a/src/sql/base/browser/ui/designer/interfaces.ts b/src/sql/base/browser/ui/designer/interfaces.ts index f5694a512a..e99e7b305d 100644 --- a/src/sql/base/browser/ui/designer/interfaces.ts +++ b/src/sql/base/browser/ui/designer/interfaces.ts @@ -52,6 +52,7 @@ export interface DesignerState { valid: boolean; dirty: boolean; saving: boolean; + processing: boolean; } export const NameProperty = 'name'; diff --git a/src/sql/base/browser/ui/inputBox/inputBox.ts b/src/sql/base/browser/ui/inputBox/inputBox.ts index e2870ca680..14562abace 100644 --- a/src/sql/base/browser/ui/inputBox/inputBox.ts +++ b/src/sql/base/browser/ui/inputBox/inputBox.ts @@ -176,4 +176,13 @@ export class InputBox extends vsInputBox { super.width = width; this.element.style.width = 'fit-content'; } + + public override get value() { + return super.value; + } + + public override set value(newValue: string) { + this._lastLoseFocusValue = newValue; + super.value = newValue; + } } diff --git a/src/sql/base/browser/ui/loadingSpinner/loadingSpinner.ts b/src/sql/base/browser/ui/loadingSpinner/loadingSpinner.ts new file mode 100644 index 0000000000..1971f49b68 --- /dev/null +++ b/src/sql/base/browser/ui/loadingSpinner/loadingSpinner.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * 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!./media/loadingSpinner'; +import * as nls from 'vs/nls'; +import { status } from 'vs/base/browser/ui/aria/aria'; +import { Disposable } from 'vs/base/common/lifecycle'; +import * as DOM from 'vs/base/browser/dom'; +import { mixin } from 'vs/base/common/objects'; + +const DefaultLoadingMessage = nls.localize('loadingMessage', "Loading"); +const DefaultLoadingCompletedMessage = nls.localize('loadingCompletedMessage', "Loading completed"); + +export interface LoadingSpinnerOptions { + /** + * Whether to show the messages. The default value is false. + */ + showText?: boolean; + /** + * Whether the loading spinner should take up all the avaliable spaces. The default value is false. + */ + fullSize?: boolean; +} + +const defaultLoadingSpinnerOptions: LoadingSpinnerOptions = { + showText: false, + fullSize: false +}; + +export class LoadingSpinner extends Disposable { + private _loading: boolean = false; + private _loadingMessage?: string; + private _loadingCompletedMessage?: string; + private _loadingSpinner: HTMLElement; + private _loadingSpinnerText: HTMLElement; + private _options: LoadingSpinnerOptions; + + constructor(private _container: HTMLElement, options?: LoadingSpinnerOptions) { + super(); + this._options = mixin(options || {}, defaultLoadingSpinnerOptions, false); + this._loadingSpinner = DOM.$(`.loading-spinner-component-container${this._options.fullSize ? '.full-size' : ''}`); + this._loadingSpinner.appendChild(DOM.$('.loading-spinner.codicon.in-progress')); + if (this._options.showText) { + this._loadingSpinnerText = this._loadingSpinner.appendChild(DOM.$('')); + } + } + + get loadingMessage(): string { + return this._loadingMessage ?? DefaultLoadingMessage; + } + + set loadingMessage(v: string) { + this._loadingMessage = v; + } + + get loadingCompletedMessage(): string { + return this._loadingCompletedMessage ?? DefaultLoadingCompletedMessage; + } + + set loadingCompletedMessage(v: string) { + this._loadingCompletedMessage = v; + } + + get loading(): boolean { + return this._loading; + } + + set loading(v: boolean) { + if (v !== this._loading) { + this._loading = v; + const message = this._loading ? this.loadingMessage : this.loadingCompletedMessage; + status(message); + if (this._loading) { + this._container.appendChild(this._loadingSpinner); + } else { + this._container.removeChild(this._loadingSpinner); + } + if (this._options.showText) { + this._loadingSpinnerText.innerText = message; + } + } + } +} diff --git a/src/sql/base/browser/ui/loadingSpinner/media/loadingSpinner.css b/src/sql/base/browser/ui/loadingSpinner/media/loadingSpinner.css new file mode 100644 index 0000000000..c7599625fc --- /dev/null +++ b/src/sql/base/browser/ui/loadingSpinner/media/loadingSpinner.css @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.loading-spinner-component-container { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; +} + +.loading-spinner-component-container .loading-spinner { + height: 20px; + padding: 5px; +} + +.loading-spinner-component-container.full-size { + width: 100%; + height: 100%; +} diff --git a/src/sql/workbench/contrib/tableDesigner/browser/actions.ts b/src/sql/workbench/contrib/tableDesigner/browser/actions.ts index bda7934e11..b8bf70a6a9 100644 --- a/src/sql/workbench/contrib/tableDesigner/browser/actions.ts +++ b/src/sql/workbench/contrib/tableDesigner/browser/actions.ts @@ -34,7 +34,7 @@ export class SaveTableChangesAction extends Action { } private updateState(): void { - this.enabled = this._input.dirty && this._input.valid && !this._input.saving; + this.enabled = this._input.dirty && this._input.valid && !this._input.processing; } override dispose() { diff --git a/src/sql/workbench/services/tableDesigner/browser/tableDesignerComponentInput.ts b/src/sql/workbench/services/tableDesigner/browser/tableDesignerComponentInput.ts index 9ba02ac10b..4bc6f2fa04 100644 --- a/src/sql/workbench/services/tableDesigner/browser/tableDesignerComponentInput.ts +++ b/src/sql/workbench/services/tableDesigner/browser/tableDesignerComponentInput.ts @@ -18,6 +18,7 @@ export class TableDesignerComponentInput implements DesignerComponentInput { private _valid: boolean = true; private _dirty: boolean = false; private _saving: boolean = false; + private _processing: boolean = false; private _onStateChange = new Emitter(); public readonly onStateChange: Event = this._onStateChange.event; @@ -39,6 +40,10 @@ export class TableDesignerComponentInput implements DesignerComponentInput { return this._saving; } + get processing(): boolean { + return this._processing; + } + get objectTypeDisplayName(): string { return localize('tableDesigner.tableObjectType', "Table"); } @@ -58,11 +63,12 @@ export class TableDesignerComponentInput implements DesignerComponentInput { } async processEdit(edit: DesignerEdit): Promise { + this.updateState(this.valid, this.dirty, this.saving, true); const result = await this._provider.processTableEdit(this._tableInfo, this._viewModel!, edit); if (result.isValid) { this._viewModel = result.viewModel; } - this.updateState(result.isValid, true, this.saving); + this.updateState(result.isValid, true, this.saving, false); return { isValid: result.isValid, errors: result.errors @@ -75,37 +81,40 @@ export class TableDesignerComponentInput implements DesignerComponentInput { message: localize('tableDesigner.savingChanges', "Saving table designer changes...") }); try { - this.updateState(this.valid, this.dirty, true); + this.updateState(this.valid, this.dirty, true, true); await this._provider.saveTable(this._tableInfo, this._viewModel); - this.updateState(true, false, false); + this.updateState(true, false, false, false); notificationHandle.updateMessage(localize('tableDesigner.savedChangeSuccess', "The changes have been successfully saved.")); } catch (error) { notificationHandle.updateSeverity(Severity.Error); notificationHandle.updateMessage(localize('tableDesigner.saveChangeError', "An error occured while saving changes: {0}", error?.message ?? error)); - this.updateState(this.valid, this.dirty, false); + this.updateState(this.valid, this.dirty, false, false); } } async revert(): Promise { - this.updateState(true, false, false); + this.updateState(true, false, false, false); } - private updateState(valid: boolean, dirty: boolean, saving: boolean): void { - if (this._dirty !== dirty || this._valid !== valid || this._saving !== saving) { + private updateState(valid: boolean, dirty: boolean, saving: boolean, processing: boolean): void { + if (this._dirty !== dirty || this._valid !== valid || this._saving !== saving || this._processing !== processing) { this._dirty = dirty; this._valid = valid; this._saving = saving; + this._processing = processing; this._onStateChange.fire({ valid: this._valid, dirty: this._dirty, - saving: this._saving + saving: this._saving, + processing: this._processing }); } } private async initialize(): Promise { + this.updateState(this.valid, this.dirty, this.saving, true); const designerInfo = await this._provider.getTableDesignerInfo(this._tableInfo); - + this.updateState(this.valid, this.dirty, this.saving, false); this._viewModel = designerInfo.viewModel; this.setDefaultData();