آنلاین

ساخت فرم‌های پویا

بسیاری از فرم‌ها، مثل پرسش‌نامه‌ها، می‌توانند از نظر قالب و هدف بسیار شبیه هم باشند. برای اینکه ساخت نسخه‌های مختلف چنین فرمی سریع‌تر و ساده‌تر شود، می‌توانید یک dynamic form template بر اساس metadataای بسازید که business object model را توصیف می‌کند. سپس از template استفاده کنید تا فرم‌های جدید را بر اساس تغییرات data model به‌صورت خودکار تولید کنید.

این تکنیک به‌خصوص وقتی مفید است که نوعی فرم دارید که محتوای آن باید مرتب تغییر کند تا با نیازهای business و regulatory که سریع تغییر می‌کنند هماهنگ بماند. یک use case معمول، پرسش‌نامه است. ممکن است لازم باشد در contextهای مختلف از کاربرها input بگیرید. قالب و style فرم‌هایی که کاربر می‌بیند باید ثابت بماند، در حالی که سوال‌های واقعی‌ای که باید بپرسید با context تغییر می‌کنند.

در این tutorial، یک dynamic form می‌سازید که یک پرسش‌نامه‌ی پایه را نمایش می‌دهد. شما یک application آنلاین برای درخواست استخدام قهرمان‌ها می‌سازید. agency دائما فرایند application را تغییر می‌دهد، اما با استفاده از dynamic form می‌توانید فرم‌های جدید را بدون تغییر کد application در لحظه بسازید.

این tutorial شما را از این مرحله‌ها عبور می‌دهد:

  1. reactive forms را برای یک پروژه فعال کنید.
  2. یک data model برای نمایش form controlها ایجاد کنید.
  3. model را با sample data پر کنید.
  4. componentای توسعه دهید که form controlها را به‌صورت dynamic بسازد.

فرمی که می‌سازید از input validation و styling برای بهتر کردن تجربه‌ی کاربر استفاده می‌کند. این فرم یک دکمه‌ی Submit دارد که فقط وقتی همه‌ی inputهای کاربر معتبر باشند فعال می‌شود، و input نامعتبر را با رنگ‌بندی و پیام خطا مشخص می‌کند.

نسخه‌ی پایه می‌تواند تکامل پیدا کند تا تنوع بیشتری از سوال‌ها، rendering نرم‌تر و تجربه‌ی کاربری بهتر را پشتیبانی کند.

فعال کردن reactive forms برای پروژه

dynamic formها بر پایه‌ی reactive forms هستند.

برای اینکه application به directiveهای reactive form دسترسی داشته باشد، ReactiveFormsModule را از package مربوط به @angular/forms داخل componentهای لازم import کنید.

ts
// #docregion
import {Component, computed, inject, input} from '@angular/core';
import {FormGroup, ReactiveFormsModule} from '@angular/forms';

import {DynamicFormQuestionComponent} from './dynamic-form-question.component';

import {QuestionBase} from './question-base';
import {QuestionControlService} from './question-control.service';

@Component({
  selector: 'app-dynamic-form',
  templateUrl: './dynamic-form.component.html',
  providers: [QuestionControlService],
  imports: [DynamicFormQuestionComponent, ReactiveFormsModule],
})
export class DynamicFormComponent {
  private readonly qcs = inject(QuestionControlService);

  readonly questions = input<QuestionBase<string>[] | null>([]);
  readonly form = computed<FormGroup>(() =>
    this.qcs.toFormGroup(this.questions() as QuestionBase<string>[]),
  );
  payLoad = '';

  onSubmit() {
    this.payLoad = JSON.stringify(this.form().getRawValue());
  }
}
ts
// #docregion
import {Component, input, Input} from '@angular/core';
import {FormGroup, ReactiveFormsModule} from '@angular/forms';

import {QuestionBase} from './question-base';

@Component({
  selector: 'app-question',
  templateUrl: './dynamic-form-question.component.html',
  imports: [ReactiveFormsModule],
})
export class DynamicFormQuestionComponent {
  readonly question = input.required<QuestionBase<string>>();
  readonly form = input.required<FormGroup>();

