- 
                Notifications
    
You must be signed in to change notification settings  - Fork 6.8k
 
feat(input): add custom error state matcher #4750
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
Conversation
| 
           I'm wondering if this should be a more generally configurable concept;   | 
    
| 
           @kara should also weigh in as this touches on forms  | 
    
56b9846    to
    5a270f3      
    Compare
  
    5a270f3    to
    f22a77a      
    Compare
  
    1f38a53    to
    d06612a      
    Compare
  
    | 
           @jelbourn since you last reviewed, I've made the following changes: 
 I'm far from married to # 3, but wanted to explore other global options. Also if this isn't the right approach, that's fine, but I'd like to keep the conversation moving.  | 
    
        
          
                src/lib/core/error/error-options.ts
              
                Outdated
          
        
      | export const MD_ERROR_GLOBAL_OPTIONS = | ||
| new InjectionToken<() => boolean>('md-error-global-options'); | ||
| 
               | 
          ||
| export type ErrorStateMatcherType = | 
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.
just ErrorStateMatcher is fine
        
          
                src/lib/core/error/error-options.ts
              
                Outdated
          
        
      | 
               | 
          ||
| /** 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'); | 
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.
generic type should be ErrorOptions
| })); | ||
| 
               | 
          ||
| it('should display an error message when a custom error matcher returns true', async(() => { | ||
| fixture.destroy(); | 
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.
can we just put this in a different describe block or something? its weird that we destroy the original fixture here
        
          
                src/lib/core/error/error-options.ts
              
                Outdated
          
        
      | 
               | 
          ||
| export interface ErrorOptions { | ||
| errorStateMatcher?: ErrorStateMatcherType; | ||
| showOnDirty?: boolean; | 
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.
I feel like this is structured kind of weirdly, how about if we remove the showOnDirty option and instead just add a couple implementations that the user can choose from (e.g. DefaultErrorStateMatcher, ShowOnDirtyErrorStateMatcher)
| 
               | 
          ||
| customFixture.whenStable().then(() => { | ||
| expect(containerEl.querySelectorAll('md-error').length) | ||
| .toBe(0, 'Expected no error messages after being touched.'); | 
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.
nit: indent continuations by 4
        
          
                src/lib/input/input-container.ts
              
                Outdated
          
        
      | this.id = this.id; | ||
| 
               | 
          ||
| this._errorOptions = errorOptions ? errorOptions : {}; | ||
| this.errorStateMatcher = this._errorOptions.errorStateMatcher || undefined; | 
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.
if you create the DefaultErrorStateMatcher class I suggested earlier this can be || new DefaultErrorStateMatcher()
d06612a    to
    84841d0      
    Compare
  
    84841d0    to
    b0d69cc      
    Compare
  
    | 
           @mmalerba I've addressed your comments! Please advise if I misinterpreted any of your class implementation suggestions.  | 
    
        
          
                src/lib/core/error/error-options.ts
              
                Outdated
          
        
      | 
               | 
          ||
| /** 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'); | 
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.
nit: I think this will fit on 1 line
        
          
                src/lib/core/error/error-options.ts
              
                Outdated
          
        
      | errorStateMatcher?: ErrorStateMatcher; | ||
| } | ||
| 
               | 
          ||
| export class DefaultErrorStateMatcher { | 
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.
oh sorry, I don't know why I said class, I just meant functions defaultErrorStateMatcher
| @Component({ | ||
| template: ` | ||
| <form #form="ngForm" novalidate> | ||
| <md-input-container> | 
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.
nit: indent continuation by 4
| 
           @mmalerba done! The convenience function  isInvalid && (isDirty || isSubmitted)But I'm curious if the expected behavior might be, isInvalid && (isDirty || isTouched || isSubmitted) | 
    
        
          
                src/demo-app/input/input-demo.ts
              
                Outdated
          
        
      | } | ||
| } | ||
| 
               | 
          ||
| customErrorStateMatcher(c: NgControl): boolean { | 
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 a FormControl instance.
        
          
                src/lib/input/input-container.ts
              
                Outdated
          
        
      | (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 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.
        
          
                src/lib/core/error/error-options.ts
              
                Outdated
          
        
      | } | ||
| 
               | 
          ||
| export function showOnDirtyErrorStateMatcher(control: NgControl, formGroup: FormGroupDirective, | ||
| form: NgForm): boolean { | 
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.
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.
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.
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
- 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));
}- Just pass 
submittedand don't expose eitherFormGroupDirectiveorNgForm 
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 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.
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.
@kara were you ever able to check into why submitted is missing from the Form interface?
| renderError = true; | ||
| } | ||
| 
               | 
          ||
| @Component({ | 
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.
Remove novalidate? This is added by default by the NgNoValidate directive in @angular/forms.
| @Component({ | ||
| template: ` | ||
| <form #form="ngForm" novalidate> | ||
| <md-input-container> | 
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.
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.
83954c5    to
    9bc13bb      
    Compare
  
    | 
           @kara comments addressed. Could you take another look?  | 
    
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.
This is looking good, just a few more little things. @mmalerba Are you finished reviewing?
        
          
                src/lib/core/error/error-options.ts
              
                Outdated
          
        
      | 
               | 
          ||
| export function defaultErrorStateMatcher(control: FormControl, form: FormGroupDirective | NgForm) { | ||
| const isInvalid = control.invalid; | ||
| const isTouched = control.touched; | 
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.
Nit: these const assignments seem to be adding bloat, given how short control.touched already is. I'm thinking it's easier to scan this function if you just inline (same on line 32).
const isSubmitted = form && form.submitted;
return !!(control.invalid && (control.touched || isSubmitted));| }); | ||
| 
               | 
          ||
| 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 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.
| 
           LGTM, waiting on @mmalerba to do a last pass.  | 
    
| errorStateMatcher?: ErrorStateMatcher; | ||
| } | ||
| 
               | 
          ||
| export function defaultErrorStateMatcher(control: FormControl, form: FormGroupDirective | NgForm) { | 
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.
docs
| return !!(control.invalid && (control.touched || isSubmitted)); | ||
| } | ||
| 
               | 
          ||
| export function showOnDirtyErrorStateMatcher(control: FormControl, | 
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.
docs
* feat(input): Add custom error state matcher * Address comments * Address comments pt. 2 * Use FormControl and only one of incompatible form options * Remove unnecesary async tests and const declarations * Add jsdoc comments to error state matchers
| 
           This issue has been automatically locked due to inactivity. Read more about our automatic conversation locking policy. This action has been performed automatically by a bot.  | 
    
Fixes #4232
cc @mmalerba