Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Angular ngModelChange is late when updating NgModel

Tags:

angular

I'm making a directive using angular 8 to do some processing and convert the text to uppercase. Simplified code below:

html:

<input class="form-control" id="label" name="label" required myDirective>

directive:

import { Directive, HostListener } from '@angular/core';
import { NgControl } from '@angular/forms';

@Directive({
  selector: '[myDirective]'
})
export class Mydirective {
  constructor(private control: NgControl) { }

  processInput(value: any) {
     // do some formatting
     return value.toUpperCase();
  }

  @HostListener('ngModelChange', ['$event'])
  ngModelChange(value: any) {
     this.control.valueAccessor.writeValue(this.processInput(value));
  }
}

Right now, the view is updated correctly, however the model is late by one step. for example: if input text shows 'AAAA' then ng-reflect-model will show 'AAAa'.

I have reproduced the error in stackblitz: Error Reproduced in Stackblitz

Any idea where I am wrong?

Thanks before!

like image 663
Daniel W Avatar asked Aug 31 '20 08:08

Daniel W


2 Answers

TLDR

StackBlitz.

my-directive.directive.ts

/* ... */

ngOnInit () {
  const initialOnChange = (this.ngControl.valueAccessor as any).onChange;

  (this.ngControl.valueAccessor as any).onChange = (value) => initialOnChange(this.processInput(value));
}

/* ... */

@HostListener('ngModelChange', ['$event'])
ngModelChange(value: any) {
  this.ngControl.valueAccessor.writeValue(this.processInput(value));
}

Detailed answer

Let's see why it didn't work initially.

Angular has default value accessors for certain elements, such as for input type='text', input type='checkbox' etc...

A ControlValueAccessor is the middleman between the VIEW layer and the MODEL layer. When a user types into an input, the VIEW notifies the ControlValueAccessor, which has the job to inform the MODEL.

For instance, when the input event occurs, the onChange method of the ControlValueAccessor will be called. Here's how onChange looks like for every ControlValueAccessor:

function setUpViewChangePipeline(control: FormControl, dir: NgControl): void {
  dir.valueAccessor!.registerOnChange((newValue: any) => {
    control._pendingValue = newValue;
    control._pendingChange = true;
    control._pendingDirty = true;

    if (control.updateOn === 'change') updateControl(control, dir);
  });
}

The magic happens in updateControl:

function updateControl(control: FormControl, dir: NgControl): void {
  if (control._pendingDirty) control.markAsDirty();
  control.setValue(control._pendingValue, {emitModelToViewChange: false});
 
  // !
  dir.viewToModelUpdate(control._pendingValue);
  control._pendingChange = false;
}

dir.viewToModelUpdate(control._pendingValue); is what invokes the ngModelChange event in the custom directive. What this means is that the model value is the value from the input(in lowercase). And because ControlValueAccessor.writeValue only writes the value to the VIEW, there will be a delay between the VIEW's value and the MODEL's value.

It's worth mentioning that FormControl.setValue(val) will write val to both layers, VIEW and MODEL, but if we were to used this, there would be an infinite loop, since setValue() internally calls viewToModelUpdate(because the MODEL has to be updated), and viewToModelUpdate calls setValue().

Let's have a look at a possible solution:

ngOnInit () {
  const initialOnChange = (this.ngControl.valueAccessor as any).onChange;

  (this.ngControl.valueAccessor as any).onChange = (value) => initialOnChange(this.processInput(value));
}

With this approach, you're modifying your data at the VIEW layer, before it is sent to the ControlValueAccessor.

And we can be sure that onChange exists on every built-in ControlValueAccessor.

search results

If you are going to create a custom one, just make sure it has an onChange property. TypeScript can help you with that.

If you'd like to read more about internals of @angular/forms, I'd recommend having a look at A thorough exploration of Angular Forms.

like image 197
Andrei Gătej Avatar answered Oct 09 '22 10:10

Andrei Gătej


you can get it using

  @HostListener('input', ['$event'])
  ngModelChange(event: any) {
    const item = event.target
    const value = item.value;
    const pos = item.selectionStart;
    this.control.control.setValue(this.processInput(value), { emit: false });
    item.selectionStart = item.selectionEnd = pos
  }

See that we use @HostListener input, to get the item, not only the value. This allow us position the cursor in his position after change the value

NOTE: To make a simple uppercase it's better use css text-transform:uppercase and, when we want to get the value use toUpperCase()

NOTE2: about mask see this SO

like image 35
Eliseo Avatar answered Oct 09 '22 08:10

Eliseo