  get isValid() {
    return this.form().controls[this.question().key].valid;
  }
}

ساخت object model فرم

یک dynamic form به object modelای نیاز دارد که بتواند همه‌ی سناریوهای موردنیاز functionality فرم را توصیف کند. فرم نمونه‌ی درخواست استخدام قهرمان‌ها مجموعه‌ای از questionهاست؛ یعنی هر control در فرم باید یک question بپرسد و یک answer بپذیرد.

data model برای این نوع فرم باید یک question را نمایش دهد. مثال شامل DynamicFormQuestionComponent است که question را به‌عنوان object بنیادی model تعریف می‌کند.

QuestionBase زیر یک base class برای مجموعه‌ای از controlهاست که می‌توانند question و answer آن را در فرم نمایش دهند.

question-base.ts
// #docregion
export class QuestionBase<T> {
  value: T | undefined;
  key: string;
  label: string;
  required: boolean;
  order: number;
  controlType: string;
  type: string;
  options: {key: string; value: string}[];

  constructor(
    options: {
      value?: T;
      key?: string;
      label?: string;
      required?: boolean;
      order?: number;
      controlType?: string;
      type?: string;
      options?: {key: string; value: string}[];
    } = {},
  ) {
    this.value = options.value;
    this.key = options.key || '';
    this.label = options.label || '';
    this.required = !!options.required;
    this.order = options.order === undefined ? 1 : options.order;
    this.controlType = options.controlType || '';
    this.type = options.type || '';
    this.options = options.options || [];
  }
}

تعریف کلاس‌های control

از این base، مثال دو کلاس جدید به نام‌های TextboxQuestion و DropdownQuestion مشتق می‌کند که typeهای مختلف control را نمایش می‌دهند. وقتی در مرحله‌ی بعد form template را می‌سازید، این نوع‌های خاص question را instantiate می‌کنید تا controlهای مناسب به‌صورت dynamic render شوند.

نوع control مربوط به TextboxQuestion در form template با یک element از نوع <input> نمایش داده می‌شود. این control یک question نمایش می‌دهد و اجازه می‌دهد کاربرها input وارد کنند. attribute مربوط به type برای element بر اساس field مربوط به type که در argument مربوط به options مشخص شده تعریف می‌شود، مثلا text، email یا url.

question-textbox.ts
// #docregion
import {QuestionBase} from './question-base';

export class TextboxQuestion extends QuestionBase<string> {
  override controlType = 'textbox';
}

نوع control مربوط به DropdownQuestion فهرستی از گزینه‌ها را در یک select box نمایش می‌دهد.

question-dropdown.ts
// #docregion
import {QuestionBase} from './question-base';

export class DropdownQuestion extends QuestionBase<string> {
  override controlType = 'dropdown';
}

Compose کردن form groupها

یک dynamic form از service برای ساخت مجموعه‌های گروه‌بندی‌شده از input controlها بر اساس form model استفاده می‌کند. QuestionControlService زیر مجموعه‌ای از instanceهای FormGroup را جمع می‌کند که metadata را از question model مصرف می‌کنند. می‌توانید مقدارهای پیش‌فرض و validation ruleها را مشخص کنید.

question-control.service.ts
// #docregion
import {Injectable} from '@angular/core';
import {FormControl, FormGroup, Validators} from '@angular/forms';

import {QuestionBase} from './question-base';

@Injectable()
export class QuestionControlService {
  toFormGroup(questions: QuestionBase<string>[]) {
    const group: any = {};

    questions.forEach((question) => {
      group[question.key] = question.required
        ? new FormControl(question.value || '', Validators.required)
        : new FormControl(question.value || '');
    });
    return new FormGroup(group);
  }
}

Compose کردن محتوای dynamic form

خود dynamic form با یک container component نمایش داده می‌شود که در مرحله‌ای بعد آن را اضافه می‌کنید. هر question در template مربوط به form component با tag <app-question> نمایش داده می‌شود که با یک instance از DynamicFormQuestionComponent match می‌شود.

