From 3ff8d87ab6e020b2ed94b91031d444904f2e3b85 Mon Sep 17 00:00:00 2001 From: Will Howell Date: Mon, 22 May 2017 21:04:12 -0400 Subject: [PATCH 1/6] feat(input): Add custom error state matcher --- src/demo-app/input/input-demo.html | 11 +++ src/demo-app/input/input-demo.ts | 10 +- src/lib/core/core.ts | 7 ++ src/lib/core/error/error-options.ts | 14 +++ src/lib/input/input-container.spec.ts | 136 ++++++++++++++++++++++++++ src/lib/input/input-container.ts | 27 ++++- src/lib/input/input.md | 40 ++++++++ 7 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 src/lib/core/error/error-options.ts diff --git a/src/demo-app/input/input-demo.html b/src/demo-app/input/input-demo.html index c0d6c7a9dc9a..c70fa0349f41 100644 --- a/src/demo-app/input/input-demo.html +++ b/src/demo-app/input/input-demo.html @@ -94,6 +94,17 @@

Inside a form

+ +

With a custom error function

+ + + This field is required + + diff --git a/src/demo-app/input/input-demo.ts b/src/demo-app/input/input-demo.ts index 387e632dbee5..7f0432c1916b 100644 --- a/src/demo-app/input/input-demo.ts +++ b/src/demo-app/input/input-demo.ts @@ -1,5 +1,5 @@ import {Component} from '@angular/core'; -import {FormControl, Validators} from '@angular/forms'; +import {FormControl, Validators, NgControl} from '@angular/forms'; let max = 5; @@ -23,6 +23,7 @@ export class InputDemo { errorMessageExample1: string; errorMessageExample2: string; errorMessageExample3: string; + errorMessageExample4: string; dividerColorExample1: string; dividerColorExample2: string; dividerColorExample3: string; @@ -43,4 +44,11 @@ export class InputDemo { this.items.push({ value: ++max }); } } + + customErrorStateMatcher(c: NgControl): boolean { + const isDirty = c.dirty; + const isInvalid = c.invalid; + + return isDirty && isInvalid; + } } diff --git a/src/lib/core/core.ts b/src/lib/core/core.ts index 0be7b12636bb..b7bd62339038 100644 --- a/src/lib/core/core.ts +++ b/src/lib/core/core.ts @@ -118,6 +118,13 @@ export { MD_PLACEHOLDER_GLOBAL_OPTIONS } from './placeholder/placeholder-options'; +// Error +export { + ErrorStateMatcherType, + ErrorOptions, + MD_ERROR_GLOBAL_OPTIONS +} from './error/error-options'; + @NgModule({ imports: [ MdLineModule, diff --git a/src/lib/core/error/error-options.ts b/src/lib/core/error/error-options.ts new file mode 100644 index 000000000000..223575629f6c --- /dev/null +++ b/src/lib/core/error/error-options.ts @@ -0,0 +1,14 @@ +import {InjectionToken} from '@angular/core'; +import {NgControl, FormGroupDirective, NgForm} from '@angular/forms'; + +/** Injection token that can be used to specify the global error options. */ +export const MD_ERROR_GLOBAL_OPTIONS = + new InjectionToken<() => boolean>('md-error-global-options'); + +export type ErrorStateMatcherType = + (control: NgControl, parentFormGroup: FormGroupDirective, parentForm: NgForm) => boolean; + +export interface ErrorOptions { + errorStateMatcher?: ErrorStateMatcherType; + showOnDirty?: boolean; +} diff --git a/src/lib/input/input-container.spec.ts b/src/lib/input/input-container.spec.ts index cd10fb53dbe3..0fda4f7d59a2 100644 --- a/src/lib/input/input-container.spec.ts +++ b/src/lib/input/input-container.spec.ts @@ -6,6 +6,7 @@ import { FormGroupDirective, FormsModule, NgForm, + NgControl, ReactiveFormsModule, Validators } from '@angular/forms'; @@ -23,6 +24,7 @@ import { getMdInputContainerPlaceholderConflictError } from './input-container-errors'; import {MD_PLACEHOLDER_GLOBAL_OPTIONS} from '../core/placeholder/placeholder-options'; +import {MD_ERROR_GLOBAL_OPTIONS} from '../core/error/error-options'; describe('MdInputContainer', function () { beforeEach(async(() => { @@ -56,6 +58,7 @@ describe('MdInputContainer', function () { MdInputContainerWithDynamicPlaceholder, MdInputContainerWithFormControl, MdInputContainerWithFormErrorMessages, + MdInputContainerWithCustomErrorStateMatcher, MdInputContainerWithFormGroupErrorMessages, MdInputContainerWithId, MdInputContainerWithPrefixAndSuffix, @@ -705,6 +708,116 @@ describe('MdInputContainer', function () { }); })); + it('should display an error message when a custom error matcher returns true', async(() => { + fixture.destroy(); + + let customFixture = TestBed.createComponent(MdInputContainerWithCustomErrorStateMatcher); + let component: MdInputContainerWithCustomErrorStateMatcher; + + customFixture.detectChanges(); + component = customFixture.componentInstance; + containerEl = customFixture.debugElement.query(By.css('md-input-container')).nativeElement; + + expect(component.formControl.invalid).toBe(true, 'Expected form control to be invalid'); + expect(containerEl.querySelectorAll('md-error').length).toBe(0, 'Expected no error messages'); + + component.formControl.markAsTouched(); + customFixture.detectChanges(); + + customFixture.whenStable().then(() => { + expect(containerEl.querySelectorAll('md-error').length) + .toBe(0, 'Expected no error messages after being touched.'); + + component.errorState = true; + customFixture.detectChanges(); + + customFixture.whenStable().then(() => { + expect(containerEl.querySelectorAll('md-error').length) + .toBe(1, 'Expected one error messages to have been rendered.'); + }); + }); + })); + + it('should display an error message when global error matcher returns true', () => { + + // Global error state matcher that will always cause errors to show + function globalErrorStateMatcher() { + return true; + } + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ + FormsModule, + MdInputModule, + NoopAnimationsModule, + ReactiveFormsModule, + ], + declarations: [ + MdInputContainerWithFormErrorMessages + ], + providers: [ + { + provide: MD_ERROR_GLOBAL_OPTIONS, + useValue: { errorStateMatcher: globalErrorStateMatcher } } + ] + }); + + let customFixture = TestBed.createComponent(MdInputContainerWithFormErrorMessages); + customFixture.detectChanges(); + + containerEl = customFixture.debugElement.query(By.css('md-input-container')).nativeElement; + testComponent = customFixture.componentInstance; + + // Expect the control to still be untouched but the error to show due to the global setting + expect(testComponent.formControl.untouched).toBe(true, 'Expected untouched form control'); + expect(containerEl.querySelectorAll('md-error').length).toBe(1, 'Expected an error message'); + }); + + it('should display an error message when global setting shows errors on dirty', async() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ + FormsModule, + MdInputModule, + NoopAnimationsModule, + ReactiveFormsModule, + ], + declarations: [ + MdInputContainerWithFormErrorMessages + ], + providers: [ + { provide: MD_ERROR_GLOBAL_OPTIONS, useValue: { showOnDirty: true } } + ] + }); + + let customFixture = TestBed.createComponent(MdInputContainerWithFormErrorMessages); + customFixture.detectChanges(); + + containerEl = customFixture.debugElement.query(By.css('md-input-container')).nativeElement; + testComponent = customFixture.componentInstance; + + expect(testComponent.formControl.invalid).toBe(true, 'Expected form control to be invalid'); + expect(containerEl.querySelectorAll('md-error').length).toBe(0, 'Expected no error messages'); + + testComponent.formControl.markAsTouched(); + customFixture.detectChanges(); + + customFixture.whenStable().then(() => { + expect(containerEl.querySelectorAll('md-error').length) + .toBe(0, 'Expected no error messages when touched'); + + testComponent.formControl.markAsDirty(); + customFixture.detectChanges(); + + customFixture.whenStable().then(() => { + expect(containerEl.querySelectorAll('md-error').length) + .toBe(1, 'Expected one error message when dirty'); + }); + }); + + }); + it('should hide the errors and show the hints once the input becomes valid', async(() => { testComponent.formControl.markAsTouched(); fixture.detectChanges(); @@ -1018,6 +1131,29 @@ class MdInputContainerWithFormErrorMessages { renderError = true; } +@Component({ + template: ` +
+ + + Please type something + This field is required + +
+ ` +}) +class MdInputContainerWithCustomErrorStateMatcher { + @ViewChild('form') form: NgForm; + formControl = new FormControl('', Validators.required); + errorState = false; + + customErrorStateMatcher(): boolean { + return this.errorState; + } +} + @Component({ template: `
diff --git a/src/lib/input/input-container.ts b/src/lib/input/input-container.ts index f614a1de78dc..3770d2da5870 100644 --- a/src/lib/input/input-container.ts +++ b/src/lib/input/input-container.ts @@ -42,6 +42,11 @@ import { PlaceholderOptions, MD_PLACEHOLDER_GLOBAL_OPTIONS } from '../core/placeholder/placeholder-options'; +import { + ErrorStateMatcherType, + ErrorOptions, + MD_ERROR_GLOBAL_OPTIONS +} from '../core/error/error-options'; // Invalid input type. Using one of these will throw an MdInputContainerUnsupportedTypeError. const MD_INPUT_INVALID_TYPES = [ @@ -137,6 +142,7 @@ export class MdInputDirective { private _required = false; private _id: string; private _cachedUid: string; + private _errorOptions: ErrorOptions; /** Whether the element is focused or not. */ focused = false; @@ -189,6 +195,9 @@ export class MdInputDirective { } } + /** A function used to control when error messages are shown. */ + @Input() errorStateMatcher: ErrorStateMatcherType; + /** The input element's value. */ get value() { return this._elementRef.nativeElement.value; } set value(value: string) { this._elementRef.nativeElement.value = value; } @@ -224,10 +233,14 @@ export class MdInputDirective { private _platform: Platform, @Optional() @Self() public _ngControl: NgControl, @Optional() private _parentForm: NgForm, - @Optional() private _parentFormGroup: FormGroupDirective) { + @Optional() private _parentFormGroup: FormGroupDirective, + @Optional() @Inject(MD_ERROR_GLOBAL_OPTIONS) errorOptions: ErrorOptions) { // Force setter to be called in case id was not specified. this.id = this.id; + + this._errorOptions = errorOptions ? errorOptions : {}; + this.errorStateMatcher = this._errorOptions.errorStateMatcher || undefined; } /** Focuses the input element. */ @@ -250,11 +263,23 @@ export class MdInputDirective { /** Whether the input is in an error state. */ _isErrorState(): boolean { const control = this._ngControl; + return this.errorStateMatcher + ? this.errorStateMatcher(control, this._parentFormGroup, this._parentForm) + : this._defaultErrorStateMatcher(control); + } + + /** Default error state calculation */ + private _defaultErrorStateMatcher(control: NgControl): boolean { const isInvalid = control && control.invalid; const isTouched = control && control.touched; + const isDirty = control && control.dirty; const isSubmitted = (this._parentFormGroup && this._parentFormGroup.submitted) || (this._parentForm && this._parentForm.submitted); + if (this._errorOptions.showOnDirty) { + return !!(isInvalid && (isDirty || isSubmitted)); + } + return !!(isInvalid && (isTouched || isSubmitted)); } diff --git a/src/lib/input/input.md b/src/lib/input/input.md index 47dca6836bad..6043f9abaedc 100644 --- a/src/lib/input/input.md +++ b/src/lib/input/input.md @@ -107,3 +107,43 @@ The underline (line under the `input` content) color can be changed by using the attribute of `md-input-container`. A value of `primary` is the default and will correspond to the theme primary color. Alternatively, `accent` or `warn` can be specified to use the theme's accent or warn color. + +### Custom Error Matcher + +By default, error messages are shown when the control is invalid and the user has interacted with +(touched) the element or the parent form has been submitted. If you wish to customize this +behavior (e.g. to show the error as soon as the invalid control is dirty), you can use the +`errorStateMatcher` property of the `mdInput`. To use this property, create a function in +your component class that returns a boolean. A result of `true` will display the error messages. + +```html + + + This field is required + +``` + +```ts +function myErrorStateMatcher(control: NgControl, parentFg: FormGroupDirective, parentForm: NgForm): boolean { + return control.invalid && control.dirty; +} +``` + +A global error state matcher can be specified by setting the `MD_ERROR_GLOBAL_OPTIONS` provider. This applies +to all inputs. + +```ts +@NgModule({ + providers: [ + {provide: MD_ERROR_GLOBAL_OPTIONS, useValue: { errorStateMatcher: myErrorStateMatcher }} + ] +}) +``` + +Here are the available global options: + + +| Name | Type | Description | +| ----------------- | -------- | ----------- | +| errorStateMatcher | Function | Returns a boolean specifying if the error should be shown | +| showOnDirty | boolean | If true, the error will show when the control is dirty, not touched. |P \ No newline at end of file From b0d69cc6a516232e297ad9911f1ad397c712393f Mon Sep 17 00:00:00 2001 From: Will Howell Date: Thu, 22 Jun 2017 11:55:43 -0400 Subject: [PATCH 2/6] Address comments --- src/lib/core/core.ts | 2 +- src/lib/core/error/error-options.ts | 39 ++++++- src/lib/input/input-container.spec.ts | 140 +++++++++++++------------- src/lib/input/input-container.ts | 27 ++--- src/lib/input/input.md | 13 ++- 5 files changed, 123 insertions(+), 98 deletions(-) diff --git a/src/lib/core/core.ts b/src/lib/core/core.ts index b7bd62339038..7088c3817186 100644 --- a/src/lib/core/core.ts +++ b/src/lib/core/core.ts @@ -120,7 +120,7 @@ export { // Error export { - ErrorStateMatcherType, + ErrorStateMatcher, ErrorOptions, MD_ERROR_GLOBAL_OPTIONS } from './error/error-options'; diff --git a/src/lib/core/error/error-options.ts b/src/lib/core/error/error-options.ts index 223575629f6c..d2f71b1b719a 100644 --- a/src/lib/core/error/error-options.ts +++ b/src/lib/core/error/error-options.ts @@ -1,14 +1,45 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + import {InjectionToken} from '@angular/core'; import {NgControl, FormGroupDirective, NgForm} from '@angular/forms'; /** Injection token that can be used to specify the global error options. */ export const MD_ERROR_GLOBAL_OPTIONS = - new InjectionToken<() => boolean>('md-error-global-options'); + new InjectionToken('md-error-global-options'); -export type ErrorStateMatcherType = +export type ErrorStateMatcher = (control: NgControl, parentFormGroup: FormGroupDirective, parentForm: NgForm) => boolean; export interface ErrorOptions { - errorStateMatcher?: ErrorStateMatcherType; - showOnDirty?: boolean; + errorStateMatcher?: ErrorStateMatcher; +} + +export class DefaultErrorStateMatcher { + + errorStateMatcher(control: NgControl, formGroup: FormGroupDirective, form: NgForm): boolean { + const isInvalid = control && control.invalid; + const isTouched = control && control.touched; + const isSubmitted = (formGroup && formGroup.submitted) || + (form && form.submitted); + + return !!(isInvalid && (isTouched || isSubmitted)); + } +} + +export class ShowOnDirtyErrorStateMatcher { + + errorStateMatcher(control: NgControl, formGroup: FormGroupDirective, form: NgForm): boolean { + const isInvalid = control && control.invalid; + const isDirty = control && control.dirty; + const isSubmitted = (formGroup && formGroup.submitted) || + (form && form.submitted); + + return !!(isInvalid && (isDirty || isSubmitted)); + } } diff --git a/src/lib/input/input-container.spec.ts b/src/lib/input/input-container.spec.ts index 0fda4f7d59a2..b67dc4fa5014 100644 --- a/src/lib/input/input-container.spec.ts +++ b/src/lib/input/input-container.spec.ts @@ -24,7 +24,7 @@ import { getMdInputContainerPlaceholderConflictError } from './input-container-errors'; import {MD_PLACEHOLDER_GLOBAL_OPTIONS} from '../core/placeholder/placeholder-options'; -import {MD_ERROR_GLOBAL_OPTIONS} from '../core/error/error-options'; +import {MD_ERROR_GLOBAL_OPTIONS, ShowOnDirtyErrorStateMatcher} from '../core/error/error-options'; describe('MdInputContainer', function () { beforeEach(async(() => { @@ -708,32 +708,74 @@ describe('MdInputContainer', function () { }); })); - it('should display an error message when a custom error matcher returns true', async(() => { - fixture.destroy(); + it('should hide the errors and show the hints once the input becomes valid', async(() => { + testComponent.formControl.markAsTouched(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(containerEl.classList) + .toContain('mat-input-invalid', 'Expected container to have the invalid CSS class.'); + expect(containerEl.querySelectorAll('md-error').length) + .toBe(1, 'Expected one error message to have been rendered.'); + expect(containerEl.querySelectorAll('md-hint').length) + .toBe(0, 'Expected no hints to be shown.'); + + testComponent.formControl.setValue('something'); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(containerEl.classList).not.toContain('mat-input-invalid', + 'Expected container not to have the invalid class when valid.'); + expect(containerEl.querySelectorAll('md-error').length) + .toBe(0, 'Expected no error messages when the input is valid.'); + expect(containerEl.querySelectorAll('md-hint').length) + .toBe(1, 'Expected one hint to be shown once the input is valid.'); + }); + }); + })); + + it('should not hide the hint if there are no error messages', async(() => { + testComponent.renderError = false; + fixture.detectChanges(); + + expect(containerEl.querySelectorAll('md-hint').length) + .toBe(1, 'Expected one hint to be shown on load.'); + + testComponent.formControl.markAsTouched(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(containerEl.querySelectorAll('md-hint').length) + .toBe(1, 'Expected one hint to still be shown.'); + }); + })); + + }); - let customFixture = TestBed.createComponent(MdInputContainerWithCustomErrorStateMatcher); - let component: MdInputContainerWithCustomErrorStateMatcher; + describe('custom error behavior', () => { + it('should display an error message when a custom error matcher returns true', async(() => { + let fixture = TestBed.createComponent(MdInputContainerWithCustomErrorStateMatcher); + fixture.detectChanges(); - customFixture.detectChanges(); - component = customFixture.componentInstance; - containerEl = customFixture.debugElement.query(By.css('md-input-container')).nativeElement; + let component = fixture.componentInstance; + let containerEl = fixture.debugElement.query(By.css('md-input-container')).nativeElement; expect(component.formControl.invalid).toBe(true, 'Expected form control to be invalid'); expect(containerEl.querySelectorAll('md-error').length).toBe(0, 'Expected no error messages'); component.formControl.markAsTouched(); - customFixture.detectChanges(); + fixture.detectChanges(); - customFixture.whenStable().then(() => { + fixture.whenStable().then(() => { expect(containerEl.querySelectorAll('md-error').length) - .toBe(0, 'Expected no error messages after being touched.'); + .toBe(0, 'Expected no error messages after being touched.'); component.errorState = true; - customFixture.detectChanges(); + fixture.detectChanges(); - customFixture.whenStable().then(() => { + fixture.whenStable().then(() => { expect(containerEl.querySelectorAll('md-error').length) - .toBe(1, 'Expected one error messages to have been rendered.'); + .toBe(1, 'Expected one error messages to have been rendered.'); }); }); })); @@ -763,18 +805,19 @@ describe('MdInputContainer', function () { ] }); - let customFixture = TestBed.createComponent(MdInputContainerWithFormErrorMessages); - customFixture.detectChanges(); + let fixture = TestBed.createComponent(MdInputContainerWithFormErrorMessages); - containerEl = customFixture.debugElement.query(By.css('md-input-container')).nativeElement; - testComponent = customFixture.componentInstance; + fixture.detectChanges(); + + let containerEl = fixture.debugElement.query(By.css('md-input-container')).nativeElement; + let testComponent = fixture.componentInstance; // Expect the control to still be untouched but the error to show due to the global setting expect(testComponent.formControl.untouched).toBe(true, 'Expected untouched form control'); expect(containerEl.querySelectorAll('md-error').length).toBe(1, 'Expected an error message'); }); - it('should display an error message when global setting shows errors on dirty', async() => { + it('should display an error message when using ShowOnDirtyErrorStateMatcher', async(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ imports: [ @@ -787,79 +830,36 @@ describe('MdInputContainer', function () { MdInputContainerWithFormErrorMessages ], providers: [ - { provide: MD_ERROR_GLOBAL_OPTIONS, useValue: { showOnDirty: true } } + { provide: MD_ERROR_GLOBAL_OPTIONS, useClass: ShowOnDirtyErrorStateMatcher } ] }); - let customFixture = TestBed.createComponent(MdInputContainerWithFormErrorMessages); - customFixture.detectChanges(); + let fixture = TestBed.createComponent(MdInputContainerWithFormErrorMessages); + fixture.detectChanges(); - containerEl = customFixture.debugElement.query(By.css('md-input-container')).nativeElement; - testComponent = customFixture.componentInstance; + let containerEl = fixture.debugElement.query(By.css('md-input-container')).nativeElement; + let testComponent = fixture.componentInstance; expect(testComponent.formControl.invalid).toBe(true, 'Expected form control to be invalid'); expect(containerEl.querySelectorAll('md-error').length).toBe(0, 'Expected no error messages'); - testComponent.formControl.markAsTouched(); - customFixture.detectChanges(); - - customFixture.whenStable().then(() => { - expect(containerEl.querySelectorAll('md-error').length) - .toBe(0, 'Expected no error messages when touched'); - - testComponent.formControl.markAsDirty(); - customFixture.detectChanges(); - - customFixture.whenStable().then(() => { - expect(containerEl.querySelectorAll('md-error').length) - .toBe(1, 'Expected one error message when dirty'); - }); - }); - - }); - - it('should hide the errors and show the hints once the input becomes valid', async(() => { testComponent.formControl.markAsTouched(); fixture.detectChanges(); fixture.whenStable().then(() => { - expect(containerEl.classList) - .toContain('mat-input-invalid', 'Expected container to have the invalid CSS class.'); expect(containerEl.querySelectorAll('md-error').length) - .toBe(1, 'Expected one error message to have been rendered.'); - expect(containerEl.querySelectorAll('md-hint').length) - .toBe(0, 'Expected no hints to be shown.'); + .toBe(0, 'Expected no error messages when touched'); - testComponent.formControl.setValue('something'); + testComponent.formControl.markAsDirty(); fixture.detectChanges(); fixture.whenStable().then(() => { - expect(containerEl.classList).not.toContain('mat-input-invalid', - 'Expected container not to have the invalid class when valid.'); expect(containerEl.querySelectorAll('md-error').length) - .toBe(0, 'Expected no error messages when the input is valid.'); - expect(containerEl.querySelectorAll('md-hint').length) - .toBe(1, 'Expected one hint to be shown once the input is valid.'); + .toBe(1, 'Expected one error message when dirty'); }); }); - })); - - it('should not hide the hint if there are no error messages', async(() => { - testComponent.renderError = false; - fixture.detectChanges(); - expect(containerEl.querySelectorAll('md-hint').length) - .toBe(1, 'Expected one hint to be shown on load.'); - - testComponent.formControl.markAsTouched(); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(containerEl.querySelectorAll('md-hint').length) - .toBe(1, 'Expected one hint to still be shown.'); - }); })); - }); it('should not have prefix and suffix elements when none are specified', () => { diff --git a/src/lib/input/input-container.ts b/src/lib/input/input-container.ts index 3770d2da5870..c9db2cf8da1a 100644 --- a/src/lib/input/input-container.ts +++ b/src/lib/input/input-container.ts @@ -43,7 +43,8 @@ import { MD_PLACEHOLDER_GLOBAL_OPTIONS } from '../core/placeholder/placeholder-options'; import { - ErrorStateMatcherType, + DefaultErrorStateMatcher, + ErrorStateMatcher, ErrorOptions, MD_ERROR_GLOBAL_OPTIONS } from '../core/error/error-options'; @@ -196,7 +197,7 @@ export class MdInputDirective { } /** A function used to control when error messages are shown. */ - @Input() errorStateMatcher: ErrorStateMatcherType; + @Input() errorStateMatcher: ErrorStateMatcher; /** The input element's value. */ get value() { return this._elementRef.nativeElement.value; } @@ -240,7 +241,8 @@ export class MdInputDirective { this.id = this.id; this._errorOptions = errorOptions ? errorOptions : {}; - this.errorStateMatcher = this._errorOptions.errorStateMatcher || undefined; + this.errorStateMatcher = this._errorOptions.errorStateMatcher + || new DefaultErrorStateMatcher().errorStateMatcher; } /** Focuses the input element. */ @@ -263,24 +265,7 @@ export class MdInputDirective { /** Whether the input is in an error state. */ _isErrorState(): boolean { const control = this._ngControl; - return this.errorStateMatcher - ? this.errorStateMatcher(control, this._parentFormGroup, this._parentForm) - : this._defaultErrorStateMatcher(control); - } - - /** Default error state calculation */ - private _defaultErrorStateMatcher(control: NgControl): boolean { - const isInvalid = control && control.invalid; - const isTouched = control && control.touched; - const isDirty = control && control.dirty; - const isSubmitted = (this._parentFormGroup && this._parentFormGroup.submitted) || - (this._parentForm && this._parentForm.submitted); - - if (this._errorOptions.showOnDirty) { - return !!(isInvalid && (isDirty || isSubmitted)); - } - - return !!(isInvalid && (isTouched || isSubmitted)); + return this.errorStateMatcher(control, this._parentFormGroup, this._parentForm); } /** Make sure the input is a supported type. */ diff --git a/src/lib/input/input.md b/src/lib/input/input.md index 6043f9abaedc..e8000f311eb1 100644 --- a/src/lib/input/input.md +++ b/src/lib/input/input.md @@ -142,8 +142,17 @@ to all inputs. Here are the available global options: - | Name | Type | Description | | ----------------- | -------- | ----------- | | errorStateMatcher | Function | Returns a boolean specifying if the error should be shown | -| showOnDirty | boolean | If true, the error will show when the control is dirty, not touched. |P \ No newline at end of file + + +If you just wish to make all inputs behave the same as the default, but show errors when +dirty instead of touched, you can use the `ShowOnDirtyErrorStateMatcher` implementation. + +```ts +@NgModule({ + providers: [ + { provide: MD_ERROR_GLOBAL_OPTIONS, useClass: ShowOnDirtyErrorStateMatcher } + ] +}) \ No newline at end of file From ab870d170734d70689171a5c01808eb1ba824a64 Mon Sep 17 00:00:00 2001 From: Will Howell Date: Thu, 22 Jun 2017 15:13:32 -0400 Subject: [PATCH 3/6] Address comments pt. 2 --- src/demo-app/input/input-demo.ts | 4 ++-- src/lib/core/core.ts | 4 +++- src/lib/core/error/error-options.ts | 33 ++++++++++++--------------- src/lib/input/input-container.spec.ts | 13 +++++++---- src/lib/input/input-container.ts | 5 ++-- src/lib/input/input.md | 29 ++++++++--------------- 6 files changed, 40 insertions(+), 48 deletions(-) diff --git a/src/demo-app/input/input-demo.ts b/src/demo-app/input/input-demo.ts index 7f0432c1916b..f8e4c3287c2a 100644 --- a/src/demo-app/input/input-demo.ts +++ b/src/demo-app/input/input-demo.ts @@ -46,9 +46,9 @@ export class InputDemo { } customErrorStateMatcher(c: NgControl): boolean { - const isDirty = c.dirty; + const hasInteraction = c.dirty || c.touched; const isInvalid = c.invalid; - return isDirty && isInvalid; + return !!(hasInteraction && isInvalid); } } diff --git a/src/lib/core/core.ts b/src/lib/core/core.ts index 7088c3817186..96d2c009d5ac 100644 --- a/src/lib/core/core.ts +++ b/src/lib/core/core.ts @@ -122,7 +122,9 @@ export { export { ErrorStateMatcher, ErrorOptions, - MD_ERROR_GLOBAL_OPTIONS + MD_ERROR_GLOBAL_OPTIONS, + defaultErrorStateMatcher, + showOnDirtyErrorStateMatcher } from './error/error-options'; @NgModule({ diff --git a/src/lib/core/error/error-options.ts b/src/lib/core/error/error-options.ts index d2f71b1b719a..25ea761dd0b3 100644 --- a/src/lib/core/error/error-options.ts +++ b/src/lib/core/error/error-options.ts @@ -10,8 +10,7 @@ import {InjectionToken} from '@angular/core'; import {NgControl, FormGroupDirective, NgForm} from '@angular/forms'; /** Injection token that can be used to specify the global error options. */ -export const MD_ERROR_GLOBAL_OPTIONS = - new InjectionToken('md-error-global-options'); +export const MD_ERROR_GLOBAL_OPTIONS = new InjectionToken('md-error-global-options'); export type ErrorStateMatcher = (control: NgControl, parentFormGroup: FormGroupDirective, parentForm: NgForm) => boolean; @@ -20,26 +19,24 @@ export interface ErrorOptions { errorStateMatcher?: ErrorStateMatcher; } -export class DefaultErrorStateMatcher { +export function defaultErrorStateMatcher(control: NgControl, formGroup: FormGroupDirective, + form: NgForm): boolean { - errorStateMatcher(control: NgControl, formGroup: FormGroupDirective, form: NgForm): boolean { - const isInvalid = control && control.invalid; - const isTouched = control && control.touched; - const isSubmitted = (formGroup && formGroup.submitted) || - (form && form.submitted); + const isInvalid = control && control.invalid; + const isTouched = control && control.touched; + const isSubmitted = (formGroup && formGroup.submitted) || + (form && form.submitted); - return !!(isInvalid && (isTouched || isSubmitted)); - } + return !!(isInvalid && (isTouched || isSubmitted)); } -export class ShowOnDirtyErrorStateMatcher { +export function showOnDirtyErrorStateMatcher(control: NgControl, formGroup: FormGroupDirective, + form: NgForm): boolean { - errorStateMatcher(control: NgControl, formGroup: FormGroupDirective, form: NgForm): boolean { - const isInvalid = control && control.invalid; - const isDirty = control && control.dirty; - const isSubmitted = (formGroup && formGroup.submitted) || - (form && form.submitted); + const isInvalid = control && control.invalid; + const isDirty = control && control.dirty; + const isSubmitted = (formGroup && formGroup.submitted) || + (form && form.submitted); - return !!(isInvalid && (isDirty || isSubmitted)); - } + return !!(isInvalid && (isDirty || isSubmitted)); } diff --git a/src/lib/input/input-container.spec.ts b/src/lib/input/input-container.spec.ts index b67dc4fa5014..2f946249a3fe 100644 --- a/src/lib/input/input-container.spec.ts +++ b/src/lib/input/input-container.spec.ts @@ -24,7 +24,7 @@ import { getMdInputContainerPlaceholderConflictError } from './input-container-errors'; import {MD_PLACEHOLDER_GLOBAL_OPTIONS} from '../core/placeholder/placeholder-options'; -import {MD_ERROR_GLOBAL_OPTIONS, ShowOnDirtyErrorStateMatcher} from '../core/error/error-options'; +import {MD_ERROR_GLOBAL_OPTIONS, showOnDirtyErrorStateMatcher} from '../core/error/error-options'; describe('MdInputContainer', function () { beforeEach(async(() => { @@ -817,7 +817,7 @@ describe('MdInputContainer', function () { expect(containerEl.querySelectorAll('md-error').length).toBe(1, 'Expected an error message'); }); - it('should display an error message when using ShowOnDirtyErrorStateMatcher', async(() => { + it('should display an error message when using showOnDirtyErrorStateMatcher', async(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ imports: [ @@ -830,7 +830,10 @@ describe('MdInputContainer', function () { MdInputContainerWithFormErrorMessages ], providers: [ - { provide: MD_ERROR_GLOBAL_OPTIONS, useClass: ShowOnDirtyErrorStateMatcher } + { + provide: MD_ERROR_GLOBAL_OPTIONS, + useValue: { errorStateMatcher: showOnDirtyErrorStateMatcher } + } ] }); @@ -1136,8 +1139,8 @@ class MdInputContainerWithFormErrorMessages { + [formControl]="formControl" + [errorStateMatcher]="customErrorStateMatcher.bind(this)"> Please type something This field is required diff --git a/src/lib/input/input-container.ts b/src/lib/input/input-container.ts index c9db2cf8da1a..fb172a342ca4 100644 --- a/src/lib/input/input-container.ts +++ b/src/lib/input/input-container.ts @@ -43,7 +43,7 @@ import { MD_PLACEHOLDER_GLOBAL_OPTIONS } from '../core/placeholder/placeholder-options'; import { - DefaultErrorStateMatcher, + defaultErrorStateMatcher, ErrorStateMatcher, ErrorOptions, MD_ERROR_GLOBAL_OPTIONS @@ -241,8 +241,7 @@ export class MdInputDirective { this.id = this.id; this._errorOptions = errorOptions ? errorOptions : {}; - this.errorStateMatcher = this._errorOptions.errorStateMatcher - || new DefaultErrorStateMatcher().errorStateMatcher; + this.errorStateMatcher = this._errorOptions.errorStateMatcher || defaultErrorStateMatcher; } /** Focuses the input element. */ diff --git a/src/lib/input/input.md b/src/lib/input/input.md index e8000f311eb1..f381dd0c1781 100644 --- a/src/lib/input/input.md +++ b/src/lib/input/input.md @@ -110,11 +110,12 @@ warn color. ### Custom Error Matcher -By default, error messages are shown when the control is invalid and the user has interacted with -(touched) the element or the parent form has been submitted. If you wish to customize this -behavior (e.g. to show the error as soon as the invalid control is dirty), you can use the -`errorStateMatcher` property of the `mdInput`. To use this property, create a function in -your component class that returns a boolean. A result of `true` will display the error messages. +By default, error messages are shown when the control is invalid and either the user has interacted with +(touched) the element or the parent form has been submitted. If you wish to override this +behavior (e.g. to show the error as soon as the invalid control is dirty or when a parent form group +is invalid), you can use the `errorStateMatcher` property of the `mdInput`. To use this property, +create a function in your component class that returns a boolean. A result of `true` will display +the error messages. ```html @@ -125,17 +126,18 @@ your component class that returns a boolean. A result of `true` will display the ```ts function myErrorStateMatcher(control: NgControl, parentFg: FormGroupDirective, parentForm: NgForm): boolean { - return control.invalid && control.dirty; + return !!(control.invalid && control.dirty); } ``` A global error state matcher can be specified by setting the `MD_ERROR_GLOBAL_OPTIONS` provider. This applies -to all inputs. +to all inputs. For convenience, `showOnDirtyErrorStateMatcher` is available in order to globally cause +input errors to show when the input is dirty and invalid. ```ts @NgModule({ providers: [ - {provide: MD_ERROR_GLOBAL_OPTIONS, useValue: { errorStateMatcher: myErrorStateMatcher }} + {provide: MD_ERROR_GLOBAL_OPTIONS, useValue: { errorStateMatcher: showOnDirtyErrorStateMatcher }} ] }) ``` @@ -145,14 +147,3 @@ Here are the available global options: | Name | Type | Description | | ----------------- | -------- | ----------- | | errorStateMatcher | Function | Returns a boolean specifying if the error should be shown | - - -If you just wish to make all inputs behave the same as the default, but show errors when -dirty instead of touched, you can use the `ShowOnDirtyErrorStateMatcher` implementation. - -```ts -@NgModule({ - providers: [ - { provide: MD_ERROR_GLOBAL_OPTIONS, useClass: ShowOnDirtyErrorStateMatcher } - ] -}) \ No newline at end of file From 9bc13bba505378339bc8ae627cd1129c0497546e Mon Sep 17 00:00:00 2001 From: Will Howell Date: Mon, 26 Jun 2017 18:02:07 -0400 Subject: [PATCH 4/6] Use FormControl and only one of incompatible form options --- src/demo-app/input/input-demo.ts | 4 ++-- src/lib/core/error/error-options.ts | 27 +++++++++++---------------- src/lib/input/input-container.spec.ts | 17 ++++++++++------- src/lib/input/input-container.ts | 5 +++-- src/lib/input/input.md | 10 ++++++++-- 5 files changed, 34 insertions(+), 29 deletions(-) diff --git a/src/demo-app/input/input-demo.ts b/src/demo-app/input/input-demo.ts index f8e4c3287c2a..f0b7d9d7bbbd 100644 --- a/src/demo-app/input/input-demo.ts +++ b/src/demo-app/input/input-demo.ts @@ -1,5 +1,5 @@ import {Component} from '@angular/core'; -import {FormControl, Validators, NgControl} from '@angular/forms'; +import {FormControl, Validators} from '@angular/forms'; let max = 5; @@ -45,7 +45,7 @@ export class InputDemo { } } - customErrorStateMatcher(c: NgControl): boolean { + customErrorStateMatcher(c: FormControl): boolean { const hasInteraction = c.dirty || c.touched; const isInvalid = c.invalid; diff --git a/src/lib/core/error/error-options.ts b/src/lib/core/error/error-options.ts index 25ea761dd0b3..0b5ab91a2618 100644 --- a/src/lib/core/error/error-options.ts +++ b/src/lib/core/error/error-options.ts @@ -7,36 +7,31 @@ */ import {InjectionToken} from '@angular/core'; -import {NgControl, FormGroupDirective, NgForm} from '@angular/forms'; +import {FormControl, FormGroupDirective, Form, NgForm} from '@angular/forms'; /** Injection token that can be used to specify the global error options. */ export const MD_ERROR_GLOBAL_OPTIONS = new InjectionToken('md-error-global-options'); export type ErrorStateMatcher = - (control: NgControl, parentFormGroup: FormGroupDirective, parentForm: NgForm) => boolean; + (control: FormControl, form: FormGroupDirective | NgForm) => boolean; export interface ErrorOptions { errorStateMatcher?: ErrorStateMatcher; } -export function defaultErrorStateMatcher(control: NgControl, formGroup: FormGroupDirective, - form: NgForm): boolean { - - const isInvalid = control && control.invalid; - const isTouched = control && control.touched; - const isSubmitted = (formGroup && formGroup.submitted) || - (form && form.submitted); +export function defaultErrorStateMatcher(control: FormControl, form: FormGroupDirective | NgForm) { + const isInvalid = control.invalid; + const isTouched = control.touched; + const isSubmitted = form && form.submitted; return !!(isInvalid && (isTouched || isSubmitted)); } -export function showOnDirtyErrorStateMatcher(control: NgControl, formGroup: FormGroupDirective, - form: NgForm): boolean { - - const isInvalid = control && control.invalid; - const isDirty = control && control.dirty; - const isSubmitted = (formGroup && formGroup.submitted) || - (form && form.submitted); +export function showOnDirtyErrorStateMatcher(control: FormControl, + form: FormGroupDirective | NgForm) { + const isInvalid = control.invalid; + const isDirty = control.dirty; + const isSubmitted = form && form.submitted; return !!(isInvalid && (isDirty || isSubmitted)); } diff --git a/src/lib/input/input-container.spec.ts b/src/lib/input/input-container.spec.ts index 2f946249a3fe..d0cbaf0289b6 100644 --- a/src/lib/input/input-container.spec.ts +++ b/src/lib/input/input-container.spec.ts @@ -6,7 +6,6 @@ import { FormGroupDirective, FormsModule, NgForm, - NgControl, ReactiveFormsModule, Validators } from '@angular/forms'; @@ -760,10 +759,12 @@ describe('MdInputContainer', function () { let component = fixture.componentInstance; let containerEl = fixture.debugElement.query(By.css('md-input-container')).nativeElement; - expect(component.formControl.invalid).toBe(true, 'Expected form control to be invalid'); + const control = component.formGroup.get('name')!; + + expect(control.invalid).toBe(true, 'Expected form control to be invalid'); expect(containerEl.querySelectorAll('md-error').length).toBe(0, 'Expected no error messages'); - component.formControl.markAsTouched(); + control.markAsTouched(); fixture.detectChanges(); fixture.whenStable().then(() => { @@ -1136,10 +1137,10 @@ class MdInputContainerWithFormErrorMessages { @Component({ template: ` - + Please type something This field is required @@ -1148,8 +1149,10 @@ class MdInputContainerWithFormErrorMessages { ` }) class MdInputContainerWithCustomErrorStateMatcher { - @ViewChild('form') form: NgForm; - formControl = new FormControl('', Validators.required); + formGroup = new FormGroup({ + name: new FormControl('', Validators.required) + }); + errorState = false; customErrorStateMatcher(): boolean { diff --git a/src/lib/input/input-container.ts b/src/lib/input/input-container.ts index fb172a342ca4..07fea22ced03 100644 --- a/src/lib/input/input-container.ts +++ b/src/lib/input/input-container.ts @@ -29,7 +29,7 @@ import { } from '@angular/core'; import {animate, state, style, transition, trigger} from '@angular/animations'; import {coerceBooleanProperty, Platform} from '../core'; -import {FormGroupDirective, NgControl, NgForm} from '@angular/forms'; +import {FormGroupDirective, NgControl, NgForm, FormControl} from '@angular/forms'; import {getSupportedInputTypes} from '../core/platform/features'; import { getMdInputContainerDuplicatedHintError, @@ -264,7 +264,8 @@ export class MdInputDirective { /** Whether the input is in an error state. */ _isErrorState(): boolean { const control = this._ngControl; - return this.errorStateMatcher(control, this._parentFormGroup, this._parentForm); + const form = this._parentFormGroup || this._parentForm; + return control && this.errorStateMatcher(control.control as FormControl, form); } /** Make sure the input is a supported type. */ diff --git a/src/lib/input/input.md b/src/lib/input/input.md index f381dd0c1781..14c8d08c2160 100644 --- a/src/lib/input/input.md +++ b/src/lib/input/input.md @@ -125,8 +125,14 @@ the error messages. ``` ```ts -function myErrorStateMatcher(control: NgControl, parentFg: FormGroupDirective, parentForm: NgForm): boolean { - return !!(control.invalid && control.dirty); +function myErrorStateMatcher(control: FormControl, form: FormGroupDirective | NgForm): boolean { + // Error when invalid control is dirty, touched, or submitted + const isInvalid = control.invalid; + const isDirty = control.dirty; + const isTouched = control.touched; + const isSubmitted = form && form.submitted; + + return !!(isInvalid && (isDirty || isTouched || isSubmitted))); } ``` From 6f92cdb1cb9f88d32cf52c447ad5d0b45615cf7e Mon Sep 17 00:00:00 2001 From: Will Howell Date: Mon, 26 Jun 2017 19:18:02 -0400 Subject: [PATCH 5/6] Remove unnecesary async tests and const declarations --- src/lib/core/error/error-options.ts | 10 ++----- src/lib/input/input-container.spec.ts | 40 +++++++++++---------------- src/lib/input/input.md | 6 +--- 3 files changed, 19 insertions(+), 37 deletions(-) diff --git a/src/lib/core/error/error-options.ts b/src/lib/core/error/error-options.ts index 0b5ab91a2618..e5eb8a8a1036 100644 --- a/src/lib/core/error/error-options.ts +++ b/src/lib/core/error/error-options.ts @@ -20,18 +20,12 @@ export interface ErrorOptions { } export function defaultErrorStateMatcher(control: FormControl, form: FormGroupDirective | NgForm) { - const isInvalid = control.invalid; - const isTouched = control.touched; const isSubmitted = form && form.submitted; - - return !!(isInvalid && (isTouched || isSubmitted)); + return !!(control.invalid && (control.touched || isSubmitted)); } export function showOnDirtyErrorStateMatcher(control: FormControl, form: FormGroupDirective | NgForm) { - const isInvalid = control.invalid; - const isDirty = control.dirty; const isSubmitted = form && form.submitted; - - return !!(isInvalid && (isDirty || isSubmitted)); + return !!(control.invalid && (control.dirty || isSubmitted)); } diff --git a/src/lib/input/input-container.spec.ts b/src/lib/input/input-container.spec.ts index d0cbaf0289b6..089d3b259958 100644 --- a/src/lib/input/input-container.spec.ts +++ b/src/lib/input/input-container.spec.ts @@ -752,7 +752,7 @@ describe('MdInputContainer', function () { }); describe('custom error behavior', () => { - it('should display an error message when a custom error matcher returns true', async(() => { + it('should display an error message when a custom error matcher returns true', () => { let fixture = TestBed.createComponent(MdInputContainerWithCustomErrorStateMatcher); fixture.detectChanges(); @@ -762,24 +762,21 @@ describe('MdInputContainer', function () { const control = component.formGroup.get('name')!; expect(control.invalid).toBe(true, 'Expected form control to be invalid'); - expect(containerEl.querySelectorAll('md-error').length).toBe(0, 'Expected no error messages'); + expect(containerEl.querySelectorAll('md-error').length) + .toBe(0, 'Expected no error messages'); control.markAsTouched(); fixture.detectChanges(); - fixture.whenStable().then(() => { - expect(containerEl.querySelectorAll('md-error').length) - .toBe(0, 'Expected no error messages after being touched.'); + expect(containerEl.querySelectorAll('md-error').length) + .toBe(0, 'Expected no error messages after being touched.'); - component.errorState = true; - fixture.detectChanges(); + component.errorState = true; + fixture.detectChanges(); - fixture.whenStable().then(() => { - expect(containerEl.querySelectorAll('md-error').length) - .toBe(1, 'Expected one error messages to have been rendered.'); - }); - }); - })); + expect(containerEl.querySelectorAll('md-error').length) + .toBe(1, 'Expected one error messages to have been rendered.'); + }); it('should display an error message when global error matcher returns true', () => { @@ -850,19 +847,14 @@ describe('MdInputContainer', function () { testComponent.formControl.markAsTouched(); fixture.detectChanges(); - fixture.whenStable().then(() => { - expect(containerEl.querySelectorAll('md-error').length) - .toBe(0, 'Expected no error messages when touched'); - - testComponent.formControl.markAsDirty(); - fixture.detectChanges(); + expect(containerEl.querySelectorAll('md-error').length) + .toBe(0, 'Expected no error messages when touched'); - fixture.whenStable().then(() => { - expect(containerEl.querySelectorAll('md-error').length) - .toBe(1, 'Expected one error message when dirty'); - }); - }); + testComponent.formControl.markAsDirty(); + fixture.detectChanges(); + expect(containerEl.querySelectorAll('md-error').length) + .toBe(1, 'Expected one error message when dirty'); })); }); diff --git a/src/lib/input/input.md b/src/lib/input/input.md index 14c8d08c2160..36e4bfe0e805 100644 --- a/src/lib/input/input.md +++ b/src/lib/input/input.md @@ -127,12 +127,8 @@ the error messages. ```ts function myErrorStateMatcher(control: FormControl, form: FormGroupDirective | NgForm): boolean { // Error when invalid control is dirty, touched, or submitted - const isInvalid = control.invalid; - const isDirty = control.dirty; - const isTouched = control.touched; const isSubmitted = form && form.submitted; - - return !!(isInvalid && (isDirty || isTouched || isSubmitted))); + return !!(control.invalid && (control.dirty || control.touched || isSubmitted))); } ``` From ea477cc6e50e47501e1405e8bf00c1a179ac5bfb Mon Sep 17 00:00:00 2001 From: Will Howell Date: Tue, 27 Jun 2017 17:24:12 -0400 Subject: [PATCH 6/6] Add jsdoc comments to error state matchers --- src/lib/core/error/error-options.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/core/error/error-options.ts b/src/lib/core/error/error-options.ts index e5eb8a8a1036..ec1cd71e42ea 100644 --- a/src/lib/core/error/error-options.ts +++ b/src/lib/core/error/error-options.ts @@ -19,11 +19,13 @@ export interface ErrorOptions { errorStateMatcher?: ErrorStateMatcher; } +/** Returns whether control is invalid and is either touched or is a part of a submitted form. */ export function defaultErrorStateMatcher(control: FormControl, form: FormGroupDirective | NgForm) { const isSubmitted = form && form.submitted; return !!(control.invalid && (control.touched || isSubmitted)); } +/** Returns whether control is invalid and is either dirty or is a part of a submitted form. */ export function showOnDirtyErrorStateMatcher(control: FormControl, form: FormGroupDirective | NgForm) { const isSubmitted = form && form.submitted;