許多表單,例如問卷,在格式與意圖上可能彼此非常相似。 為了更快、更容易地產生這類表單的不同版本,你可以依描述業務物件模型的中繼資料,建立一個_動態表單範本_。 然後,依資料模型的變化,使用該範本自動產生新表單。
當你有一種表單,其內容必須頻繁改變以因應快速變化的業務與法規要求時,這種技巧特別有用。 典型的使用案例就是問卷。 你可能需要在不同情境下取得使用者的輸入。 使用者看到的表單格式與樣式應保持一致,而實際需要詢問的問題則依情境而有所不同。
在本教學中,你將建立一個呈現基本問卷的動態表單。 你將建立一個供英雄求職的線上應用程式。 該仲介機構不斷調整應徵流程,但使用動態表單, 你就能即時建立新表單,無需修改應用程式程式碼。
本教學將帶你完成下列步驟:
- 為專案啟用反應式表單。
- 建立用來表示表單控制項的資料模型。
- 把範例資料填入模型。
- 開發一個動態建立表單控制項的元件。
你建立的表單使用輸入驗證與樣式來改善使用者體驗。 它有一個 Save 按鈕,只有在所有使用者輸入都有效時才會啟用,並以顏色標示與錯誤訊息標記無效的輸入。
這個基本版本可以逐步演進,支援更豐富多樣的問題、更優雅的算繪,以及更好的使用者體驗。
為專案啟用反應式表單
動態表單是以反應式表單為基礎。
若要讓應用程式能存取反應式表單指令,請把 @angular/forms 套件的 ReactiveFormsModule 匯入需要的元件。
建立表單物件模型
動態表單需要一個物件模型,能描述表單功能所需的所有情境。 範例中的英雄應徵表單是一組問題,也就是說,表單中的每個控制項都必須提出一個問題並接受一個答案。
這類表單的資料模型必須能表示一個問題。
範例包含 DynamicFormQuestionComponent,它把問題定義為模型中的基本物件。
下列 QuestionBase 是一組控制項的基底類別,這些控制項可以在表單中表示問題及其答案。
question-base.ts
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 || [];
}
}
定義控制項類別
範例從這個基底衍生出兩個新類別 TextboxQuestion 與 DropdownQuestion,代表不同的控制項類型。
在下一步建立表單範本時,你會具現化這些特定的問題類型,以便動態算繪適當的控制項。
TextboxQuestion 控制項類型在表單範本中使用 <input> 元素表示。它呈現一個問題,讓使用者輸入內容。該元素的 type 屬性依 options 參數中指定的 type 欄位來定義(例如 text、email、url)。
question-textbox.ts
import {QuestionBase} from './question-base';
export class TextboxQuestion extends QuestionBase<string> {
override controlType = 'textbox';
}
DropdownQuestion 控制項類型會在選取框中呈現一份選項清單。
question-dropdown.ts
import {QuestionBase} from './question-base';
export class DropdownQuestion extends QuestionBase<string> {
override controlType = 'dropdown';
}
組合表單群組
動態表單使用服務,依表單模型建立分組的輸入控制項集合。
下列 QuestionControlService 會根據問題模型中的中繼資料,建立一個由 FormControl 執行個體組成的 FormGroup。
你可以指定預設值與驗證規則。
question-control.service.ts
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);
}
}
組合動態表單內容
動態表單本身由一個容器元件表示,你會在後面的步驟中加入它。
每個問題在表單元件的範本中都以 <app-question> 標籤表示,它對應到 DynamicFormQuestionComponent 的一個執行個體。
DynamicFormQuestionComponent 負責依資料繫結的問題物件中的值,算繪個別問題的詳細內容。
表單仰賴 [formGroup] 指令 把範本 HTML 連接到底層的控制項物件。
DynamicFormQuestionComponent 會接收由 QuestionControlService 建立的表單群組,並為其問題算繪控制項,依據該處設定的規則顯示驗證狀態。
DynamicFormQuestionComponent 的目標是呈現你在模型中定義的問題類型。
目前你只有兩種問題類型,但你可以想像還能有更多。
範本中的 @switch 區塊決定要顯示哪一種問題類型。
這個 switch 使用了帶有 formControlName 與 formGroup 選擇器的指令。
這兩個指令都定義在 ReactiveFormsModule 中。
提供資料
還需要另一個服務,來提供一組特定的問題,藉此建立個別表單。
在本練習中,你建立 QuestionService,從硬編碼的範例資料提供這份問題陣列。
在真實世界的應用程式中,該服務可能會從後端系統擷取資料。
不過,關鍵在於:你完全可以透過 QuestionService 傳回的物件來控制英雄求職的問題。
當需求改變時,若要維護這份問卷,你只需要在 questions 陣列中新增、更新與移除物件。
QuestionService 以陣列形式提供一組問題,該陣列繫結到 input() 問題。
question.service.ts
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));
}
}
建立動態表單範本
DynamicFormComponent 元件是表單的入口點與主要容器,在範本中使用 <app-dynamic-form> 表示。
DynamicFormComponent 元件把每個問題繫結到對應 DynamicFormQuestionComponent 的 <app-question> 元素,藉此呈現問題清單。
顯示表單
若要顯示動態表單的執行個體,AppComponent 外殼範本會把 QuestionService 傳回的 questions 陣列傳給表單容器元件 <app-dynamic-form>。
app.component.ts
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();
}
模型與資料如此分離,讓你可以把這些元件重新用於任何類型的問卷調查,只要它與_問題_物件模型相容即可。
確保資料有效
表單範本使用中繼資料的動態資料繫結來算繪表單,不對特定問題做任何寫死的假設。 它會動態加入控制項中繼資料與驗證條件。
為了確保輸入有效,Save 按鈕在表單處於有效狀態之前會保持停用。 當表單有效時,點選 Save,應用程式就會把目前的表單值以 JSON 形式算繪出來。
下圖顯示最終的表單。