import { Component, Input, Output, EventEmitter, TemplateRef, Optional, OnInit, OnChanges, ChangeDetectionStrategy } from '@angular/core'; import { FormBuilder, FormControl, FormGroup, FormArray, FormGroupName, AbstractControl, ControlContainer } from '@angular/forms'; import { SuperForm } from 'angular-super-validator'; import { buildFormGroupFunctionFactory, buildFormControl } from './services/_formdata-utils'; import { cloneDeep } from 'lodash/fp'; export interface DynarowContext { control: AbstractControl; meta: StringMap; } @Component({ selector: 'app-dynaform', templateUrl: './dynaform.component.html', styleUrls: ['./dynaform.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush // or ChangeDetectionStrategy.OnPush - should be more efficient. Experiment later. }) export class DynaformComponent implements OnInit, OnChanges { /* * DynaformComponent: * * USAGE: * * Supply component with either a FormGroup or the name of a FormGroup, * the forms full metadata tree, * and, optionally, a TemplateRef used as a field row template (overrides the default template) * * e.g. * * * * * If supplied with just a FormGroupName it will retieve the FormGroup from the injected ControlContainer * */ @Input() formGroup?: FormGroup; @Input() formGroupName?: FormGroupName; @Input() set meta(data) { // console.log('Dynaform Set Meta'); // this.formMetaData = this.formMetaData || data; // WHY? WHY? WHY? - leave in for now, just in case this.formMetaData = data; } @Input() template?: TemplateRef; @Input() debug = false; @Output() call: EventEmitter = new EventEmitter(); formMetaData: StringMap; // TODO: Tighten up type controlNames: string[]; dynaFormRows: string[]; path: string[]; // path of current FormGroup - can be used to respond differently based on nesting level in template // Colours for CSS in console conRed = 'color: white; background-color: maroon; font-weight: bold;'; conGreen = 'color: white; background-color: green; font-weight: bold;'; conOlive = 'color: white; background-color: olive; font-weight: bold;'; constructor( @Optional() private cc: ControlContainer ) {} ngOnInit() { // console.log('Dyanaform ngOnInit'); } ngOnChanges() { // Triggered when inputs change console.log('%c *** DynaformChange *** ', this.conOlive); console.log(this.formMetaData); // Get the formGroup from the formGroupName if necessary if (!this.formGroup && this.formGroupName) { this.formGroup = this.cc.control as FormGroup; // Get theFormGroup from the injected ControlContainer } if (!this.formGroup) { throw new Error(`Dynaform Component initialised without [formGroup] or cant't find formGroup from formGroupName ${this.formGroupName}`); } if (typeof this.formMetaData !== 'object') { throw new Error('Dynaform: [meta] should be an object'); } this.controlNames = Object.keys(this.formGroup.controls); this.path = this.getFormGroupPath(); if (this.debug && this.path.length < 2) { this.displayDebuggingInConsole(); } // If we're given a formGroupName or nested FormGroup, and the form's full (or partial but fuller) metadata tree, // drill down to find *this* FormGroup's metadata const path = [...this.path]; // Clone to avoid mutating this.path const metaDataKeysExpected = this.controlNames.join(','); while (path.length && metaDataKeysExpected !== this.getContolKeysCSVFromMetadata(this.formMetaData)) { const branch = path.pop(); this.formMetaData = this.formMetaData[branch].meta; } this.dynaFormRows = Object.keys(this.formMetaData); // Check we've got a "FormGroup <---> MetaData" match const metaDataKeysFound = this.getContolKeysCSVFromMetadata(this.formMetaData); if (metaDataKeysFound !== metaDataKeysExpected) { throw new Error(` Dynaform can't match FormGroup's controls with metadata Expected ${metaDataKeysExpected} Got ${metaDataKeysFound}` ); } } getFormGroupPath(): string[] { // Get the full path of the FormGroup (used to match and validate metadata) let path = []; if (!this.formGroup && this.formGroupName) { // formGroupName supplied, so get the full path from the ControlContainer path = this.cc.path.reverse(); } else { // formGroup supplied directly, so reconstruct current FormGroup's full path let fg = this.formGroup; while (fg.parent) { // Find the identity of 'fg' in the parent FormGroup's controls const fgIdentity = Object.entries(fg.parent.controls).find(([key, candidate]) => candidate === fg); path.push(fgIdentity[0]); fg = fg.parent as FormGroup; } } return path; } getEmbeddedDynaformClasses(meta: StringMap): StringMap { let ngClassObj = {}; if (Array.isArray(meta.class)) { ngClassObj = (meta.class as string[]).reduce((acc, className) => (acc[className] = true, acc), ngClassObj); } else if (typeof meta.class === 'string' && meta.class) { ngClassObj[meta.class] = true; } return ngClassObj; } isField(meta: StringMap): boolean { return !meta.type.includes('Container') && meta.type !== 'RepeatingField'; } getTemplateContext(controlName: string): DynarowContext { return { control: this.formGroup.get(controlName), meta: this.formMetaData[controlName] }; } getRepeatingTemplateContext(name: string, index: number): DynarowContext { const rcFormArray = this.formGroup.get(name) as FormArray; const result = { control: rcFormArray.at(index), meta: this.formMetaData[name]['meta'][index] }; return result; } getRowClass(control: FormControl, meta: StringMap): string { const fieldTypeClass = meta.type ? meta.type.toLowerCase().replace('component', '') : ''; const fieldClass = Array.isArray(meta.class) ? meta.class.join(' ') : meta.class; const containerClass = fieldClass ? (meta.type === 'Container' ? ` ${fieldClass}` : ` row-${fieldClass}`) : ''; const errorClass = control && control.touched && control.invalid ? ' dyna-error' : ''; return `row-${fieldTypeClass}${containerClass}${errorClass}`; } getRepeatingContainerLabel(repeatingContainerName: string, index: number): string { // Get the label for a repeating container, // used on buttons to switch between containers (when only one is shown at a time i.e display = 'SINGLE') const rcMeta = this.formMetaData[repeatingContainerName]; const primaryField = rcMeta.primaryField; if (primaryField) { // The primaryField has been specified, so return its value const rcFormArray = this.formGroup.get(repeatingContainerName) as FormArray; const formGroup = rcFormArray.at(index); return formGroup.get(primaryField).value || '…'; } else { // Otherwise return the 'button' of the container (in the array of containers) return rcMeta.meta[index].button; } } focusContainer(name: string, index: number): void { // Show a particular member of a Repeating Container Group (used when only one is shown at a time i.e display = 'SINGLE') const rcMeta = this.formMetaData[name]; rcMeta.meta = rcMeta.meta.map( (container, i) => ({ ...container, focussed: i === index }) ); } addAllowed(name: string): boolean { const meta = this.formMetaData[name]; return typeof meta.maxRepeat === 'number' && meta.maxRepeat > meta.meta.length; } // Maybe move the AddRF and deleteRF funtions to _formdata-utils.ts ? addRepeatingFieldMember(name: string): void { // (1) Check that we can still add controls if (!this.addAllowed(name)) { return; } // (2) Add metadata for new container member const rfMeta = this.formMetaData[name]; const fieldTemplate = cloneDeep(rfMeta.__template); const i = this.formMetaData[name].meta.length; fieldTemplate.name = `group${i+1}`; // CHECK rfMeta.meta.push(fieldTemplate); // (3) Add FormControl to FormArray const newFormControl = buildFormControl(fieldTemplate); (this.formGroup.get(name) as FormArray).push(newFormControl); } // Maybe move the AddRC and deleteRC funtions to _formdata-utils.ts ? addRepeatingContainerMember(name: string): void { // (1) Check that we can still add controls if (!this.addAllowed(name)) { return; } // (2) Add metadata for new container member const rcMeta = this.formMetaData[name]; const containerTemplate = cloneDeep(rcMeta.__template); const i = this.formMetaData[name].meta.length; containerTemplate.name = `group${i+1}`; rcMeta.meta.push(containerTemplate); // (3) Add FormGroup for new container member const buildFormGroup = buildFormGroupFunctionFactory(new FormBuilder()); const newFormGroup = buildFormGroup(containerTemplate.meta); (this.formGroup.get(name) as FormArray).push(newFormGroup); // (4) Focus new container member if display = 'SINGLE' (i.e. we're only showing one at once) if (rcMeta.display === 'SINGLE') { this.focusContainer(name, i); } } deleteAllowed(name: string): boolean { const meta = this.formMetaData[name]; return typeof meta.minRepeat === 'number' && meta.minRepeat < meta.meta.length; } // Maybe move the AddRC and deleteRC funtions to _formdata-utils.ts ? deleteRepeatingMember(name: string, index: number): void { // (1) Check that we can still delete controls if (!this.deleteAllowed(name)) { return; } // (2) Delete from the metadata, and rename the groups const rcMeta = this.formMetaData[name]; const metaArr = rcMeta.meta; const newMetaArr = [ ...metaArr.slice(0, index), ...metaArr.slice(index + 1) ] .map((m, i) => { m.name = `group${i+1}`; return m; }); rcMeta.meta = newMetaArr; // (3) Delete the corresponding FormGroup from the FormArray (this.formGroup.get(name) as FormArray).removeAt(index); // (4) Focus the last if display = 'SINGLE' (i.e. we're only showing one at once) if (rcMeta.display === 'SINGLE') { this.focusContainer(name, newMetaArr.length - 1); } } getValidationFailureMessage(control: FormControl, meta: StringMap) { if (control.errors) { const errKeys = Object.keys(control.errors); console.log(errKeys); return meta.valFailureMsgs[errKeys[0]]; } } getValidationErrors() { if (!this.formGroup.valid) { const errorsFlat = SuperForm.getAllErrorsFlat(this.formGroup); return errorsFlat; } return false; } handleCallback(fnId: string) { this.call.emit(fnId); } private getContolKeysCSVFromMetadata(metadata: StringMap): string { // Return CSV of control keys in current nesting-level's metadata, // excluding metadata points that don't create FormControls, FromGroups or FormArrays // (identified by their 'noFormControl' flag) // e.g. ButtonGroups, HTMLChunks, etc. return Object.entries(metadata) .filter(([key, val]) => !(val as StringMap).noFormControls) .reduce((acc, [key]) => [...acc, key], []) .join(','); } private displayDebuggingInConsole(): void { if (this.debug) { console.log('%c *** MetaData *** ', this.conGreen); console.dir(this.formMetaData); console.log('%c *** FormGroup *** ', this.conGreen); console.dir(this.formGroup); } } }