Skip to content

Commit

Permalink
feat(select): add support for custom error state matcher
Browse files Browse the repository at this point in the history
* Allows for the select's error state matcher to be overwritten through an `@Input`.
* Switches `MatSelect` over to use the same global provider for its error state as `MatInput`.

**Note:** This is a resubmit of #6147 that works with our latest setup and excludes a few changes.
  • Loading branch information
crisbeto committed Sep 30, 2017
1 parent 3c6f7a2 commit dfeaa7f
Show file tree
Hide file tree
Showing 11 changed files with 143 additions and 109 deletions.
21 changes: 14 additions & 7 deletions src/demo-app/input/input-demo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import {FormControl, Validators} from '@angular/forms';
import {Component, ChangeDetectionStrategy} from '@angular/core';
import {FormControl, NgControl, FormGroupDirective, NgForm, Validators} from '@angular/forms';
import {ErrorStateMatcher} from '@angular/material';


let max = 5;
Expand Down Expand Up @@ -52,10 +53,16 @@ export class InputDemo {
}
}

customErrorStateMatcher(c: FormControl): boolean {
const hasInteraction = c.dirty || c.touched;
const isInvalid = c.invalid;
customErrorStateMatcher: ErrorStateMatcher = {
isErrorState: (control: NgControl | null) => {
if (control) {
const hasInteraction = control.dirty || control.touched;
const isInvalid = control.invalid;

return !!(hasInteraction && isInvalid);
}
return !!(hasInteraction && isInvalid);
}

return false;
}
};
}
36 changes: 14 additions & 22 deletions src/lib/core/error/error-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,21 @@
* found in the LICENSE file at https://angular.io/license
*/

import {InjectionToken} from '@angular/core';
import {FormControl, FormGroupDirective, NgForm} from '@angular/forms';
import {Injectable} from '@angular/core';
import {FormGroupDirective, NgForm, NgControl} from '@angular/forms';

/** Injection token that can be used to specify the global error options. */
export const MAT_ERROR_GLOBAL_OPTIONS =
new InjectionToken<ErrorOptions>('mat-error-global-options');

export type ErrorStateMatcher =
(control: FormControl, form: FormGroupDirective | NgForm) => boolean;

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));
/** Error state matcher that matches when a control is invalid and dirty. */
@Injectable()
export class ShowOnDirtyErrorStateMatcher implements ErrorStateMatcher {
isErrorState(control: NgControl | null, form: FormGroupDirective | NgForm | null): boolean {
return control ? !!(control.invalid && (control.dirty || (form && form.submitted))) : false;
}
}

/** 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;
return !!(control.invalid && (control.dirty || isSubmitted));
/** Provider that defines how form controls behave with regards to displaying error messages. */
@Injectable()
export class ErrorStateMatcher {
isErrorState(control: NgControl | null, form: FormGroupDirective | NgForm | null): boolean {
return control ? !!(control.invalid && (control.touched || (form && form.submitted))) : false;
}
}
2 changes: 2 additions & 0 deletions src/lib/input/input-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {NgModule} from '@angular/core';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatTextareaAutosize} from './autosize';
import {MatInput} from './input';
import {ErrorStateMatcher} from '@angular/material/core';


@NgModule({
Expand All @@ -31,5 +32,6 @@ import {MatInput} from './input';
MatInput,
MatTextareaAutosize,
],
providers: [ErrorStateMatcher],
})
export class MatInputModule {}
27 changes: 14 additions & 13 deletions src/lib/input/input.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,12 @@ warn color.

### Custom Error Matcher

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
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 `matInput`. To use this property,
create a function in your component class that returns a boolean. A result of `true` will display
the error messages.
create an `ErrorStateMatcher` object in your component class that has a `isErrorState` function which
returns a boolean. A result of `true` will display the error messages.

```html
<mat-form-field>
Expand All @@ -126,25 +126,26 @@ the error messages.
```

```ts
function myErrorStateMatcher(control: FormControl, form: FormGroupDirective | NgForm): boolean {
// Error when invalid control is dirty, touched, or submitted
const isSubmitted = form && form.submitted;
return !!(control.invalid && (control.dirty || control.touched || isSubmitted));
class MyErrorStateMatcher implements ErrorStateMatcher {
isErrorState(control: NgControl | null, form: FormGroupDirective | NgForm | null): boolean {
// Error when invalid control is dirty, touched, or submitted
const isSubmitted = form && form.submitted;
return !!(control && control.invalid && (control.dirty || control.touched || isSubmitted)));
}
}
```

