Compare commits

...

6 Commits

Author SHA1 Message Date
Kevin C. Coram ff1268e129
Add summary of static method approach to main README 2020-01-08 22:13:08 -05:00
Kevin C. Coram 5d0c467849
Document static method approach 2020-01-08 22:13:07 -05:00
Kevin C. Coram f1344707e7
Stronger statement about potential problem with tying form creation to component rendering 2020-01-08 22:12:56 -05:00
Kevin C. Coram 8802b52e54
Use OnPush change detection strategy 2020-01-08 21:33:38 -05:00
Kevin C. Coram b7870b607b
Remove FormBuilder dependency; rname factory methods
* Update factory methods to use the FormGroup(), FormArray(), and
  FormControl() constructors instead of the FormBuilder service.
  This avoids needing to pass the service into each of the static
  factory methods.
* Rename the factory methods to `buildForm`. The consistent use
  of the same name establishes a pattern and makes it clearer
  that the factory methods have the same purpose -- to create the
  (sub)form for the specific component.
2020-01-07 22:39:53 -05:00
Kevin C. Coram cb8144044e
Add fourth approach: Static methods on child components 2020-01-04 09:42:53 -05:00
37 changed files with 694 additions and 2 deletions

View File

@ -25,3 +25,9 @@ The [Parent Form](apps/parent-form) application explores this approach.
Another approach is to allow the outermost, or parent, component create the full Reactive Form. Each child component is given the `FormGroup` containing the portion of the form that it is responsible for rendering.
The [Global Form](apps/global-form) application explores this approach.
## Parent Component Creates Form; Child Components Define Structure
Another approach is to allow the parent component to maintain control of creating the full Reactive Form, while allowing each child component to define the shape of the form data by means of static factory methods defined within the child component code.
The [Static Method](apps/static-factory-methods/README.md) application explores this approach.

View File

