diff --git a/src/sql/parts/modelComponents/componentBase.ts b/src/sql/parts/modelComponents/componentBase.ts index 6897b9b757..a44f276605 100644 --- a/src/sql/parts/modelComponents/componentBase.ts +++ b/src/sql/parts/modelComponents/componentBase.ts @@ -24,7 +24,8 @@ export class ItemDescriptor { export abstract class ComponentBase extends Disposable implements IComponent, OnDestroy, OnInit { protected properties: { [key: string]: any; } = {}; - protected _valid: boolean = true; + private _valid: boolean = true; + protected _validations: (() => boolean | Thenable)[] = []; private _eventQueue: IComponentEventArgs[] = []; constructor( protected _changeRef: ChangeDetectorRef) { @@ -44,6 +45,7 @@ export abstract class ComponentBase extends Disposable implements IComponent, On protected baseInit(): void { if (this.modelStore) { this.modelStore.registerComponent(this); + this._validations.push(() => this.modelStore.validate(this)); } } @@ -67,6 +69,7 @@ export abstract class ComponentBase extends Disposable implements IComponent, On } this.properties = properties; this.layout(); + this.validate(); } protected getProperties(): TPropertyBag { @@ -84,6 +87,7 @@ export abstract class ComponentBase extends Disposable implements IComponent, On eventType: ComponentEventType.PropertiesChanged, args: this.getProperties() }); + this.validate(); } public get enabled(): boolean { @@ -96,16 +100,6 @@ export abstract class ComponentBase extends Disposable implements IComponent, On 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) { @@ -123,6 +117,21 @@ export abstract class ComponentBase extends Disposable implements IComponent, On this._eventQueue.push(event); } } + + public validate(): Thenable { + let validations = this._validations.map(validation => Promise.resolve(validation())); + return Promise.all(validations).then(values => { + let isValid = values.every(value => value === true); + if (this._valid !== isValid) { + this._valid = isValid; + this.fireEvent({ + eventType: ComponentEventType.validityChanged, + args: this._valid + }); + } + return isValid; + }); + } } export abstract class ContainerBase extends ComponentBase { @@ -133,11 +142,17 @@ export abstract class ContainerBase extends ComponentBase { ) { super(_changeRef); this.items = []; + this._validations.push(() => this.items.every(item => this.modelStore.getComponent(item.descriptor.id).valid)); } /// IComponent container-related implementation public addToContainer(componentDescriptor: IComponentDescriptor, config: any): void { this.items.push(new ItemDescriptor(componentDescriptor, config)); + this.modelStore.eventuallyRunOnComponent(componentDescriptor.id, component => component.registerEventHandler(event => { + if (event.eventType === ComponentEventType.validityChanged) { + this.validate(); + } + })); this._changeRef.detectChanges(); } diff --git a/src/sql/parts/modelComponents/inputbox.component.ts b/src/sql/parts/modelComponents/inputbox.component.ts index e3cbe461e3..32bfd36171 100644 --- a/src/sql/parts/modelComponents/inputbox.component.ts +++ b/src/sql/parts/modelComponents/inputbox.component.ts @@ -52,15 +52,17 @@ export default class InputBoxComponent extends ComponentBase implements ICompone return undefined; } else { return { - content: nls.localize('invalidValueError', 'Invalid value'), + content: this._input.inputElement.validationMessage || nls.localize('invalidValueError', 'Invalid value'), type: MessageType.ERROR }; } } - } + }, + useDefaultValidation: true }; this._input = new InputBox(this._inputContainer.nativeElement, this._commonService.contextViewService, inputOptions); + this._validations.push(() => !this._input.inputElement.validationMessage); this._register(this._input); this._register(attachInputBoxStyler(this._input, this._commonService.themeService)); @@ -74,6 +76,13 @@ export default class InputBoxComponent extends ComponentBase implements ICompone } } + public validate(): Thenable { + return super.validate().then(valid => { + this._input.validate(); + return valid; + }); + } + ngOnDestroy(): void { this.baseDestroy(); } @@ -91,6 +100,10 @@ export default class InputBoxComponent extends ComponentBase implements ICompone public setProperties(properties: { [key: string]: any; }): void { super.setProperties(properties); + this._input.inputElement.type = this.inputType; + if (this.inputType === 'number') { + this._input.inputElement.step = 'any'; + } this._input.value = this.value; this._input.setAriaLabel(this.ariaLabel); this._input.setPlaceHolder(this.placeHolder); @@ -98,11 +111,8 @@ export default class InputBoxComponent extends ComponentBase implements ICompone if (this.width) { this._input.width = this.width; } - } - - public setValid(valid: boolean): void { - super.setValid(valid); - this._input.validate(); + this._input.inputElement.required = this.required; + this.validate(); } // CSS-bound properties @@ -146,4 +156,20 @@ export default class InputBoxComponent extends ComponentBase implements ICompone public set width(newValue: number) { this.setPropertyFromUI((props, value) => props.width = value, newValue); } + + public get inputType(): string { + return this.getPropertyOrDefault((props) => props.inputType, 'text'); + } + + public set inputType(newValue: string) { + this.setPropertyFromUI((props, value) => props.inputType = value, newValue); + } + + public get required(): boolean { + return this.getPropertyOrDefault((props) => props.required, false); + } + + public set required(newValue: boolean) { + this.setPropertyFromUI((props, value) => props.required = value, newValue); + } } diff --git a/src/sql/parts/modelComponents/interfaces.ts b/src/sql/parts/modelComponents/interfaces.ts index 2cf9194fcc..a735e49933 100644 --- a/src/sql/parts/modelComponents/interfaces.ts +++ b/src/sql/parts/modelComponents/interfaces.ts @@ -24,7 +24,7 @@ export interface IComponent { setLayout?: (layout: any) => void; setProperties?: (properties: { [key: string]: any; }) => void; readonly valid?: boolean; - setValid(valid: boolean): void; + validate(): Thenable; } export const COMPONENT_CONFIG = new InjectionToken('component_config'); @@ -88,4 +88,12 @@ export interface IModelStore { * @memberof IModelStore */ eventuallyRunOnComponent(componentId: string, action: (component: IComponent) => T): Promise; + /** + * Register a callback that will validate components when given a component ID + */ + registerValidationCallback(callback: (componentId: string) => Thenable): void; + /** + * Run all validations for the given component and return the new validation value + */ + validate(component: IComponent): Thenable; } diff --git a/src/sql/parts/modelComponents/modelStore.ts b/src/sql/parts/modelComponents/modelStore.ts index da76476e84..65d0793946 100644 --- a/src/sql/parts/modelComponents/modelStore.ts +++ b/src/sql/parts/modelComponents/modelStore.ts @@ -27,6 +27,7 @@ export class ModelStore implements IModelStore { private _descriptorMappings: { [x: string]: IComponentDescriptor } = {}; private _componentMappings: { [x: string]: IComponent } = {}; private _componentActions: { [x: string]: Deferred } = {}; + private _validationCallbacks: ((componentId: string) => Thenable)[] = []; constructor() { } @@ -66,6 +67,15 @@ export class ModelStore implements IModelStore { } } + registerValidationCallback(callback: (componentId: string) => Thenable): void { + this._validationCallbacks.push(callback); + } + + validate(component: IComponent): Thenable { + let componentId = Object.entries(this._componentMappings).find(([id, mappedComponent]) => component === mappedComponent)[0]; + return Promise.all(this._validationCallbacks.map(callback => callback(componentId))).then(validations => validations.every(validation => validation === true)); + } + private addPendingAction(componentId: string, action: (component: IComponent) => T): Promise { // We create a promise and chain it onto a tracking promise whose resolve method // will only be called once the component is created diff --git a/src/sql/parts/modelComponents/viewBase.ts b/src/sql/parts/modelComponents/viewBase.ts index f533dbacab..ca821bd8f0 100644 --- a/src/sql/parts/modelComponents/viewBase.ts +++ b/src/sql/parts/modelComponents/viewBase.ts @@ -39,9 +39,10 @@ export abstract class ViewBase extends AngularDisposable implements IModelView { private _onEventEmitter = new Emitter(); - initializeModel(rootComponent: IComponentShape): void { + initializeModel(rootComponent: IComponentShape, validationCallback: (componentId: string) => Thenable): void { let descriptor = this.defineComponent(rootComponent); this.rootDescriptor = descriptor; + this.modelStore.registerValidationCallback(validationCallback); // Kick off the build by detecting changes to the model this.changeRef.detectChanges(); } @@ -91,10 +92,6 @@ 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 @@ -113,4 +110,8 @@ export abstract class ViewBase extends AngularDisposable implements IModelView { public get onEvent(): Event { return this._onEventEmitter.event; } + + public validate(componentId: string): Thenable { + return new Promise(resolve => this.modelStore.eventuallyRunOnComponent(componentId, component => resolve(component.validate()))); + } } \ No newline at end of file diff --git a/src/sql/services/model/modelViewService.ts b/src/sql/services/model/modelViewService.ts index a760b34a15..ee0c3a7001 100644 --- a/src/sql/services/model/modelViewService.ts +++ b/src/sql/services/model/modelViewService.ts @@ -15,12 +15,12 @@ export interface IView { } export interface IModelView extends IView { - initializeModel(rootComponent: IComponentShape): void; + initializeModel(rootComponent: IComponentShape, validationCallback?: (componentId: string) => Thenable): void; clearContainer(componentId: string): void; 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; + validate(componentId: string): Thenable; } diff --git a/src/sql/sqlops.proposed.d.ts b/src/sql/sqlops.proposed.d.ts index b315cac59f..79e3a155ae 100644 --- a/src/sql/sqlops.proposed.d.ts +++ b/src/sql/sqlops.proposed.d.ts @@ -72,7 +72,7 @@ declare module 'sqlops' { /** * Run the component's validations */ - validate(): void; + validate(): Thenable; } export interface FormComponent { @@ -219,12 +219,16 @@ declare module 'sqlops' { status?: StatusIndicator; } + export type InputBoxInputType = 'color' | 'date' | 'datetime-local' | 'email' | 'month' | 'number' | 'password' | 'range' | 'search' | 'text' | 'time' | 'url' | 'week'; + export interface InputBoxProperties { value?: string; ariaLabel?: string; placeHolder?: string; height: number; width: number; + inputType?: InputBoxInputType; + required?: boolean; } export interface CheckBoxProperties { @@ -315,7 +319,7 @@ declare module 'sqlops' { /** * Run the model view root component's validations */ - validate(): void; + validate(): Thenable; /** * Initializes the model with a root component definition. diff --git a/src/sql/workbench/api/common/sqlExtHostTypes.ts b/src/sql/workbench/api/common/sqlExtHostTypes.ts index e97fb1589c..8cd3df018d 100644 --- a/src/sql/workbench/api/common/sqlExtHostTypes.ts +++ b/src/sql/workbench/api/common/sqlExtHostTypes.ts @@ -92,7 +92,8 @@ export interface IItemConfig { export enum ComponentEventType { PropertiesChanged, onDidChange, - onDidClick + onDidClick, + validityChanged } export interface IComponentEventArgs { diff --git a/src/sql/workbench/api/node/extHostModelView.ts b/src/sql/workbench/api/node/extHostModelView.ts index 8ffae9e542..7efc30a379 100644 --- a/src/sql/workbench/api/node/extHostModelView.ts +++ b/src/sql/workbench/api/node/extHostModelView.ts @@ -18,7 +18,7 @@ import { IActionDescriptor } from 'vs/editor/standalone/browser/standaloneCodeEd class ModelBuilderImpl implements sqlops.ModelBuilder { private nextComponentId: number; - private readonly _eventHandlers = new Map(); + private readonly _componentBuilders = new Map>(); constructor(private readonly _proxy: MainThreadModelViewShape, private readonly _handle: number) { this.nextComponentId = 0; @@ -27,72 +27,91 @@ class ModelBuilderImpl implements sqlops.ModelBuilder { navContainer(): sqlops.ContainerBuilder { let id = this.getNextComponentId(); let container: ContainerBuilderImpl = new ContainerBuilderImpl(this._proxy, this._handle, ModelComponentTypes.NavContainer, id); - this._eventHandlers.set(id, container); + this._componentBuilders.set(id, container); return container; } flexContainer(): sqlops.FlexBuilder { let id = this.getNextComponentId(); let container: ContainerBuilderImpl = new ContainerBuilderImpl(this._proxy, this._handle, ModelComponentTypes.FlexContainer, id); - this._eventHandlers.set(id, container); + this._componentBuilders.set(id, container); return container; } formContainer(): sqlops.FormBuilder { let id = this.getNextComponentId(); let container = new FormContainerBuilder(this._proxy, this._handle, ModelComponentTypes.Form, id); - this._eventHandlers.set(id, container); + this._componentBuilders.set(id, container); return container; } card(): sqlops.ComponentBuilder { let id = this.getNextComponentId(); - return this.withEventHandler(new CardWrapper(this._proxy, this._handle, id), id); + let builder: ComponentBuilderImpl = this.getComponentBuilder(new CardWrapper(this._proxy, this._handle, id), id); + this._componentBuilders.set(id, builder); + return builder; } inputBox(): sqlops.ComponentBuilder { let id = this.getNextComponentId(); - return this.withEventHandler(new InputBoxWrapper(this._proxy, this._handle, id), id); + let builder: ComponentBuilderImpl = this.getComponentBuilder(new InputBoxWrapper(this._proxy, this._handle, id), id); + this._componentBuilders.set(id, builder); + return builder; } checkBox(): sqlops.ComponentBuilder { let id = this.getNextComponentId(); - return this.withEventHandler(new CheckBoxWrapper(this._proxy, this._handle, id), id); + let builder: ComponentBuilderImpl = this.getComponentBuilder(new CheckBoxWrapper(this._proxy, this._handle, id), id); + this._componentBuilders.set(id, builder); + return builder; } button(): sqlops.ComponentBuilder { let id = this.getNextComponentId(); - return this.withEventHandler(new ButtonWrapper(this._proxy, this._handle, id), id); + let builder: ComponentBuilderImpl = this.getComponentBuilder(new ButtonWrapper(this._proxy, this._handle, id), id); + this._componentBuilders.set(id, builder); + return builder; } dropDown(): sqlops.ComponentBuilder { let id = this.getNextComponentId(); - return this.withEventHandler(new DropDownWrapper(this._proxy, this._handle, id), id); + let builder: ComponentBuilderImpl = this.getComponentBuilder(new DropDownWrapper(this._proxy, this._handle, id), id); + this._componentBuilders.set(id, builder); + return builder; } dashboardWidget(widgetId: string): sqlops.ComponentBuilder { let id = this.getNextComponentId(); - return this.withEventHandler(new ComponentWrapper(this._proxy, this._handle, ModelComponentTypes.DashboardWidget, id), id); + let builder = this.getComponentBuilder(new ComponentWrapper(this._proxy, this._handle, ModelComponentTypes.DashboardWidget, id), id); + this._componentBuilders.set(id, builder); + return builder; } dashboardWebview(webviewId: string): sqlops.ComponentBuilder { let id = this.getNextComponentId(); - return this.withEventHandler(new ComponentWrapper(this._proxy, this._handle, ModelComponentTypes.DashboardWebview, id), id); + let builder: ComponentBuilderImpl = this.getComponentBuilder(new ComponentWrapper(this._proxy, this._handle, ModelComponentTypes.DashboardWebview, id), id); + this._componentBuilders.set(id, builder); + return builder; } - withEventHandler(component: ComponentWrapper, id: string): sqlops.ComponentBuilder { + getComponentBuilder(component: ComponentWrapper, id: string): ComponentBuilderImpl { let componentBuilder: ComponentBuilderImpl = new ComponentBuilderImpl(component); - this._eventHandlers.set(id, componentBuilder); + this._componentBuilders.set(id, componentBuilder); return componentBuilder; } handleEvent(componentId: string, eventArgs: IComponentEventArgs): void { - let eventHandler = this._eventHandlers.get(componentId); + let eventHandler = this._componentBuilders.get(componentId); if (eventHandler) { eventHandler.handleEvent(eventArgs); } } + public runCustomValidations(componentId: string): boolean { + let component = this._componentBuilders.get(componentId).componentWrapper(); + return component.runCustomValidations(); + } + private getNextComponentId(): string { return `component${this._handle}_${this.nextComponentId++}`; } @@ -112,13 +131,17 @@ class ComponentBuilderImpl implements sqlops.Compone return this._component; } + componentWrapper(): ComponentWrapper { + return this._component; + } + withProperties(properties: U): sqlops.ComponentBuilder { this._component.properties = properties; return this; } withValidation(validation: (component: T) => boolean): sqlops.ComponentBuilder { - this._component.validations.push(validation); + this._component.customValidations.push(validation); return this; } @@ -150,7 +173,6 @@ class ContainerBuilderImpl ext let componentWrapper = item as ComponentWrapper; return new InternalItemConfig(componentWrapper, itemLayout); }); - components.forEach(component => component.onValidityChanged(() => this._component.validate())); return this; } } @@ -182,7 +204,6 @@ class FormContainerBuilder extends ContainerBuilderImpl this._component.validate()); }); return this; } @@ -208,7 +229,7 @@ class ComponentWrapper implements sqlops.Component { public properties: { [key: string]: any } = {}; public layout: any; public itemConfigs: InternalItemConfig[]; - public validations: ((component: ThisType) => boolean)[] = []; + public customValidations: ((component: ThisType) => boolean)[] = []; private _valid: boolean = true; private _onValidityChangedEmitter = new Emitter(); public readonly onValidityChanged = this._onValidityChangedEmitter.event; @@ -224,12 +245,6 @@ 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 { @@ -280,7 +295,6 @@ 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); } @@ -303,7 +317,9 @@ class ComponentWrapper implements sqlops.Component { public onEvent(eventArgs: IComponentEventArgs) { if (eventArgs && eventArgs.eventType === ComponentEventType.PropertiesChanged) { this.properties = eventArgs.args; - this.validate(); + } + else if (eventArgs && eventArgs.eventType === ComponentEventType.validityChanged) { + this._valid = eventArgs.args; } else if (eventArgs) { let emitter = this._emitterMap.get(eventArgs.eventType); if (emitter) { @@ -312,11 +328,10 @@ class ComponentWrapper implements sqlops.Component { } } - protected setProperty(key: string, value: any): Thenable { + protected async setProperty(key: string, value: any): Promise { 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); @@ -326,10 +341,10 @@ class ComponentWrapper implements sqlops.Component { this._onErrorEmitter.fire(err); } - public validate(): void { + public runCustomValidations(): boolean { let isValid = true; try { - this.validations.forEach(validation => { + this.customValidations.forEach(validation => { if (!validation(this)) { isValid = false; } @@ -337,12 +352,11 @@ class ComponentWrapper implements sqlops.Component { } 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); - } + return isValid; + } + + public validate() { + return this._proxy.$validate(this._handle, this._id); } public get valid(): boolean { @@ -434,6 +448,13 @@ class InputBoxWrapper extends ComponentWrapper implements sqlops.InputBoxCompone this.setProperty('width', v); } + public get inputType(): sqlops.InputBoxInputType { + return this.properties['inputType']; + } + public set inputType(v: sqlops.InputBoxInputType) { + this.setProperty('inputType', v); + } + public get onTextChanged(): vscode.Event { let emitter = this._emitterMap.get(ComponentEventType.onDidChange); return emitter && emitter.event; @@ -566,12 +587,15 @@ class ModelViewImpl implements sqlops.ModelView { 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(); + public validate(): Thenable { + return this._proxy.$validate(this._handle, this._component.id); + } + + public runCustomValidations(componentId: string): boolean { + return this._modelBuilder.runCustomValidations(componentId); } } @@ -610,4 +634,9 @@ export class ExtHostModelView implements ExtHostModelViewShape { view.handleEvent(componentId, eventArgs); } } + + $runCustomValidations(handle: number, componentId: string): Thenable { + const view = this._modelViews.get(handle); + return Promise.resolve(view.runCustomValidations(componentId)); + } } diff --git a/src/sql/workbench/api/node/mainThreadModelView.ts b/src/sql/workbench/api/node/mainThreadModelView.ts index c3bad8d78e..68b8846626 100644 --- a/src/sql/workbench/api/node/mainThreadModelView.ts +++ b/src/sql/workbench/api/node/mainThreadModelView.ts @@ -46,7 +46,7 @@ export class MainThreadModelView extends Disposable implements MainThreadModelVi $initializeModel(handle: number, rootComponent: IComponentShape): Thenable { return this.execModelViewAction(handle, (modelView) => { - modelView.initializeModel(rootComponent); + modelView.initializeModel(rootComponent, (componentId) => this.runCustomValidations(handle, componentId)); }); } @@ -82,8 +82,12 @@ 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)); + $validate(handle: number, componentId: string): Thenable { + return new Promise(resolve => this.execModelViewAction(handle, (modelView) => resolve(modelView.validate(componentId)))); + } + + private runCustomValidations(handle: number, componentId: string): Thenable { + return this._proxy.$runCustomValidations(handle, componentId); } private execModelViewAction(handle: number, action: (m: IModelView) => T): Thenable { diff --git a/src/sql/workbench/api/node/sqlExtHost.protocol.ts b/src/sql/workbench/api/node/sqlExtHost.protocol.ts index d12dcf9639..902de1f76e 100644 --- a/src/sql/workbench/api/node/sqlExtHost.protocol.ts +++ b/src/sql/workbench/api/node/sqlExtHost.protocol.ts @@ -520,6 +520,7 @@ export interface ExtHostModelViewShape { $onClosed(handle: number): void; $registerWidget(handle: number, id: string, connection: sqlops.connection.Connection, serverInfo: sqlops.ServerInfo): void; $handleEvent(handle: number, id: string, eventArgs: any); + $runCustomValidations(handle: number, id: string): Thenable; } export interface MainThreadModelViewShape extends IDisposable { @@ -530,7 +531,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; + $validate(handle: number, componentId: string): Thenable; } export interface ExtHostObjectExplorerShape { diff --git a/src/sqltest/parts/modelComponents/componentBase.test.ts b/src/sqltest/parts/modelComponents/componentBase.test.ts new file mode 100644 index 0000000000..e13e9b5737 --- /dev/null +++ b/src/sqltest/parts/modelComponents/componentBase.test.ts @@ -0,0 +1,132 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ComponentBase, ContainerBase } from 'sql/parts/modelComponents/componentBase'; +import { IComponentDescriptor, IModelStore, ComponentEventType } from 'sql/parts/modelComponents/interfaces'; +import { ModelStore } from 'sql/parts/modelComponents/modelStore'; +import { ChangeDetectorRef } from '@angular/core'; + +'use strict'; + +class TestComponent extends ComponentBase { + public descriptor: IComponentDescriptor; + + constructor(public modelStore: IModelStore, id: string) { + super(undefined); + this.descriptor = modelStore.createComponentDescriptor('TestComponent', id); + this.baseInit(); + } + + ngOnInit() { } + setLayout() { } + + public addValidation(validation: () => boolean | Thenable) { + this._validations.push(validation); + } +} + +class TestContainer extends ContainerBase { + public descriptor: IComponentDescriptor; + + constructor(public modelStore: IModelStore, id: string) { + super(undefined); + this.descriptor = modelStore.createComponentDescriptor('TestContainer', id); + this._changeRef = { + detectChanges: () => undefined + } as ChangeDetectorRef; + this.baseInit(); + } + + ngOnInit() { } + setLayout() { } + + public addValidation(validation: () => boolean | Thenable) { + this._validations.push(validation); + } +} + +suite('ComponentBase Validation Tests', () => { + let testComponent: TestComponent; + let testContainer: TestContainer; + let modelStore: IModelStore; + + setup(() => { + modelStore = new ModelStore(); + testComponent = new TestComponent(modelStore, 'testComponent'); + testContainer = new TestContainer(modelStore, 'testContainer'); + }); + + test('Component validation runs external validations stored in the model store', done => { + assert.equal(testComponent.valid, true, 'Test component validity did not default to true'); + let validationCalls = 0; + modelStore.registerValidationCallback(componentId => { + validationCalls += 1; + return Promise.resolve(false); + }); + + testComponent.validate().then(valid => { + try { + assert.equal(validationCalls, 1, 'External validation was not called once'); + assert.equal(valid, false, 'Validate call did not return correct value from the external validation'); + assert.equal(testComponent.valid, false, 'Validate call did not update the component valid property'); + done(); + } catch (err) { + done(err); + } + }, err => done(err)); + }); + + test('Component validation runs default component validations', done => { + assert.equal(testComponent.valid, true, 'Test component validity did not default to true'); + let validationCalls = 0; + testComponent.addValidation(() => { + validationCalls += 1; + return false; + }); + + testComponent.validate().then(valid => { + try { + assert.equal(validationCalls, 1, 'Default validation was not called once'); + assert.equal(valid, false, 'Validate call did not return correct value from the default validation'); + assert.equal(testComponent.valid, false, 'Validate call did not update the component valid property'); + done(); + } catch (err) { + done(err); + } + }, err => done(err)); + }); + + test('Container validation reflects child component validity', done => { + assert.equal(testContainer.valid, true, 'Test container validity did not default to true'); + testContainer.addToContainer(testComponent.descriptor, undefined); + testComponent.addValidation(() => false); + testComponent.validate().then(() => { + testContainer.validate().then(valid => { + assert.equal(valid, false, 'Validate call did not return correct value for container child validation'); + assert.equal(testContainer.valid, false, 'Validate call did not update the container valid property'); + done(); + }, err => done(err)); + }, err => done(err)); + }); + + test('Container child validity changes cause the parent container validity to change', done => { + testContainer.registerEventHandler(event => { + try { + if (event.eventType === ComponentEventType.validityChanged) { + assert.equal(testContainer.valid, false, 'Test container validity did not change to false when child validity changed'); + assert.equal(event.args, false, 'ValidityChanged event did not contain the updated container validity'); + done(); + } + } catch (err) { + done(err); + } + }); + testComponent.addValidation(() => false); + testContainer.addToContainer(testComponent.descriptor, undefined); + testComponent.validate(); + }); +}); \ No newline at end of file diff --git a/src/sqltest/workbench/api/extHostModelView.test.ts b/src/sqltest/workbench/api/extHostModelView.test.ts index cce847a208..d22a983c8a 100644 --- a/src/sqltest/workbench/api/extHostModelView.test.ts +++ b/src/sqltest/workbench/api/extHostModelView.test.ts @@ -19,9 +19,6 @@ suite('ExtHostModelView Validation Tests', () => { 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; @@ -38,7 +35,6 @@ suite('ExtHostModelView Validation Tests', () => { $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 = { @@ -47,7 +43,6 @@ suite('ExtHostModelView Validation Tests', () => { 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); @@ -56,11 +51,11 @@ suite('ExtHostModelView Validation Tests', () => { inputBox = view.modelBuilder.inputBox() .withValidation(component => component.value === validText) .component(); - dropDownBox = view.modelBuilder.dropDown().component(); - formContainer = view.modelBuilder.formContainer() + let dropDownBox = view.modelBuilder.dropDown().component(); + let formContainer = view.modelBuilder.formContainer() .withItems([inputBox, dropDownBox]) .component(); - flexContainer = view.modelBuilder.flexContainer() + let flexContainer = view.modelBuilder.flexContainer() .withItems([formContainer]) .component(); await view.initializeModel(flexContainer); @@ -70,47 +65,57 @@ suite('ExtHostModelView Validation Tests', () => { 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('The custom validation output of a component gets set when it is initialized', done => { + extHostModelView.$runCustomValidations(handle, inputBox.id).then(valid => { + try { + assert.equal(valid, false, 'Empty input box did not validate as false'); + done(); + } catch (err) { + done(err); + } + }, 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('The custom validation output of a component changes if its value changes', done => { + inputBox.value = validText; + extHostModelView.$runCustomValidations(handle, inputBox.id).then(valid => { + try { + assert.equal(valid, true, 'Valid input box did not validate as valid'); + done(); + } catch (err) { + done(err); + } + }, 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); - } + test('The custom validation output of a component changes after a PropertiesChanged event', done => { + extHostModelView.$handleEvent(handle, inputBox.id, { + args: { + 'value': validText + }, + eventType: ComponentEventType.PropertiesChanged + } as IComponentEventArgs); + extHostModelView.$runCustomValidations(handle, inputBox.id).then(valid => { + try { + assert.equal(valid, true, 'Valid input box did not validate as valid after PropertiesChanged event'); + done(); + } catch (err) { + done(err); + } + }, err => done(err)); + }); + + test('The validity of a component is set by main thread validationChanged events', () => { + assert.equal(inputBox.valid, true, 'Component validity is true by default'); + extHostModelView.$handleEvent(handle, inputBox.id, { + eventType: ComponentEventType.validityChanged, + args: false + }); + assert.equal(inputBox.valid, false, 'Input box did not update validity to false based on the validityChanged event'); + extHostModelView.$handleEvent(handle, inputBox.id, { + eventType: ComponentEventType.validityChanged, + args: true + }); + assert.equal(inputBox.valid, true, 'Input box did not update validity to true based on the validityChanged event'); }); }); \ No newline at end of file diff --git a/src/vs/base/browser/ui/inputbox/inputBox.ts b/src/vs/base/browser/ui/inputbox/inputBox.ts index 20ee03ea99..bc04607cfb 100644 --- a/src/vs/base/browser/ui/inputbox/inputBox.ts +++ b/src/vs/base/browser/ui/inputbox/inputBox.ts @@ -32,6 +32,7 @@ export interface IInputOptions extends IInputBoxStyles { // {{SQL CARBON EDIT}} Candidate for addition to vscode min?: string; max?: string; + useDefaultValidation?: boolean; } export interface IInputBoxStyles { @@ -355,6 +356,14 @@ export class InputBox extends Widget { if (this.validation) { result = this.validation(this.value); + // {{SQL CARBON EDIT}} + if (!result && this.options.useDefaultValidation && this.inputElement.validationMessage) { + result = { + content: this.inputElement.validationMessage, + type: MessageType.ERROR + }; + } + if (!result) { this.inputElement.removeAttribute('aria-invalid'); this.hideMessage();