动态表单

We can't always justify the cost and time to build handcrafted forms, especially if we'll need a great number of them, they're similar to each other, and they change frequently to meet rapidly changing business and regulatory requirements.

有时候手动编写和维护表单所需工作量和时间会过大。特别是在需要编写大量表单时。表单都很相似,而且随着业务和监管需求的迅速变化,表单也要随之变化,这样维护的成本过高。

It may be more economical to create the forms dynamically, based on metadata that describe the business object model.

基于业务对象模型的元数据,动态创建表单可能会更划算。

In this cookbook we show how to use formGroup to dynamically render a simple form with different control types and validation. It's a primitive start. It might evolve to support a much richer variety of questions, more graceful rendering, and superior user experience. All such greatness has humble beginnings.

在此烹饪宝典中,我们会展示如何利用formGroup来动态渲染一个简单的表单,包括各种控件类型和验证规则。 这个起点很简陋,但可以在这个基础上添加丰富多彩的问卷问题、更优美的渲染以及更卓越的用户体验。

In our example we use a dynamic form to build an online application experience for heroes seeking employment. The agency is constantly tinkering with the application process. We can create the forms on the fly without changing our application code.

在本例中,我们使用动态表单,为正在找工作的英雄们创建一个在线申请表。英雄管理局会不断修改申请流程,我们要在不修改应用代码的情况下,动态创建这些表单。

Table of contents

目录

Bootstrap

程序启动

Question Model

问卷问题模型

Form Component

表单组件

Questionnaire Metadata

问卷元数据

Dynamic Template

动态模板

See the .

参见在线例子

Bootstrap

程序启动

We start by creating an NgModule called AppModule.

让我们从创建一个名叫AppModuleNgModule开始。

In our example we will be using Reactive Forms.

在本例子中,我们将使用响应式表单(Reactive Forms)。

Reactive Forms belongs to a different NgModule called ReactiveFormsModule, so in order to access any Reactive Forms directives, we have to import ReactiveFormsModule from the @angular/forms library.

响应式表单属于另外一个叫做ReactiveFormsModuleNgModule,所以,为了使用响应式表单类的指令,我们得往@angular/forms库中引入ReactiveFormsModule模块。

We bootstrap our AppModule in main.ts.

我们在main.ts中启动AppModule

import { BrowserModule } from '@angular/platform-browser'; import { ReactiveFormsModule } from '@angular/forms'; import { NgModule } from '@angular/core'; import { AppComponent } from './app.component'; import { DynamicFormComponent } from './dynamic-form.component'; import { DynamicFormQuestionComponent } from './dynamic-form-question.component'; @NgModule({ imports: [ BrowserModule, ReactiveFormsModule ], declarations: [ AppComponent, DynamicFormComponent, DynamicFormQuestionComponent ], bootstrap: [ AppComponent ] }) export class AppModule { constructor() { } } import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { AppModule } from './app/app.module'; platformBrowserDynamic().bootstrapModule(AppModule);

Question Model

问卷问题模型

The next step is to define an object model that can describe all scenarios needed by the form functionality. The hero application process involves a form with a lot of questions. The "question" is the most fundamental object in the model.

第一步是定义一个对象模型,用来描述所有表单功能需要的场景。英雄的申请流程涉及到一个包含很多问卷问题的表单。问卷问题是最基础的对象模型。

We have created QuestionBase as the most fundamental question class.

下面是我们建立的最基础的问卷问题基类,名叫QuestionBase

src/app/question-base.ts

