Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 672
Bielik Avatar asked Apr 21 '26 23:04

Bielik


1 Answers

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.

  • In my example i am drawing new marker.
  • DefaultValueAccessor just updates value property https://github.com/angular/angular/blob/4.2.0-rc.0/packages/forms/src/directives/default_value_accessor.ts#L76
  • Datepicker in angular2 material is setting inner value as you do https://github.com/angular/material2/blob/123d7eced4b4f808fc03c945504d68280752d533/src/lib/datepicker/datepicker-input.ts#L202

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);
  • Your example https://plnkr.co/edit/Q11HXhWKrndrA8Tjr6KH?p=preview

  • DefaultValueAccessor https://github.com/angular/angular/blob/4.2.0-rc.0/packages/forms/src/directives/default_value_accessor.ts#L88

  • Material2 https://github.com/angular/material2/blob/123d7eced4b4f808fc03c945504d68280752d533/src/lib/datepicker/datepicker-input.ts#L173

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

like image 88
yurzui Avatar answered Apr 23 '26 13:04

yurzui



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!