UNPKG

@timmons-group/config-form

Version:

React Components and helpers to build a form via configuration with react-hook-form and MUI

486 lines (483 loc) 16.2 kB
import axios from 'axios'; import { useState, useEffect } from 'react'; import { useLayout, isEmpty, dateStringNormalizer } from '@timmons-group/shared-react-components'; import { VALIDATIONS, CONDITIONAL_RENDER, SPECIAL_ATTRS, DEFAULT_VALUE, REQUIRED, DISABLED, MIN_VALUE, MAX_VALUE, MAX_LENGTH, MIN_LENGTH, MAX_VALUE_ERROR_TEXT, MIN_VALUE_ERROR_TEXT, PLACEHOLDER, FIELD_TYPES, TODAY_DEFAULT, ID_FIELD, LABEL_FIELD } from '../constants.js'; import { createFieldValidation, getSelectValue, multiToPayload } from '../helpers/formHelpers.js'; const validationTypes = Object.values(VALIDATIONS); const conditionalRenderProps = Object.values(CONDITIONAL_RENDER); const specialProps = Object.values(SPECIAL_ATTRS); function useFormLayout(type, key, url = null, urlDomain = null, asyncOptions, loadedLayout = null) { const [data, isLoading] = useLayout(type, key, url, loadedLayout); const [parsedLayout, setParsedLayout] = useState(null); const [isParsing, setIsParsing] = useState(true); useEffect(() => { if (!isLoading && data) { const waitForParse = async () => { const parsed = await parseFormLayout(data, urlDomain, asyncOptions); setParsedLayout(parsed); setIsParsing(false); }; waitForParse(); } }, [data, isLoading]); return [parsedLayout, isParsing]; } const parseFormLayout = async (layout, urlDomain, options) => { if (!layout) { return {}; } const sections = []; const triggerFields = /* @__PURE__ */ new Map(); const fields = /* @__PURE__ */ new Map(); const asyncFields = /* @__PURE__ */ new Map(); if (layout.sections?.length) { layout.sections.forEach((section) => { const parsedSection = parseSection(section, fields, triggerFields, asyncFields); if (parsedSection) { sections.push(parsedSection); } }); } finishParsingTriggerFields(triggerFields, fields); const fetchData = async (fieldId, url) => { const fetchUrl = urlDomain ? `${urlDomain}${url}` : url; const specialFieldProps = fields.get(fieldId).specialProps; const mappedId = specialFieldProps?.[ID_FIELD]; const mappedLabel = specialFieldProps?.[LABEL_FIELD]; const things = await axios.get(fetchUrl).then( (res) => { const { data } = res || {}; if (options?.choiceFormatter && typeof options.choiceFormatter === "function") { const parsedOptions = options.choiceFormatter(fieldId, res, { mappedId, mappedLabel }); return parsedOptions; } else if (data?.length) { return data.map((d) => ({ ...d, id: d[mappedId] || d.id, label: d[mappedLabel] || d.name })); } } ).catch((error) => { if (error.name !== "CanceledError") { console.error(" ", fieldId, "Error fetching data", error); } }); return things; }; if (asyncFields.size > 0) { const asyncLoaders = {}; asyncFields.forEach((choiceUrl, fieldId) => { asyncLoaders[fieldId] = () => fetchData(fieldId, choiceUrl); }); const optTypes = Object.keys(asyncLoaders).map((fieldPath) => ( // return Promise that stores the loadedChoices into the correct model asyncLoaders[fieldPath]().then((loaded) => { const existingField = fields.get(fieldPath); existingField.render.choices = loaded; fields.set(fieldPath, existingField); }) )); if (optTypes.length) { await Promise.all(optTypes); } } return { sections, fields, triggerFields }; }; const finishParsingTriggerFields = (triggerFields, fields) => { triggerFields.forEach((trigField) => { const touches = trigField.touches; touches.forEach((conditions, touchedId) => { conditions.forEach((condition, aFI) => { const layout = /* @__PURE__ */ new Map(); const validationProps = /* @__PURE__ */ new Map(); if (!fields.has(touchedId)) { return; } const field = fields.get(touchedId); parseValidation(validationProps, field.modelData); parseValidation(validationProps, field.render); let customValidation = field.customValidation; if (condition.then.customValidation) { customValidation = condition.then.customValidation; } Object.keys(condition.then).forEach((key) => { if (conditionalRenderProps.includes(key)) { layout.set(key, condition.then[key]); } if (validationTypes.includes(key)) { validationProps.set(key, condition.then[key]); } }); const { type, label } = field; const mergedField = { ...field }; const dynRender = Object.fromEntries(layout); mergedField.render = { ...field.render, ...dynRender }; const theValidation = customValidation ? customValidation(mergedField) : createFieldValidation(type, label, validationProps, mergedField); condition.then = { layout, validation: theValidation }; }); }); }); }; const isWhen = (when) => { if (typeof when === "string") { return false; } let validWhen = false; if (when?.fieldId && when?.operation) { if (when?.value !== void 0 || when.operation === "isNull" || when.operation === "isNotNull") { validWhen = true; } } return validWhen; }; const isNewConditional = (conditional) => { return isWhen(conditional?.when) && !!conditional?.then; }; const transformLegacyCondition = (fieldId, condition, index) => { const { when: triggerFieldId, then, isValid } = condition; let value = condition?.is; return { conditionId: `${fieldId}-${triggerFieldId}-${index}`, when: { fieldId: triggerFieldId, value, operation: isValid ? "isNotNull" : "eq" }, then }; }; const transformCondition = (fieldId, condition, index) => { if (isNewConditional(condition)) { return { conditionId: `${fieldId}-${condition.when.fieldId}-${index}`, ...condition }; } return transformLegacyCondition(fieldId, condition, index); }; const parseNewConditions = (fieldId, triggerFields, conditions) => { if (!conditions || conditions.length === 0) { return; } conditions.forEach((condition, index) => { const processedCondition = transformCondition(fieldId, condition, index); const triggerId = processedCondition.when.fieldId; const trigField = triggerFields.get(triggerId) || { id: triggerId, touches: /* @__PURE__ */ new Map() }; const affectedFieldConditions = trigField.touches.get(fieldId) || []; affectedFieldConditions.push(processedCondition); trigField.touches.set(fieldId, affectedFieldConditions); triggerFields.set(triggerId, trigField); }); }; function parseSection(section, fieldMap, triggerFieldMap, asyncFieldsMap) { if (!section) { return {}; } const { layout, editable, enabled } = section; const parsedSection = { name: section.name, title: section.title, order: section.order, description: section.description, editable, enabled, fields: [] }; if (layout?.length) { layout.forEach((field) => { const parsedField = parseField(field, asyncFieldsMap); if (parsedField) { const { path } = parsedField; fieldMap.set(path, parsedField); parsedSection.fields.push(path); const { conditions } = parsedField; if (conditions?.length) { parseNewConditions(path, triggerFieldMap, conditions); } } }); } return parsedSection; } function parseField(field, asyncFieldsMap = null) { if (!field) { return {}; } const { path, label, type, model, conditions = [], linkFormat } = field; const name = path || model?.name || `unknown${model?.id || ""}`; const hidden = !!field[CONDITIONAL_RENDER.HIDDEN]; const parsedField = { id: name, path, conditions, // conditionals, label, type, hidden, specialProps: {}, [DEFAULT_VALUE]: field[DEFAULT_VALUE], modelData: model?.data || {}, // Note any validation that are needed for a trigger field should be added here // The triggerfield logic will parse the base field first then the trigger field (which allows for overrides via "then") render: { type, label, name, // Boolean properties hidden, [REQUIRED]: !!field[REQUIRED], [DISABLED]: !!field[DISABLED], [CONDITIONAL_RENDER.READ_ONLY]: !!field[CONDITIONAL_RENDER.READ_ONLY], inline: !!field.inline, emptyMessage: field.emptyMessage, //Number properties [MIN_VALUE]: field[MIN_VALUE], [MAX_VALUE]: field[MAX_VALUE], [MAX_LENGTH]: field[MAX_LENGTH], [MIN_LENGTH]: field[MIN_LENGTH], //String properties [MAX_VALUE_ERROR_TEXT]: field[MAX_VALUE_ERROR_TEXT], [MIN_VALUE_ERROR_TEXT]: field[MIN_VALUE_ERROR_TEXT], [CONDITIONAL_RENDER.ALT_HELPER]: field[CONDITIONAL_RENDER.ALT_HELPER], [CONDITIONAL_RENDER.ICON_HELPER]: field[CONDITIONAL_RENDER.ICON_HELPER], [CONDITIONAL_RENDER.HELPER]: field[CONDITIONAL_RENDER.HELPER], [CONDITIONAL_RENDER.REQ_TEXT]: field[CONDITIONAL_RENDER.REQ_TEXT], [PLACEHOLDER]: field[PLACEHOLDER], solitary: field.solitary, singleColumnSize: field.singleColumnSize, linkFormat } }; const { data = {} } = model || {}; if (data && parsedField.specialProps) { specialProps.forEach((prop) => { if (data[prop] && parsedField.specialProps) { parsedField.specialProps[prop] = data[prop]; } }); } const types = Object.keys(FIELD_TYPES); const typeIndex = Object.values(FIELD_TYPES).indexOf(type); if (!types[typeIndex]) { console.warn(`Field type ${type} is not supported by the form builder`); return parsedField; } if (type === FIELD_TYPES.CHOICE || type === FIELD_TYPES.OBJECT) { const updatedRender = { ...parsedField.render, choices: [], multiple: !!field.multiple, checkbox: !!field.checkbox, radio: !!field.radio, type }; parsedField.render = updatedRender; } if (type === FIELD_TYPES.DATE) { const updatedRender = { ...parsedField.render, disableFuture: !!field.disableFuture, disableFutureErrorText: field.disableFutureErrorText }; parsedField.render = updatedRender; } if (type === FIELD_TYPES.LONG_TEXT) { const updatedRender = { ...parsedField.render, isMultiLine: true }; parsedField.render = updatedRender; } if (type === FIELD_TYPES.TEXT) { const updatedRender = { ...parsedField.render, zip: !!field.zip, phone: !!field.phone, email: !!field.email }; parsedField.render = updatedRender; } if (field.possibleChoices) { const choices = field?.possibleChoices ? field?.possibleChoices.map((item) => ({ ...item, label: item.label ?? item.name, id: item.id })) : []; parsedField.render.choices = choices; } else if (field.url) { if (type !== FIELD_TYPES.CHOICE && type !== FIELD_TYPES.OBJECT) { console.warn(`Field type ${type} does not support async choices`); } asyncFieldsMap.set(field.path, field.url); } if (type === FIELD_TYPES.CLUSTER) { const subFields = field.layout?.map((subF) => parseField(subF, asyncFieldsMap)) || []; parsedField.subFields = subFields; const updatedRender = { ...parsedField.render, // Allow for custom labels addLabel: field.addLabel, removeLabel: field.removeLabel, clusterColumnCount: field.clusterColumnCount, type }; parsedField.render = updatedRender; } if (field.customValidation) { parsedField.customValidation = field.customValidation; parsedField.validations = field.customValidation(parsedField); } else { const validations = /* @__PURE__ */ new Map(); parseValidation(validations, field); parseValidation(validations, data); if (validations.size) { parsedField.validations = createFieldValidation(type, label, validations, parsedField); } } return parsedField; } function parseValidation(validationMap, data, debug = false) { if (!data) { return; } Object.keys(data).forEach((key) => { if (debug) { console.debug("~parseValidation~", key, data[key]); } if (validationTypes.includes(key) && data[key] !== void 0) { validationMap.set(key, data[key]); } }); } function getFieldValue(field, formData) { let { type } = field || field?.render || {}; const { render } = field || {}; const name = render.name || `unknown${render.id}`; let inData = formData?.[name]; if ((inData === void 0 || inData === null) && !isEmpty(field[DEFAULT_VALUE])) { inData = field[DEFAULT_VALUE]; } let value = null; switch (type) { case FIELD_TYPES.LONG_TEXT: case FIELD_TYPES.TEXT: case FIELD_TYPES.LINK: case FIELD_TYPES.INT: case FIELD_TYPES.CURRENCY: case FIELD_TYPES.FLOAT: { value = isEmpty(inData) ? "" : inData; break; } case FIELD_TYPES.FLAG: { value = !!inData; break; } case FIELD_TYPES.DATE: { if (inData) { const theDate = inData === TODAY_DEFAULT ? /* @__PURE__ */ new Date() : new Date(dateStringNormalizer(inData)); inData = theDate; } value = inData ?? null; break; } case FIELD_TYPES.CHOICE: case FIELD_TYPES.OBJECT: { value = getObjectTypeValue(inData, field); break; } case FIELD_TYPES.CLUSTER: { value = getClusterValue(inData, field); break; } } return { value, name }; } const getObjectTypeValue = (data, field) => { const { render } = field; const { multiple, checkbox } = render; const dataType = typeof data; if (dataType === "object") { if (multiple && checkbox) { if (Array.isArray(data)) { return getSelectValue(true, data) || []; } else { return []; } } else { return getSelectValue(multiple || false, data) || ""; } } else if (!data && multiple) { return []; } return data || ""; }; const getClusterValue = (data, field) => { const clusterData = []; if (Array.isArray(data) && data.length) { data.forEach((nug) => { const lineData = {}; const { subFields } = field || []; if (Array.isArray(subFields) && subFields.length) { subFields.forEach((subF) => { const { name: fName, value: fValue } = getFieldValue(subF, nug); lineData[fName] = fValue; }); } clusterData.push(lineData); }); } return clusterData; }; function processFieldValue(field, value) { let apiValue = value; switch (field.type) { case FIELD_TYPES.LINK: break; case FIELD_TYPES.DATE: break; case FIELD_TYPES.FLAG: apiValue = !!apiValue; break; case FIELD_TYPES.CURRENCY: case FIELD_TYPES.FLOAT: apiValue = parseFloat(apiValue); break; case FIELD_TYPES.INT: apiValue = parseInt(apiValue, 10); break; case FIELD_TYPES.CHOICE: case FIELD_TYPES.OBJECT: if (field.isArrayData) { apiValue = multiToPayload(apiValue); } else if (!field.isStringId) { apiValue = parseInt(apiValue, 10); } break; case FIELD_TYPES.CLUSTER: { const clusterData = []; if (Array.isArray(value) && value.length) { value.forEach((nug) => { const lineData = {}; const subFields = field.render.fields; if (Array.isArray(subFields) && subFields.length) { subFields.forEach((subF) => { const subName = subF.render.name; lineData[subName] = processFieldValue(subF, nug[subName]); }); } clusterData.push(lineData); }); } apiValue = clusterData; break; } default: if (apiValue !== null && apiValue !== void 0) { apiValue = apiValue.toString().trim(); } break; } return apiValue; } export { getFieldValue, parseField, parseFormLayout, parseSection, processFieldValue, useFormLayout }; //# sourceMappingURL=useFormLayout.js.map