Разработчики angular, как правило знают, что для работы с формами существует два подхода: reactive forms и template driven forms. Также, хорошо известно, что для работы с формами разработан такой функционал как валидация, однако исчерпывающе описано его применения для подхода reactive forms. Давайте рассмотрим как можно получить те же преимущества для template driven подхода.

Допустим, у нас есть поле ввода.

<input [(ngModel)]="item.name" />

Мы хотим, чтобы оно было не менее N символов в длину, а в случае ошибки - добавить класс error. Конечно, можно сделать это напрямую, но давайте воспользуемся механизмом валидации angular. Создадим функцию валидатор.

export function minLengthValidator(minLength: number): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    if(control.value?.length >= 3) {
      return null;
    }
    return {
      minLength: `Min length is ${minLength}`
    };
  };
}

Далее, чтобы добавить валидатор в шаблоне компонента - создадим директиву

@Directive({
  selector: '[appMinLength]',
  providers: [
    {
      provide: NG_VALIDATORS,
      useExisting: MinLengthDirective,
      multi: true,
    },
  ],
})
export class MinLengthDirective implements Validator {
  @Input() appMinLength: number;

  validate(control: AbstractControl): ValidationErrors | null {
    if (this.appMinLength === null || this.appMinLength === undefined || this.appMinLength < 1) {
      return null;
    }
    return minLengthValidator(this.appMinLength)(control);
  }
}

Теперь мы можем использовать валидатор через директиву в шаблоне

<input [(ngModel)]="item.name" [appMinLength]="10" />

Благодаря тому что у директивы NgModel указано свойство exportAs, мы можем получить к ней доступ в шаблоне, вот как это выглядит в исходниках angular

@Directive({
  selector: '[ngModel]:not([formControlName]):not([formControl])',
  providers: [formControlBinding],
  exportAs: 'ngModel'
})
export class NgModel extends NgControl implements OnChanges, OnDestroy

Тут же мы видим, что NgModel наследует от NgControl.

Итак, получим доступ к нашему контролу, и добавим класс с ошибкой, если контрол трогали, и его значение является неверным.

<input #model="ngModel" 
       [(ngModel)]="item.name" 
       [appMinLength]="10" 
       [class.error]="model.control.invalid && model.control.touched" />

Уже недурно, но давайте рассмотрим случай, когда часть редактируемых полей находится в дочерних компонентах, а мы хотим управлять всем этим делом из родительского. Например у нас есть модель данных, которая содержит массив дочерних элементов, каждый из которых мы хотим провалидировать и, допустим, не дать сохранить данные если что-то заполнено неверно.

export interface MyModel {
  name: string;
  items: MyItem[];
}

export interface MyItem {
  name: string;
  age: number;
}

Далее рассмотрим компонент формы

@Component({
  selector: 'app-my-form',
  template: `<form #form>
              <div class="row">
                <div style="width: 70px;">name:</div>
                <input [class.error]="model.control.invalid && model.control.touched"
                        #model="ngModel"
                        type="text"
                        required
                        [appMinLength]="10"
                        [(ngModel)]="data.name"
                        name="name" />
              </div>
              <div>Items:</div>
              <app-my-item [item]="item" *ngFor="let item of data.items"></app-my-item>
              <div>
                <button type="button" (click)="save()">Save</button>
                <button type="button" (click)="add()">Add</button>
              </div>
            </form>`,
  styleUrls: ['./my-form.component.css'],
  imports: [FormsModule, MyItemComponent, CommonModule, MinLengthDirective],
  standalone: true,
})
export class MyFormComponent {
  @ViewChild(NgForm) form: NgForm;

  data: MyModel = {
    name: '',
    items: [
      {
        age: null,
        name: '',
      },
    ],
  };

  save() {
    console.log(this.form.controls);
  }

  add() {
    this.data.items.push({
      name: '',
      age: null,
    });
  }
}

И компонент для элемента списка

@Component({
  selector: 'app-my-item',
  template: `<div class="row">
              <div style="width: 70px;">name:</div>
                <input [class.error]="nameModel.control.invalid && nameModel.control.touched"
                        #nameModel="ngModel" 
                        type="text" 
                        required
                        [appMinLength]="10"
                        [(ngModel)]="item.name" />
              </div>
              <div class="row">
                <div style="width: 70px;">age:</div>
                <input [class.error]="ageModel.control.invalid && ageModel.control.touched"
                        type="number"
                        #ageModel="ngModel"
                        type="text"                  
                        required
                        [(ngModel)]="item.age" />
              </div>`,
  styleUrls: ['./my-item.component.css'],
  imports: [FormsModule, MinLengthDirective, CommonModule],
  standalone: true,
})
export class MyItemComponent {
  @Input() item: MyItem;
}

На данный момент наши контролы уже подсветятся ошибкой, но при нажатии на save() мы увидим только один контрол. Однако хочется получить доступ к состоянию формы с учетом дочерних компонент. Для этого, чтобы получить доступ к родительской форме нам нужно внедрить ControlContainer в дочерние компоненты. Немного изменим декларацию компонента MyItem

@Component({
  selector: 'app-my-item',
  template: `<div [ngModelGroup]="item.id.toString()">
              <div class="row">
                <div style="width: 70px;">name:</div>
                  <input [class.error]="nameModel.control.invalid && nameModel.control.touched"
                          name="name"
                          #nameModel="ngModel" 
                          type="text" 
                          required
                          [appMinLength]="10"
                          [(ngModel)]="item.name" />
                </div>
                <div class="row">
                  <div style="width: 70px;">age:</div>
                  <input [class.error]="ageModel.control.invalid && ageModel.control.touched"
                        name="age"
                        type="number"
                        #ageModel="ngModel"
                        type="text"                  
                        required
                        [(ngModel)]="item.age" />
                </div>
              </div>`,
  styleUrls: ['./my-item.component.css'],
  imports: [FormsModule, MinLengthDirective, CommonModule],
  standalone: true,
  viewProviders: [
    {
      provide: ControlContainer,
      useExisting: NgForm,
    },
  ],
})
export class MyItemComponent {
  @Input() item: MyItem;
}

Теперь у нас есть полный контроль над элементами формы, и ее состоянием, можно дописать функционал компонента формы, с валидацией и реакцией на ее состояние

@Component({
  selector: 'app-my-form',
  ...
})
export class MyFormComponent {
  ...

  save() {
    console.log(this.form.controls);
    this.form.form.markAllAsTouched();
    this.form.form.updateValueAndValidity();
    if (this.form.valid) {
      // Do the saving stuff
    }
  }

  ...
}

Полный код примера из статьи

Спасибо за внимание, надеюсь, эта информация окажется для кого-то полезной.

Комментарии (4)


  1. kemsky
    00.00.0000 00:00
    +1

    Любопытно, ControlContainer нигде не упоминается в документации.


    1. Vahman Автор
      00.00.0000 00:00

      Ну да, вот в документации не густо https://angular.io/api/forms/ControlContainer


  1. anonymous
    00.00.0000 00:00

    НЛО прилетело и опубликовало эту надпись здесь


    1. Vahman Автор
      00.00.0000 00:00

      Если честно, не понятна задача, можете уточнить? И задачу, и как решали?