DynamicFormQuestionComponent مسئول render کردن جزئیات یک question جداگانه بر اساس مقدارهای موجود در object مربوط به question است که data-bound شده. فرم برای وصل کردن HTML template به control objectهای underlying به directive مربوط به [[formGroup]](api/forms/FormGroupDirective 'API reference') تکیه می‌کند. DynamicFormQuestionComponent، form groupها را می‌سازد و آن‌ها را با controlهایی که در question model تعریف شده‌اند پر می‌کند و ruleهای display و validation را مشخص می‌کند.

html
<!-- #docregion -->
<div [formGroup]="form()">
  <label [attr.for]="question().key">{{ question().label }}</label>

  <div>
    @switch (question().controlType) {
      @case ('textbox') {
        <input [formControlName]="question().key" [id]="question().key" [type]="question().type" />
      }
      @case ('dropdown') {
        <select [id]="question().key" [formControlName]="question().key">
          @for (opt of question().options; track opt) {
            <option [value]="opt.key">{{ opt.value }}</option>
          }
        </select>
      }
    }
  </div>

  @if (!isValid) {
    <div class="errorMessage">{{ question().label }} is required</div>
  }
</div>
ts
// #docregion
import {Component, input, Input} from '@angular/core';
import {FormGroup, ReactiveFormsModule} from '@angular/forms';

import {QuestionBase} from './question-base';

@Component({
  selector: 'app-question',
  templateUrl: './dynamic-form-question.component.html',
  imports: [ReactiveFormsModule],
})
export class DynamicFormQuestionComponent {
  readonly question = input.required<QuestionBase<string>>();
  readonly form = input.required<FormGroup>();

  get isValid() {
    return this.form().controls[this.question().key].valid;
  }
}

هدف DynamicFormQuestionComponent نمایش typeهای question تعریف‌شده در model شماست. در این نقطه فقط دو نوع question دارید، اما می‌توانید انواع بسیار بیشتری را تصور کنید. block مربوط به @switch در template مشخص می‌کند کدام نوع question نمایش داده شود. این switch از directiveهایی با selectorهای formControlName و formGroup استفاده می‌کند. هر دو directive در ReactiveFormsModule تعریف شده‌اند.

فراهم کردن داده

service دیگری لازم است تا مجموعه‌ای مشخص از questionها را برای ساخت یک فرم جداگانه فراهم کند. برای این تمرین، QuestionService را می‌سازید تا این array از questionها را از sample data hard-coded فراهم کند. در یک app واقعی، service ممکن است داده را از یک backend system fetch کند. نکته‌ی اصلی اما این است که questionهای درخواست استخدام قهرمان‌ها را کاملا از طریق objectهایی کنترل می‌کنید که از QuestionService برمی‌گردند. برای نگهداری پرسش‌نامه هم‌زمان با تغییر requirementها، فقط لازم است objectهای array مربوط به questions را اضافه، به‌روزرسانی یا حذف کنید.

QuestionService مجموعه‌ای از questionها را به شکل آرایه‌ای فراهم می‌کند که به input() مربوط به questions bind شده است.

question.service.ts
// #docregion
import {Injectable} from '@angular/core';

import {DropdownQuestion} from './question-dropdown';
import {QuestionBase} from './question-base';
import {TextboxQuestion} from './question-textbox';
import {of} from 'rxjs';

@Injectable()
export class QuestionService {
  // TODO: get from a remote source of question metadata
  getQuestions() {
    const questions: QuestionBase<string>[] = [
      new DropdownQuestion({
        key: 'favoriteAnimal',
        label: 'Favorite Animal',
        options: [
          {key: 'cat', value: 'Cat'},
          {key: 'dog', value: 'Dog'},
          {key: 'horse', value: 'Horse'},
          {key: 'capybara', value: 'Capybara'},
        ],
        order: 3,
      }),

      new TextboxQuestion({
        key: 'firstName',
        label: 'First name',
        value: 'Alex',
        required: true,
        order: 1,
      }),

      new TextboxQuestion({
        key: 'emailAddress',
        label: 'Email',
        type: 'email',
        order: 2,
      }),
    ];

    return of(questions.sort((a, b) => a.order - b.order));
  }
}

