/* * 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 * metaRF = metadata for Repeating 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, AbstractControl, AbstractControlOptions } from '@angular/forms'; import { cloneDeep, omit, reduce } from 'lodash/fp'; import * as fmdModels from '../models/field.model'; import { meta } from '@mock/testfields.v15'; // --------------------------------------------------------------------------------------------------------------------- // AutoMeta: Generate Automatic Metadata from a model // --------------------------------------------------------------------------------------------------------------------- const isScalar = (val: any) => typeof val === 'boolean' || typeof val === 'number' || typeof val === 'string'; const isArray = (val: any) => Array.isArray(val); const keyValPairToMeta = (val: any, key: string) => ({ 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]: [string, StringMap]) => { if (typeof metaG[key] === 'object' || createFromExtra) { // If the key exists (in the model) OR we're creating from metadata const isCon = isContainer(val); const metaFoG = metaG[key] || {}; const seed = isCon ? {} : containerSeed; // Container's don't seed themselves, only their children /* console.log('******************* BEFORE MODELLING *******************'); console.log(val); console.log('IS REPEATING', isRepeating(val)); console.log('HAS META', hasMeta(val)); console.log('IS ARRAY', Array.isArray(val.meta)); console.log('MEMBER 1 DEFINED', !!val.meta[0]); console.log('OBJECT VALUES .........', Object.values(val.meta[0])); console.log('MEMBER 1 1st entry .........', Object.values(val.meta[0])[0]); console.log('MEMBER 1 1st entry is SCALAR', isScalar(Object.values(val.meta[0]))); */ /* console.log('IS REP Field', isRepeatingField(val)); console.log('IS REP Container', isRepeatingContainer(val)); console.log('IS ORD Container', isContainer(val)); */ if (isRepeatingContainer(val)) { // We've got a Repeating Container const baseObjWithAllKeys = getRCBaseObjectWithAllKeys(metaFoG, val, createFromExtra); metaFoG.meta = generateRepeatedGroup(metaFoG, val, baseObjWithAllKeys); const extra = { ...omit(['meta', 'seed'], val as StringMap), meta: metaFoG.meta.map( rgMem => combineExtraMeta( rgMem.meta, val['meta'][0], createFromExtra, val['seed'] || containerSeed ) ) }; combinedMeta[key] = combineMetaForField(metaFoG, {}, extra); // Stash a 'conbtainer template' for adding extra containers to the repeating container combinedMeta[key].__containerTemplate = combineExtraMeta( cloneDeep(baseObjWithAllKeys), val['meta'][0], false, val['seed'] || containerSeed ); } else // ---------------------------------------------- { let extra: StringMap; if (isCon) { // We've got a container extra = { ...val, meta: combineExtraMeta( // RECURSION metaFoG.meta || {}, val['meta'], createFromExtra, val['seed'] || containerSeed // Inherit seeded data if this group's seed isn't set ) } } else // ---------------------------------------------- { if (val.minRepeat || val.maxRepeat || val.initialRepeat) { // We've got a repeating field const metaForFieldToRepeat = { ...containerSeed, ...omit(['seed', 'minRepeat', 'maxRepeat', 'initialRepeat', 'showAddControl', 'showDeleteControl'], val as StringMap) }; delete val.type; extra = { ...val, meta: Array.from(Array(val.initialRepeat || 1).keys()).map((f, i) => ({ ...metaForFieldToRepeat, label: `${val.label || key} ${i + 1}` })) } } else { // We've got a standard field extra = 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, baseObjWithAllKeys): 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); 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; } // Get Repeating Container Base Object With All Keys const getRCBaseObjectWithAllKeys = (metaFoG, extraMeta, createFromExtra = false): StringMap => { // 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 = isArray(metaFoG.meta) && metaFoG.meta.length ? 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, ''), {})); return baseObjWithAllKeys; } // --------------------------------------------------------------------------------------------------------------------- // Build Form-Field-Type-Specific Metadata (using the field models in dynaform/models) // --------------------------------------------------------------------------------------------------------------------- // MAYBE CHANGE INTO A MODULE SO WE CAN USE CLOSURE BUT ALSO METHODS INSIDE IT // More Elegant But Not Urgent const buildModeledFieldGroupMember = metaFog => metaFog; // THIS MAY BREAK THINGS NOW WE'VE MOVED FUNCTION INTO CLOSURE BELOW const buildFieldSpecificMetaInClosure = (metaG, context) => { const resolveType = (metaFoG: StringMap): string => { if (metaFoG.type) { return metaFoG.type; } if (isRepeatingField(metaFoG)) { return 'repeatingField'; } 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 (['RepeatingField', 'Container', 'RepeatingContainer', 'Heading'].includes(start) || 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, context); }; // Build Form Group Member const buildModeledFieldGroupMember = (metaFoG) => { const modeledGroupMember = buildModeledField(metaFoG); console.log('------------- DEBUGGING -------------'); console.log(modeledGroupMember); console.log('IS REP Field', isRepeatingField(modeledGroupMember)); console.log('IS REP Container', isRepeatingContainer(modeledGroupMember)); console.log('IS ORD Container', isContainer(modeledGroupMember)); console.log(modeledGroupMember); if (isRepeatingField(modeledGroupMember)) { modeledGroupMember.meta = modeledGroupMember.meta.map(metaF => buildModeledField(metaF)); } else if (isContainer(modeledGroupMember)) { modeledGroupMember.meta = _buildFieldSpecificMeta(modeledGroupMember.meta); } else if (isRepeatingContainer(modeledGroupMember)) { modeledGroupMember.meta = modeledGroupMember.meta.map(rcMem => ({ ...rcMem, meta: _buildFieldSpecificMeta(rcMem.meta) })); modeledGroupMember.__containerTemplate = { ...modeledGroupMember.meta[0], meta: _buildFieldSpecificMeta(modeledGroupMember.__containerTemplate), name: '__containerTemplate', button: '' }; } 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 => _buildFieldSpecificMeta(addMissingNames(metaG)); // DEBUGGING console.log('buildFieldSpecificMetaInClosure', metaG, context); return buildFieldSpecificMeta(metaG); // RUN CODE } // --------------------------------------------------------------------------------------------------------------------- // 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 (hasMeta(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 // --------------------------------------------------------------------------------------------------------------------- // TODO: In progress: elegantly adding validators at FormGroup and FormArray levels // Working, but code needs a rework 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 = (metaFoG): AbstractControlOptions => ({ validators: metaFoG.validators, asyncValidators: metaFoG.asyncValidators, // blur not working for custom components, so use change for custom and blur for text updateOn: getUpdateOn(metaFoG.type) }); const BVAL = metaFoG => { if (!metaFoG || !(metaFoG.validators || metaFoG.asyncValidators)) { return undefined; } console.log(metaFoG); const res = buildValidators(metaFoG); console.log(res); return res; } const buildFormControl = metaF => new FormControl(buildControlState(metaF), buildValidators(metaF)); // Build Form Array containing either Form Controls or Form Groups const buildFormArray = (metaG, grMeta?): FormArray => { return fb.array( metaG.map(m => isContainer(m) ? _buildFormGroup(m.meta) : buildFormControl(m)), buildValidators(grMeta) ); }; // Build Form Group Member // Builds a FormControl, FormArray or another FormGroup - which in turn can contain any of these const buildFormGroupMember = metaFoG => hasMeta(metaFoG) ? (isArray(metaFoG.meta) ? buildFormArray(metaFoG.meta, metaFoG) : _buildFormGroup(metaFoG.meta, metaFoG)) : // TODO: STINKY! REWORK with 1 param buildFormControl(metaFoG); const buildFormGroupReducerIteree = (res, metaFoG) => { return metaFoG.noFormControls ? res : { ...res, [metaFoG.name]: buildFormGroupMember(metaFoG) }; }; const _buildFormGroup = (_metaG, grMeta?) => fb.group(reduce(buildFormGroupReducerIteree, {}, _metaG), BVAL(grMeta)); // 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); // 'object' const generateNewModel = (originalModel, updates) => { return updateObject(originalModel, updates); }; // NOT FINISHED!!! const updateMeta = (newMeta: StringMap, path: string, meta: StringMap): StringMap => { // TODO: Finish this later if (path === '/') { const updatedMeta = updateObject(meta || this.meta, newMeta, true); return updatedMeta; } // Drill down and update the branch specified by 'path' - all but final segment indicates a container // What about array types? Think about this later! // What about group components. Think about this later. // What about bad paths. Think about this later. console.log(path); const segments = path.split('.'); console.log(segments); let branchMeta = meta; while (isContainer(branchMeta)) { const s = segments.shift(); console.log(s, branchMeta[s].meta); // TODO: add array check branchMeta = branchMeta[s].meta; } while (segments.length > 1) { const s = segments.shift(); console.log(s, branchMeta[s]); // TODO: add array check branchMeta = branchMeta[s]; } branchMeta = branchMeta[segments[0]]; console.log(segments[0], branchMeta); // Then something like... const updatedMeta = updateObject(branchMeta, newMeta, true); branchMeta = updatedMeta; console.log(branchMeta); return meta; }; const updateObject = (obj, updates, createAdditionalKeys = false) => { // THIS DOES NOT MUTATE obj, instead returning a new object if (!isRealObject(obj)) { obj = {}; } if (Object.keys(obj).length === 0) { createAdditionalKeys = true; } const shallowClone = { ...obj }; // This might be inefficient - consider using immutable or immer Object.entries(updates).forEach(([key, val]) => safeSet(shallowClone, key, val, createAdditionalKeys)); return shallowClone; }; const safeSet = (obj, key, val, createAdditionalKeys = false) => { // THIS MUTATES obj - consider the wisdom of this if (!createAdditionalKeys && !obj.hasOwnProperty(key)) { return; } const currentVal = obj[key]; if (val === currentVal) { return; } if (undefinedNullOrScalar(currentVal)) { // console.log('safeSet undefinedNullOrScalar', key, val); obj[key] = val; } else { if (Array.isArray(currentVal)) { if (typeof val === 'object') { // Replace array console.log('safeSet array', key, val); obj[key] = Array.isArray(val) ? val : Object.values(val); } else { // Append to end of existing array? Create a single element array? throw new Error(`safeSet Error: Expected array or object @key ${key} but got scalar Rejected update was ${val}`); } } else if (typeof val === 'object') { // Deep merge obj[key] = updateObject(obj[key], val, createAdditionalKeys); } else { throw new Error(`safeSet Error: Can't deep merge object into scalar @key ${key} Rejected update was ${val}`); } } }; const undefinedNullOrScalar = val => { if (val === null || val === undefined) { return true; } const t = typeof val; return t === 'number' || t === 'string' || t === 'boolean'; }; // --------------------------------------------------------------------------------------------------------------------- // Helper Functions // --------------------------------------------------------------------------------------------------------------------- // Add Property to object, returning updated object const addProp = (obj, key, val) => { obj[key] = val; return obj; }; // Has Meta // Helper function to distinguish a group from a simple field - where a group is a container os repeating container, or repeating field * AFTER* modelling const hasMeta = (metaFoG): boolean => !!metaFoG.meta; // Is Repeating const isRepeating = (metaFoG): boolean => !!(metaFoG.minRepeat || metaFoG.maxRepeat || metaFoG.initialRepeat); // Is Repeating Field // Helper function to distinguish a repeating field (a field that can be repeated 1...N times) const isRepeatingField = (metaRF): boolean => metaRF.type && metaRF.type.toLowerCase() === 'repeatingfield' || ( !metaRF.type && isRepeating(metaRF) && ( !hasMeta(metaRF) || hasMeta (metaRF) && Array.isArray(metaRF.meta) && metaRF.meta[0] && isScalar(Object.values(metaRF.meta[0])[0]) ) ); // Is Container // Helper function to distinguish container group (a group of child fields) const isContainer = (metaFoG): boolean => metaFoG.type && metaFoG.type.toLowerCase() === 'container' || ( !metaFoG.type && hasMeta(metaFoG) && !Array.isArray(metaFoG.meta) ); // Is Repeating Container // Helper function to distinguish a repeating container group (a group of child fields that can be repeated 1...N times) const isRepeatingContainer = (metaFoG): boolean => metaFoG.type && metaFoG.type.toLowerCase() === 'repeatingcontainer' || ( !metaFoG.type && isRepeating(metaFoG) && hasMeta(metaFoG) && Array.isArray(metaFoG.meta) && metaFoG.meta[0] && !isScalar(Object.values(metaFoG.meta[0])[0]) ); // Add Missing Names // Helper function to add any missing 'name' properties to Fields and Groups using property's key, recursively // BUT not to repeatingContainer members const addNameIfMissing = (metaFoG, key) => metaFoG.name ? metaFoG : addProp(metaFoG, 'name', key); const addNameToSelfAndChildren = ( [key, metaFoG] ) => { metaFoG = addNameIfMissing(metaFoG, key); if (hasMeta(metaFoG) && !isRepeatingContainer(metaFoG)) { metaFoG.meta = isArray(metaFoG.meta) ? Object.values(addMissingNames(metaFoG.meta)) : addMissingNames(metaFoG.meta); // Recursion } return [key, metaFoG]; }; const addMissingNames = metaG => Object.entries(metaG) .map(addNameToSelfAndChildren) .reduce((res, [key, val]) => addProp(res, key, val), {}); // Add Missing Field-Specific Meta // Helper function to add any missing Field-Specific Metadata (using models in dynaform/models), recursively // Checks the constrctor, which should NOT be a plain Object, but rather TextField, TextareaField, SelectField, etc. const add_FSM_IfMissing = metaFoG => metaFoG.constructor.name === 'Object' ? buildModeledFieldGroupMember(metaFoG) : metaFoG; const add_FSM_ToSelfAndChildren = ( [key, metaFoG] ) => { metaFoG = add_FSM_IfMissing(metaFoG); return [key, metaFoG]; }; const addMissingFieldSpecificMeta = metaG => Object.entries(metaG) .map(add_FSM_ToSelfAndChildren) .reduce((res, [key, val]) => addProp(res, key, val), {}); // --------------------------------------------------------------------------------------------------------------------- // Exports // --------------------------------------------------------------------------------------------------------------------- export { autoMeta, combineModelWithMeta, combineExtraMeta, execMetaReorderingInstructions, buildFieldSpecificMetaInClosure, extractFieldMappings, buildFormGroupFunctionFactory, resetAsyncValidatorsRecursive, touchAndUpdateValidityRecursive, resetValidityRecursive, generateNewModel, updateMeta, };