@timmons-group/config-form
Version:
React Components and helpers to build a form via configuration with react-hook-form and MUI
413 lines (410 loc) • 14.5 kB
JavaScript
import { useMemo, useState, useRef, useLayoutEffect, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { object } from 'yup';
import { yupResolver } from '@hookform/resolvers/yup';
import { getFieldValue } from './useFormLayout.js';
import axios from 'axios';
import { CONDITIONAL_RENDER, DEFAULT_VALUE, ID_FIELD, LABEL_FIELD } from '../constants.js';
import { FIELD_TYPES, objectReducer } from '@timmons-group/shared-react-components';
import { defaultChoiceMapper, checkConditional } from '../helpers/formHelpers.js';
const processDynamicFormLayout = (formLayout, data) => {
const validations = {};
const defaultValues = {};
const fieldsToWatch = {};
formLayout.sections.forEach((section) => {
for (const fieldId of section.fields) {
if (formLayout.fields.has(fieldId)) {
const field = formLayout.fields.get(fieldId);
const { name, value } = getFieldValue(field, data || {});
defaultValues[name] = value;
if (!field.render?.readOnly) {
validations[field.id] = field.validations;
}
if (formLayout.triggerFields.has(fieldId)) {
fieldsToWatch[fieldId] = true;
}
}
}
});
return {
defaultValues,
validations,
watchFields: Object.entries(fieldsToWatch).map(([key]) => key)
};
};
const processAffectedFields = (triggerField, fields, formValue, options) => {
const { appliedConditionals } = options;
if (formValue === "")
formValue = null;
const processedFields = [];
let affectedFields = triggerField.touches || [];
affectedFields.forEach((conditions, touchedId) => {
const conditional = {
hasAsync: false,
loadedChoices: {},
hasRenderValue: false,
isUpdating: false,
noPasses: true
};
conditions.forEach(({ conditionId, when, then }) => {
const passes = checkConditional(when, formValue);
if (passes) {
conditional.noPasses = false;
const loadOut = { ...then };
const layout = loadOut?.layout;
const isHidden = layout?.get(CONDITIONAL_RENDER.HIDDEN);
if (!isHidden && layout?.has("url")) {
conditional.hasAsync = true;
const remoteUrl = layout?.get("url")?.replace("##thevalue##", formValue);
conditional.asyncLoader = () => fetchChoices(touchedId, remoteUrl, {
mappedId: layout?.get(ID_FIELD),
mappedLabel: layout?.get(LABEL_FIELD),
triggerFieldId: touchedId,
...options
});
}
const renderId = layout?.get(CONDITIONAL_RENDER.RENDER_PROPERTY_ID);
if (!isHidden && renderId) {
const { render: { choices } } = fields.get(triggerField.id);
const triggerChoice = choices?.find((c) => c.id === formValue);
if (triggerChoice) {
const renderValue = objectReducer(triggerChoice, renderId) || "";
if (renderValue !== void 0 && renderValue !== null) {
conditional.hasRenderValue = true;
conditional.renderValue = renderValue;
}
}
}
conditional.isUpdating = true;
conditional.loadOut = loadOut;
}
const isApplied = appliedConditionals.current[conditionId] === passes;
if (!isApplied || conditional.hasAsync) {
appliedConditionals.current[conditionId] = passes;
processedFields.push({ id: touchedId, conditional });
}
});
});
return processedFields;
};
const createRenderSection = (section, fieldMap) => {
const formSection = {
name: section.name || section.title,
description: section.description,
fields: [],
visible: false
};
let visibleCount = 0;
section.fields.forEach((fieldPath) => {
const field = fieldMap.get(fieldPath) || {};
const { render } = field || {};
if (!render.hidden) {
visibleCount++;
}
formSection.fields.push({ render: { ...render }, subFields: field.subFields, type: field.type, [DEFAULT_VALUE]: field[DEFAULT_VALUE] });
});
formSection.visible = visibleCount > 0;
return formSection;
};
const useConfigForm = (formLayout, data, options, addCustomValidations, formOptions) => {
const { defaultValues, watchFields, validations: dynamicValidations } = useMemo(() => {
return processDynamicFormLayout(formLayout, data);
}, [formLayout, data]);
const [sections, setSections] = useState([]);
const [validations, setValidations] = useState({});
const [formProcessing, setFormProcessing] = useState(true);
const [readyForWatches, setReadyForWatches] = useState(false);
const appliedConditionals = useRef({});
const mode = formOptions?.mode ?? "onBlur";
const validationSchema = useMemo(
() => {
if (addCustomValidations && typeof addCustomValidations === "function") {
const newValids = addCustomValidations(validations);
if (newValids) {
return object({ ...newValids });
}
return object({ ...validations });
}
return object({ ...validations });
},
[validations]
);
const useFormObject = useForm({
mode,
defaultValues,
resolver: yupResolver(validationSchema),
shouldUnregister: true
});
const { formState, watch, trigger, reset, resetField, setError, clearErrors, getFieldState } = useFormObject;
useLayoutEffect(() => {
if (!formProcessing) {
setFormProcessing(true);
}
initTheForm({
formLayout,
setSections,
validations: dynamicValidations,
setValidations,
isResetting: false,
watchFields,
setFormProcessing,
setReadyForWatches,
defaultValues,
options: { ...options, setError, clearErrors, resetField, appliedConditionals }
});
return () => {
appliedConditionals.current = {};
};
}, [formLayout]);
useEffect(() => {
if (formState.isSubmitted) {
trigger();
}
}, [validationSchema]);
const forceReset = () => {
reset(defaultValues);
if (!formProcessing) {
setFormProcessing(true);
}
appliedConditionals.current = {};
initTheForm({
formLayout,
sections,
setSections,
validations: dynamicValidations,
setValidations,
isResetting: true,
watchFields,
setFormProcessing,
setReadyForWatches,
defaultValues,
options: { ...options, setError, clearErrors, resetField, appliedConditionals }
});
};
useLayoutEffect(() => {
let subscription = null;
if (readyForWatches && !subscription) {
subscription = watch((formValues, { name, type }) => {
let watched = watchFields.includes(name);
if (!watched) {
return;
}
if (type !== "change") {
const maybeCluster = formLayout.fields.get(name);
if (maybeCluster.type !== FIELD_TYPES.CLUSTER) {
return;
}
}
const finishSetup = ({ renderSections, resetFields, dynValid }) => {
for (const field in resetFields) {
const fieldToReset = formLayout.fields.get(field);
const { value } = getFieldValue(fieldToReset, {});
resetField(field, { defaultValue: value });
}
setSections(renderSections);
setValidations((prevValues) => {
return {
...prevValues,
...dynValid
};
});
for (const field in dynValid) {
const fState = getFieldState(field);
if (fState?.isDirty) {
clearErrors(field);
}
}
setFormProcessing(false);
};
renderTheSections({
sections,
fields: formLayout.fields,
triggerFields: formLayout.triggerFields,
values: formValues,
watchFields,
fromWatch: true,
triggeringFieldId: name,
options: { ...options, setError, clearErrors, resetField, appliedConditionals },
finishSetup
});
});
}
return () => {
subscription?.unsubscribe();
};
}, [readyForWatches, watchFields]);
return {
useFormObject,
sections,
formProcessing,
forceReset
};
};
const initTheForm = ({ formLayout, setSections, validations, setValidations, isResetting, watchFields, setFormProcessing, setReadyForWatches, defaultValues, options }) => {
const finishSetup = ({ renderSections, dynValid }) => {
setSections(renderSections);
if (Object.keys(validations).length > 0) {
setValidations(() => {
return {
...validations,
...dynValid
};
});
}
setFormProcessing(false);
if (watchFields.length > 0 && !isResetting) {
setReadyForWatches(true);
}
};
renderTheSections({
sections: formLayout.sections,
fields: formLayout.fields,
triggerFields: formLayout.triggerFields,
values: defaultValues,
watchFields,
options,
fromWatch: false,
finishSetup
});
};
const renderTheSections = ({ sections, fields, triggerFields, values, watchFields, finishSetup, options, fromWatch, triggeringFieldId }) => {
let renderSections = fromWatch ? sections : [];
if (!fromWatch) {
sections.forEach((section) => {
renderSections.push(createRenderSection(section, fields));
});
}
const newAreUpdating = {};
const newUpdatedFields = [];
const newAsyncLoaders = {};
const loadedChoices = {};
const dynValid = {};
const resetFields = {};
let hasNewAsync = false;
const updateNewConditional = (fieldId, conditional) => {
if (conditional.isUpdating && !conditional.noPasses) {
newAreUpdating[fieldId] = true;
if (conditional.hasAsync && conditional.asyncLoader) {
hasNewAsync = true;
newAsyncLoaders[fieldId] = conditional.asyncLoader;
}
newUpdatedFields.push({ id: fieldId, type: "update", ...conditional.loadOut });
}
};
const watchesToCheck = fromWatch ? [triggeringFieldId] : watchFields;
watchesToCheck.forEach((fieldId) => {
const usedTriggerField = triggerFields.get(fieldId);
if (!usedTriggerField) {
return;
}
const triggeringField = triggerFields.get(fieldId);
const formValue = values[fieldId];
const updatednew = processAffectedFields(triggeringField, fields, formValue, options);
updatednew.forEach(({ id, conditional }) => {
updateNewConditional(id, conditional);
});
if (fromWatch) {
updatednew.forEach(({ id, conditional }) => {
if (conditional.noPasses) {
if (!newAreUpdating[id]) {
newAreUpdating[id] = true;
newUpdatedFields.push({ id, type: "reset" });
}
}
});
}
});
const hasUpdates = Object.keys(newAreUpdating).length > 0;
if (hasUpdates) {
if (hasNewAsync) {
const optTypes = Object.keys(newAsyncLoaders).map((fId) => (
// return Promise that stores the loadedChoices into the correct model
newAsyncLoaders[fId]().then((loaded) => {
loadedChoices[fId] = loaded;
})
));
if (optTypes.length) {
Promise.all(optTypes).finally(() => {
renderSections = processConditionalUpdate(renderSections, fields, newUpdatedFields, loadedChoices, dynValid, resetFields);
if (finishSetup && typeof finishSetup === "function") {
finishSetup({ renderSections, dynValid, resetFields });
}
});
}
} else {
renderSections = processConditionalUpdate(renderSections, fields, newUpdatedFields, null, dynValid, resetFields);
finishSetup({ renderSections, dynValid, resetFields });
}
} else {
finishSetup({ renderSections, dynValid, resetFields });
}
};
const processConditionalUpdate = (sections, fields, updatedFields, asyncThings = {}, dynValid = {}, resetFields = {}) => {
const revalidates = {};
const layoutSections = sections.map((section) => {
updatedFields.forEach((field) => {
const fieldObject = fields.get(field.id);
const sectionField = section.fields.find((x) => x.render.name === fieldObject.id);
if (sectionField) {
revalidates[fieldObject.id] = true;
let { render } = sectionField;
if (field.type === "update") {
if (!fieldObject.render?.readOnly) {
dynValid[fieldObject.id] = field.validation;
}
const updatedLayout = Object.fromEntries(field.layout);
const choices = asyncThings ? asyncThings[field.id] : null;
const asyncRender = choices ? { choices } : {};
render = { ...render, ...updatedLayout, ...asyncRender };
} else {
if (!fieldObject.render?.readOnly) {
dynValid[fieldObject.id] = fieldObject.validations;
}
if (fieldObject.render?.hidden || fieldObject.render?.disabled) {
resetFields[fieldObject.id] = true;
}
render = { ...render, ...fieldObject.render };
}
sectionField.render = render;
if (sectionField.render.disabled) {
resetFields[fieldObject.id] = true;
}
}
});
let hasVisible = false;
for (const field of section.fields) {
if (!field.render.hidden) {
hasVisible = true;
break;
}
}
section.visible = hasVisible;
return section;
});
return layoutSections;
};
const fetchChoices = async (fieldId, url, { clearErrors, setError, resetField, urlDomain, mappedId, mappedLabel, triggerFieldId, choiceFormatter }) => {
resetField(fieldId);
const fetchUrl = urlDomain ? `${urlDomain}${url}` : url;
const things = await axios.get(fetchUrl).then(
(res) => {
if (clearErrors && typeof clearErrors === "function") {
clearErrors(fieldId);
}
if (choiceFormatter && typeof choiceFormatter === "function") {
return choiceFormatter(fieldId, res, { triggerFieldId, mappedId, mappedLabel });
}
return defaultChoiceMapper(res, { mappedId, mappedLabel });
}
).catch((error) => {
if (error.name !== "CanceledError") {
console.error(" ", fieldId, "Error fetching data", error);
if (setError && typeof setError === "function") {
setError(fieldId, { type: "custom", message: "There was a problem loading the possible choices for this field" });
}
}
});
return things || [];
};
export { fetchChoices, processDynamicFormLayout, useConfigForm };
//# sourceMappingURL=useConfigForm.js.map