Привязка ngModel к сложным данным

Некоторое время я исследовал, можно ли и как привязать сложную модель к ngModel. Есть статьи, показывающие, как это можно сделать для простых данных (например, строки), таких как это. Но то, что я хочу сделать, более сложное. Допустим, у меня есть класс:

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

Теперь я собираюсь использовать его в нескольких местах приложения, поэтому я хочу инкапсулировать его в компонент:

<coordinates-form></coordinates-form>

Я также хотел бы передать эту модель компоненту, использующему ngModel, чтобы воспользоваться такими вещами, как угловые формы, но до сих пор безуспешно. Вот пример:

<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>

Действительно ли возможно это сделать или мой подход просто неверен? На данный момент я решил использовать компонент с обычным вводом и выдачей события при изменении, но я чувствую, что это довольно быстро станет беспорядочным.

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());
  }
}

Использование:

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

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

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

Я получаю ошибку Cannot read property 'longitude' of undefined. Для простой модели, такой как строка или число, она работает без проблем.


person Bielik    schedule 19.05.2017    source источник
comment
Вы можете написать собственный компонент, который implements ControlValueAccessor, если вы это имеете в виду?   -  person jonrsharpe    schedule 19.05.2017
comment
@jonrsharpe Да, я знаю, что могу создать компонент, использующий ngModel, реализовав ControlValueAccessor для простых данных, таких как строка. Я даже дал ссылку на статью, которая делает именно это, но только для простых данных, таких как строка или число. Можно ли сделать это для пользовательского класса, подобного тому, который я здесь показал?   -  person Bielik    schedule 19.05.2017
comment
Все методы интерфейса any, почему бы не попробовать и посмотреть, что произойдет? Не могли бы вы подробнее рассказать о неудачных попытках с помощью минимального воспроизводимого примера?   -  person jonrsharpe    schedule 19.05.2017
comment
@jonrsharpe Я сделал, я следил за статьей, и в конце была ошибка. Я не помню, что именно это было, потому что я уже удалил код, но потом я смог сделать это снова. В принципе, насколько я понимаю, было что-то не так с тем фактом, что моя модель не просто имеет значение, а имеет свойства. К завтрашнему дню у меня будет время воспроизвести пример и обновить вопрос.   -  person Bielik    schedule 19.05.2017
comment
@jonrsharpe Я обновил вопрос, чтобы более подробно проиллюстрировать проблему. Если вам все еще нужно больше информации, я могу предоставить ссылку на github.   -  person Bielik    schedule 19.05.2017


Ответы (1)


Свойство value сначала равно undefined.

Чтобы решить эту проблему, вам нужно изменить привязку, например:

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

и также измените его на latitude

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

Обновлять

Только что заметил, что вы запускаете событие onChange в установщике, поэтому вам нужно изменить ссылку:

[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 };
}

Обновленный Plunker

Пример Plunker с картой Google

Обновление 2

Когда вы имеете дело с настраиваемым элементом управления формой, вам необходимо реализовать этот интерфейс:

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;
}

Вот минимальная реализация:

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;
  }
}

Он будет работать для любого типа значения. Просто примените это для вашего случая.

Здесь вам не нужен массив вроде (см. Обновление Plunker)

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

Когда компонент получает новое значение, он запускает writeValue, где вам нужно обновить какое-то значение, которое будет использоваться в вашем настраиваемом шаблоне. В вашем примере вы обновляете свойство value, которое используется вместе с ngModel в шаблоне.

Когда вам нужно распространить изменения на AbstractControl, вы должны вызвать onChange метод, который вы зарегистрировали в registerOnChange.

Я написал this.value = { ...this.value };, потому что это просто

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

он вызовет установщик, в котором вы вызываете метод onChange. Другой способ - напрямую вызвать onChange, который обычно используется

this.onChange(this.value);
person yurzui    schedule 19.05.2017
comment
Благодарю за ваш ответ. Это действительно помогло с ошибкой, но теперь есть другая проблема: он не регистрируется наModelChange таким образом. Например, если бы я добавил к примеру использования (ngModelChange)='test("coordinates")'>, ничего бы не произошло. Возможно, мне следует как-то вызвать этот changed Array из ValueAccessorBase или установить его с помощью этого метода установки значения. Хотелось бы услышать ваше мнение о том, как это должно быть сделано элегантно и чисто. Заранее спасибо. - person Bielik; 20.05.2017
comment
Я был так занят своей демонстрацией, что забыл проверить вашу :) - person yurzui; 20.05.2017
comment
Большое спасибо за обновление, мне это очень помогло. У меня остались бы те же вопросы, если бы у вас было время: - person Bielik; 20.05.2017
comment
1. this.value = { ...this.value }; используется для вызова метода «установить значение», верно? 2. Действительно ли это правильный подход к созданию подобных компонентов? Я потратил некоторое время на изучение статей по этому поводу, но мне не повезло. Для меня это казалось естественным путем, но теперь у меня есть сомнения. Вы бы это сделали в реальной жизни или использовали бы что-нибудь еще. 3. Можно ли создать такой компонент, используя внутреннюю форму? В нем уже реализованы все необходимые методы. Я пытался сделать это сам, но мне не хватало технических навыков, чтобы реализовать это. - person Bielik; 20.05.2017
comment
Еще раз большое спасибо, вы мне очень помогли. В заключение, поскольку я не заметил, что за вопрос подняли голос, я хотел спросить, есть ли что-то, что я сделал не так, или вопрос слишком тривиален. Я провел много исследований и не нашел примеров или статей, в которых обсуждалась бы эта тема. Я спрашиваю, чтобы я мог улучшить дальнейшие вопросы. - person Bielik; 21.05.2017
comment
Боже мой, они сделали его ооочень сложным по сравнению с AngularJS, почему так? .. То же самое работало и без строчки кода. - person Ivan Zverev; 01.02.2018