diff --git a/public/docs/_examples/cb-dynamic-form/e2e-spec.js b/public/docs/_examples/cb-dynamic-form/e2e-spec.js new file mode 100644 index 0000000000..9254c86717 --- /dev/null +++ b/public/docs/_examples/cb-dynamic-form/e2e-spec.js @@ -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); + }); + }); + +}); diff --git a/public/docs/_examples/cb-dynamic-form/ts/.gitignore b/public/docs/_examples/cb-dynamic-form/ts/.gitignore new file mode 100644 index 0000000000..cf44e148ba --- /dev/null +++ b/public/docs/_examples/cb-dynamic-form/ts/.gitignore @@ -0,0 +1 @@ +**/*.js \ No newline at end of file diff --git a/public/docs/_examples/cb-dynamic-form/ts/app/app.component.ts b/public/docs/_examples/cb-dynamic-form/ts/app/app.component.ts new file mode 100644 index 0000000000..a30bf8f677 --- /dev/null +++ b/public/docs/_examples/cb-dynamic-form/ts/app/app.component.ts @@ -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: ` +
+

Job Application for Heroes

+ +
+ `, + directives: [DynamicForm], + providers: [QuestionService] +}) +export class AppComponent { + questions:any[] + + constructor(service: QuestionService) { + this.questions = service.getQuestions(); + } +} diff --git a/public/docs/_examples/cb-dynamic-form/ts/app/dynamic-form-question.component.html b/public/docs/_examples/cb-dynamic-form/ts/app/dynamic-form-question.component.html new file mode 100644 index 0000000000..410eb26527 --- /dev/null +++ b/public/docs/_examples/cb-dynamic-form/ts/app/dynamic-form-question.component.html @@ -0,0 +1,17 @@ + +
+
{{question.label}}
+ +
+ + + + + +
+ +
{{question.label}} is required
+
diff --git a/public/docs/_examples/cb-dynamic-form/ts/app/dynamic-form-question.component.ts b/public/docs/_examples/cb-dynamic-form/ts/app/dynamic-form-question.component.ts new file mode 100644 index 0000000000..2348e4d8b9 --- /dev/null +++ b/public/docs/_examples/cb-dynamic-form/ts/app/dynamic-form-question.component.ts @@ -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; + @Input() form:ControlGroup; + get isValid() { return this.form.controls[this.question.key].valid; } +} diff --git a/public/docs/_examples/cb-dynamic-form/ts/app/dynamic-form.component.html b/public/docs/_examples/cb-dynamic-form/ts/app/dynamic-form.component.html new file mode 100644 index 0000000000..7cb4b08ce3 --- /dev/null +++ b/public/docs/_examples/cb-dynamic-form/ts/app/dynamic-form.component.html @@ -0,0 +1,17 @@ + +
+
+ +
+ +
+ +
+ +
+
+ +
+ Saved the following values
{{payLoad}} +
+
diff --git a/public/docs/_examples/cb-dynamic-form/ts/app/dynamic-form.component.ts b/public/docs/_examples/cb-dynamic-form/ts/app/dynamic-form.component.ts new file mode 100644 index 0000000000..aac95acb39 --- /dev/null +++ b/public/docs/_examples/cb-dynamic-form/ts/app/dynamic-form.component.ts @@ -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[] = []; + form: ControlGroup; + payLoad = ''; + + constructor(private _qcs: QuestionControlService) { } + + ngOnInit(){ + this.form = this._qcs.toControlGroup(this.questions); + } + + onSubmit() { + this.payLoad = JSON.stringify(this.form.value); + } +} diff --git a/public/docs/_examples/cb-dynamic-form/ts/app/main.ts b/public/docs/_examples/cb-dynamic-form/ts/app/main.ts new file mode 100644 index 0000000000..dd43b98095 --- /dev/null +++ b/public/docs/_examples/cb-dynamic-form/ts/app/main.ts @@ -0,0 +1,5 @@ +import {bootstrap} from 'angular2/platform/browser'; +import {AppComponent} from './app.component'; + +bootstrap(AppComponent, []) + .catch((err:any) => console.error(err)); diff --git a/public/docs/_examples/cb-dynamic-form/ts/app/question-base.ts b/public/docs/_examples/cb-dynamic-form/ts/app/question-base.ts new file mode 100644 index 0000000000..c22de02989 --- /dev/null +++ b/public/docs/_examples/cb-dynamic-form/ts/app/question-base.ts @@ -0,0 +1,25 @@ +// #docregion +export class QuestionBase{ + 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 || ''; + } +} diff --git a/public/docs/_examples/cb-dynamic-form/ts/app/question-control.service.ts b/public/docs/_examples/cb-dynamic-form/ts/app/question-control.service.ts new file mode 100644 index 0000000000..cc7ebc751a --- /dev/null +++ b/public/docs/_examples/cb-dynamic-form/ts/app/question-control.service.ts @@ -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[] ) { + let group = {}; + + questions.forEach(question => { + group[question.key] = question.required ? [question.value || '', Validators.required] : []; + }); + return this._fb.group(group); + } +} diff --git a/public/docs/_examples/cb-dynamic-form/ts/app/question-dropdown.ts b/public/docs/_examples/cb-dynamic-form/ts/app/question-dropdown.ts new file mode 100644 index 0000000000..1c3ca2807a --- /dev/null +++ b/public/docs/_examples/cb-dynamic-form/ts/app/question-dropdown.ts @@ -0,0 +1,12 @@ +// #docregion +import {QuestionBase} from './question-base'; + +export class DropdownQuestion extends QuestionBase{ + controlType = 'dropdown'; + options:{key:string, value:string}[] = []; + + constructor(options:{} = {}){ + super(options); + this.options = options['options'] || []; + } +} diff --git a/public/docs/_examples/cb-dynamic-form/ts/app/question-textbox.ts b/public/docs/_examples/cb-dynamic-form/ts/app/question-textbox.ts new file mode 100644 index 0000000000..573209d944 --- /dev/null +++ b/public/docs/_examples/cb-dynamic-form/ts/app/question-textbox.ts @@ -0,0 +1,12 @@ +// #docregion +import {QuestionBase} from './question-base'; + +export class TextboxQuestion extends QuestionBase{ + controlType = 'textbox'; + type:string; + + constructor(options:{} = {}){ + super(options); + this.type = options['type'] || ''; + } +} diff --git a/public/docs/_examples/cb-dynamic-form/ts/app/question.service.ts b/public/docs/_examples/cb-dynamic-form/ts/app/question.service.ts new file mode 100644 index 0000000000..0b805758f4 --- /dev/null +++ b/public/docs/_examples/cb-dynamic-form/ts/app/question.service.ts @@ -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[] = [ + + 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); + } +} diff --git a/public/docs/_examples/cb-dynamic-form/ts/example-config.json b/public/docs/_examples/cb-dynamic-form/ts/example-config.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/public/docs/_examples/cb-dynamic-form/ts/index.html b/public/docs/_examples/cb-dynamic-form/ts/index.html new file mode 100644 index 0000000000..a5874bff3f --- /dev/null +++ b/public/docs/_examples/cb-dynamic-form/ts/index.html @@ -0,0 +1,39 @@ + + + + + Dynamic Form + + + + + + + + + + + + + + + + + + + + Loading app... + + + diff --git a/public/docs/_examples/cb-dynamic-form/ts/plnkr.json b/public/docs/_examples/cb-dynamic-form/ts/plnkr.json new file mode 100644 index 0000000000..faa498f46f --- /dev/null +++ b/public/docs/_examples/cb-dynamic-form/ts/plnkr.json @@ -0,0 +1,9 @@ +{ + "description": "Dynamic Form", + "files":[ + "!**/*.d.ts", + "!**/*.js", + "!**/*.[1].*" + ], + "tags":["cookbook"] +} diff --git a/public/docs/_examples/cb-dynamic-form/ts/sample.css b/public/docs/_examples/cb-dynamic-form/ts/sample.css new file mode 100644 index 0000000000..fe2cc28481 --- /dev/null +++ b/public/docs/_examples/cb-dynamic-form/ts/sample.css @@ -0,0 +1,7 @@ +.errorMessage{ + color:red; +} + +.form-row{ + margin-top: 10px; +} \ No newline at end of file diff --git a/public/docs/ts/latest/cookbook/_data.json b/public/docs/ts/latest/cookbook/_data.json index 5dfbf9c992..31e71596e4 100644 --- a/public/docs/ts/latest/cookbook/_data.json +++ b/public/docs/ts/latest/cookbook/_data.json @@ -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" } } diff --git a/public/docs/ts/latest/cookbook/dynamic-form.jade b/public/docs/ts/latest/cookbook/dynamic-form.jade new file mode 100644 index 0000000000..e196074b66 --- /dev/null +++ b/public/docs/ts/latest/cookbook/dynamic-form.jade @@ -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*. + + +: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 + +: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='.') + + +: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 `` component element. + The `` 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. + + +: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') + + +: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) \ No newline at end of file diff --git a/public/resources/images/cookbooks/dynamic-form/dynamic-form.png b/public/resources/images/cookbooks/dynamic-form/dynamic-form.png new file mode 100644 index 0000000000..54b680a672 Binary files /dev/null and b/public/resources/images/cookbooks/dynamic-form/dynamic-form.png differ