@ -287,6 +287,93 @@
}
}
}
},
"static-factory-methods": {
"projectType": "application",
"schematics": {},
"root": "apps/static-factory-methods",
"sourceRoot": "apps/static-factory-methods/src",
"prefix": "nested-forms",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/apps/static-factory-methods",
"index": "apps/static-factory-methods/src/index.html",
"main": "apps/static-factory-methods/src/main.ts",
"polyfills": "apps/static-factory-methods/src/polyfills.ts",
"tsConfig": "apps/static-factory-methods/tsconfig.app.json",
"aot": false,
"assets": [
"apps/static-factory-methods/src/favicon.ico",
"apps/static-factory-methods/src/assets"
],
"styles": ["apps/static-factory-methods/src/styles.css"],
"scripts": []
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "apps/static-factory-methods/src/environments/environment.ts",
"with": "apps/static-factory-methods/src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
}
]
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "static-factory-methods:build"
},
"configurations": {
"production": {
"browserTarget": "static-factory-methods:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "static-factory-methods:build"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"apps/static-factory-methods/tsconfig.app.json",
"apps/static-factory-methods/tsconfig.spec.json"
],
"exclude": ["**/node_modules/**", "!apps/static-factory-methods/**"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"options": {
"jestConfig": "apps/static-factory-methods/jest.config.js",
"tsConfig": "apps/static-factory-methods/tsconfig.spec.json",
"setupFile": "apps/static-factory-methods/src/test-setup.ts"
}
}
}
}
},
"cli": {

View File

@ -78,6 +78,6 @@ Calling `this.parent.addControl(....)` is what ensures that the controls created
## Cons
- The creation of the form controls is tightly coupled with the templates
- The creation of the form controls is tightly coupled with the templates, to the point that if a child component is not rendered for some reason, the form controls won't exist either.
- Since each child component encapsulates its form controls, the overall shape of the form data is not always clear

View File

@ -0,0 +1,87 @@
# Parent Component Creates Form; Child Components Define Structure
Another approach for refactoring a component into child sub-components where the parent component is responsible for creating the entre Reactive Form would be to define static factory methods within each child component rather than within a full-fledged service. As with the [Parent Component Creates Form and Passes Form Controls Into Child Components (Global Form)](../global-form/README.md) approach, the appropriate form controls would be passed into the children.
In many ways, this approach is a hybrid between the Parent Form and Global Form approaches.
```typescript
export class AppComponent implements OnInit, OnDestroy {
contact: Contact;
form: FormGroup;
private subscription: Subscription;
constructor(private service: ContactService, private fb: FormBuilder) {}
public ngOnInit() {
this.subscription = this.service
.loadContact()
.subscribe((data: Contact) => {
this.contact = data;
this.form = this.fb.group({
name: NameComponent.buildForm(data.name),
addresses: AddressListComponent.buildForm(data.addresses),
});
});
}
}
```
The HTML templating will be identical to the Global Form approach.
## Static Form Builder Methods
Rather than having a separate factory service, this approach uses static methods on each of the child sub-classes. This approach intentionally couples the logic for creating a sub-form structure with the component that would display it, keeping the logic in one place rather than separating it between components and an otherwise unrelated service. The rule-of-thumb in this approach is that the component which needs to display the form to a user will best know what the structure of that form needs to be.
### Name Component
```typescript
static buildForm(name: Name): FormGroup {
return new FormGroup({
firstName: new FormControl(name ? name.firstName : ''),
lastName: new FormControl(name ? name.lastName : ''),
middleName: new FormControl(name ? name.middleName : ''),
prefix: new FormControl(name ? name.prefix : ''),
suffix: new FormControl(name ? name.suffix : ''),
});
}
```
### Address List Component
```typescript
static buildForm(addresses: Address[]): FormArray {
const list: FormArray = new FormArray([]);
if (addresses) {
addresses.forEach(addr => {
list.push(AddressComponent.buildForm(addr));
});
}
return list;
}
```
### Address Component
```typescript
static buildForm(addr: Address): FormGroup {
return new FormGroup({
line1: new FormControl(addr ? addr.line1 : ''),
line2: new FormControl(addr ? addr.line2 : ''),
city: new FormControl(addr ? addr.city : ''),
state: new FormControl(addr ? addr.state : ''),
postalCode: new FormControl(addr ? addr.postalCode : ''),
});
}
```
## Pros
- The child components encapsulate the form controls and their display, while keeping the form creation logic separate from the actual template rendering
- The child components can easily be re-used
## Cons
- The overall shape of the form from the parent component's perspective is not always clear

View File

@ -0,0 +1,12 @@
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# You can see what browsers were selected by your queries by running:
# npx browserslist
> 0.5%
last 2 versions
Firefox ESR
not dead
not IE 9-11 # For IE 9-11 support, remove 'not'.

View File

@ -0,0 +1,9 @@
module.exports = {
name: 'static-factory-methods',
preset: '../../jest.config.js',
coverageDirectory: '../../coverage/apps/static-factory-methods',
snapshotSerializers: [
'jest-preset-angular/AngularSnapshotSerializer.js',
'jest-preset-angular/HTMLCommentSerializer.js',
],
};

View File

@ -0,0 +1,3 @@
<ng-container *ngFor="let addr of addressArray?.controls">
<nested-forms-address [addressGroup]="addr"></nested-forms-address>
</ng-container>

View File

@ -0,0 +1,28 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { AddressComponent } from './../address/address.component';
import { AddressListComponent } from './address-list.component';
describe('AddressListComponent', () => {
let component: AddressListComponent;
let fixture: ComponentFixture<AddressListComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [ReactiveFormsModule],
declarations: [ AddressListComponent, AddressComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AddressListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,35 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnInit,
} from '@angular/core';
import { FormArray } from '@angular/forms';
import { Address } from '@nested-forms/contact';
import { AddressComponent } from '../address/address.component';
@Component({
selector: 'nested-forms-address-list',
templateUrl: './address-list.component.html',
styleUrls: ['./address-list.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddressListComponent implements OnInit {
@Input() addressArray: FormArray;
static buildForm(addresses: Address[]): FormArray {
const list: FormArray = new FormArray([]);
if (addresses) {
addresses.forEach(addr => {
list.push(AddressComponent.buildForm(addr));
});
}
return list;
}
constructor() {}
ngOnInit() {}
}

View File

@ -0,0 +1,27 @@
<form *ngIf="addressGroup" [formGroup]="addressGroup">
<div>
<label for="line1">Line 1: </label>
<input name="line1" formControlName="line1">
</div>
<div>
<label for="line2">Line 2: </label>
<input name="line2" formControlName="line2">
</div>
<div>
<label for="city">City: </label>
<input name="city" formControlName="city">
</div>
<div>
<label for="state">State: </label>
<input name="state" formControlName="state">
</div>
<div>
<label for="postalCode">Postal Code: </label>
<input name="postalCode" formControlName="postalCode">
</div>
</form>

View File

@ -0,0 +1,27 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { AddressComponent } from './address.component';
describe('AddressComponent', () => {
let component: AddressComponent;
let fixture: ComponentFixture<AddressComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [ReactiveFormsModule],
declarations: [ AddressComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AddressComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,32 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnInit,
} from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { Address } from '@nested-forms/contact';
@Component({
selector: 'nested-forms-address',
templateUrl: './address.component.html',
styleUrls: ['./address.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddressComponent implements OnInit {
@Input() addressGroup: FormGroup;
static buildForm(addr: Address): FormGroup {
return new FormGroup({
line1: new FormControl(addr ? addr.line1 : ''),
line2: new FormControl(addr ? addr.line2 : ''),
city: new FormControl(addr ? addr.city : ''),
state: new FormControl(addr ? addr.state : ''),
postalCode: new FormControl(addr ? addr.postalCode : ''),
});
}
constructor() {}
ngOnInit() {}
}

View File

@ -0,0 +1,8 @@
<form [formGroup]="form">
<nested-forms-name [nameGroup]="form.get('name')"></nested-forms-name>
<nested-forms-address-list [addressArray]="form.get('addresses')"></nested-forms-address-list>
</form>
<hr />
<pre>
{{ form.value | json }}
</pre>

View File

@ -0,0 +1,33 @@
import { ReactiveFormsModule } from '@angular/forms';
import { RouterTestingModule } from '@angular/router/testing';
import { TestBed, async } from '@angular/core/testing';
import { AppComponent } from './app.component';
import { NameComponent } from './name/name.component';
import { AddressListComponent } from './address-list/address-list.component';
import { AddressComponent } from './address/address.component';
import { ContactModule } from '@nested-forms/contact';
describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
RouterTestingModule,
ReactiveFormsModule,
ContactModule.forRoot(),
],
declarations: [
AppComponent,
NameComponent,
AddressComponent,
AddressListComponent,
],
providers: [],
}).compileComponents();
}));
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
});
});

