From aba0a8bff21a2e5f9f3dc1b6955a9a5c32cd1301 Mon Sep 17 00:00:00 2001 From: jo186026 Date: Tue, 25 Jul 2017 07:46:53 -0500 Subject: [PATCH] fix(dynamic-forms): dynamically add, edit, remove Form Controls during runtime Refactored form controls to use FormArray API to add, edit, and remove controls. closes #624 --- .../dynamic-element.component.ts | 16 +- .../dynamic-input.component.html | 10 +- .../dynamic-input/dynamic-input.component.ts | 2 + .../dynamic-forms.component.html | 32 +-- .../dynamic-forms/dynamic-forms.component.ts | 179 +++++++++++---- .../services/dynamic-forms.service.ts | 6 +- .../services/form-array-utils.spec.ts | 217 ++++++++++++++++++ .../services/form-array-utils.ts | 112 +++++++++ 8 files changed, 514 insertions(+), 60 deletions(-) create mode 100644 src/platform/dynamic-forms/services/form-array-utils.spec.ts create mode 100644 src/platform/dynamic-forms/services/form-array-utils.ts diff --git a/src/platform/dynamic-forms/dynamic-element.component.ts b/src/platform/dynamic-forms/dynamic-element.component.ts index 0c3e1e2977..730fc2bb92 100644 --- a/src/platform/dynamic-forms/dynamic-element.component.ts +++ b/src/platform/dynamic-forms/dynamic-element.component.ts @@ -1,7 +1,7 @@ import { Component, Directive, Input, HostBinding, OnInit } from '@angular/core'; import { ViewChild, ViewContainerRef } from '@angular/core'; import { ComponentFactoryResolver, ComponentRef, forwardRef } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormControl } from '@angular/forms'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormGroup } from '@angular/forms'; import { TdDynamicElement, TdDynamicType, TdDynamicFormsService } from './services/dynamic-forms.service'; import { AbstractControlValueAccessor } from './dynamic-elements/abstract-control-value-accesor'; @@ -42,7 +42,12 @@ export class TdDynamicElementComponent extends AbstractControlValueAccessor /** * Sets form control of the element. */ - @Input() dynamicControl: FormControl; + @Input() dynamicControl: FormGroup; + + /** + * Maps control object in FormGroup. + */ + @Input() elementName: string; /** * Sets label to be displayed. @@ -106,6 +111,7 @@ export class TdDynamicElementComponent extends AbstractControlValueAccessor .create(this.childElement.viewContainer.injector); this.childElement.viewContainer.insert(ref.hostView); ref.instance.control = this.dynamicControl; + ref.instance.elementName = this.elementName; ref.instance.label = this.label; ref.instance.type = this.type; ref.instance._value = this._value; @@ -115,12 +121,18 @@ export class TdDynamicElementComponent extends AbstractControlValueAccessor ref.instance.selections = this.selections; ref.instance.registerOnChange((value: any) => { this.value = value; + + let valueObect: any = {}; + valueObect[ this.elementName ] = value; + + this.dynamicControl.setValue(valueObect); }); this.registerOnModelChange((value: any) => { // fix to check if value is NaN (type=number) if (!Number.isNaN(value)) { ref.instance.value = value; } + }); } diff --git a/src/platform/dynamic-forms/dynamic-elements/dynamic-input/dynamic-input.component.html b/src/platform/dynamic-forms/dynamic-elements/dynamic-input/dynamic-input.component.html index f5d8ab3290..4d733f008a 100644 --- a/src/platform/dynamic-forms/dynamic-elements/dynamic-input/dynamic-input.component.html +++ b/src/platform/dynamic-forms/dynamic-elements/dynamic-input/dynamic-input.component.html @@ -11,9 +11,13 @@ flex> - Max: {{max}} - Min: {{min}} + Max: {{max}} + Min: {{min}} - \ No newline at end of file + +
+ +
+ diff --git a/src/platform/dynamic-forms/dynamic-elements/dynamic-input/dynamic-input.component.ts b/src/platform/dynamic-forms/dynamic-elements/dynamic-input/dynamic-input.component.ts index cde783d444..5f0197dc72 100644 --- a/src/platform/dynamic-forms/dynamic-elements/dynamic-input/dynamic-input.component.ts +++ b/src/platform/dynamic-forms/dynamic-elements/dynamic-input/dynamic-input.component.ts @@ -21,6 +21,8 @@ export class TdDynamicInputComponent extends AbstractControlValueAccessor implem label: string = ''; + elementName: string = ''; + type: string = undefined; required: boolean = undefined; diff --git a/src/platform/dynamic-forms/dynamic-forms.component.html b/src/platform/dynamic-forms/dynamic-forms.component.html index eeaaca7293..5f2048c168 100644 --- a/src/platform/dynamic-forms/dynamic-forms.component.html +++ b/src/platform/dynamic-forms/dynamic-forms.component.html @@ -1,20 +1,22 @@
-
- - +
+ +
- \ No newline at end of file + diff --git a/src/platform/dynamic-forms/dynamic-forms.component.ts b/src/platform/dynamic-forms/dynamic-forms.component.ts index d096341a28..a0f46f0ab7 100644 --- a/src/platform/dynamic-forms/dynamic-forms.component.ts +++ b/src/platform/dynamic-forms/dynamic-forms.component.ts @@ -1,47 +1,33 @@ -import { Component, Input, ChangeDetectorRef } from '@angular/core'; -import { FormGroup, FormBuilder, AbstractControl } from '@angular/forms'; +import { Component, Input, ChangeDetectorRef, DoCheck } from '@angular/core'; +import { FormGroup, FormBuilder, AbstractControl, FormArray } from '@angular/forms'; -import { TdDynamicFormsService, ITdDynamicElementConfig } from './services/dynamic-forms.service'; +import { TdDynamicFormsService, ITdDynamicElementConfig, ITdFormGroup } from './services/dynamic-forms.service'; +import { FormArrayUtils } from './services/form-array-utils'; +import has = Reflect.has; + +export enum constant { + FORM_ARRAY = 'formArray', +} @Component({ selector: 'td-dynamic-forms', templateUrl: './dynamic-forms.component.html', }) -export class TdDynamicFormsComponent { +export class TdDynamicFormsComponent implements DoCheck { - private _elements: ITdDynamicElementConfig[]; + // dirty check + private _elementsLength: number = 0; + private _formArrayUtils: FormArrayUtils; dynamicForm: FormGroup; + elementConfigs: { [key: string ]: ITdDynamicElementConfig } = {}; /** * elements: ITdDynamicElementConfig[] * JS Object that will render the elements depending on its config. * [name] property is required. */ - @Input('elements') - set elements(elements: ITdDynamicElementConfig[]){ - if (elements) { - if (this._elements) { - this._elements.forEach((elem: ITdDynamicElementConfig) => { - this.dynamicForm.removeControl(elem.name); - }); - } - let duplicates: string[] = []; - elements.forEach((elem: ITdDynamicElementConfig) => { - this._dynamicFormsService.validateDynamicElementName(elem.name); - if (duplicates.indexOf(elem.name) > -1) { - throw new Error(`Dynamic element name: "${elem.name}" is duplicated`); - } - duplicates.push(elem.name); - this.dynamicForm.registerControl(elem.name, this._dynamicFormsService.createFormControl(elem)); - }); - this._elements = elements; - this._changeDetectorRef.detectChanges(); - } - } - get elements(): ITdDynamicElementConfig[] { - return this._elements; - } + @Input('elements') elements: ITdDynamicElementConfig[] = []; /** * Getter property for dynamic [FormGroup]. @@ -65,7 +51,7 @@ export class TdDynamicFormsComponent { */ get value(): any { if (this.dynamicForm) { - return this.dynamicForm.value; + return this._formArrayUtils.getValue(); } return {}; } @@ -73,13 +59,9 @@ export class TdDynamicFormsComponent { /** * Getter property for [errors] of dynamic [FormGroup]. */ - get errors(): {[name: string]: any} { + get errors(): { [name: string]: any } { if (this.dynamicForm) { - let errors: {[name: string]: any} = {}; - for (let name in this.dynamicForm.controls) { - errors[name] = this.dynamicForm.controls[name].errors; - } - return errors; + return this._formArrayUtils.getErrors(); } return {}; } @@ -87,9 +69,9 @@ export class TdDynamicFormsComponent { /** * Getter property for [controls] of dynamic [FormGroup]. */ - get controls(): {[key: string]: AbstractControl} { + get controls(): { [key: string]: AbstractControl } { if (this.dynamicForm) { - return this.dynamicForm.controls; + return this._formArrayUtils.getControls(); } return {}; } @@ -97,6 +79,125 @@ export class TdDynamicFormsComponent { constructor(private _formBuilder: FormBuilder, private _dynamicFormsService: TdDynamicFormsService, private _changeDetectorRef: ChangeDetectorRef) { - this.dynamicForm = this._formBuilder.group({}); + this.dynamicForm = this._formBuilder.group({ + formArray: this._formBuilder.array([]), + }); + + // Not use constructor because FormArray should not be an instance variable. + this._formArrayUtils = new FormArrayUtils(); + this._formArrayUtils.formArray = this.dynamicForm.controls[ constant.FORM_ARRAY ]; + } + + ngDoCheck(): void { + + if (!this.elements) { + return; + } + + // manual dirty check + if (this.elements.length === this._elementsLength) { + this.compareControls(); + } else { + let doAdd: boolean = this.elements.length > this._elementsLength; + + // set pristine + this._elementsLength = this.elements.length; + + if (doAdd) { + this.pushControls(); + } else { + this.removeControls(); + } + } + + } + + pushControls(): void { + + let duplicates: string[] = []; + this.elements.forEach((elem: ITdDynamicElementConfig) => { + + if (duplicates.indexOf(elem.name) > -1) { + throw new Error(`Dynamic element name: "${elem.name}" is duplicated`); + } + duplicates.push(elem.name); + + if (!this._formArrayUtils.contains(elem.name)) { + + // tslint:disable-next-line + this.elementConfigs[ elem.name ] = elem; + + // Let FormGroup API create the control + // tslint:disable-next-line + (this.dynamicForm.controls[ constant.FORM_ARRAY ]).push(this.createFormGroup(elem)); + } + }); + + this._changeDetectorRef.detectChanges(); + + } + + createFormGroup(elem: ITdDynamicElementConfig): FormGroup { + let group: any = {}; + elem.default = elem.default || ''; + + // group[ elem.name ] = [ defaultValue, validators ]; + group[ elem.name ] = this._dynamicFormsService.createFormControl(elem); + + let newGroup: FormGroup = this._formBuilder.group(group); + (newGroup).tdElementConfig = elem; + + return newGroup; + } + + removeControls(): void { + // tslint:disable-next-line + let arrayControl: FormArray = this.dynamicForm.controls[ constant.FORM_ARRAY ]; + + let removeControlsByName: string[] = this._formArrayUtils.getControlNamesToRemoveByElements(this.elements); + + removeControlsByName.forEach((name: string) => { + + // remove ITdDynamicElementConfig reference + delete this.elementConfigs[ name ]; + + // Need to get index every iteration + // since remove control re-indexes array. + let indexInFormArray: number = this._formArrayUtils.indexOf(name); + arrayControl.removeAt(indexInFormArray); + }); + + this._changeDetectorRef.detectChanges(); + } + + compareControls(): void { + + let hasChanged: any = []; + + this.elements.forEach((elem: ITdDynamicElementConfig, index: number) => { + if (elem !== this.elementConfigs[ elem.name ]) { + hasChanged.push({ + index: index, + elem: elem, + }); + + this.elementConfigs[ elem.name ] = elem; + } + }); + + if (hasChanged.length) { + while (hasChanged.length) { + let changedElem: any = hasChanged.shift(); + + // remove old FormGroup + (this.dynamicForm.controls[ constant.FORM_ARRAY ]).removeAt(changedElem.index); + + // create new FormGroup + let newGroup: FormGroup = this.createFormGroup(changedElem.elem); + + // insert new FormGroup + (this.dynamicForm.controls[ constant.FORM_ARRAY ]).insert(changedElem.index, newGroup); + } + } } } diff --git a/src/platform/dynamic-forms/services/dynamic-forms.service.ts b/src/platform/dynamic-forms/services/dynamic-forms.service.ts index 916c2b8729..2d74299487 100644 --- a/src/platform/dynamic-forms/services/dynamic-forms.service.ts +++ b/src/platform/dynamic-forms/services/dynamic-forms.service.ts @@ -1,5 +1,5 @@ import { Injectable, Provider, SkipSelf, Optional } from '@angular/core'; -import { Validators, ValidatorFn, FormControl } from '@angular/forms'; +import { Validators, ValidatorFn, FormControl, FormGroup } from '@angular/forms'; import { TdDynamicInputComponent } from '../dynamic-elements/dynamic-input/dynamic-input.component'; import { TdDynamicTextareaComponent } from '../dynamic-elements/dynamic-textarea/dynamic-textarea.component'; @@ -36,6 +36,10 @@ export interface ITdDynamicElementConfig { default?: any; } +export interface ITdFormGroup extends FormGroup { + tdElementConfig: ITdDynamicElementConfig; +} + export const DYNAMIC_ELEMENT_NAME_REGEX: RegExp = /^[a-zA-Z]+[a-zA-Z0-9-_]*$/; @Injectable() diff --git a/src/platform/dynamic-forms/services/form-array-utils.spec.ts b/src/platform/dynamic-forms/services/form-array-utils.spec.ts new file mode 100644 index 0000000000..f8ca10a0a1 --- /dev/null +++ b/src/platform/dynamic-forms/services/form-array-utils.spec.ts @@ -0,0 +1,217 @@ +import { FormBuilder, FormArray, FormGroup, Validators, AbstractControl } from '@angular/forms'; +import { FormArrayUtils } from './form-array-utils'; +import { ITdDynamicElementConfig, TdDynamicType } from './dynamic-forms.service'; + +describe('Class: FormArrayUtils', () => { + + let fb: FormBuilder; + let formGroup: FormGroup; + let formArrayUtils: FormArrayUtils; + + // helper functions + let subFormGroupFactory: Function; + let loadFormArray: Function; + + beforeEach(() => { + + fb = new FormBuilder(); + + // Parent form Group containing FormArray as 'formArray' + formGroup = fb.group({ + formArray: fb.array([]), + }); + + // Add 'formArray' to FormArrayUtils class property + formArrayUtils = new FormArrayUtils(); + formArrayUtils.formArray = formGroup.controls.formArray; + + // Helper function to create sub form groups ( controllers ) + subFormGroupFactory = (name: string): FormGroup => { + let groupConfig: any = {}; + + // Generic control -- not testing the control itself + groupConfig[ name ] = ['', Validators.required]; + + return fb.group(groupConfig); + }; + + loadFormArray = () => { + // push to FormArray + (formGroup.controls.formArray).push(subFormGroupFactory('fooName')); + (formGroup.controls.formArray).push(subFormGroupFactory('barName')); + (formGroup.controls.formArray).push(subFormGroupFactory('bazName')); + }; + }); + + afterEach(() => { + fb = undefined; + formGroup = undefined; + formArrayUtils = undefined; + subFormGroupFactory = undefined; + loadFormArray = undefined; + }); + + it('expect formArray to be defined on FormGroup controls', () => { + expect(formGroup.controls.formArray).toBeDefined(); + }); + + it('should get control name', () => { + let fooFormGroup: FormGroup = subFormGroupFactory('fooName'); + + expect(formArrayUtils.getControlName(<{ [ key: string]: AbstractControl }>fooFormGroup.controls)).toBe('fooName'); + }); + + it('should get names from multiple controls', () => { + // push fooName', 'barName', 'bazName' to FormArray + loadFormArray(); + + expect(formArrayUtils.getControlNames()).toEqual(['fooName', 'barName', 'bazName']); + }); + + it('should return true if FormArray contains control name', () => { + + // push fooName', 'barName', 'bazName' to FormArray + loadFormArray(); + + expect(formArrayUtils.contains('barName')).toBeTruthy(); + }); + + it('should return false if FormArray does not contain control name', () => { + // push fooName', 'barName', 'bazName' to FormArray + loadFormArray(); + + expect(formArrayUtils.contains('bumName')).toBeFalsy(); + }); + + it('should return FormArray index of control by name', () => { + // push fooName', 'barName', 'bazName' to FormArray + loadFormArray(); + + expect(formArrayUtils.indexOf('fooName')).toEqual(0); + expect(formArrayUtils.indexOf('barName')).toEqual(1); + expect(formArrayUtils.indexOf('bazName')).toEqual(2); + + // Not in FormArray + expect(formArrayUtils.indexOf('bumName')).toEqual(-1); + }); + + it('should return array of control names to remove from FormArray', () => { + + // push fooName', 'barName', 'bazName' to FormArray + loadFormArray(); + + // 'barName' is not included in formGroup2. + // 'bumName' is added + let newControlNames: string[] = ['fooName', 'bazName', 'bumName']; + + expect(formArrayUtils.getControlNamesToRemove(newControlNames)).toEqual(['barName']); + }); + + it('should get names from array of ITdDynamicElementConfig', () => { + + // type is not tested, but required by interface ITdDynamicElementConfig + let configs: ITdDynamicElementConfig[] = [ + { name: 'elem1', type: TdDynamicType.Text }, + { name: 'elem2', type: TdDynamicType.Text }, + { name: 'elem3', type: TdDynamicType.Text }, + ]; + + // Not in FormArray + expect(formArrayUtils.getElementNames(configs)).toEqual(['elem1', 'elem2', 'elem3']); + }); + + it('should get control names to remove from array of ITdDynamicElementConfig', () => { + + // push fooName', 'barName', 'bazName' to FormArray + loadFormArray(); + + // 'barName' is not included in formGroup2. + // 'bumName' is added. + // type is not tested, but required by interface ITdDynamicElementConfig + let configs: ITdDynamicElementConfig[] = [ + { name: 'fooName', type: TdDynamicType.Text }, + { name: 'bazName', type: TdDynamicType.Text }, + { name: 'bumName', type: TdDynamicType.Text }, + ]; + + expect(formArrayUtils.getControlNamesToRemoveByElements(configs)).toEqual(['barName']); + }); + + it('should return object with control keys defined', () => { + + // push fooName', 'barName', 'bazName' to FormArray + loadFormArray(); + + let controls: any = formArrayUtils.getControls(); + + expect(controls.fooName).toEqual(jasmine.anything()); + expect(controls.barName).toEqual(jasmine.anything()); + expect(controls.bazName).toEqual(jasmine.anything()); + }); + + it('should return value of controls', () => { + + // push fooName', 'barName', 'bazName' to FormArray + let subGroupFoo: any = subFormGroupFactory('fooName'); + let subGroupBar: any = subFormGroupFactory('barName'); + let subGroupBaz: any = subFormGroupFactory('bazName'); + + (formGroup.controls.formArray).push(subGroupFoo); + (formGroup.controls.formArray).push(subGroupBar); + (formGroup.controls.formArray).push(subGroupBaz); + + subGroupFoo.setValue({ + fooName: 'fooValue', + }); + + subGroupBar.setValue({ + barName: 'barValue', + }); + + subGroupBaz.setValue({ + bazName: 'bazValue', + }); + + let values: any = formArrayUtils.getValue(); + + expect(values.fooName).toBe('fooValue'); + expect(values.barName).toBe('barValue'); + expect(values.bazName).toBe('bazValue'); + + // not on values object + expect(values.bumName).toBeUndefined(); + }); + + it('should return error object', () => { + + // push fooName', 'barName', 'bazName' to FormArray + loadFormArray(); + + let controls: any = formArrayUtils.getControls(); + + // so barName control does not have an error + controls.barName.setValue({ + barName: 'some value', + }); + + let errors: any = formArrayUtils.getErrors(); + + // Has error + expect(errors.fooName.required).toBeTruthy(); + expect(errors.bazName.required).toBeTruthy(); + + // No error + expect(errors.barName).toBeNull(); + }); + + it('should return object containing only pushed control keys', () => { + + // push fooName', 'barName', 'bazName' to FormArray + loadFormArray(); + + let controls: any = formArrayUtils.getControls(); + + expect(Object.keys(controls)).toEqual(['fooName', 'barName', 'bazName']); + }); + +}); diff --git a/src/platform/dynamic-forms/services/form-array-utils.ts b/src/platform/dynamic-forms/services/form-array-utils.ts new file mode 100644 index 0000000000..993e370b82 --- /dev/null +++ b/src/platform/dynamic-forms/services/form-array-utils.ts @@ -0,0 +1,112 @@ +import { AbstractControl, FormArray, FormGroup } from '@angular/forms'; +import { ITdDynamicElementConfig } from './dynamic-forms.service'; + +export class FormArrayUtils { + + private _formArray: FormArray; + set formArray(val: FormArray) { + this._formArray = val; + } + + /** + * Since these Sub FormGroup controls are managed + * specifically for dynamic-forms use case, + * it is safely assumed there is only one + * control per FormGroup. + * + * @param control + * @returns {any} + */ + getControlName(control: { [ key: string]: AbstractControl }): string { + if (control) { + + // Return the only Control key in FormGroup controls. + return Object.keys(control)[ 0 ]; + } + + return undefined; + } + + getControlNames(): string[] { + + if (this._formArray.controls) { + return this._formArray.controls.reduce((acc: any, ctrl: any, index: number) => { + acc.push(this.getControlName(ctrl.controls)); + return acc; + }, []); + } + + return []; + } + + contains(name: string): boolean { + return this.getControlNames().indexOf(name) !== -1; + } + + indexOf(name: string): number { + return this.getControlNames().indexOf(name); + } + + getControlNamesToRemove( newControlNames: string[]): string[] { + return this.getControlNames().reduce((acc: any, name: string) => { + if (newControlNames.indexOf(name) === -1) { + acc.push(name); + } + return acc; + }, []); + } + + getElementNames( elements: ITdDynamicElementConfig[] ): string[] { + return elements.reduce((acc: any, elem: any) => { + acc.push(elem.name); + return acc; + }, []); + } + + getControlNamesToRemoveByElements(elements: ITdDynamicElementConfig[]): string[] { + return this.getControlNamesToRemove(this.getElementNames(elements)); + } + + getValue(): { [key: string]: any } { + let names: string[] = this.getControlNames(); + + let value: any = names.reduce((acc: any, name: string) => { + let indexOfControl: number = this.indexOf(name); + + let currentValue: any = (this._formArray.controls[indexOfControl]).controls[name].value; + + // Unit tests expect null value for empty string. + /* tslint:disable-next-line */ + acc[name] = ( typeof currentValue === 'string' && currentValue.length === 0) ? null : currentValue; + return acc; + }, {}); + + return value || {}; + + } + + getErrors(): { [key: string]: any } { + let names: string[] = this.getControlNames(); + + let errors: any = names.reduce((acc: any, name: string) => { + let indexOfControl: number = this.indexOf(name); + acc[name] = (this._formArray.controls[indexOfControl]).controls[name].errors; + return acc; + }, {}); + + return errors || {}; + + } + + getControls(): { [key: string]: AbstractControl } { + let names: string[] = this.getControlNames(); + + let controls: AbstractControl[] = names.reduce((acc: any, name: string) => { + let indexOfControl: number = this.indexOf(name); + acc[name] = (this._formArray.controls[indexOfControl]).controls[name]; + return acc; + }, {}); + + return controls || {}; + } +}