ساخت template مربوط به dynamic form

component مربوط به DynamicFormComponent نقطه‌ی ورود و container اصلی فرم است؛ فرمی که در template با <app-dynamic-form> نمایش داده می‌شود.

component مربوط به DynamicFormComponent با bind کردن هر question به یک element از نوع <app-question> که با DynamicFormQuestionComponent match می‌شود، فهرستی از questionها را نمایش می‌دهد.

html
<!-- #docregion -->
<div>
  <form (ngSubmit)="onSubmit()" [formGroup]="form()">
    @for (question of questions(); track question) {
      <div class="form-row">
        <app-question [question]="question" [form]="form()" />
      </div>
    }

    <div class="form-row">
      <button type="submit" [disabled]="!form().valid">Save</button>
    </div>
  </form>

  @if (payLoad) {
    <div class="form-row"><strong>Saved the following values</strong><br />{{ payLoad }}</div>
  }
</div>
ts
// #docregion
import {Component, computed, inject, input} from '@angular/core';
import {FormGroup, ReactiveFormsModule} from '@angular/forms';

import {DynamicFormQuestionComponent} from './dynamic-form-question.component';

import {QuestionBase} from './question-base';
import {QuestionControlService} from './question-control.service';

@Component({
  selector: 'app-dynamic-form',
  templateUrl: './dynamic-form.component.html',
  providers: [QuestionControlService],
  imports: [DynamicFormQuestionComponent, ReactiveFormsModule],
})
export class DynamicFormComponent {
  private readonly qcs = inject(QuestionControlService);

  readonly questions = input<QuestionBase<string>[] | null>([]);
  readonly form = computed<FormGroup>(() =>
    this.qcs.toFormGroup(this.questions() as QuestionBase<string>[]),
  );
  payLoad = '';

  onSubmit() {
    this.payLoad = JSON.stringify(this.form().getRawValue());
  }
}

نمایش فرم

برای نمایش یک instance از dynamic form، template مربوط به shell در AppComponent، آرایه‌ی questions برگشتی از QuestionService را به form container component یعنی <app-dynamic-form> پاس می‌دهد.

app.component.ts
// #docregion
import {Component, inject} from '@angular/core';
import {AsyncPipe} from '@angular/common';

import {DynamicFormComponent} from './dynamic-form.component';

import {QuestionService} from './question.service';
import {QuestionBase} from './question-base';
import {Observable} from 'rxjs';

@Component({
  selector: 'app-root',
  template: `
    <div>
      <h2>Job Application for Heroes</h2>
      <app-dynamic-form [questions]="questions$ | async" />
    </div>
  `,
  providers: [QuestionService],
  imports: [AsyncPipe, DynamicFormComponent],
})
export class AppComponent {
  questions$: Observable<QuestionBase<string>[]> = inject(QuestionService).getQuestions();
}

این جداسازی model و data به شما اجازه می‌دهد componentها را برای هر نوع survey دوباره استفاده کنید، تا وقتی با object model مربوط به question سازگار باشد.

اطمینان از داده‌ی معتبر

form template از dynamic data binding مربوط به metadata استفاده می‌کند تا بدون فرض hardcoded درباره‌ی questionهای مشخص، فرم را render کند. همچنین control metadata و validation criteria را به‌صورت dynamic اضافه می‌کند.

برای اطمینان از input معتبر، دکمه‌ی Save تا زمانی که فرم در وضعیت معتبر نباشد disabled است. وقتی فرم معتبر است، روی Save کلیک کنید تا application مقدارهای فعلی فرم را به‌صورت JSON render کند.

شکل زیر فرم نهایی را نشان می‌دهد.

Dynamic-Form

گام‌های بعدی