Skip to content

Commit

Permalink
feat(select): add support for custom errorStateMatcher
Browse files Browse the repository at this point in the history
* Allows for the `md-select` error behavior to be configured through an `@Input`, as well as globally through the same provider as `md-input-container`.
* Simplifies the signature of some of the error option symbols.
  • Loading branch information
crisbeto committed Jul 30, 2017
1 parent 846899d commit a4e2e19
Show file tree
Hide file tree
Showing 5 changed files with 95 additions and 29 deletions.
19 changes: 8 additions & 11 deletions src/lib/core/error/error-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,24 @@
*/

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

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

export type ErrorStateMatcher =
(control: FormControl, form: FormGroupDirective | NgForm) => boolean;
(control: NgControl | null, form: FormGroupDirective | NgForm | null) => 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));
}
export const defaultErrorStateMatcher: ErrorStateMatcher = (control, form) => {
return control ? !!(control.invalid && (control.touched || (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));
}
export const showOnDirtyErrorStateMatcher: ErrorStateMatcher = (control, form) => {
return control ? !!(control.invalid && (control.dirty || (form && form.submitted))) : false;
};
7 changes: 2 additions & 5 deletions src/lib/input/input-container.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1194,7 +1194,7 @@ class MdInputContainerWithFormErrorMessages {
<md-input-container>
<input mdInput
formControlName="name"
[errorStateMatcher]="customErrorStateMatcher.bind(this)">
[errorStateMatcher]="customErrorStateMatcher">
<md-hint>Please type something</md-hint>
<md-error>This field is required</md-error>
</md-input-container>
Expand All @@ -1207,10 +1207,7 @@ class MdInputContainerWithCustomErrorStateMatcher {
});

errorState = false;

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

@Component({
Expand Down
9 changes: 4 additions & 5 deletions src/lib/input/input-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
} from '@angular/core';
import {animate, state, style, transition, trigger} from '@angular/animations';
import {coerceBooleanProperty, Platform} from '../core';
import {FormGroupDirective, NgControl, NgForm, FormControl} from '@angular/forms';
import {FormGroupDirective, NgControl, NgForm} from '@angular/forms';
import {getSupportedInputTypes} from '../core/platform/features';
import {
getMdInputContainerDuplicatedHintError,
Expand Down Expand Up @@ -240,7 +240,7 @@ export class MdInputDirective implements OnChanges, OnDestroy, DoCheck {

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

Expand Down Expand Up @@ -297,9 +297,8 @@ export class MdInputDirective implements OnChanges, OnDestroy, DoCheck {
/** Re-evaluates the error state. This is only relevant with @angular/forms. */
private _updateErrorState() {
const oldState = this._isErrorState;
const control = this._ngControl;
const parent = this._parentFormGroup || this._parentForm;
const newState = control && this.errorStateMatcher(control.control as FormControl, parent);
const newState = this.errorStateMatcher(this._ngControl,
this._parentFormGroup || this._parentForm);

if (newState !== oldState) {
this._isErrorState = newState;
Expand Down
65 changes: 64 additions & 1 deletion src/lib/select/select.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {Subject} from 'rxjs/Subject';
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
import {dispatchFakeEvent, dispatchKeyboardEvent, wrappedErrorMessage} from '@angular/cdk/testing';
import {ScrollDispatcher} from '../core/overlay/scroll/scroll-dispatcher';
import {MD_ERROR_GLOBAL_OPTIONS, ErrorOptions} from '../core/error/error-options';
import {
FloatPlaceholderType,
MD_PLACEHOLDER_GLOBAL_OPTIONS
Expand Down Expand Up @@ -74,7 +75,8 @@ describe('MdSelect', () => {
BasicSelectWithoutForms,
BasicSelectWithoutFormsPreselected,
BasicSelectWithoutFormsMultiple,
SelectInsideFormGroup
SelectInsideFormGroup,
CustomErrorBehaviorSelect
],
providers: [
{provide: OverlayContainer, useFactory: () => {
Expand Down Expand Up @@ -2660,6 +2662,46 @@ describe('MdSelect', () => {
.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._isErrorState()).toBe(false);

customErrorFixture.componentInstance.errorStateMatcher = matcher;
customErrorFixture.detectChanges();

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

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

fixture.destroy();

TestBed.resetTestingModule().configureTestingModule({
imports: [MdSelectModule, ReactiveFormsModule, FormsModule, NoopAnimationsModule],
declarations: [SelectInsideFormGroup],
providers: [{ provide: MD_ERROR_GLOBAL_OPTIONS, useValue: errorOptions }],
});

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

errorFixture.detectChanges();

expect(component.select._isErrorState()).toBe(true);
expect(errorOptions.errorStateMatcher).toHaveBeenCalled();
});
});

});
Expand Down Expand Up @@ -3132,6 +3174,7 @@ class InvalidSelectInForm {
})
class SelectInsideFormGroup {
@ViewChild(FormGroupDirective) formGroupDirective: FormGroupDirective;
@ViewChild(MdSelect) select: MdSelect;
formControl = new FormControl('', Validators.required);
formGroup = new FormGroup({
food: this.formControl
Expand Down Expand Up @@ -3197,3 +3240,23 @@ class BasicSelectWithoutFormsMultiple {

@ViewChild(MdSelect) select: MdSelect;
}

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

24 changes: 17 additions & 7 deletions src/lib/select/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ import {
// tslint:disable-next-line:no-unused-variable
import {ScrollStrategy, RepositionScrollStrategy} from '../core/overlay/scroll';
import {Platform} from '@angular/cdk/platform';
import {
defaultErrorStateMatcher,
ErrorStateMatcher,
ErrorOptions,
MD_ERROR_GLOBAL_OPTIONS
} from '../core/error/error-options';

/**
* The following style constants are necessary to save here in order
Expand Down Expand Up @@ -216,6 +222,9 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
/** Deals with configuring placeholder options */
private _placeholderOptions: PlaceholderOptions;

/** Options that determine how an invalid select behaves. */
private _errorOptions: ErrorOptions;

/**
* The width of the trigger. Must be saved to set the min width of the overlay panel
* and the width of the selected value.
Expand Down Expand Up @@ -359,6 +368,9 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
/** Input that can be used to specify the `aria-labelledby` attribute. */
@Input('aria-labelledby') ariaLabelledby: string = '';

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

/** Combined stream of all of the child options' change events. */
get optionSelectionChanges(): Observable<MdOptionSelectionChange> {
return merge(...this.options.map(option => option.onSelectionChange));
Expand Down Expand Up @@ -393,7 +405,8 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
@Self() @Optional() public _control: NgControl,
@Attribute('tabindex') tabIndex: string,
@Optional() @Inject(MD_PLACEHOLDER_GLOBAL_OPTIONS) placeholderOptions: PlaceholderOptions,
@Inject(MD_SELECT_SCROLL_STRATEGY) private _scrollStrategyFactory) {
@Inject(MD_SELECT_SCROLL_STRATEGY) private _scrollStrategyFactory,
@Optional() @Inject(MD_ERROR_GLOBAL_OPTIONS) errorOptions: ErrorOptions) {

super(renderer, elementRef);

Expand All @@ -404,6 +417,8 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
this._tabIndex = parseInt(tabIndex) || 0;
this._placeholderOptions = placeholderOptions ? placeholderOptions : {};
this.floatPlaceholder = this._placeholderOptions.float || 'auto';
this._errorOptions = errorOptions || {};
this.errorStateMatcher = this._errorOptions.errorStateMatcher || defaultErrorStateMatcher;
}

ngOnInit() {
Expand Down Expand Up @@ -632,12 +647,7 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On

/** Whether the select is in an error state. */
_isErrorState(): boolean {
const isInvalid = this._control && this._control.invalid;
const isTouched = this._control && this._control.touched;
const isSubmitted = (this._parentFormGroup && this._parentFormGroup.submitted) ||
(this._parentForm && this._parentForm.submitted);

return !!(isInvalid && (isTouched || isSubmitted));
return this.errorStateMatcher(this._control, this._parentFormGroup || this._parentForm);
}

/**
Expand Down

0 comments on commit a4e2e19

Please sign in to comment.