@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
JavaScript
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