diff --git a/src/sql/parts/modelComponents/componentBase.ts b/src/sql/parts/modelComponents/componentBase.ts index 3ffb9d4827..38f660628e 100644 --- a/src/sql/parts/modelComponents/componentBase.ts +++ b/src/sql/parts/modelComponents/componentBase.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import 'vs/css!./flexContainer'; -import { Component, Input, Inject, ChangeDetectorRef, forwardRef, ComponentFactoryResolver, +import { + Component, Input, Inject, ChangeDetectorRef, forwardRef, ComponentFactoryResolver, ViewChild, ElementRef, Injector, OnDestroy, OnInit } from '@angular/core'; @@ -18,14 +19,16 @@ import Event, { Emitter } from 'vs/base/common/event'; import { IDisposable, Disposable } from 'vs/base/common/lifecycle'; export class ItemDescriptor { - constructor(public descriptor: IComponentDescriptor, public config: T) {} + constructor(public descriptor: IComponentDescriptor, public config: T) { } } export abstract class ComponentBase extends Disposable implements IComponent, OnDestroy, OnInit { protected properties: { [key: string]: any; } = {}; - constructor ( + protected _valid: boolean = true; + private _eventQueue: IComponentEventArgs[] = []; + constructor( protected _changeRef: ChangeDetectorRef) { - super(); + super(); } /// IComponent implementation @@ -56,7 +59,7 @@ export abstract class ComponentBase extends Disposable implements IComponent, On this.dispose(); } - abstract setLayout (layout: any): void; + abstract setLayout(layout: any): void; public setProperties(properties: { [key: string]: any; }): void { if (!properties) { @@ -77,21 +80,49 @@ export abstract class ComponentBase extends Disposable implements IComponent, On protected setPropertyFromUI(propertySetter: (TPropertyBag, TValue) => void, value: TValue) { propertySetter(this.getProperties(), value); - this._onEventEmitter.fire({ + this.fireEvent({ eventType: ComponentEventType.PropertiesChanged, args: this.getProperties() }); } - public get onEvent(): Event { - return this._onEventEmitter.event; - } - public get title(): string { let properties = this.getProperties(); let title = properties['title']; return title ? title : ''; } + + public get valid(): boolean { + return this._valid; + } + + public setValid(valid: boolean): void { + if (this._valid !== valid) { + this._valid = valid; + this.fireEvent({ + eventType: ComponentEventType.validityChanged, + args: valid + }); + } + } + + public registerEventHandler(handler: (event: IComponentEventArgs) => void): IDisposable { + if (this._eventQueue) { + while (this._eventQueue.length > 0) { + let event = this._eventQueue.pop(); + handler(event); + } + this._eventQueue = undefined; + } + return this._onEventEmitter.event(handler); + } + + private fireEvent(event: IComponentEventArgs) { + this._onEventEmitter.fire(event); + if (this._eventQueue) { + this._eventQueue.push(event); + } + } } export abstract class ContainerBase extends ComponentBase { @@ -115,5 +146,5 @@ export abstract class ContainerBase extends ComponentBase { this._changeRef.detectChanges(); } - abstract setLayout (layout: any): void; + abstract setLayout(layout: any): void; } diff --git a/src/sql/parts/modelComponents/inputbox.component.ts b/src/sql/parts/modelComponents/inputbox.component.ts index 680e5a5689..11da4e7e64 100644 --- a/src/sql/parts/modelComponents/inputbox.component.ts +++ b/src/sql/parts/modelComponents/inputbox.component.ts @@ -10,10 +10,11 @@ import { import * as sqlops from 'sqlops'; import Event, { Emitter } from 'vs/base/common/event'; +import * as nls from 'vs/nls'; import { ComponentBase } from 'sql/parts/modelComponents/componentBase'; import { IComponent, IComponentDescriptor, IModelStore, ComponentEventType } from 'sql/parts/modelComponents/interfaces'; -import { InputBox, IInputOptions } from 'vs/base/browser/ui/inputbox/inputBox'; +import { InputBox, IInputOptions, MessageType } from 'vs/base/browser/ui/inputbox/inputBox'; import { CommonServiceInterface } from 'sql/services/common/commonServiceInterface.service'; import { attachInputBoxStyler, attachListStyler } from 'vs/platform/theme/common/styler'; @@ -44,7 +45,19 @@ export default class InputBoxComponent extends ComponentBase implements ICompone if (this._inputContainer) { let inputOptions: IInputOptions = { placeholder: '', - ariaLabel: '' + ariaLabel: '', + validationOptions: { + validation: () => { + if (this.valid) { + return undefined; + } else { + return { + content: nls.localize('invalidValueError', 'Invalid value'), + type: MessageType.ERROR + }; + } + } + } }; this._input = new InputBox(this._inputContainer.nativeElement, this._commonService.contextViewService, inputOptions); @@ -81,6 +94,11 @@ export default class InputBoxComponent extends ComponentBase implements ICompone this._input.value = this.value; } + public setValid(valid: boolean): void { + super.setValid(valid); + this._input.validate(); + } + // CSS-bound properties public get value(): string { diff --git a/src/sql/parts/modelComponents/interfaces.ts b/src/sql/parts/modelComponents/interfaces.ts index d457839444..67acfef764 100644 --- a/src/sql/parts/modelComponents/interfaces.ts +++ b/src/sql/parts/modelComponents/interfaces.ts @@ -6,6 +6,7 @@ import { InjectionToken } from '@angular/core'; import * as sqlops from 'sqlops'; import Event, { Emitter } from 'vs/base/common/event'; +import { IDisposable } from 'vs/base/common/lifecycle'; /** * An instance of a model-backed component. This will be a UI element @@ -17,12 +18,14 @@ export interface IComponent { descriptor: IComponentDescriptor; modelStore: IModelStore; layout(); + registerEventHandler(handler: (event: IComponentEventArgs) => void): IDisposable; clearContainer?: () => void; addToContainer?: (componentDescriptor: IComponentDescriptor, config: any) => void; setLayout?: (layout: any) => void; setProperties?: (properties: { [key: string]: any; }) => void; + readonly valid?: boolean; + setValid(valid: boolean): void; title?: string; - onEvent?: Event; } export const COMPONENT_CONFIG = new InjectionToken('component_config'); @@ -60,7 +63,8 @@ export interface IComponentEventArgs { export enum ComponentEventType { PropertiesChanged, onDidChange, - onDidClick + onDidClick, + validityChanged } export interface IModelStore { diff --git a/src/sql/parts/modelComponents/modelStore.ts b/src/sql/parts/modelComponents/modelStore.ts index fb12837c13..da76476e84 100644 --- a/src/sql/parts/modelComponents/modelStore.ts +++ b/src/sql/parts/modelComponents/modelStore.ts @@ -12,7 +12,7 @@ import { IModelStore, IComponentDescriptor, IComponent } from './interfaces'; import { Extensions, IComponentRegistry } from 'sql/platform/dashboard/common/modelComponentRegistry'; import { Deferred } from 'sql/base/common/promise'; -const componentRegistry = Registry.as(Extensions.ComponentContribution); +const componentRegistry = Registry.as(Extensions.ComponentContribution); class ComponentDescriptor implements IComponentDescriptor { diff --git a/src/sql/parts/modelComponents/viewBase.ts b/src/sql/parts/modelComponents/viewBase.ts index ce3a37f757..f533dbacab 100644 --- a/src/sql/parts/modelComponents/viewBase.ts +++ b/src/sql/parts/modelComponents/viewBase.ts @@ -91,6 +91,10 @@ export abstract class ViewBase extends AngularDisposable implements IModelView { this.queueAction(componentId, (component) => component.setProperties(properties)); } + setValid(componentId: string, valid: boolean): void { + this.queueAction(componentId, (component) => component.setValid(valid)); + } + private queueAction(componentId: string, action: (component: IComponent) => T): void { this.modelStore.eventuallyRunOnComponent(componentId, action).catch(err => { // TODO add error handling @@ -99,12 +103,10 @@ export abstract class ViewBase extends AngularDisposable implements IModelView { registerEvent(componentId: string) { this.queueAction(componentId, (component) => { - if (component.onEvent) { - this._register(component.onEvent(e => { - e.componentId = componentId; - this._onEventEmitter.fire(e); - })); - } + this._register(component.registerEventHandler(e => { + e.componentId = componentId; + this._onEventEmitter.fire(e); + })); }); } diff --git a/src/sql/platform/dialog/dialogContainer.component.ts b/src/sql/platform/dialog/dialogContainer.component.ts index f67f9aac0b..bfa9d54dc3 100644 --- a/src/sql/platform/dialog/dialogContainer.component.ts +++ b/src/sql/platform/dialog/dialogContainer.component.ts @@ -11,9 +11,11 @@ import { ModelViewContent } from 'sql/parts/modelComponents/modelViewContent.com import { BootstrapParams } from 'sql/services/bootstrap/bootstrapParams'; import { BOOTSTRAP_SERVICE_ID, IBootstrapService } from 'sql/services/bootstrap/bootstrapService'; import Event, { Emitter } from 'vs/base/common/event'; +import { ComponentEventType } from '../../parts/modelComponents/interfaces'; export interface DialogComponentParams extends BootstrapParams { modelViewId: string; + validityChangedCallback: (valid: boolean) => void; } @Component({ @@ -27,16 +29,23 @@ export interface DialogComponentParams extends BootstrapParams { export class DialogContainer implements AfterContentInit { private _onResize = new Emitter(); public readonly onResize: Event = this._onResize.event; + private _params: DialogComponentParams; public modelViewId: string; @ViewChild(ModelViewContent) private _modelViewContent: ModelViewContent; constructor( @Inject(forwardRef(() => ElementRef)) el: ElementRef, @Inject(BOOTSTRAP_SERVICE_ID) bootstrapService: IBootstrapService) { - this.modelViewId = (bootstrapService.getBootstrapParams(el.nativeElement.tagName) as DialogComponentParams).modelViewId; + this._params = bootstrapService.getBootstrapParams(el.nativeElement.tagName) as DialogComponentParams; + this.modelViewId = this._params.modelViewId; } ngAfterContentInit(): void { + this._modelViewContent.onEvent(event => { + if (event.eventType === ComponentEventType.validityChanged) { + this._params.validityChangedCallback(event.args); + } + }); } public layout(): void { diff --git a/src/sql/platform/dialog/dialogModal.ts b/src/sql/platform/dialog/dialogModal.ts index 379ae71f73..43b7129be6 100644 --- a/src/sql/platform/dialog/dialogModal.ts +++ b/src/sql/platform/dialog/dialogModal.ts @@ -21,6 +21,8 @@ import { attachButtonStyler } from 'vs/platform/theme/common/styler'; import { Button } from 'vs/base/browser/ui/button/button'; import { SIDE_BAR_BACKGROUND } from 'vs/workbench/common/theme'; import { localize } from 'vs/nls'; +import Event, { Emitter } from 'vs/base/common/event'; +import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; export class DialogModal extends Modal { private _dialogPane: DialogPane; @@ -102,8 +104,10 @@ export class DialogModal extends Modal { } public done(): void { - this.dispose(); - this.hide(); + if (this._dialog.okButton.enabled) { + this.dispose(); + this.hide(); + } } public cancel(): void { @@ -119,6 +123,20 @@ export class DialogModal extends Modal { super.show(); } + /** + * Overridable to change behavior of escape key + */ + protected onClose(e: StandardKeyboardEvent) { + this.cancel(); + } + + /** + * Overridable to change behavior of enter key + */ + protected onAccept(e: StandardKeyboardEvent) { + this.done(); + } + public dispose(): void { super.dispose(); this._dialogPane.dispose(); diff --git a/src/sql/platform/dialog/dialogPane.ts b/src/sql/platform/dialog/dialogPane.ts index 33689a22e7..c6111ddaff 100644 --- a/src/sql/platform/dialog/dialogPane.ts +++ b/src/sql/platform/dialog/dialogPane.ts @@ -16,12 +16,16 @@ import { DialogComponentParams } from 'sql/platform/dialog/dialogContainer.compo import { Builder } from 'vs/base/browser/builder'; import { IThemable } from 'vs/platform/theme/common/styler'; import { Disposable } from 'vs/base/common/lifecycle'; +import Event, { Emitter } from 'vs/base/common/event'; export class DialogPane extends Disposable implements IThemable { private _activeTabIndex: number; private _tabbedPanel: TabbedPanel; private _moduleRef: NgModuleRef<{}>; + // Validation + private _modelViewValidityMap = new Map(); + // HTML Elements private _body: HTMLElement; private _tabBar: HTMLElement; @@ -46,12 +50,20 @@ export class DialogPane extends Disposable implements IThemable { } else { this._tabbedPanel = new TabbedPanel(this._body); this._dialog.content.forEach((tab, tabIndex) => { + let tabContainer = document.createElement('div'); + tabContainer.style.display = 'none'; + this._body.appendChild(tabContainer); + this.initializeModelViewContainer(tabContainer, tab.content); this._tabbedPanel.pushTab({ title: tab.title, identifier: 'dialogPane.' + this._dialog.title + '.' + tabIndex, view: { render: (container) => { - this.initializeModelViewContainer(container, tab.content); + if (tabContainer.parentElement === this._body) { + this._body.removeChild(tabContainer); + } + container.appendChild(tabContainer); + tabContainer.style.display = 'block'; }, layout: (dimension) => { } } as IPanelView @@ -72,7 +84,10 @@ export class DialogPane extends Disposable implements IThemable { DialogModule, bodyContainer, 'dialog-modelview-container', - { modelViewId: modelViewId } as DialogComponentParams, + { + modelViewId: modelViewId, + validityChangedCallback: (valid: boolean) => this._setValidity(modelViewId, valid) + } as DialogComponentParams, undefined, (moduleRef) => this._moduleRef = moduleRef); } @@ -93,6 +108,21 @@ export class DialogPane extends Disposable implements IThemable { this._body.style.color = styles.dialogForeground ? styles.dialogForeground.toString() : undefined; } + private _setValidity(modelViewId: string, valid: boolean) { + let oldValidity = this.isValid(); + this._modelViewValidityMap.set(modelViewId, valid); + let newValidity = this.isValid(); + if (newValidity !== oldValidity) { + this._dialog.notifyValidityChanged(newValidity); + } + } + + private isValid(): boolean { + let valid = true; + this._modelViewValidityMap.forEach(value => valid = valid && value); + return valid; + } + public dispose() { super.dispose(); this._moduleRef.destroy(); diff --git a/src/sql/platform/dialog/dialogTypes.ts b/src/sql/platform/dialog/dialogTypes.ts index f86d08fff3..382710914a 100644 --- a/src/sql/platform/dialog/dialogTypes.ts +++ b/src/sql/platform/dialog/dialogTypes.ts @@ -28,11 +28,24 @@ export class Dialog implements sqlops.window.modelviewdialog.Dialog { public cancelButton: DialogButton = new DialogButton(Dialog.CANCEL_BUTTON_LABEL, true); public customButtons: DialogButton[]; + private _valid: boolean = true; + private _validityChangedEmitter = new Emitter(); + public readonly onValidityChanged = this._validityChangedEmitter.event; + constructor(public title: string, content?: string | DialogTab[]) { if (content) { this.content = content; } } + + public get valid(): boolean { + return this._valid; + } + + public notifyValidityChanged(valid: boolean) { + this._valid = valid; + this._validityChangedEmitter.fire(valid); + } } export class DialogButton implements sqlops.window.modelviewdialog.Button { diff --git a/src/sql/services/model/modelViewService.ts b/src/sql/services/model/modelViewService.ts index 1361e5350c..a760b34a15 100644 --- a/src/sql/services/model/modelViewService.ts +++ b/src/sql/services/model/modelViewService.ts @@ -20,6 +20,7 @@ export interface IModelView extends IView { addToContainer(containerId: string, item: IItemConfig): void; setLayout(componentId: string, layout: any): void; setProperties(componentId: string, properties: { [key: string]: any }): void; + setValid(componentId: string, valid: boolean): void; registerEvent(componentId: string); onEvent: Event; } diff --git a/src/sql/sqlops.proposed.d.ts b/src/sql/sqlops.proposed.d.ts index 5e2cfdb2bc..3d7995f046 100644 --- a/src/sql/sqlops.proposed.d.ts +++ b/src/sql/sqlops.proposed.d.ts @@ -31,6 +31,7 @@ declare module 'sqlops' { export interface ComponentBuilder { component(): T; withProperties(properties: U): ComponentBuilder; + withValidation(validation: (component: T) => boolean): ComponentBuilder; } export interface ContainerBuilder extends ComponentBuilder { withLayout(layout: TLayout): ContainerBuilder; @@ -56,6 +57,21 @@ declare module 'sqlops' { * @memberof Component */ updateProperties(properties: { [key: string]: any }): Thenable; + + /** + * Event fired to notify that the component's validity has changed + */ + readonly onValidityChanged: vscode.Event; + + /** + * Whether the component is valid or not + */ + readonly valid: boolean; + + /** + * Run the component's validations + */ + validate(): void; } export interface FormComponent { @@ -264,6 +280,21 @@ declare module 'sqlops' { */ readonly modelBuilder: ModelBuilder; + /** + * Whether or not the model view's root component is valid + */ + readonly valid: boolean; + + /** + * Raised when the model view's valid property changes + */ + readonly onValidityChanged: vscode.Event; + + /** + * Run the model view root component's validations + */ + validate(): void; + /** * Initializes the model with a root component definition. * Once this has been done, the components will be laid out in the UI and @@ -336,6 +367,16 @@ declare module 'sqlops' { * Any additional buttons that should be displayed */ customButtons: Button[]; + + /** + * Whether the dialog's content is valid + */ + readonly valid: boolean; + + /** + * Fired whenever the dialog's valid property changes + */ + readonly onValidityChanged: vscode.Event; } export interface DialogTab { diff --git a/src/sql/workbench/api/node/extHostModelView.ts b/src/sql/workbench/api/node/extHostModelView.ts index d783234704..7688b1715e 100644 --- a/src/sql/workbench/api/node/extHostModelView.ts +++ b/src/sql/workbench/api/node/extHostModelView.ts @@ -25,17 +25,23 @@ class ModelBuilderImpl implements sqlops.ModelBuilder { navContainer(): sqlops.ContainerBuilder { let id = this.getNextComponentId(); - return new ContainerBuilderImpl(this._proxy, this._handle, ModelComponentTypes.NavContainer, id); + let container: ContainerBuilderImpl = new ContainerBuilderImpl(this._proxy, this._handle, ModelComponentTypes.NavContainer, id); + this._eventHandlers.set(id, container); + return container; } flexContainer(): sqlops.FlexBuilder { let id = this.getNextComponentId(); - return new ContainerBuilderImpl(this._proxy, this._handle, ModelComponentTypes.FlexContainer, id); + let container: ContainerBuilderImpl = new ContainerBuilderImpl(this._proxy, this._handle, ModelComponentTypes.FlexContainer, id); + this._eventHandlers.set(id, container); + return container; } formContainer(): sqlops.FormBuilder { let id = this.getNextComponentId(); - return new FormContainerBuilder(this._proxy, this._handle, ModelComponentTypes.Form, id); + let container = new FormContainerBuilder(this._proxy, this._handle, ModelComponentTypes.Form, id); + this._eventHandlers.set(id, container); + return container; } card(): sqlops.ComponentBuilder { @@ -110,6 +116,11 @@ class ComponentBuilderImpl implements sqlops.Compone return this; } + withValidation(validation: (component: T) => boolean): sqlops.ComponentBuilder { + this._component.validations.push(validation); + return this; + } + handleEvent(eventArgs: IComponentEventArgs) { this._component.onEvent(eventArgs); } @@ -138,6 +149,7 @@ class ContainerBuilderImpl ext let componentWrapper = item as ComponentWrapper; return new InternalItemConfig(componentWrapper, itemLayout); }); + components.forEach(component => component.onValidityChanged(() => this._component.validate())); return this; } } @@ -169,6 +181,7 @@ class FormContainerBuilder extends ContainerBuilderImpl this._component.validate()); }); return this; } @@ -194,6 +207,10 @@ class ComponentWrapper implements sqlops.Component { public properties: { [key: string]: any } = {}; public layout: any; public itemConfigs: InternalItemConfig[]; + public validations: ((component: ThisType) => boolean)[] = []; + private _valid: boolean = true; + private _onValidityChangedEmitter = new Emitter(); + public readonly onValidityChanged = this._onValidityChangedEmitter.event; private _onErrorEmitter = new Emitter(); public readonly onError: vscode.Event = this._onErrorEmitter.event; @@ -206,6 +223,12 @@ class ComponentWrapper implements sqlops.Component { ) { this.properties = {}; this.itemConfigs = []; + this.validations.push((component: this) => { + return component.items.every(item => { + item.validate(); + return item.valid; + }); + }); } public get id(): string { @@ -248,6 +271,7 @@ class ComponentWrapper implements sqlops.Component { } let config = new InternalItemConfig(itemImpl, itemLayout); this.itemConfigs.push(config); + itemImpl.onValidityChanged(() => this.validate()); this._proxy.$addToContainer(this._handle, this.id, config.toIItemConfig()).then(undefined, this.handleError); } @@ -270,18 +294,20 @@ class ComponentWrapper implements sqlops.Component { public onEvent(eventArgs: IComponentEventArgs) { if (eventArgs && eventArgs.eventType === ComponentEventType.PropertiesChanged) { this.properties = eventArgs.args; + this.validate(); } else if (eventArgs) { - let emitter = this._emitterMap.get(eventArgs.eventType); - if (emitter) { - emitter.fire(); - } + let emitter = this._emitterMap.get(eventArgs.eventType); + if (emitter) { + emitter.fire(); } + } } protected setProperty(key: string, value: any): Thenable { if (!this.properties[key] || this.properties[key] !== value) { // Only notify the front end if a value has been updated this.properties[key] = value; + this.validate(); return this.notifyPropertyChanged(); } return Promise.resolve(true); @@ -290,6 +316,29 @@ class ComponentWrapper implements sqlops.Component { private handleError(err: Error): void { this._onErrorEmitter.fire(err); } + + public validate(): void { + let isValid = true; + try { + this.validations.forEach(validation => { + if (!validation(this)) { + isValid = false; + } + }); + } catch (e) { + isValid = false; + } + let oldValid = this._valid; + if (this._valid !== isValid) { + this._valid = isValid; + this._proxy.$notifyValidation(this._handle, this._id, isValid); + this._onValidityChangedEmitter.fire(this._valid); + } + } + + public get valid(): boolean { + return this._valid; + } } class ContainerWrapper extends ComponentWrapper implements sqlops.Container { @@ -428,8 +477,11 @@ class ButtonWrapper extends ComponentWrapper implements sqlops.ButtonComponent { class ModelViewImpl implements sqlops.ModelView { public onClosedEmitter = new Emitter(); + private _onValidityChangedEmitter = new Emitter(); + public readonly onValidityChanged = this._onValidityChangedEmitter.event; private _modelBuilder: ModelBuilderImpl; + private _component: sqlops.Component; constructor( private readonly _proxy: MainThreadModelViewShape, @@ -456,17 +508,28 @@ class ModelViewImpl implements sqlops.ModelView { return this._modelBuilder; } + public get valid(): boolean { + return this._component.valid; + } + public handleEvent(componentId: string, eventArgs: IComponentEventArgs): void { this._modelBuilder.handleEvent(componentId, eventArgs); } public initializeModel(component: T): Thenable { + component.onValidityChanged(valid => this._onValidityChangedEmitter.fire(valid)); + this._component = component; let componentImpl = component as ComponentWrapper; if (!componentImpl) { return Promise.reject(nls.localize('unknownConfig', 'Unkown component configuration, must use ModelBuilder to create a configuration object')); } + componentImpl.validate(); return this._proxy.$initializeModel(this._handle, componentImpl.toComponentShape()); } + + public validate(): void { + this._component.validate(); + } } export class ExtHostModelView implements ExtHostModelViewShape { diff --git a/src/sql/workbench/api/node/extHostModelViewDialog.ts b/src/sql/workbench/api/node/extHostModelViewDialog.ts index a019f4bc6c..f5dccd0ab7 100644 --- a/src/sql/workbench/api/node/extHostModelViewDialog.ts +++ b/src/sql/workbench/api/node/extHostModelViewDialog.ts @@ -21,10 +21,18 @@ class DialogImpl implements sqlops.window.modelviewdialog.Dialog { public okButton: sqlops.window.modelviewdialog.Button; public cancelButton: sqlops.window.modelviewdialog.Button; public customButtons: sqlops.window.modelviewdialog.Button[]; + public readonly onValidityChanged: vscode.Event; + private _valid: boolean = true; constructor(private _extHostModelViewDialog: ExtHostModelViewDialog) { this.okButton = this._extHostModelViewDialog.createButton(nls.localize('dialogOkLabel', 'Done')); this.cancelButton = this._extHostModelViewDialog.createButton(nls.localize('dialogCancelLabel', 'Cancel')); + this.onValidityChanged = this._extHostModelViewDialog.getValidityChangedEvent(this); + this.onValidityChanged(valid => this._valid = valid); + } + + public get valid(): boolean { + return this._valid; } } @@ -89,6 +97,7 @@ export class ExtHostModelViewDialog implements ExtHostModelViewDialogShape { private readonly _tabHandles = new Map(); private readonly _buttonHandles = new Map(); + private readonly _validityEmitters = new Map>(); private readonly _onClickCallbacks = new Map void>(); constructor( @@ -134,6 +143,13 @@ export class ExtHostModelViewDialog implements ExtHostModelViewDialogShape { this._onClickCallbacks.get(handle)(); } + public $onDialogValidityChanged(handle: number, valid: boolean): void { + let emitter = this._validityEmitters.get(handle); + if (emitter) { + emitter.fire(valid); + } + } + public open(dialog: sqlops.window.modelviewdialog.Dialog): void { let handle = this.getDialogHandle(dialog); this.updateDialogContent(dialog); @@ -208,4 +224,14 @@ export class ExtHostModelViewDialog implements ExtHostModelViewDialogShape { button.label = label; return button; } + + public getValidityChangedEvent(dialog: sqlops.window.modelviewdialog.Dialog) { + let handle = this.getDialogHandle(dialog); + let emitter = this._validityEmitters.get(handle); + if (!emitter) { + emitter = new Emitter(); + this._validityEmitters.set(handle, emitter); + } + return emitter.event; + } } \ No newline at end of file diff --git a/src/sql/workbench/api/node/mainThreadModelView.ts b/src/sql/workbench/api/node/mainThreadModelView.ts index 0cb1726819..c3bad8d78e 100644 --- a/src/sql/workbench/api/node/mainThreadModelView.ts +++ b/src/sql/workbench/api/node/mainThreadModelView.ts @@ -82,6 +82,10 @@ export class MainThreadModelView extends Disposable implements MainThreadModelVi return this.execModelViewAction(handle, (modelView) => modelView.setProperties(componentId, properties)); } + $notifyValidation(handle: number, componentId: string, valid: boolean): Thenable { + return this.execModelViewAction(handle, (modelView) => modelView.setValid(componentId, valid)); + } + private execModelViewAction(handle: number, action: (m: IModelView) => T): Thenable { let modelView: IModelView = this._dialogs.get(handle); let result = action(modelView); diff --git a/src/sql/workbench/api/node/mainThreadModelViewDialog.ts b/src/sql/workbench/api/node/mainThreadModelViewDialog.ts index 6a198ebeb2..4e20acb13b 100644 --- a/src/sql/workbench/api/node/mainThreadModelViewDialog.ts +++ b/src/sql/workbench/api/node/mainThreadModelViewDialog.ts @@ -52,6 +52,7 @@ export class MainThreadModelViewDialog implements MainThreadModelViewDialogShape let cancelButton = this.getButton(details.cancelButton); dialog.okButton = okButton; dialog.cancelButton = cancelButton; + dialog.onValidityChanged(valid => this._proxy.$onDialogValidityChanged(handle, valid)); this._dialogs.set(handle, dialog); } diff --git a/src/sql/workbench/api/node/sqlExtHost.protocol.ts b/src/sql/workbench/api/node/sqlExtHost.protocol.ts index 53f9c0372f..79b3616b64 100644 --- a/src/sql/workbench/api/node/sqlExtHost.protocol.ts +++ b/src/sql/workbench/api/node/sqlExtHost.protocol.ts @@ -530,6 +530,7 @@ export interface MainThreadModelViewShape extends IDisposable { $setLayout(handle: number, componentId: string, layout: any): Thenable; $setProperties(handle: number, componentId: string, properties: { [key: string]: any }): Thenable; $registerEvent(handle: number, componentId: string): Thenable; + $notifyValidation(handle: number, componentId: string, valid: boolean): Thenable; } export interface ExtHostObjectExplorerShape { @@ -547,6 +548,7 @@ export interface MainThreadObjectExplorerShape extends IDisposable { export interface ExtHostModelViewDialogShape { $onButtonClick(handle: number): void; + $onDialogValidityChanged(handle: number, valid: boolean): void; } export interface MainThreadModelViewDialogShape extends IDisposable { diff --git a/src/sqltest/platform/dialog/dialogPane.test.ts b/src/sqltest/platform/dialog/dialogPane.test.ts index db6b0e1b37..e1c8ddfcef 100644 --- a/src/sqltest/platform/dialog/dialogPane.test.ts +++ b/src/sqltest/platform/dialog/dialogPane.test.ts @@ -3,8 +3,9 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Dialog, DialogTab } from 'sql/platform/dialog/dialogTypes'; +import * as assert from 'assert'; import { Mock, It, Times } from 'typemoq'; +import { Dialog, DialogTab } from 'sql/platform/dialog/dialogTypes'; import { IBootstrapService } from 'sql/services/bootstrap/bootstrapService'; import { DialogPane } from 'sql/platform/dialog/dialogPane'; import { DialogComponentParams } from 'sql/platform/dialog/dialogContainer.component'; @@ -76,4 +77,48 @@ suite('Dialog Pane Tests', () => { undefined, It.isAny()), Times.once()); }); + + test('Dialog validation gets set based on the validity of the model view content', () => { + // Set up the mock bootstrap service to intercept validation callbacks + let validationCallbacks: ((valid: boolean) => void)[] = []; + mockBootstrapService.reset(); + mockBootstrapService.setup(x => x.bootstrap(It.isAny(), It.isAny(), It.isAny(), It.isAny(), undefined, It.isAny())).callback( + (moduleType, container, selectorString, params: DialogComponentParams, input, callbackSetModule) => { + validationCallbacks.push(params.validityChangedCallback); + }); + + let modelViewId1 = 'test_content_1'; + let modelViewId2 = 'test_content_2'; + dialog.content = [new DialogTab('tab1', modelViewId1), new DialogTab('tab2', modelViewId2)]; + let dialogPane = new DialogPane(dialog, mockBootstrapService.object); + dialogPane.createBody(container); + + let validityChanges: boolean[] = []; + dialog.onValidityChanged(valid => validityChanges.push(valid)); + + // If I set tab 2's validation to false + validationCallbacks[1](false); + + // Then the whole dialog's validation is false + assert.equal(dialog.valid, false); + assert.equal(validityChanges.length, 1); + assert.equal(validityChanges[0], false); + + // If I then set it back to true + validationCallbacks[1](true); + + // Then the whole dialog's validation is true + assert.equal(dialog.valid, true); + assert.equal(validityChanges.length, 2); + assert.equal(validityChanges[1], true); + + // If I set tab 1's validation to false + validationCallbacks[0](false); + + // Then the whole dialog's validation is false + assert.equal(dialog.valid, false); + assert.equal(validityChanges.length, 3); + assert.equal(validityChanges[2], false); + + }); }); \ No newline at end of file diff --git a/src/sqltest/workbench/api/extHostModelView.test.ts b/src/sqltest/workbench/api/extHostModelView.test.ts new file mode 100644 index 0000000000..cce847a208 --- /dev/null +++ b/src/sqltest/workbench/api/extHostModelView.test.ts @@ -0,0 +1,116 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { Mock, It, Times, MockBehavior } from 'typemoq'; +import * as sqlops from 'sqlops'; +import { ExtHostModelView } from 'sql/workbench/api/node/extHostModelView'; +import { MainThreadModelViewShape } from 'sql/workbench/api/node/sqlExtHost.protocol'; +import { IMainContext } from 'vs/workbench/api/node/extHost.protocol'; +import { Deferred } from 'sql/base/common/promise'; +import { IComponentShape, IItemConfig, ComponentEventType, IComponentEventArgs } from 'sql/workbench/api/common/sqlExtHostTypes'; + +'use strict'; + +suite('ExtHostModelView Validation Tests', () => { + let extHostModelView: ExtHostModelView; + let mockProxy: Mock; + let modelView: sqlops.ModelView; + let inputBox: sqlops.InputBoxComponent; + let dropDownBox: sqlops.DropDownComponent; + let formContainer: sqlops.FormContainer; + let flexContainer: sqlops.FlexContainer; + let validText = 'valid'; + let widgetId = 'widget_id'; + let handle = 1; + // let viewInitialized: Deferred; + let initializedModels: IComponentShape[]; + + setup(done => { + // Set up the MainThreadModelViewShape proxy + mockProxy = Mock.ofInstance({ + $registerProvider: (id: string) => undefined, + $initializeModel: (handle: number, rootComponent: IComponentShape) => undefined, + $clearContainer: (handle: number, componentId: string) => undefined, + $addToContainer: (handle: number, containerId: string, item: IItemConfig) => undefined, + $setLayout: (handle: number, componentId: string, layout: any) => undefined, + $setProperties: (handle: number, componentId: string, properties: { [key: string]: any }) => undefined, + $registerEvent: (handle: number, componentId: string) => undefined, + $notifyValidation: (handle: number, componentId: string, valid: boolean) => undefined, + dispose: () => undefined + }, MockBehavior.Loose); + let mainContext = { + getProxy: proxyType => mockProxy.object + }; + mockProxy.setup(x => x.$initializeModel(It.isAny(), It.isAny())).returns(() => Promise.resolve()); + mockProxy.setup(x => x.$registerEvent(It.isAny(), It.isAny())).returns(() => Promise.resolve()); + mockProxy.setup(x => x.$setProperties(It.isAny(), It.isAny(), It.isAny())).returns(() => Promise.resolve()); + mockProxy.setup(x => x.$notifyValidation(It.isAny(), It.isAny(), It.isAny())).returns(() => Promise.resolve()); + + // Register a model view of an input box and drop down box inside a form container inside a flex container + extHostModelView = new ExtHostModelView(mainContext); + extHostModelView.$registerProvider(widgetId, async view => { + modelView = view; + inputBox = view.modelBuilder.inputBox() + .withValidation(component => component.value === validText) + .component(); + dropDownBox = view.modelBuilder.dropDown().component(); + formContainer = view.modelBuilder.formContainer() + .withItems([inputBox, dropDownBox]) + .component(); + flexContainer = view.modelBuilder.flexContainer() + .withItems([formContainer]) + .component(); + await view.initializeModel(flexContainer); + done(); + }); + + extHostModelView.$registerWidget(handle, widgetId, undefined, undefined); + }); + + test('The validity of a component and its containers gets set when it is initialized', done => { + try { + assert.equal(modelView.valid, false, 'modelView was not marked as invalid'); + assert.equal(inputBox.valid, false, 'inputBox was not marked as invalid'); + assert.equal(formContainer.valid, false, 'formContainer was not marked as invalid'); + assert.equal(flexContainer.valid, false, 'flexContainer was not marked as invalid'); + assert.equal(dropDownBox.valid, true, 'dropDownBox was marked as invalid'); + done(); + } catch (err) { + done(err); + } + }); + + test('Containers reflect validity changes of contained components', done => { + try { + inputBox.value = validText; + assert.equal(modelView.valid, true, 'modelView was not marked as valid'); + assert.equal(inputBox.valid, true, 'inputBox was not marked as valid'); + assert.equal(formContainer.valid, true, 'formContainer was not marked as valid'); + assert.equal(flexContainer.valid, true, 'flexContainer was not marked as valid'); + done(); + } catch (err) { + done(err); + } + }); + + test('PropertiesChanged events cause validation', done => { + try { + extHostModelView.$handleEvent(handle, inputBox.id, { + args: { + 'value': validText + }, + eventType: ComponentEventType.PropertiesChanged + } as IComponentEventArgs); + assert.equal(modelView.valid, true, 'modelView was not marked as valid'); + assert.equal(inputBox.valid, true, 'inputBox was not marked as valid'); + assert.equal(formContainer.valid, true, 'formContainer was not marked as valid'); + assert.equal(flexContainer.valid, true, 'flexContainer was not marked as valid'); + done(); + } catch (err) { + done(err); + } + }); +}); \ No newline at end of file