UNPKG

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