export class QuestionBase<T>{ value: T; key: string; label: string; required: boolean; order: number; controlType: string; constructor(options: { value?: T, key?: string, label?: string, required?: boolean, order?: number, controlType?: 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 || ''; } }

From this base we derived two new classes in TextboxQuestion and DropdownQuestion that represent Textbox and Dropdown questions. The idea is that the form will be bound to specific question types and render the appropriate controls dynamically.

在这个基础上,我们派生出两个新类TextboxQuestionDropdownQuestion,分别代表文本框和下拉框。这么做的初衷是,表单能动态绑定到特定的问卷问题类型,并动态渲染出合适的控件。

TextboxQuestion supports multiple html5 types like text, email, url etc via the type property.

TextboxQuestion可以通过type属性来支持多种HTML5元素类型,比如文本、邮件、网址等。

src/app/question-textbox.ts

import { QuestionBase } from './question-base'; export class TextboxQuestion extends QuestionBase<string> { controlType = 'textbox'; type: string; constructor(options: {} = {}) { super(options); this.type = options['type'] || ''; } }

DropdownQuestion presents a list of choices in a select box.

DropdownQuestion表示一个带可选项列表的选择框。

src/app/question-dropdown.ts

import { QuestionBase } from './question-base'; export class DropdownQuestion extends QuestionBase<string> { controlType = 'dropdown'; options: {key: string, value: string}[] = []; constructor(options: {} = {}) { super(options); this.options = options['options'] || []; } }

Next we have defined QuestionControlService, a simple service for transforming our questions to a FormGroup. In a nutshell, the form group consumes the metadata from the question model and allows us to specify default values and validation rules.

接下来,我们定义了QuestionControlService,一个可以把问卷问题转换为FormGroup的服务。 简而言之,这个FormGroup使用问卷模型的元数据,并允许我们设置默认值和验证规则。

src/app/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 { constructor() { } toFormGroup(questions: QuestionBase<any>[] ) { let group: any = {}; questions.forEach(question => { group[question.key] = question.required ? new FormControl(question.value || '', Validators.required) : new FormControl(question.value || ''); }); return new FormGroup(group); } }

Question form components

问卷表单组件

Now that we have defined the complete model we are ready to create components to represent the dynamic form.

现在我们已经有一个定义好的完整模型了,接着就可以开始创建一个展现动态表单的组件。

DynamicFormComponent is the entry point and the main container for the form.

DynamicFormComponent是表单的主要容器和入口点。

<div> <form (ngSubmit)="onSubmit()" [formGroup]="form"> <div *ngFor="let question of questions" class="form-row"> <df-question [question]="question" [form]="form"></df-question> </div> <div class="form-row"> <button type="submit" [disabled]="!form.valid">Save</button> </div> </form> <div *ngIf="payLoad" class="form-row"> <strong>Saved the following values</strong><br>{{payLoad}} </div> </div> import { Component, Input, OnInit } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { QuestionBase } from './question-base'; import { QuestionControlService } from './question-control.service'; @Component({ moduleId: module.id, selector: 'dynamic-form', templateUrl: './dynamic-form.component.html', providers: [ QuestionControlService ] }) export class DynamicFormComponent implements OnInit { @Input() questions: QuestionBase<any>[] = []; form: FormGroup; payLoad = ''; constructor(private qcs: QuestionControlService) { } ngOnInit() { this.form = this.qcs.toFormGroup(this.questions); } onSubmit() { this.payLoad = JSON.stringify(this.form.value); } }

It presents a list of questions, each question bound to a <df-question> component element. The <df-question> tag matches the DynamicFormQuestionComponent, the component responsible for rendering the details of each individual question based on values in the data-bound question object.

它代表了问卷问题列表,每个问题都被绑定到一个<df-question>组件元素。 <df-question>标签匹配到的是组件DynamicFormQuestionComponent,该组件的职责是根据各个问卷问题对象的值来动态渲染表单控件。

<div [formGroup]="form"> <label [attr.for]="question.key">{{question.label}}</label> <div [ngSwitch]="question.controlType"> <input *ngSwitchCase="'textbox'" [formControlName]="question.key" [id]="question.key" [type]="question.type"> <select [id]="question.key" *ngSwitchCase="'dropdown'" [formControlName]="question.key"> <option *ngFor="let opt of question.options" [value]="opt.key">{{opt.value}}</option> </select> </div> <div class="errorMessage" *ngIf="!isValid">{{question.label}} is required</div> </div> import { Component, Input } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { QuestionBase } from './question-base'; @Component({ moduleId: module.id, selector: 'df-question', templateUrl: './dynamic-form-question.component.html' }) export class DynamicFormQuestionComponent { @Input() question: QuestionBase<any>; @Input() form: FormGroup; get isValid() { return this.form.controls[this.question.key].valid; } }

Notice this component can present any type of question in our model. We only have two types of questions at this point but we can imagine many more. The ngSwitch determines which type of question to display.

请注意,这个组件能代表模型里的任何问题类型。目前,还只有两种问题类型,但可以添加更多类型。可以用ngSwitch决定显示哪种类型的问题。

In both components we're relying on Angular's formGroup to connect the template HTML to the underlying control objects, populated from the question model with display and validation rules.

在这两个组件中,我们依赖Angular的formGroup来把模板HTML和底层控件对象连接起来,该对象从问卷问题模型里获取渲染和验证规则。

formControlName and formGroup are directives defined in ReactiveFormsModule. Our templates can can access these directives directly since we imported ReactiveFormsModule from AppModule.

formControlNameformGroup是在ReactiveFormsModule中定义的指令。我们之所以能在模板中使用它们,是因为我们往AppModule中导入了ReactiveFormsModule

Questionnaire data

问卷数据

DynamicFormComponent expects the list of questions in the form of an array bound to @Input() questions.

DynamicForm期望得到一个问题列表,该列表被绑定到@Input() questions属性。

The set of questions we have defined for the job application is returned from the QuestionService. In a real app we'd retrieve these questions from storage.

QuestionService会返回为工作申请表定义的那组问题列表。在真实的应用程序环境中,我们会从数据库里获得这些问题列表。

The key point is that we control the hero job application questions entirely through the objects returned from QuestionService. Questionnaire maintenance is a simple matter of adding, updating, and removing objects from the questions array.

关键是,我们完全根据QuestionService返回的对象来控制英雄的工作申请表。 要维护这份问卷,只要非常简单的添加、更新和删除questions数组中的对象就可以了。

src/app/question.service.ts

import { Injectable } from '@angular/core'; import { DropdownQuestion } from './question-dropdown'; import { QuestionBase } from './question-base'; import { TextboxQuestion } from './question-textbox'; @Injectable() export class QuestionService { // Todo: get from a remote source of question metadata // Todo: make asynchronous getQuestions() { let questions: QuestionBase<any>[] = [ new DropdownQuestion({ key: 'brave', label: 'Bravery Rating', options: [ {key: 'solid', value: 'Solid'}, {key: 'great', value: 'Great'}, {key: 'good', value: 'Good'}, {key: 'unproven', value: 'Unproven'} ], order: 3 }), new TextboxQuestion({ key: 'firstName', label: 'First name', value: 'Bombasto', required: true, order: 1 }), new TextboxQuestion({ key: 'emailAddress', label: 'Email', type: 'email', order: 2 }) ]; return questions.sort((a, b) => a.order - b.order); } }

Finally, we display an instance of the form in the AppComponent shell.

最后,在AppComponent里显示出表单。

app.component.ts

import { Component } from '@angular/core'; import { QuestionService } from './question.service'; @Component({ selector: 'my-app', template: ` <div> <h2>Job Application for Heroes</h2> <dynamic-form [questions]="questions"></dynamic-form> </div> `, providers: [QuestionService] }) export class AppComponent { questions: any[]; constructor(service: QuestionService) { this.questions = service.getQuestions(); } }

Dynamic Template

动态模板

Although in this example we're modelling a job application for heroes, there are no references to any specific hero question outside the objects returned by QuestionService.

在这个例子中,虽然我们是在为英雄的工作申请表建模,但是除了QuestionService返回的那些对象外,没有其它任何地方是与英雄有关的。

This is very important since it allows us to repurpose the components for any type of survey as long as it's compatible with our question object model. The key is the dynamic data binding of metadata used to render the form without making any hardcoded assumptions about specific questions. In addition to control metadata, we are also adding validation dynamically.

这点非常重要,因为只要与问卷对象模型兼容,就可以在任何类型的调查问卷中复用这些组件。 这里的关键是用到元数据的动态数据绑定来渲染表单,对问卷问题没有任何硬性的假设。除控件的元数据外,还可以动态添加验证规则。

The Save button is disabled until the form is in a valid state. When the form is valid, we can click Save and the app renders the current form values as JSON. This proves that any user input is bound back to the data model. Saving and retrieving the data is an exercise for another time.

表单验证通过之前,保存按钮是禁用的。验证通过后,就可以点击保存按钮,程序会把当前值渲染成JSON显示出来。 这表明任何用户输入都被传到了数据模型里。至于如何储存和提取数据则是另一话题了。

The final form looks like this:

完整的表单看起来是这样的:

Dynamic-Form

Back to top

回到顶部