docs(cb-dynamic-form): new cookbook on dyn form gen with NgFormModel
This commit is contained in:
parent
b3eb189ec3
commit
db7fba867c
|
@ -0,0 +1,24 @@
|
|||
describe('Dynamic Form', function () {
|
||||
|
||||
beforeAll(function () {
|
||||
browser.get('');
|
||||
});
|
||||
|
||||
it('should submit form', function () {
|
||||
var firstNameElement = element.all(by.css('input[id=firstName]')).get(0);
|
||||
expect(firstNameElement.getAttribute('value')).toEqual('Bombasto');
|
||||
|
||||
var emailElement = element.all(by.css('input[id=emailAddress]')).get(0);
|
||||
var email = 'test@test.com';
|
||||
emailElement.sendKeys(email);
|
||||
expect(emailElement.getAttribute('value')).toEqual(email);
|
||||
|
||||
element(by.css('select option[value="solid"]')).click()
|
||||
|
||||
var saveButton = element.all(by.css('button')).get(0);
|
||||
saveButton.click().then(function(){
|
||||
expect(element(by.xpath("//strong[contains(text(),'Saved the following values')]")).isPresent()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
**/*.js
|
|
@ -0,0 +1,23 @@
|
|||
// #docregion
|
||||
import {Component} from 'angular2/core'
|
||||
import {DynamicForm} from './dynamic-form.component';
|
||||
import {QuestionService} from './question.service';
|
||||
|
||||
@Component({
|
||||
selector: 'my-app',
|
||||
template: `
|
||||
<div>
|
||||
<h2>Job Application for Heroes</h2>
|
||||
<dynamic-form [questions]="questions"></dynamic-form>
|
||||
</div>
|
||||
`,
|
||||
directives: [DynamicForm],
|
||||
providers: [QuestionService]
|
||||
})
|
||||
export class AppComponent {
|
||||
questions:any[]
|
||||
|
||||
constructor(service: QuestionService) {
|
||||
this.questions = service.getQuestions();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
<!-- #docregion -->
|
||||
<div [ngFormModel]="form">
|
||||
<div class="formHeading">{{question.label}}</div>
|
||||
|
||||
<div [ngSwitch]="question.controlType">
|
||||
|
||||
<input *ngSwitchWhen="'textbox'" [ngControl]="question.key"
|
||||
[id]="question.key" [type]="question.type">
|
||||
|
||||
<select *ngSwitchWhen="'dropdown'" [ngControl]="question.key">
|
||||
<option *ngFor="#opt of question.options" [value]="opt.key">{{opt.value}}</option>
|
||||
</select>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="errorMessage" *ngIf="!isValid">{{question.label}} is required</div>
|
||||
</div>
|
|
@ -0,0 +1,14 @@
|
|||
// #docregion
|
||||
import {Component, Input} from 'angular2/core';
|
||||
import {ControlGroup} from 'angular2/common';
|
||||
import {QuestionBase} from './question-base';
|
||||
|
||||
@Component({
|
||||
selector:'df-question',
|
||||
templateUrl:'app/dynamic-form-question.component.html'
|
||||
})
|
||||
export class DynamicFormQuestionComponent {
|
||||
@Input() question:QuestionBase<any>;
|
||||
@Input() form:ControlGroup;
|
||||
get isValid() { return this.form.controls[this.question.key].valid; }
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
<!-- #docregion -->
|
||||
<div>
|
||||
<form (ngSubmit)="onSubmit()" [ngFormModel]="form">
|
||||
|
||||
<div *ngFor="#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>
|
|
@ -0,0 +1,30 @@
|
|||
// #docregion
|
||||
import {Component, Input, OnInit} from 'angular2/core';
|
||||
import {ControlGroup} from 'angular2/common';
|
||||
|
||||
import {QuestionBase} from './question-base';
|
||||
import {QuestionControlService} from './question-control.service';
|
||||
import {DynamicFormQuestionComponent} from './dynamic-form-question.component';
|
||||
|
||||
@Component({
|
||||
selector:'dynamic-form',
|
||||
templateUrl:'app/dynamic-form.component.html',
|
||||
directives: [DynamicFormQuestionComponent],
|
||||
providers: [QuestionControlService]
|
||||
})
|
||||
export class DynamicForm {
|
||||
|
||||
@Input() questions: QuestionBase<any>[] = [];
|
||||
form: ControlGroup;
|
||||
payLoad = '';
|
||||
|
||||
constructor(private _qcs: QuestionControlService) { }
|
||||
|
||||
ngOnInit(){
|
||||
this.form = this._qcs.toControlGroup(this.questions);
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
this.payLoad = JSON.stringify(this.form.value);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import {bootstrap} from 'angular2/platform/browser';
|
||||
import {AppComponent} from './app.component';
|
||||
|
||||
bootstrap(AppComponent, [])
|
||||
.catch((err:any) => console.error(err));
|
|
@ -0,0 +1,25 @@
|
|||
// #docregion
|
||||
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 || '';
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
// #docregion
|
||||
import {Injectable} from 'angular2/core';
|
||||
import {ControlGroup, FormBuilder, Validators} from 'angular2/common';
|
||||
import {QuestionBase} from './question-base';
|
||||
|
||||
@Injectable()
|
||||
export class QuestionControlService {
|
||||
constructor(private _fb:FormBuilder){ }
|
||||
|
||||
toControlGroup(questions:QuestionBase<any>[] ) {
|
||||
let group = {};
|
||||
|
||||
questions.forEach(question => {
|
||||
group[question.key] = question.required ? [question.value || '', Validators.required] : [];
|
||||
});
|
||||
return this._fb.group(group);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
// #docregion
|
||||
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'] || [];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
// #docregion
|
||||
import {QuestionBase} from './question-base';
|
||||
|
||||
export class TextboxQuestion extends QuestionBase<string>{
|
||||
controlType = 'textbox';
|
||||
type:string;
|
||||
|
||||
constructor(options:{} = {}){
|
||||
super(options);
|
||||
this.type = options['type'] || '';
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
// #docregion
|
||||
import {Injectable} from 'angular2/core';
|
||||
import {QuestionBase} from './question-base';
|
||||
import {DynamicForm} from './dynamic-form.component';
|
||||
import {TextboxQuestion} from './question-textbox';
|
||||
import {DropdownQuestion} from './question-dropdown';
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<base href="/">
|
||||
<title>Dynamic Form</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- #docregion style -->
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link rel="stylesheet" href="sample.css">
|
||||
<!-- #enddocregion style -->
|
||||
|
||||
<!-- IE required polyfills, in this exact order -->
|
||||
<script src="node_modules/es6-shim/es6-shim.min.js"></script>
|
||||
<script src="node_modules/systemjs/dist/system-polyfills.js"></script>
|
||||
<script src="node_modules/angular2/es6/dev/src/testing/shims_for_IE.js"></script>
|
||||
|
||||
<script src="node_modules/angular2/bundles/angular2-polyfills.js"></script>
|
||||
<script src="node_modules/systemjs/dist/system.src.js"></script>
|
||||
<script src="node_modules/rxjs/bundles/Rx.js"></script>
|
||||
<script src="node_modules/angular2/bundles/angular2.dev.js"></script>
|
||||
<script>
|
||||
System.config({
|
||||
packages: {
|
||||
app: {
|
||||
format: 'register',
|
||||
defaultExtension: 'js'
|
||||
}
|
||||
}
|
||||
});
|
||||
System.import('app/main')
|
||||
.then(null, console.error.bind(console));
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<my-app>Loading app...</my-app>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"description": "Dynamic Form",
|
||||
"files":[
|
||||
"!**/*.d.ts",
|
||||
"!**/*.js",
|
||||
"!**/*.[1].*"
|
||||
],
|
||||
"tags":["cookbook"]
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
.errorMessage{
|
||||
color:red;
|
||||
}
|
||||
|
||||
.form-row{
|
||||
margin-top: 10px;
|
||||
}
|
|
@ -14,5 +14,10 @@
|
|||
"component-communication": {
|
||||
"title": "Component Interaction",
|
||||
"description": "Share information between different directives and components"
|
||||
},
|
||||
|
||||
"dynamic-form": {
|
||||
"title": "Dynamic Form",
|
||||
"description": "Render dynamic forms with NgFormModel"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,143 @@
|
|||
include ../_util-fns
|
||||
|
||||
:marked
|
||||
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 `ngFormModel` to dynamically to 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.
|
||||
|
||||
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*.
|
||||
|
||||
<a id="toc"></a>
|
||||
:marked
|
||||
## Table of contents
|
||||
|
||||
[Question Model](#object-model)
|
||||
|
||||
[Form Component](#form-component)
|
||||
|
||||
[Questionnaire Metadata](#questionnaire-metadata)
|
||||
|
||||
[Dynamic Template](#dynamic-template)
|
||||
|
||||
:marked
|
||||
**See the [live example](/resources/live-examples/cb-dynamic-form/ts/plnkr.html)**.
|
||||
|
||||
.l-main-section
|
||||
<a id="object-model"></a>
|
||||
:marked
|
||||
## Question Model
|
||||
|
||||
The first 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.
|
||||
|
||||
+makeExample('cb-dynamic-form/ts/app/question-base.ts','','app/question-base.ts')
|
||||
|
||||
:marked
|
||||
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.
|
||||
|
||||
`TextboxQuestion` supports multiple html5 types like text, email, url etc via the `type` property.
|
||||
|
||||
+makeExample('cb-dynamic-form/ts/app/question-textbox.ts',null,'app/question-textbox.ts')(format='.')
|
||||
|
||||
:marked
|
||||
`DropdownQuestion` presents a list of choices in a select box.
|
||||
|
||||
+makeExample('cb-dynamic-form/ts/app/question-dropdown.ts',null,'app/question-dropdown.ts')(format='.')
|
||||
|
||||
:marked
|
||||
Next we have defined `QuestionControlService`, a simple service for transforming our questions to an ngForm control group.
|
||||
In a nutshell, the control group consumes the metadata from the question model and allows us to specify default values and validation rules.
|
||||
|
||||
+makeExample('cb-dynamic-form/ts/app/question-control.service.ts',null,'app/question-control.service.ts')(format='.')
|
||||
|
||||
<a id="form-component"></a>
|
||||
:marked
|
||||
## Question form components
|
||||
Now that we have defined the complete model we are ready to create components to represent the dynamic form.
|
||||
|
||||
:marked
|
||||
`DynamicForm` is the entry point and the main container for the form.
|
||||
+makeTabs(
|
||||
`cb-dynamic-form/ts/app/dynamic-form.component.html,
|
||||
cb-dynamic-form/ts/app/dynamic-form.component.ts`,
|
||||
null,
|
||||
`dynamic-form.component.html,
|
||||
dynamic-form.component.ts`
|
||||
)
|
||||
:marked
|
||||
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.
|
||||
|
||||
+makeTabs(
|
||||
`cb-dynamic-form/ts/app/dynamic-form-question.component.html,
|
||||
cb-dynamic-form/ts/app/dynamic-form-question.component.ts`,
|
||||
null,
|
||||
`dynamic-form-question.component.html,
|
||||
dynamic-form-question.component.ts`
|
||||
)
|
||||
:marked
|
||||
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.
|
||||
|
||||
In both components we're relying on Angular's **ngFormMode** to connect the template HTML to the
|
||||
underlying control objects, populated from the question model with display and validation rules.
|
||||
|
||||
<a id="questionnaire-metadata"></a>
|
||||
:marked
|
||||
## Questionnaire data
|
||||
:marked
|
||||
`DynamicForm` expects the list of questions in the form of an array bound to `@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.
|
||||
|
||||
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.
|
||||
|
||||
+makeExample('cb-dynamic-form/ts/app/question.service.ts','','app/question.service.ts')
|
||||
|
||||
:marked
|
||||
Finally, we display an instance of the form in the `AppComponent` shell.
|
||||
|
||||
+makeExample('cb-dynamic-form/ts/app/app.component.ts','','app.component.ts')
|
||||
|
||||
<a id="dynamic-template"></a>
|
||||
:marked
|
||||
## Dynamic Template
|
||||
Although in this example we're model a job application for heroes, there are no references to any specific hero question
|
||||
outside the objects returned by `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.
|
||||
|
||||
:marked
|
||||
The final form looks like this:
|
||||
figure.image-display
|
||||
img(src="/resources/images/cookbooks/dynamic-form/dynamic-form.png" alt="Dynamic-Form")
|
||||
|
||||
|
||||
:marked
|
||||
[Back to top](#top)
|
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
Loading…
Reference in New Issue