From 57232ce9dc9ce8290eb7d6ee79a8a12209349bc8 Mon Sep 17 00:00:00 2001 From: Jeremy Danyow Date: Sat, 25 Mar 2017 17:05:22 -0700 Subject: [PATCH] feat(ValidationController): events fixes #318 --- doc/article/en-US/validation-basics.md | 4 +++ src/aurelia-validation.ts | 1 + src/validate-event.ts | 41 ++++++++++++++++++++++++++ src/validation-controller.ts | 38 ++++++++++++++++++++++++ test/basic.ts | 29 +++++++++++++++++- 5 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 src/validate-event.ts diff --git a/doc/article/en-US/validation-basics.md b/doc/article/en-US/validation-basics.md index bf5dedbc..b70f8d89 100644 --- a/doc/article/en-US/validation-basics.md +++ b/doc/article/en-US/validation-basics.md @@ -383,6 +383,10 @@ You may need to surface validation errors from other sources. Perhaps while atte The validation controller renders errors by sending them to implementations of the `ValidationRenderer` interface. The library ships with a built-in renderer that "renders" the errors to an array property for data-binding/templating purposes. This is covered in the [displaying errors](aurelia-doc://section/11/version/1.0.0) section below. You can create your own [custom renderer](aurelia-doc://section/12/version/1.0.0) and add it to the controller's set of renderers using the `addRenderer(renderer)` method. +### Events + +The validation controller has a `subscribe(callback: (event: ValidateEvent) => void)` method you can use to subscribe to validate and reset events. Callbacks will be invoked whenever the controller's validate and reset methods are called. Callbacks will be passed an instance `ValidateEvent` which contains properties you can use to determine the overall validity state as well as the result of the validate or reset invocation. Refer to the API docs for more info. + ## [Validator](aurelia-doc://section/5/version/1.0.0) `Validator` is an interface used by the `ValidationController` to do the behind-the-scenes work of validating objects and properties. The `aurelia-validation` plugin ships with an implementation of this interface called the `StandardValidator`, which knows how to evaluate rules created by `aurelia-validation`'s fluent API. When you use a `Validator` directly to validate a particular object or property, there are no UI side-effects- the validation results are not sent to the the validation renderers. diff --git a/src/aurelia-validation.ts b/src/aurelia-validation.ts index 5ebac7b1..f5d8d47e 100644 --- a/src/aurelia-validation.ts +++ b/src/aurelia-validation.ts @@ -5,6 +5,7 @@ export * from './get-target-dom-element'; export * from './property-info'; export * from './property-accessor-parser'; export * from './validate-binding-behavior'; +export * from './validate-event'; export * from './validate-instruction'; export * from './validate-result'; export * from './validate-trigger'; diff --git a/src/validate-event.ts b/src/validate-event.ts new file mode 100644 index 00000000..93e37d05 --- /dev/null +++ b/src/validate-event.ts @@ -0,0 +1,41 @@ +import { ValidateResult } from './validate-result'; +import { ValidateInstruction } from './validate-instruction'; +import { ControllerValidateResult } from './controller-validate-result'; + +export class ValidateEvent { + constructor( + /** + * The type of validate event. Either "validate" or "reset". + */ + public readonly type: 'validate' | 'reset', + + /** + * The controller's current array of errors. For an array containing both + * failed rules and passed rules, use the "results" property. + */ + public readonly errors: ValidateResult[], + + /** + * The controller's current array of validate results. This + * includes both passed rules and failed rules. For an array of only failed rules, + * use the "errors" property. + */ + public readonly results: ValidateResult[], + + /** + * The instruction passed to the "validate" or "reset" event. Will be null when + * the controller's validate/reset method was called with no instruction argument. + */ + public readonly instruction: ValidateInstruction | null, + + /** + * In events with type === "validate", this property will contain the result + * of validating the instruction (see "instruction" property). Use the controllerValidateResult + * to access the validate results specific to the call to "validate" + * (as opposed to using the "results" and "errors" properties to access the controller's entire + * set of results/errors). + */ + public readonly controllerValidateResult: ControllerValidateResult | null + + ) { } +} diff --git a/src/validation-controller.ts b/src/validation-controller.ts index 4bf5d93a..07968ec2 100644 --- a/src/validation-controller.ts +++ b/src/validation-controller.ts @@ -7,6 +7,7 @@ import { ValidateResult } from './validate-result'; import { ValidateInstruction } from './validate-instruction'; import { ControllerValidateResult } from './controller-validate-result'; import { PropertyAccessorParser, PropertyAccessor } from './property-accessor-parser'; +import { ValidateEvent } from './validate-event'; /** * Orchestrates validation. @@ -51,8 +52,28 @@ export class ValidationController { // Promise that resolves when validation has completed. private finishValidating: Promise = Promise.resolve(); + private eventCallbacks: ((event: ValidateEvent) => void)[] = []; + constructor(private validator: Validator, private propertyParser: PropertyAccessorParser) { } + /** + * Subscribe to controller validate and reset events. These events occur when the + * controller's "validate"" and "reset" methods are called. + * @param callback The callback to be invoked when the controller validates or resets. + */ + public subscribe(callback: (event: ValidateEvent) => void) { + this.eventCallbacks.push(callback); + return { + dispose: () => { + const index = this.eventCallbacks.indexOf(callback); + if (index === -1) { + return; + } + this.eventCallbacks.splice(index, 1); + } + }; + } + /** * Adds an object to the set of objects that should be validated when validate is called. * @param object The object. @@ -224,6 +245,7 @@ export class ValidationController { valid: newResults.find(x => !x.valid) === undefined, results: newResults }; + this.invokeCallbacks(instruction, result); return result; }) .catch(exception => { @@ -248,6 +270,7 @@ export class ValidationController { const predicate = this.getInstructionPredicate(instruction); const oldResults = this.results.filter(predicate); this.processResultDelta('reset', oldResults, []); + this.invokeCallbacks(instruction, null); } /** @@ -405,6 +428,21 @@ export class ValidationController { this.validate({ object, propertyName, rules }); } } + + private invokeCallbacks(instruction: ValidateInstruction | undefined, result: ControllerValidateResult | null) { + if (this.eventCallbacks.length === 0) { + return; + } + const event = new ValidateEvent( + result ? 'validate' : 'reset', + this.errors, + this.results, + instruction || null, + result); + for (let i = 0; i < this.eventCallbacks.length; i++) { + this.eventCallbacks[i](event); + } + } } /** diff --git a/test/basic.ts b/test/basic.ts index 0dc3ecd4..f735ba60 100644 --- a/test/basic.ts +++ b/test/basic.ts @@ -1,7 +1,7 @@ import { StageComponent, ComponentTester } from 'aurelia-testing'; import { bootstrap } from 'aurelia-bootstrapper'; import { RegistrationForm } from './resources/registration-form'; -import { validateTrigger } from '../src/aurelia-validation'; +import { validateTrigger, ValidateEvent } from '../src/aurelia-validation'; import { configure, blur, change } from './shared'; describe('end to end', () => { @@ -206,6 +206,33 @@ describe('end to end', () => { viewModel.controller.removeError(error3); expect(viewModel.controller.errors.length).toBe(0); }) + // subscribe to error events + .then(() => { + let event1: ValidateEvent; + let event2: ValidateEvent; + const spy1 = jasmine.createSpy().and.callFake((event: ValidateEvent) => event1 = event); + const spy2 = jasmine.createSpy().and.callFake((event: ValidateEvent) => event2 = event); + viewModel.controller.subscribe(spy1); + viewModel.controller.subscribe(spy2); + return change(lastName, '') + .then(() => { + expect(spy1).toHaveBeenCalled(); + expect(spy2).toHaveBeenCalled(); + expect(event1).toBe(event2); + expect(event1.errors.length).toBe(1); + spy1.calls.reset(); + spy2.calls.reset(); + event1 = null as any; + event2 = null as any; + }) + .then(() => change(firstName, '')) + .then(() => { + expect(spy1).toHaveBeenCalled(); + expect(spy2).toHaveBeenCalled(); + expect(event1).toBe(event2); + expect(event1.errors.length).toBe(2); + }); + }) // cleanup and finish. .then(() => component.dispose())