Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(input): add custom error state matcher #4750

Merged
merged 6 commits into from
Jun 28, 2017
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/demo-app/input/input-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,17 @@ <h4>Inside a form</h4>

<button color="primary" md-raised-button>Submit</button>
</form>

<h4>With a custom error function</h4>
<md-input-container>
<input mdInput
placeholder="example"
[(ngModel)]="errorMessageExample4"
[errorStateMatcher]="customErrorStateMatcher"
required>
<md-error>This field is required</md-error>
</md-input-container>

</md-card-content>
</md-card>

Expand Down
10 changes: 9 additions & 1 deletion src/demo-app/input/input-demo.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -23,6 +23,7 @@ export class InputDemo {
errorMessageExample1: string;
errorMessageExample2: string;
errorMessageExample3: string;
errorMessageExample4: string;
dividerColorExample1: string;
dividerColorExample2: string;
dividerColorExample3: string;
Expand All @@ -43,4 +44,11 @@ export class InputDemo {
this.items.push({ value: ++max });
}
}

customErrorStateMatcher(c: NgControl): boolean {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, it's a bit odd to pass through the directive because any relevant properties just fall through directly to its FormControl. I think here it would make sense to expose a FormControl instance.

const hasInteraction = c.dirty || c.touched;
const isInvalid = c.invalid;

return !!(hasInteraction && isInvalid);
}
}
9 changes: 9 additions & 0 deletions src/lib/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ export {
MD_PLACEHOLDER_GLOBAL_OPTIONS
} from './placeholder/placeholder-options';

// Error
export {
ErrorStateMatcher,
ErrorOptions,
MD_ERROR_GLOBAL_OPTIONS,
defaultErrorStateMatcher,
showOnDirtyErrorStateMatcher
} from './error/error-options';

@NgModule({
imports: [
MdLineModule,
Expand Down
42 changes: 42 additions & 0 deletions src/lib/core/error/error-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* @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<ErrorOptions>('md-error-global-options');

export type ErrorStateMatcher =
(control: NgControl, parentFormGroup: FormGroupDirective, parentForm: 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);

return !!(isInvalid && (isTouched || isSubmitted));
}

export function showOnDirtyErrorStateMatcher(control: NgControl, formGroup: FormGroupDirective,
form: NgForm): boolean {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should be surfacing both FormGroupDirective and NgForm here. They are mutually exclusive, so one of these args will always be null. We shouldn't put it on the user to check for the existence of one or the other every time. Given that they both implement the Form interface, it might make more sense to use the Form type here and only pass through the existing parent to this function.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the sentiment, but I'm not clear on how it would work. The Form interface doesn't have the submitted property, which is the main (if not only) reason we need it. I see two solutions

  1. Use the union FormGroupDirective | NgForm
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));
}
  1. Just pass submitted and don't expose either FormGroupDirective or NgForm
export function showOnDirtyErrorStateMatcher(control: FormControl, submitted: boolean) {
  const isInvalid = control.invalid;
  const isDirty = control.dirty;

  return !!(isInvalid && (isDirty || submitted));
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, the fact that the submitted property is missing from Form interface seems like an oversight (will look into it). Given that fact, I prefer #1.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kara were you ever able to check into why submitted is missing from the Form interface?


const isInvalid = control && control.invalid;
const isDirty = control && control.dirty;
const isSubmitted = (formGroup && formGroup.submitted) ||
(form && form.submitted);

return !!(isInvalid && (isDirty || isSubmitted));
}
139 changes: 139 additions & 0 deletions src/lib/input/input-container.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
FormGroupDirective,
FormsModule,
NgForm,
NgControl,
ReactiveFormsModule,
Validators
} from '@angular/forms';
Expand All @@ -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, showOnDirtyErrorStateMatcher} from '../core/error/error-options';

