Search code examples
htmlangulartypescriptangular-ngmodelangular-components

Binding ngModel to complex data


For some time I have been researching if, and how to bind complex model to ngModel. There are articles showing how it can be done for simple data (e.g. string) such as this. But what I want to do is more complex. Let's say that I have a class:

export class MyCoordinates {
    longitude: number;
    latitude: number;
}

Now I am going to use it in multiple places around the application, so I want to encapsulate it into a component:

<coordinates-form></coordinates-form>

I would also like to pass this model to the component using ngModel to take advantage of things like angular forms but was unsuccessful thus far. Here is an example:

<form #myForm="ngForm" (ngSubmit)="onSubmit()">
    <div class="form-group">
        <label for="name">Name</label>
        <input type="text" [(ngModel)]="model.name" name="name">
    </div>
    <div class="form-group">
        <label for="coordinates">Coordinates</label>
        <coordinates-form [(ngModel)]="model.coordinates" name="coordinates"></coordinates-form>
    </div>
    <button type="submit" class="btn btn-success">Submit</button>
</form>

Is actually possible to do it this way or is my approach simply wrong? For now I have settled on using component with normal input and emitting event on change but I feel like it will get messy pretty fast.

import {
  Component,
  Optional,
  Inject,
  Input,
  ViewChild,
} from '@angular/core';

import {
  NgModel,
  NG_VALUE_ACCESSOR,
} from '@angular/forms';

import { ValueAccessorBase } from '../Base/value-accessor';
import { MyCoordinates } from "app/Models/Coordinates";

@Component({
  selector: 'coordinates-form',
  template: `
    <div>
      <label>longitude</label>
      <input
        type="number"
        [(ngModel)]="value.longitude"
      />

      <label>latitude</label>
      <input
        type="number"
        [(ngModel)]="value.latitude"
      />
    </div>
  `,
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting: CoordinatesFormComponent,
    multi: true,
  }],
})
export class CoordinatesFormComponent extends ValueAccessorBase<MyCoordinates> {

  @ViewChild(NgModel) model: NgModel;

  constructor() {
    super();
  }
}

ValueAccessorBase:

import {ControlValueAccessor} from '@angular/forms';

export abstract class ValueAccessorBase<T> implements ControlValueAccessor {
  private innerValue: T;

  private changed = new Array<(value: T) => void>();
  private touched = new Array<() => void>();

  get value(): T {
    return this.innerValue;
  }

  set value(value: T) {
    if (this.innerValue !== value) {
      this.innerValue = value;
      this.changed.forEach(f => f(value));
    }
  }

  writeValue(value: T) {
    this.innerValue = value;
  }

  registerOnChange(fn: (value: T) => void) {
    this.changed.push(fn);
  }

  registerOnTouched(fn: () => void) {
    this.touched.push(fn);
  }

  touch() {
    this.touched.forEach(f => f());
  }
}

Usage:

<form #form="ngForm" (ngSubmit)="onSubmit(form.value)">

  <coordinates-form
    required
    hexadecimal
    name="coordinatesModel"
    [(ngModel)]="coordinatesModel">
  </coordinates-form>

  <button type="Submit">Submit</button>
</form>

The error I am getting Cannot read property 'longitude' of undefined. For simple model, like string or number it works without a problem.


Solution

  • The value property is undefined at first.

    To solve this issue you need to change your binding like:

    [ngModel]="value?.longitude" (ngModelChange)="value.longitude = $event"
    

    and change it for latitude as well

    [ngModel]="value?.latitude" (ngModelChange)="value.latitude = $event"
    

    Update

    Just noticed you're running onChange event within settor so you need to change reference:

    [ngModel]="value?.longitude" (ngModelChange)="handleInput('longitude', $event)"
    
    [ngModel]="value?.latitude" (ngModelChange)="handleInput('latitude', $event)"
    
    handleInput(prop, value) {
      this.value[prop] = value;
      this.value = { ...this.value };
    }
    

    Updated Plunker

    Plunker Example with google map

    Update 2

    When you deal with custom form control you need to implement this interface:

    export interface ControlValueAccessor {
      /**
       * Write a new value to the element.
       */
      writeValue(obj: any): void;
    
      /**
       * Set the function to be called when the control receives a change event.
       */
      registerOnChange(fn: any): void;
    
      /**
       * Set the function to be called when the control receives a touch event.
       */
      registerOnTouched(fn: any): void;
    
      /**
       * This function is called when the control status changes to or from "DISABLED".
       * Depending on the value, it will enable or disable the appropriate DOM element.
       *
       * @param isDisabled
       */
      setDisabledState?(isDisabled: boolean): void;
    }
    

    Here is a minimal implementation:

    export abstract class ValueAccessorBase<T> implements ControlValueAccessor {
      // view => control
      onChange = (value: T) => {};
      onTouched = () => {};
    
      writeValue(value: T) {
        // control -> view
      }
    
      registerOnChange(fn: (_: any) => void): void {
        this.onChange = fn;
      }
      registerOnTouched(fn: () => void): void {
        this.onTouched = fn;
      }
    }
    

    It will work for any type of value. Just implement it for your case.

    You do not need here array like (see update Plunker)

    private changed = new Array<(value: T) => void>();
    

    When component gets new value it will run writeValue where you need to update some value that will be used in your custom template. In your example you are updating value property which is used together with ngModel in template.

    When you need to propagate changes to AbstractControl you have to call onChange method which you registered in registerOnChange.

    I wrote this.value = { ...this.value }; because it is just

    this.value = Object.assign({}, this.value)
    

    it will call setter where you call onChange method Another way is calling onChange directly that is usually used

    this.onChange(this.value);
    

    You can do anything you like inside custom component. It can have any template and any nested components. But you have to implement logic for ControlValueAccessor to do it working with angular form.

    If you open some library such angular2 material or primeng you can find a lot of example how to implement such controls