/* * 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 * */ import { FormBuilder, FormGroup, FormArray, FormControl, ValidatorFn, AsyncValidatorFn } from '@angular/forms'; import { reduce, cloneDeep } from 'lodash/fp'; import * as fmdModels from '../models/field.model'; // REMINDER: Import this directly from @angular/forms when we upgrade to Angular 6 // While we're still on Angular 5 just declare it interface AbstractControlOptions { validators?: ValidatorFn | ValidatorFn[] | null; asyncValidators?: AsyncValidatorFn | AsyncValidatorFn[] | null; updateOn?: 'change' | 'blur' | 'submit'; } // --------------------------------------------------------------------------------------------------------------------- // AutoMeta: Generate Automatic Metadata from a raw model (or mapped raw 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 arrayMemberAutoName = val => isScalar(val) ? val : val.__typename || val.constructor.name; const arrayToMeta = array => array.map(val => ({ name: arrayMemberAutoName(val), 'value' : 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 // --------------------------------------------------------------------------------------------------------------------- // containerSeed = Metadata from container which seeds all contained fields const combineMetaForField = (metaF, containerSeed, extraMetaF) => Object.assign(metaF, containerSeed, extraMetaF); const combineExtraMeta = (metaG, extraMeta, createFromExtra = false, containerSeed = {}) => { const combinedMeta = {}; Object.entries(extraMeta).forEach(([key, val]) => { if (typeof metaG[key] === 'object' || createFromExtra) { const isCon = isContainer(val); const metaFoG = metaG[key] || {}; const seed = isCon ? {} : containerSeed; // Container's don't seed themselves 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); // --------------------------------------------------------------------------------------------------------------------- // Build Form-Field-Type-Specific Metadata (using the field models in dynaform/models) // --------------------------------------------------------------------------------------------------------------------- const buildFieldClassName = (t = 'text') => { const start = t[0].toUpperCase() + t.slice(1); if (start === 'Container' || start === 'Heading' || t.slice(-5) === 'Group') { return start; } return start + 'Field'; }; const buildModeledField = metaFoG => { const type = isContainer(metaFoG) ? 'container' : metaFoG.type; 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(metaFoG.meta); } return modeledGroupMember; }; // Build Form Group const buildModeledFieldGroupReducerIteree = (res, metaFoG) => Object.assign(res, { [metaFoG.name]: buildModeledFieldGroupMember(metaFoG) }); const _buildFieldSpecificMeta = metaG => reduce(buildModeledFieldGroupReducerIteree, {}, metaG); const buildFieldSpecificMeta = metaG => _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.isDisabled }); 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 const buildFormArray = (metaG): FormArray => fb.array(metaG.map(metaF => buildFormControl(metaF))); // Build Form Group Member - builds a FormControl, FormArray, or another FormGroup which 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 : Object.assign(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); // MAYBE only run this if first entry isn't right, for reasons of efficiency const fieldModeledMeta = addMissingFieldSpecificMeta(metaWithNameKeys); return _buildFormGroup(fieldModeledMeta); }; return buildFormGroup; }; // --------------------------------------------------------------------------------------------------------------------- // Reordering ( support for before / after instructions in metadata ) // --------------------------------------------------------------------------------------------------------------------- // Object slice function const slice = (obj, start, end = null) => Object.entries(obj) .slice(start, end !== null ? end : Object.keys(obj).length) .reduce((res, [key, val]) => addProp(res, key, val), {}); const objConcat = (obj, pos, key, val = null) => { const existsAlready = Object.keys(obj).indexOf(key); if (existsAlready) { val = obj[key]; } const start = slice(obj, 0, pos); const finish = slice(obj, pos); delete start[key]; delete finish[key]; return { ...start, [key]: val, ...finish }; }; const insertBefore = (obj, beforeKey, key, val = null) => { const targetPosition = Object.keys(obj).indexOf(beforeKey); return objConcat(obj, targetPosition, key, val); }; const insertAfter = (obj, afterKey, key, val = null) => { const targetPosition = Object.keys(obj).indexOf(afterKey) + 1; return objConcat(obj, targetPosition, key, val); }; // Process reordeing instructions recursively const _execMetaReorderingInstructions = (metaG: StringMap) => { let reorderedGroup = { ...metaG }; Object.entries(metaG).forEach(([key, metaFoG]) => { if (metaFoG.before) { reorderedGroup = insertBefore(reorderedGroup, metaFoG.before, key); } else if (metaFoG.after) { reorderedGroup = insertAfter(reorderedGroup, metaFoG.after, key); } if (isContainer(metaFoG)) { reorderedGroup[key].meta = execMetaReorderingInstructions(metaFoG.meta); } }); return reorderedGroup; }; const execMetaReorderingInstructions = (metaG: StringMap) => { return _execMetaReorderingInstructions(cloneDeep(metaG)); }; // --------------------------------------------------------------------------------------------------------------------- // Generate new model, without mutating original // (used to produce an updated copy of a model when form values are changed - will not create new keys) // --------------------------------------------------------------------------------------------------------------------- const generateNewModel = (originalModel, updates) => { return updateObject(originalModel, updates); }; const updateObject = (obj, updates, createAdditionalKeys = false) => { // THIS DOES NOT MUTATE obj, instead returning a new object const shallowClone = { ...obj }; 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 (nullOrScaler(currentVal)) { console.log('safeSet nullOrScaler', 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 nullOrScaler = val => { if (val === null) { 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; }; // Is Group // Helper function to distinguish group from field const isGroup = (metaFoG): boolean => !!metaFoG.meta; // Is Container // Helper function to distinguish container group (a group of child fields) const isContainer = (metaFoG): boolean => isGroup(metaFoG) && (!metaFoG.type || metaFoG.type.toLowerCase() === 'container'); // Add Missing Names // Helper function to add any missing 'name' properties to Fields and Groups using property's key, recursively const addNameIfMissing = (metaFoG, key) => metaFoG.name ? metaFoG : addProp(metaFoG, 'name', key); const addNameToSelfAndChildren = ( [key, metaFoG] ) => { metaFoG = addNameIfMissing(metaFoG, key); if (isGroup(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, buildFieldSpecificMeta, extractFieldMappings, buildFormGroupFunctionFactory, generateNewModel };