describe('MdInputContainer', function () {
beforeEach(async(() => {
Expand Down Expand Up @@ -56,6 +58,7 @@ describe('MdInputContainer', function () {
MdInputContainerWithDynamicPlaceholder,
MdInputContainerWithFormControl,
MdInputContainerWithFormErrorMessages,
MdInputContainerWithCustomErrorStateMatcher,
MdInputContainerWithFormGroupErrorMessages,
MdInputContainerWithId,
MdInputContainerWithPrefixAndSuffix,
Expand Down Expand Up @@ -749,6 +752,119 @@ describe('MdInputContainer', function () {

});

describe('custom error behavior', () => {
it('should display an error message when a custom error matcher returns true', async(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that we're just using reactive form directives now, these tests don't need to be async. You should be able to safely remove the async and whenStable calls.

let fixture = TestBed.createComponent(MdInputContainerWithCustomErrorStateMatcher);
fixture.detectChanges();

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();
fixture.detectChanges();

fixture.whenStable().then(() => {
expect(containerEl.querySelectorAll('md-error').length)
.toBe(0, 'Expected no error messages after being touched.');

component.errorState = true;
fixture.detectChanges();

fixture.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 fixture = TestBed.createComponent(MdInputContainerWithFormErrorMessages);

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 using showOnDirtyErrorStateMatcher', async(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [
FormsModule,
MdInputModule,
NoopAnimationsModule,
ReactiveFormsModule,
],
declarations: [
MdInputContainerWithFormErrorMessages
],
providers: [
{
provide: MD_ERROR_GLOBAL_OPTIONS,
useValue: { errorStateMatcher: showOnDirtyErrorStateMatcher }
}
]
});

let fixture = TestBed.createComponent(MdInputContainerWithFormErrorMessages);
fixture.detectChanges();

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();
fixture.detectChanges();

fixture.whenStable().then(() => {
expect(containerEl.querySelectorAll('md-error').length)
.toBe(0, 'Expected no error messages when touched');

testComponent.formControl.markAsDirty();
fixture.detectChanges();

fixture.whenStable().then(() => {
expect(containerEl.querySelectorAll('md-error').length)
.toBe(1, 'Expected one error message when dirty');
});
});

}));
});

it('should not have prefix and suffix elements when none are specified', () => {
let fixture = TestBed.createComponent(MdInputContainerWithId);
fixture.detectChanges();
Expand Down Expand Up @@ -1018,6 +1134,29 @@ class MdInputContainerWithFormErrorMessages {
renderError = true;
}

@Component({
template: `
<form #form="ngForm" novalidate>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove novalidate? This is added by default by the NgNoValidate directive in @angular/forms.

<md-input-container>
<input mdInput
[formControl]="formControl"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NgForm comes with the FormsModule and formControl comes from the ReactiveFormsModule. It's not really great to mix them, and the point of using formControl is to have a standalone control without a parent.

If you want a more "idiomatic" test case, I'd suggest switching to formGroup on the form tag and then use formControlName rather than formControl.

[errorStateMatcher]="customErrorStateMatcher.bind(this)">
<md-hint>Please type something</md-hint>
<md-error>This field is required</md-error>
</md-input-container>
</form>
`
})
class MdInputContainerWithCustomErrorStateMatcher {
@ViewChild('form') form: NgForm;
formControl = new FormControl('', Validators.required);
errorState = false;

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

@Component({
template: `
<form [formGroup]="formGroup" novalidate>
Expand Down
23 changes: 16 additions & 7 deletions src/lib/input/input-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ import {
PlaceholderOptions,
MD_PLACEHOLDER_GLOBAL_OPTIONS
} from '../core/placeholder/placeholder-options';
import {
defaultErrorStateMatcher,
ErrorStateMatcher,
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 = [
Expand Down Expand Up @@ -137,6 +143,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;
Expand Down Expand Up @@ -189,6 +196,9 @@ export class MdInputDirective {
}
}

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

/** The input element's value. */
get value() { return this._elementRef.nativeElement.value; }
set value(value: string) { this._elementRef.nativeElement.value = value; }
Expand Down Expand Up @@ -224,10 +234,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 || defaultErrorStateMatcher;
}

/** Focuses the input element. */
Expand All @@ -250,12 +264,7 @@ export class MdInputDirective {
/** Whether the input is in an error state. */
_isErrorState(): boolean {
const control = this._ngControl;
const isInvalid = control && control.invalid;
const isTouched = control && control.touched;
const isSubmitted = (this._parentFormGroup && this._parentFormGroup.submitted) ||
(this._parentForm && this._parentForm.submitted);

return !!(isInvalid && (isTouched || isSubmitted));
return this.errorStateMatcher(control, this._parentFormGroup, this._parentForm);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should be passing through this._ngControl.control here. In other words, the FormControl instance rather than the NgControl directive. I don't think most people are familiar with NgControls, and probably aren't familiar with the subtle distinction between the directive and the FormControl itself. While most properties are mirrored on the directive, a few aren't, and I foresee that being confusing.

}

/** Make sure the input is a supported type. */
Expand Down
40 changes: 40 additions & 0 deletions src/lib/input/input.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
<md-input-container>
<input mdInput [(ngModel)]="myInput" required [errorStateMatcher]="myErrorStateMatcher">
<md-error>This field is required</md-error>
</md-input-container>
```

```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. 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: showOnDirtyErrorStateMatcher }}
]
})
```

Here are the available global options:

| Name | Type | Description |
| ----------------- | -------- | ----------- |
| errorStateMatcher | Function | Returns a boolean specifying if the error should be shown |