/* * FORM METADATA UTILITIES * * Exports * ------- * autoMeta(model) - generate basic metadata from a raw or mapped model, recursively if necessary * combineExtraMeta(metadata, extraMeta) - combine extra metadata into metatdata, lazyly and recursively * combineModelWithMeta(model, extraMeta) - automatically generate metadata from model then combine extra metadata * execMetaReorderingInstructions(meta) - reorder metadata id ant 'before' or 'after' instructions are found * buildFieldSpecificMeta(metadata) - use field-type-specific metadata models to expand metadata * extractFieldMappings(metadata) - extrtact mappings from metadata 'source' attributes * buildFormGroupFunctionFactory(fb) - return a function to buildFormGroups using the supplied FormBuilder singleton * generateNewModel(originalModel, updates) - returns an updated copy of the model * * Variable names * -------------- * metaF = metadata for Field * metaG = metadata for Group (possibly nested) * metaFoG = metadata for Field or Group * rcMem = Repeating Container member * metaFoRCMem = metadata for Field or Repeating Container member * */ import { FormBuilder, FormGroup, FormArray, FormControl, AbstractControlOptions } from '@angular/forms'; import { cloneDeep, omit, reduce } from 'lodash/fp'; import * as fmdModels from '../models/field.model'; import { ComponentFactoryResolver } from '@angular/core/src/render3'; // --------------------------------------------------------------------------------------------------------------------- // AutoMeta: Generate Automatic Metadata from a model // --------------------------------------------------------------------------------------------------------------------- const isScalar = val => typeof val === 'boolean' || typeof val === 'number' || typeof val === 'string'; const isArray = val => Array.isArray(val); const keyValPairToMeta = (val, key) => ({ name: key, [isScalar(val) ? 'value' : 'meta']: val }); const keyValPairToMetaRecursive = ( [key, val] ) => { if (val === null || val === undefined) { val = ''; // Treat null or undefined as empty string, for purpose of form } const innerVal = isScalar(val) ? val : (isArray(val) ? arrayToMeta(val) : autoMeta(val)); const metaVal = keyValPairToMeta(innerVal, key); return [key, metaVal]; }; const arrayToMeta = array => array.map((val, i) => { if (isScalar(val)) { return { name: `item${i + 1}`, 'value' : val }; } else { return { name: `group${i + 1}`, meta: autoMeta(val) }; } }); const autoMeta = model => Object.entries(model) .filter(kvPair => kvPair[0] !== '__') .map(keyValPairToMetaRecursive) .reduce((res, [key, val]) => addProp(res, key, val), {}); // --------------------------------------------------------------------------------------------------------------------- // Combine automatically generated metadata with overrides (extraMeta) // --------------------------------------------------------------------------------------------------------------------- // containerSeed = Metadata from container which seeds all contained fields const combineMetaForField = (metaF, containerSeed, extraMetaF) => ({ ...metaF, ...containerSeed, ...extraMetaF }); const combineExtraMeta = (metaG, extraMeta, createFromExtra = false, containerSeed = {}) => { const combinedMeta = {}; Object.entries(extraMeta).forEach(([key, val]) => { if (typeof metaG[key] === 'object' || createFromExtra) { // If the key exists (in the model) OR we're creating from metadata const isCon = isContainer(val); const isRepeating = isRepeatingContainer(val); const metaFoG = metaG[key] || {}; const seed = isCon ? {} : containerSeed; // Container's don't seed themselves, only their children if (isRepeating) { // We've got a Repeating Container metaFoG.meta = generateRepeatedGroup(metaFoG, val, createFromExtra); const extra = { ...omit(['meta', 'seed'], val), meta: metaFoG.meta.map( rgMem => combineExtraMeta( rgMem.meta, val['meta'][0], createFromExtra, val['seed'] || containerSeed ) ) }; combinedMeta[key] = combineMetaForField(metaFoG, {}, extra); } else { // We've got a Container or a Field const extra = isCon ? { ...val, meta: combineExtraMeta( // RECURSION metaFoG.meta || {}, val['meta'], createFromExtra, val['seed'] || containerSeed // Inherit seeded data if this group's seed isn't set ) } : val; combinedMeta[key] = combineMetaForField(metaFoG, seed, extra); } } }); return { ...metaG, ...combinedMeta }; }; // Combine model with overrides (after automatically generating metadata from the model) const combineModelWithMeta = (model, extraMeta, createFromExtra = false) => combineExtraMeta(autoMeta(model), extraMeta, createFromExtra); // <--- Utilities supporting Repreating Containers ---> const generateRepeatedGroup = (metaFoG, extraMeta, createFromExtra = false): StringMap[] => { // Calculate the number of repeats const repeatInAutoMeta = Array.isArray(metaFoG.meta) ? metaFoG.meta.length : 0; const repeatInExtraMeta = extraMeta['initialRepeat'] || extraMeta['minRepeat']; const repeat = Math.max(repeatInAutoMeta, repeatInExtraMeta); // If creating from extra, make sure all group members have all keys in both model and meta (as this is a repeating group) const keysFromModel = repeatInAutoMeta ? Object.keys(metaFoG.meta[0].meta) : []; const keysFromExtraMeta = extraMeta['meta'] && extraMeta['meta'][0] ? Object.keys(extraMeta['meta'][0]) : []; const keysToInclude = createFromExtra ? Array.from(new Set([...keysFromModel, ...keysFromExtraMeta])) : keysFromModel; const baseObjWithAllKeys = autoMeta(keysToInclude.reduce((acc, key) => addProp(acc, key, ''), {})); metaFoG.meta = metaFoG.meta.map( rcMem => ({ ...rcMem, meta: { ...baseObjWithAllKeys, ...rcMem.meta } }) ); // Add extra keys to model meta // Extend repeated group from model (if any) to correct length, and add any missing names const repeatedGroup = repeatInAutoMeta ? [ ...metaFoG.meta, ...Array(repeat - repeatInAutoMeta).fill({ meta: baseObjWithAllKeys }) ] : Array(repeat).fill({ meta: baseObjWithAllKeys }); const fullyNamedRepeatedGroup = repeatedGroup.map((rgMem, i) => rgMem.name ? rgMem : { name: `group${i + 1}`, ...rgMem }); return fullyNamedRepeatedGroup; } // --------------------------------------------------------------------------------------------------------------------- // Build Form-Field-Type-Specific Metadata (using the field models in dynaform/models) // --------------------------------------------------------------------------------------------------------------------- const resolveType = (metaFoG: StringMap): string => { if (metaFoG.type) { return metaFoG.type; } if (isContainer(metaFoG)) { return 'container'; } if (isRepeatingContainer(metaFoG)) { return 'repeatingContainer'; } return 'text'; } const buildFieldClassName = (t: string): string => { const start = t[0].toUpperCase() + t.slice(1); if (start === 'Container' || start === 'RepeatingContainer' || start === 'Heading' || t.slice(-5) === 'Group') { return start; } return start + 'Field'; }; const buildModeledField = metaFoG => { const type = resolveType(metaFoG); const className = buildFieldClassName(type); if (!fmdModels[className]) { throw new Error(`No metadata model "${className}" for type "${type}"`); } return new fmdModels[className](metaFoG); }; // Build Form Group Member const buildModeledFieldGroupMember = metaFoG => { const modeledGroupMember = buildModeledField(metaFoG); if (isContainer(metaFoG)) { modeledGroupMember.meta = _buildFieldSpecificMeta(modeledGroupMember.meta); } else if (isRepeatingContainer(metaFoG)) { modeledGroupMember.meta = modeledGroupMember.meta.map(rcMem => ({ ...rcMem, meta: _buildFieldSpecificMeta(rcMem.meta) })); } return modeledGroupMember; }; // Build Form Group const buildModeledFieldGroupReducerIteree = (res, metaFoG) => ({ ...res, [metaFoG.name]: buildModeledFieldGroupMember(metaFoG) }); const _buildFieldSpecificMeta = metaG => isRepeatingContainer(metaG) ? metaG.map(rcMem => _buildFieldSpecificMeta(rcMem)) : reduce(buildModeledFieldGroupReducerIteree, {}, metaG); const buildFieldSpecificMeta = metaG => { const withNames = addMissingNames(metaG); return _buildFieldSpecificMeta(addMissingNames(metaG)); } // --------------------------------------------------------------------------------------------------------------------- // Generate mapping from source attributes // (used to grab data from model when using METAFIRST form generation) // --------------------------------------------------------------------------------------------------------------------- // Each container CAN have a datasource instead of the original model // SO ... If container source is a functional mapping, store the mapping to get datasource object later const isAbsPath = path => typeof path === 'string' && path[0] === '/'; const isRootPath = path => path === '/'; const processPath = (parentPath, path) => isAbsPath(path) ? path : `${parentPath}.${path}`; const prependParentPathRecursive = (parentPath: string, obj: StringMap) => { return Object.entries(obj) .map( ([key, mapping] ) => { let mappingRes; switch (typeof mapping) { case 'string': mappingRes = processPath(parentPath, mapping); break; case 'object': if (Array.isArray(mapping)) { // A functional mapping of the form [fn, fn] or [source, fn, fn] if (typeof mapping[0] === 'string') { const source = processPath(parentPath, mapping[0]); mappingRes = [source, ...mapping.slice(1)]; } else { const source = processPath(parentPath, mapping); mappingRes = [source, ...mapping]; } } else { mappingRes = prependParentPathRecursive(parentPath, mapping); } break; } return [key, mappingRes]; }) .reduce((acc, [key, val]) => addProp(acc, key, val), {}); }; const _extractFieldMapping = ( [key, metaFoG] ) => { let source; if (isGroup(metaFoG)) { if (Array.isArray(metaFoG.source)) { source = extractFieldMappings(metaFoG.meta); source.__ = metaFoG.source; // Store the functional mapping (including function executed later to provide container's data) } else { source = extractFieldMappings(metaFoG.meta, metaFoG.source || key); } } else { source = metaFoG.source || key; } return [key, source]; }; const extractFieldMappings = (metaG, parentPath = '') => Object.entries(metaG) .map(_extractFieldMapping) .reduce((res, [key, mapping]) => { // Work out the path prefix let prefix; if (parentPath) { if (isRootPath(parentPath) || isAbsPath(metaG[key].source) || Array.isArray(parentPath)) { // If the parentPath is the root of the data structure, or the source is an absolute path or functional datasource, // then there's no path prefix prefix = ''; } else { // Otherwise create a prefix from the parentPath prefix = parentPath ? (parentPath[0] === '/' ? parentPath.slice(1) : parentPath) : ''; } } // Work out the mapping result let mappingRes; if (typeof mapping === 'string') { // DIRECT MAPPING if (mapping[0] === '/') { mappingRes = mapping.slice(1); } else if (prefix) { mappingRes = `${prefix}.${mapping}`; } else { mappingRes = mapping; } } else if (Array.isArray(mapping)) { // FUNCTIONAL MAPPING // of the form [fn, fn] or [source, fn, fn] if (prefix) { if (typeof mapping[0] === 'function') { // Mapping of form [fn, fn] with existing parent path mappingRes = [`${prefix}.${key}`, ...mapping]; } else if (typeof mapping[0] === 'string') { // Mapping of form [source, fn, fn] with existing parent path const source = mapping[0][0] === '/' ? mapping[0].slice(1) : `${prefix}.${mapping[0]}`; mappingRes = [source, ...mapping.slice(1)]; } } else { if (typeof mapping[0] === 'function') { // Mapping of form [fn, fn] with NO parent path mappingRes = [key, ...mapping]; } else if (typeof mapping[0] === 'string') { // Mapping of form [source, fn, fn] with NO parent path const source = mapping[0][0] === '/' ? mapping[0].slice(1) : mapping[0]; mappingRes = [source, ...mapping.slice(1)]; } } } else if (typeof mapping === 'object' && prefix && !mapping.__) { // CONTAINER with parentPath, and WITHOUT its own functional datasource stored in __ // For every contained value recursively prepend the parentPath to give an absolute path mappingRes = prependParentPathRecursive(prefix, mapping); } else { mappingRes = mapping; } return addProp(res, key, mappingRes); }, {}); // --------------------------------------------------------------------------------------------------------------------- // Build Form Group Function Factory // returns a function to build FormGroups containing FormControls, FormArrays and other FormGroups // --------------------------------------------------------------------------------------------------------------------- const buildFormGroupFunctionFactory = (fb: FormBuilder): (meta) => FormGroup => { // Establishes a closure over the supplied FormBuilder and returns a function that builds FormGroups from metadata // ( it's done this way so we can use the FormBuilder singleton without reinitialising ) // Build Form Control const buildControlState = metaF => ({ value: metaF.value || metaF.default, disabled: metaF.disabled }); const buildValidators = (metaF): AbstractControlOptions => ({ validators: metaF.validators, asyncValidators: metaF.asyncValidators, // blur not working for custom components, so use change for custom and blur for text updateOn: metaF.type === 'text' || metaF.type === 'textarea' ? 'blur' : 'change' }); const buildFormControl = metaF => new FormControl(buildControlState(metaF), buildValidators(metaF)); // Build Form Array containing either Form Controls or Form Groups const buildFormArray = (metaG): FormArray => { return fb.array( metaG.map(m => isContainer(m) ? _buildFormGroup(m.meta) : buildFormControl(m)) ); }; // Build Form Group Member - builds a FormControl, FormArray or another FormGroup - which in turn can contain any of these const buildFormGroupMember = metaFoG => isGroup(metaFoG) ? (isArray(metaFoG.meta) ? buildFormArray(metaFoG.meta) : _buildFormGroup(metaFoG.meta)) : buildFormControl(metaFoG); const buildFormGroupReducerIteree = (res, metaFoG) => { return metaFoG.noFormControls ? res : { ...res, [metaFoG.name]: buildFormGroupMember(metaFoG) }; }; const _buildFormGroup = _metaG => fb.group(reduce(buildFormGroupReducerIteree, {}, _metaG)); // The main function - builds FormGroups containing other FormGroups, FormArrays and FormControls const buildFormGroup = metaG => { // Ensure that we have Field-Specific Metadata, not raw Objects const metaWithNameKeys = addMissingNames(metaG); //