View File

@ -0,0 +1,44 @@
import {
ChangeDetectionStrategy,
Component,
OnDestroy,
OnInit,
} from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { Contact, ContactService } from '@nested-forms/contact';
import { Subscription } from 'rxjs';
import { AddressListComponent } from './address-list/address-list.component';
import { NameComponent } from './name/name.component';
@Component({
selector: 'nested-forms-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent implements OnInit, OnDestroy {
contact: Contact;
form: FormGroup;
private subscription: Subscription;
constructor(private service: ContactService, private fb: FormBuilder) {}
public ngOnInit() {
this.subscription = this.service
.loadContact()
.subscribe((data: Contact) => {
this.contact = data;
this.form = this.fb.group({
name: NameComponent.buildForm(data.name),
addresses: AddressListComponent.buildForm(data.addresses),
});
});
}
public ngOnDestroy() {
if (this.subscription) {
this.subscription.unsubscribe();
}
}
}

View File

@ -0,0 +1,23 @@
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { RouterModule } from '@angular/router';
import { ReactiveFormsModule } from '@angular/forms';
import { NameComponent } from './name/name.component';
import { ContactModule } from '@nested-forms/contact';
import { AddressListComponent } from './address-list/address-list.component';
import { AddressComponent } from './address/address.component';
@NgModule({
declarations: [AppComponent, NameComponent, AddressListComponent, AddressComponent],
imports: [
BrowserModule,
RouterModule.forRoot([], { initialNavigation: 'enabled' }),
ReactiveFormsModule,
ContactModule.forRoot()
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {}

View File

@ -0,0 +1,27 @@
<form *ngIf="nameGroup" [formGroup]="nameGroup">
<div>
<label for="firstName">First Name: </label>
<input name="firstName" formControlName="firstName">
</div>
<div>
<label for="lastName">Last Name: </label>
<input name="lastName" formControlName="lastName">
</div>
<div>
<label for="middleName">Middle Name: </label>
<input name="middleName" formControlName="middleName">
</div>
<div>
<label for="prefix">Prefix: </label>
<input name="prefix" formControlName="prefix">
</div>
<div>
<label for="suffix">Suffix: </label>
<input name="suffix" formControlName="suffix">
</div>
</form>

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { NameComponent } from './name.component';
describe('NameComponent', () => {
let component: NameComponent;
let fixture: ComponentFixture<NameComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [ReactiveFormsModule],
declarations: [NameComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(NameComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,32 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnInit,
} from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { Name } from '@nested-forms/contact';
@Component({
selector: 'nested-forms-name',
templateUrl: './name.component.html',
styleUrls: ['./name.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NameComponent implements OnInit {
@Input() nameGroup: FormGroup;
static buildForm(name: Name): FormGroup {
return new FormGroup({
firstName: new FormControl(name ? name.firstName : ''),
lastName: new FormControl(name ? name.lastName : ''),
middleName: new FormControl(name ? name.middleName : ''),
prefix: new FormControl(name ? name.prefix : ''),
suffix: new FormControl(name ? name.suffix : ''),
});
}
constructor() {}
ngOnInit() {}
}

View File

@ -0,0 +1,3 @@
export const environment = {
production: true
};

View File

@ -0,0 +1,16 @@
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
export const environment = {
production: false
};
/*
* For easier debugging in development mode, you can import the following file
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
*
* This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown.
*/
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>GlobalForm</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<nested-forms-root></nested-forms-root>
</body>
</html>

View File

@ -0,0 +1,13 @@
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
platformBrowserDynamic()
.bootstrapModule(AppModule)
.catch(err => console.error(err));

View File

@ -0,0 +1,62 @@
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
*
* This file is divided into 2 sections:
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
* file.
*
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
*
* Learn more in https://angular.io/guide/browser-support
*/
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/** IE10 and IE11 requires the following for NgClass support on SVG elements */
// import 'classlist.js'; // Run `npm install --save classlist.js`.
/**
* Web Animations `@angular/platform-browser/animations`
* Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
* Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
*/
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
/**
* By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags
* because those flags need to be set before `zone.js` being loaded, and webpack
* will put import in the top of bundle, so user need to create a separate file
* in this directory (for example: zone-flags.ts), and put the following flags
* into that file, and then add the following code before importing zone.js.
* import './zone-flags.ts';
*
* The flags allowed in zone-flags.ts are listed here.
*
* The following flags will work for all browsers.
*
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
*
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
* with the following flag, it will bypass `zone.js` patch for IE/Edge
*
* (window as any).__Zone_enable_cross_context_check = true;
*
*/
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js/dist/zone'; // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/

View File

@ -0,0 +1 @@
/* You can add global styles to this file, and also import other style files */

View File

@ -0,0 +1 @@
import 'jest-preset-angular';

View File

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["**/*.ts"],
"exclude": ["src/test-setup.ts", "**/*.spec.ts"]
}

View File

@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["node", "jest"]
},
"include": ["**/*.ts"]
}

View File

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"files": ["src/test-setup.ts"],
"include": ["**/*.spec.ts", "**/*.d.ts"]
}

View File

@ -0,0 +1,7 @@
{
"extends": "../../tslint.json",
"rules": {
"directive-selector": [true, "attribute", "nestedForms", "camelCase"],
"component-selector": [true, "element", "nested-forms", "kebab-case"]
}
}

View File

@ -19,6 +19,9 @@
},
"baseline": {
"tags": []
},
"static-factory-methods": {
"tags": []
}
}
}