123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462 |
- /*
- * 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
- };
|