-
Notifications
You must be signed in to change notification settings - Fork 6.7k
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
Changes from 3 commits
3ff8d87
b0d69cc
ab870d1
9bc13bb
6f92cdb
ea477cc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think we should be surfacing both There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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));
}
export function showOnDirtyErrorStateMatcher(control: FormControl, submitted: boolean) {
const isInvalid = control.invalid;
const isDirty = control.dirty;
return !!(isInvalid && (isDirty || submitted));
} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, the fact that the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @kara were you ever able to check into why |
||
|
||
const isInvalid = control && control.invalid; | ||
const isDirty = control && control.dirty; | ||
const isSubmitted = (formGroup && formGroup.submitted) || | ||
(form && form.submitted); | ||
|
||
return !!(isInvalid && (isDirty || isSubmitted)); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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, showOnDirtyErrorStateMatcher} from '../core/error/error-options'; | ||
|
||
describe('MdInputContainer', function () { | ||
beforeEach(async(() => { | ||
|
@@ -56,6 +58,7 @@ describe('MdInputContainer', function () { | |
MdInputContainerWithDynamicPlaceholder, | ||
MdInputContainerWithFormControl, | ||
MdInputContainerWithFormErrorMessages, | ||
MdInputContainerWithCustomErrorStateMatcher, | ||
MdInputContainerWithFormGroupErrorMessages, | ||
MdInputContainerWithId, | ||
MdInputContainerWithPrefixAndSuffix, | ||
|
@@ -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(() => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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(); | ||
|
@@ -1018,6 +1134,29 @@ class MdInputContainerWithFormErrorMessages { | |
renderError = true; | ||
} | ||
|
||
@Component({ | ||
template: ` | ||
<form #form="ngForm" novalidate> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove |
||
<md-input-container> | ||
<input mdInput | ||
[formControl]="formControl" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If you want a more "idiomatic" test case, I'd suggest switching to |
||
[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> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 = [ | ||
|
@@ -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; | ||
|
@@ -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; } | ||
|
@@ -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. */ | ||
|
@@ -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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should be passing through |
||
} | ||
|
||
/** Make sure the input is a supported type. */ | ||
|
There was a problem hiding this comment.
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 aFormControl
instance.