/* ********************************************************************************************************************* * Dynaform Service * ********************************************************************************************************************* * * BUILD * ----- * This is the main method you'll use to build forms from models and metadata: * * build(model, meta = {}, createFromMeta = false) * * Takes a model and (lazy)metadata and returns an object containing a FormGroup and Modeled MetaData : * { * form: FormGroup, * meta: ModeledMetaData * } * * meta is optional, and if not supplied all the model fields will become Text inputs in the FormGroup, * with their labels set to the un-camel-cased property name * * createFromMeta is optional. * If true it will create new fields in the FormGroup even when they don't already exist in the model, * but exist in the metadata. This is NOT the default behaviour, except when an empty model is supplied. * i.e. It defaults to false, except when model == {} * * Usage * ----- * * build(model) - build everything from the model * build({}, meta) - build everything from the metadata * build(model, meta) - build by combining the model with metadata, lazyily (not every model field needs metadata, as sensible defaults) * build(model, meta, true) - build by combining model with metadata, creating new fields from metadata points that don't occur in the model * * * BUILD STRATEGYS * --------------- * * MODELFIRST - The model determinies the shape of the form * METAFIRST - The metadata determines the shape of the form * * Set the build strategy using the setBuildStrategy method e.g. * * setBuildStrategy('MODELFIRST') - the default * setBuildStrategy('NETAFIRST') * * then use the build function in the normal way * * * REGISTER * -------- * Registers callbacks attached to the form (e.g. to buttons), identified by strings. * * register({ * 'SAYHELLO': () => { alert('HELLO') }, * 'SEARCH': execSearch, * 'NEW': addNew, * }, varToBind); * * If varToBind is supplied it is bound as 'this' to the functions. * Typically you'd supply the component class instance, so that 'this' used in callbacks refers to the host component. * * * DATA IN & DATA OUT * ------------------ * updateForm - patch form values in FormGroup * buildNewModel * * * VALIDATION * ---------- * getValidationErrors - get all the validation erros * updateValidity - mark all controls as touched, and update their validity state * resetValidity - mark all controls as pristine, and remove all validation failure messages * * LOWER-LEVEL METHODS * ------------------- * * autoBuildFormGroupAndMeta(model, meta, createFromMeta) - synonym for build * autoBuildModeledMeta(model, meta, createFromMeta) - takes a model and (lazy)metadata and returns expanded metadata * * buildFormGroup(metadata) - builds FormGroups from modelled metdata, recursively if necessary * buildFieldSpecificMeta(metadata) - use field metadata models to fill out metadata * combineModelWithMeta(model, extraMeta) - automatically generated metadata for model then combines extra metadata * combineExtraMeta(metadata, extraMeta) - combine extra metadata into metatdata, lazyly and recursively * autoMeta(model) - generate basic metadata from a raw or mapped model, recursively if necessary * * * NOTES * ----- * This class acts as an injectable wraper around the exports of _formdata-utils.ts, * as well as creating a buildFormGroup function using the injected FormBuilder singleton * * * EXAMPLES * -------- * * TO ADD ... * */ import { Injectable, ComponentRef } from '@angular/core'; import { FormBuilder, FormGroup } from '@angular/forms'; import { SuperForm } from 'angular-super-validator'; import { ModelMapperService } from './model-mapper.service'; import { autoMeta, combineModelWithMeta, combineExtraMeta, execMetaReorderingInstructions, buildFieldSpecificMetaInClosure, extractFieldMappings, buildFormGroupFunctionFactory, resetAsyncValidatorsRecursive, touchAndUpdateValidityRecursive, resetValidityRecursive, advanceUpdateStategyOfInvalidControlsRecursive, generateNewModel, updateMeta } from './_formdata-utils'; import { Option } from './../models/field.model'; export interface IFormAndMeta { form: FormGroup; meta: StringMap; } export interface ICallbacks { [index: string]: () => void; } @Injectable() export class DynaformService { public form: FormGroup; public meta: StringMap; private context: any; public buildFormGroup: (meta) => FormGroup; private buildStrategy: 'MODELFIRST' | 'METAFIRST' = 'MODELFIRST'; // Make ENUM type private callbacks: ICallbacks = {}; private conGreen = 'color: white; background-color: green; font-weight: bold;'; constructor(private fb: FormBuilder, private modelMapper: ModelMapperService) { this.buildFormGroup = buildFormGroupFunctionFactory(fb); } setBuildStrategy(str): void { switch (str.toUpperCase()) { case 'MODELFIRST': // Build from model, combining optional extra metadata case 'METAFIRST': // Build from metadata, sourcing values from the model (optionally, using meta's 'source' attribute) this.buildStrategy = str.toUpperCase(); break; default: throw new Error(`DYNAFORM: Unknown Build Strategy ${str} - should be MODELFIRST or METAFIRST`); } } setContext(data: any): void { // Set any runtime data needed to build the form, e.g. options values this.context = data; } build(model: StringMap, meta = {}, createFromMeta = false): IFormAndMeta { // Executes autoBuildFormGroupAndMeta and stores the result const result = this.autoBuildFormGroupAndMeta(model, meta, createFromMeta); ({ form: this.form, meta: this.meta } = result); return result; } register(callbacks: ICallbacks, cref: ComponentRef['instance']) { if (cref) { // Bind the component instance to the callback, so that 'this' has the context of the component Object.entries(callbacks).forEach(([key, fn]) => this.callbacks[key] = fn.bind(cref)); } else { Object.assign(this.callbacks, callbacks); } } call(fnId: string) { // Handle callbacks if (typeof this.callbacks[fnId] === 'function') { try { this.callbacks[fnId](); } catch (e) { console.error('Error thrown during callback', fnId); console.error(e); } } else { console.error('Dynaform has no registered callback for', fnId); } } updateForm(newModel: StringMap, form?: FormGroup, meta?: StringMap): void { const mapping = extractFieldMappings(meta || this.meta); // Memoize const mappedModel = this.modelMapper.reverseMap(newModel, mapping); (form || this.form).patchValue(mappedModel); } updateMeta(newMeta: StringMap, path: string = '/', meta?: StringMap): StringMap { const newFullMeta = updateMeta(newMeta, path, meta || this.meta); if (!meta) { this.meta = newFullMeta; } return newFullMeta; } resetForm(form?: FormGroup): void { // Loop through the form and call reset on any Async validators resetAsyncValidatorsRecursive(form || this.form); (form || this.form).reset(); } buildNewModel(originalModel: StringMap, formVal?: StringMap, meta?: StringMap): StringMap { console.log('%c *** buildNewModel *** ', this.conGreen); const mapping = extractFieldMappings(meta || this.meta); // Memoize console.dir(mapping); const updates = this.modelMapper.forwardMap(formVal || this.form.value, mapping); console.log('%c *** Updates *** ', this.conGreen); console.dir(updates); return generateNewModel(originalModel, updates); } getValidationErrors(form?: FormGroup): StringMap | false { const _form = form || this.form; if (!_form.valid) { const errorsFlat = SuperForm.getAllErrorsFlat(_form); return errorsFlat; } return false; } updateValidity(form?: FormGroup): void { touchAndUpdateValidityRecursive(form || this.form); } resetValidity(form?: FormGroup): void { resetValidityRecursive(form || this.form); } goRealTime(form?: FormGroup): void { advanceUpdateStategyOfInvalidControlsRecursive(form || this.form); } // ----------------------------------------------------------------------------------------------------------------- // Convenience methods combining several steps autoBuildFormGroupAndMeta(model: StringMap, meta = {}, createFromMeta = false): IFormAndMeta { let _model; if (this.buildStrategy === 'MODELFIRST') { _model = model; } else if (this.buildStrategy === 'METAFIRST') { // Generate mapping from the 'source' attributes in the metadata (compatible with the model-mapper service) // Then grab values from the model const mapping = extractFieldMappings(meta); _model = this.modelMapper.reverseMap(model, mapping, false); // <-- false - excludes bits of model not explicitly referenced in meta console.log('%c *** Mapping and Mapped Model *** ', this.conGreen); console.dir(mapping); console.dir(_model); } if (this.buildStrategy === 'METAFIRST' || Object.keys(model).length === 0) { createFromMeta = true; } const fullMeta = this.autoBuildModeledMeta(_model, meta, createFromMeta); return { form: this.buildFormGroup(fullMeta), meta: fullMeta }; } autoBuildModeledMeta(model: StringMap, meta = {}, createFromMeta = false) { const modelWithMeta = this.combineModelWithMeta(model, meta, createFromMeta); const reorderedMeta = execMetaReorderingInstructions(modelWithMeta); return this.buildFieldSpecificMeta(reorderedMeta); } // ----------------------------------------------------------------------------------------------------------------- // Build field-type-specific metadata using the form field models (see dynaform/models) buildFieldSpecificMeta(meta) { return buildFieldSpecificMetaInClosure(meta, this.context); } // ----------------------------------------------------------------------------------------------------------------- // Lower-level methods combineModelWithMeta(model: StringMap, meta, createFromMeta = false) { return combineModelWithMeta(model, meta, createFromMeta); } combineExtraMeta(meta, extraMeta, createFromExtra = false) { return combineExtraMeta(meta, extraMeta, createFromExtra); } autoMeta(model) { return autoMeta(model); } // ----------------------------------------------------------------------------------------------------------------- // Convenience methods buildOptions(options): Option[] { if (Array.isArray(options)) { return options.reduce((acc, opt) => { acc.push(new Option(opt)); return acc; }, []); } else { throw new Error('Options must be supplied as an array'); } } }