import { Component, Input, Output, EventEmitter, TemplateRef, Optional, OnInit, ChangeDetectionStrategy } from '@angular/core'; import { FormControl, FormGroup, FormArray, FormGroupName, AbstractControl, ControlContainer } from '@angular/forms'; import { SuperForm } from 'angular-super-validator'; import { createMeta } from '@angular/platform-browser/src/browser/meta'; export interface DynarowContext { control: AbstractControl; meta: StringMap; } @Component({ selector: 'app-dynaform', templateUrl: './dynaform.component.html', styleUrls: ['./dynaform.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class DynaformComponent implements OnInit { /* * 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) { this.formMetaData = 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;'; constructor( @Optional() private cc: ControlContainer, ) {} ngOnInit() { // 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 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; } isField(meta: StringMap): boolean { return !meta.type.includes('Container'); } isRepeatingContainer(meta: StringMap): boolean { return meta.type === 'RepeatingContainer'; } getTemplateContext(controlName: string): DynarowContext { return { control: this.formGroup.get(controlName), meta: this.formMetaData[controlName] }; } getRCTemplateContext(repeatingContainerName: string, index: number): DynarowContext { const repeatingContainerFormArray = this.formGroup.get(repeatingContainerName) as FormArray; const result = { control: repeatingContainerFormArray.at(index), meta: this.formMetaData[repeatingContainerName]['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 ? ` container-${fieldClass}` : ''; const errorClass = control && control.touched && control.invalid ? ' dyna-error' : ''; return `row-${fieldTypeClass}${containerClass}${errorClass}`; } getRCLabel(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 repeatingContainerFormArray = this.formGroup.get(repeatingContainerName) as FormArray; const formGroup = repeatingContainerFormArray.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(repeatingContainerName: string, index: number): void { // Show a particular container of a Repeating Container group (used when only one is shown at a time i.e display = 'SINGLE') const rcMeta = this.formMetaData[repeatingContainerName]; rcMeta.meta = rcMeta.meta.map( (container, i) => ({ ...container, focussed: i === index }) ); } getValidationFailureMessage(control: FormControl, meta: StringMap) { if (control.errors) { const errKeys = Object.keys(control.errors); console.log(errKeys); return meta.valFailureMessages[errKeys[0]]; } } getValidationErrors() { if (!this.formGroup.valid) { const errorsFlat = SuperForm.getAllErrorsFlat(this.formGroup); return errorsFlat; } return 'No Errors'; } handleCallback(fnId: string) { this.call.emit(fnId); } private getContolKeysCSVFromMetadata(metadata): 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 'noFormControsl' 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); } } }