A global error state matcher can be specified by setting the `MAT_ERROR_GLOBAL_OPTIONS` provider. This applies
to all inputs. For convenience, `showOnDirtyErrorStateMatcher` is available in order to globally set
input errors to show when the input is dirty and invalid.
A global error state matcher can be specified by setting the `ErrorStateMatcher` provider. This
applies 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: MAT_ERROR_GLOBAL_OPTIONS, useValue: {errorStateMatcher: showOnDirtyErrorStateMatcher}}
{provide: ErrorStateMatcher, useClass: ShowOnDirtyErrorStateMatcher}
]
})
```

Here are the available global options:

| Name | Type | Description |
Expand Down
33 changes: 9 additions & 24 deletions src/lib/input/input.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import {
Validators,
} from '@angular/forms';
import {
MAT_ERROR_GLOBAL_OPTIONS,
MAT_PLACEHOLDER_GLOBAL_OPTIONS,
showOnDirtyErrorStateMatcher,
ShowOnDirtyErrorStateMatcher,
ErrorStateMatcher,
} from '@angular/material/core';
import {
getMatFormFieldDuplicatedHintError,
Expand Down Expand Up @@ -926,12 +926,6 @@ describe('MatInput with forms', () => {
});

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: [
Expand All @@ -944,11 +938,7 @@ describe('MatInput with forms', () => {
declarations: [
MatInputWithFormErrorMessages
],
providers: [
{
provide: MAT_ERROR_GLOBAL_OPTIONS,
useValue: { errorStateMatcher: globalErrorStateMatcher } }
]
providers: [{provide: ErrorStateMatcher, useValue: {isErrorState: () => true}}]
});

let fixture = TestBed.createComponent(MatInputWithFormErrorMessages);
Expand All @@ -963,7 +953,7 @@ describe('MatInput with forms', () => {
expect(containerEl.querySelectorAll('mat-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: [
Expand All @@ -976,12 +966,7 @@ describe('MatInput with forms', () => {
declarations: [
MatInputWithFormErrorMessages
],
providers: [
{
provide: MAT_ERROR_GLOBAL_OPTIONS,
useValue: { errorStateMatcher: showOnDirtyErrorStateMatcher }
}
]
providers: [{provide: ErrorStateMatcher, useClass: ShowOnDirtyErrorStateMatcher}]
});

let fixture = TestBed.createComponent(MatInputWithFormErrorMessages);
Expand Down Expand Up @@ -1298,7 +1283,7 @@ class MatInputWithFormErrorMessages {
<mat-form-field>
<input matInput
formControlName="name"
[errorStateMatcher]="customErrorStateMatcher.bind(this)">
[errorStateMatcher]="customErrorStateMatcher">
<mat-hint>Please type something</mat-hint>
<mat-error>This field is required</mat-error>
</mat-form-field>
Expand All @@ -1312,9 +1297,9 @@ class MatInputWithCustomErrorStateMatcher {

errorState = false;

customErrorStateMatcher(): boolean {
return this.errorState;
}
customErrorStateMatcher = {
isErrorState: () => this.errorState
};
}

@Component({
Expand Down
17 changes: 5 additions & 12 deletions src/lib/input/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,7 @@ import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {FormControl, FormGroupDirective, NgControl, NgForm} from '@angular/forms';
import {Platform, getSupportedInputTypes} from '@angular/cdk/platform';
import {getMatInputUnsupportedTypeError} from './input-errors';
import {
defaultErrorStateMatcher,
ErrorOptions,
ErrorStateMatcher,
MAT_ERROR_GLOBAL_OPTIONS
} from '@angular/material/core';
import {ErrorStateMatcher} from '@angular/material/core';
import {Subject} from 'rxjs/Subject';
import {MatFormFieldControl} from '@angular/material/form-field';

Expand Down Expand Up @@ -74,7 +69,6 @@ export class MatInput implements MatFormFieldControl<any>, OnChanges, OnDestroy,
protected _required = false;
protected _id: string;
protected _uid = `mat-input-${nextUniqueId++}`;
protected _errorOptions: ErrorOptions;
protected _previousNativeValue = this.value;
private _readonly = false;

Expand Down Expand Up @@ -129,7 +123,7 @@ export class MatInput implements MatFormFieldControl<any>, OnChanges, OnDestroy,
}
}

/** A function used to control when error messages are shown. */
/** An object used to control when error messages are shown. */
@Input() errorStateMatcher: ErrorStateMatcher;

/** The input element's value. */
Expand Down Expand Up @@ -162,12 +156,10 @@ export class MatInput implements MatFormFieldControl<any>, OnChanges, OnDestroy,
@Optional() @Self() public ngControl: NgControl,
@Optional() protected _parentForm: NgForm,
@Optional() protected _parentFormGroup: FormGroupDirective,
@Optional() @Inject(MAT_ERROR_GLOBAL_OPTIONS) errorOptions: ErrorOptions) {
private _defaultErrorStateMatcher: ErrorStateMatcher) {

// Force setter to be called in case id was not specified.
this.id = this.id;
this._errorOptions = errorOptions ? errorOptions : {};
this.errorStateMatcher = this._errorOptions.errorStateMatcher || defaultErrorStateMatcher;

// On some versions of iOS the caret gets stuck in the wrong place when holding down the delete
// key. In order to get around this we need to "jiggle" the caret loose. Since this bug only
Expand Down Expand Up @@ -232,7 +224,8 @@ export class MatInput implements MatFormFieldControl<any>, OnChanges, OnDestroy,
const oldState = this.errorState;
const ngControl = this.ngControl;
const parent = this._parentFormGroup || this._parentForm;
const newState = ngControl && this.errorStateMatcher(ngControl.control as FormControl, parent);
const matcher = this.errorStateMatcher || this._defaultErrorStateMatcher;
const newState = ngControl && matcher.isErrorState(ngControl, parent);

if (newState !== oldState) {
this.errorState = newState;
Expand Down
4 changes: 2 additions & 2 deletions src/lib/select/select-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
* 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 {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {MatSelect, MatSelectTrigger, MAT_SELECT_SCROLL_STRATEGY_PROVIDER} from './select';
import {MatCommonModule, MatOptionModule} from '@angular/material/core';
import {OverlayModule} from '@angular/cdk/overlay';
import {ErrorStateMatcher} from '@angular/material/core';


@NgModule({
Expand All @@ -22,6 +22,6 @@ import {OverlayModule} from '@angular/cdk/overlay';
],
exports: [MatSelect, MatSelectTrigger, MatOptionModule, MatCommonModule],
declarations: [MatSelect, MatSelectTrigger],
providers: [MAT_SELECT_SCROLL_STRATEGY_PROVIDER]
providers: [MAT_SELECT_SCROLL_STRATEGY_PROVIDER, ErrorStateMatcher]
})
export class MatSelectModule {}
65 changes: 64 additions & 1 deletion src/lib/select/select.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ import {
extendObject,
FloatPlaceholderType,
MAT_PLACEHOLDER_GLOBAL_OPTIONS,
MatOption
MatOption,
ErrorStateMatcher,
} from '@angular/material/core';
import {MatFormFieldModule} from '@angular/material/form-field';
import {By} from '@angular/platform-browser';
Expand Down Expand Up @@ -91,6 +92,7 @@ describe('MatSelect', () => {
FalsyValueSelect,
SelectInsideFormGroup,
NgModelCompareWithSelect,
CustomErrorBehaviorSelect,
],
providers: [
{provide: OverlayContainer, useFactory: () => {
Expand Down Expand Up @@ -2831,6 +2833,47 @@ describe('MatSelect', () => {
expect(select.getAttribute('aria-invalid'))
.toBe('true', 'Expected aria-invalid to be set to true.');
});

it('should be able to override the error matching behavior via an @Input', () => {
fixture.destroy();

const customErrorFixture = TestBed.createComponent(CustomErrorBehaviorSelect);
const component = customErrorFixture.componentInstance;
const matcher = jasmine.createSpy('error state matcher').and.returnValue(true);

customErrorFixture.detectChanges();

expect(component.control.invalid).toBe(false);
expect(component.select.errorState).toBe(false);

customErrorFixture.componentInstance.errorStateMatcher = { isErrorState: matcher };
customErrorFixture.detectChanges();

expect(component.select.errorState).toBe(true);
expect(matcher).toHaveBeenCalled();
});

it('should be able to override the error matching behavior via the injection token', () => {
const errorStateMatcher: ErrorStateMatcher = {
isErrorState: jasmine.createSpy('error state matcher').and.returnValue(true)
};

fixture.destroy();

TestBed.resetTestingModule().configureTestingModule({
imports: [MatSelectModule, ReactiveFormsModule, FormsModule, NoopAnimationsModule],
declarations: [SelectInsideFormGroup],
providers: [{ provide: ErrorStateMatcher, useValue: errorStateMatcher }],
});

const errorFixture = TestBed.createComponent(SelectInsideFormGroup);
const component = errorFixture.componentInstance;

errorFixture.detectChanges();

expect(component.select.errorState).toBe(true);
expect(errorStateMatcher.isErrorState).toHaveBeenCalled();
});
});

describe('compareWith behavior', () => {
Expand Down Expand Up @@ -3411,6 +3454,7 @@ class InvalidSelectInForm {
})
class SelectInsideFormGroup {
@ViewChild(FormGroupDirective) formGroupDirective: FormGroupDirective;
@ViewChild(MatSelect) select: MatSelect;
formControl = new FormControl('', Validators.required);
formGroup = new FormGroup({
food: this.formControl
Expand Down Expand Up @@ -3545,3 +3589,22 @@ class NgModelCompareWithSelect {
this.selectedFood = extendObject({}, newValue);
}
}

@Component({
template: `
<mat-select placeholder="Food" [formControl]="control" [errorStateMatcher]="errorStateMatcher">
<mat-option *ngFor="let food of foods" [value]="food.value">
{{ food.viewValue }}
</mat-option>
</mat-select>
`
})
class CustomErrorBehaviorSelect {
@ViewChild(MatSelect) select: MatSelect;
control = new FormControl();
foods: any[] = [
{ value: 'steak-0', viewValue: 'Steak' },
{ value: 'pizza-1', viewValue: 'Pizza' },
];
errorStateMatcher: ErrorStateMatcher;
}
Loading

0 comments on commit dfeaa7f

Please sign in to comment.