/** @module useFormConfig */
//Third party bits
import '../models/form';
import { useEffect, useMemo, useState, useLayoutEffect } from 'react';
import { useForm } from 'react-hook-form';
import { object } from 'yup';
import { yupResolver } from '@hookform/resolvers/yup';
// Internal bits
import { getFieldValue } from './useFormLayout';
import axios from 'axios';
import {
ID_FIELD,
LABEL_FIELD,
CONDITIONAL_RENDER,
DEFAULT_VALUE,
FIELD_TYPES,
ANY_VALUE
} from '../constants';
import { objectReducer } from '../helpers';
/**
* @function processDynamicFormLayout
* @description This function will take the form layout and the data and return the default values and validation schema for the form
* @param {object} formLayout
* @param {object} data
* @returns {ProcessedDynamicFormLayout} - Object containing the default values, validation schema, and fields to watch for changes
*/
export const processDynamicFormLayout = (formLayout, data) => {
// Will hold the validation schema for the form
const validations = {};
// Will hold the correctly formatted field values for the form
const defaultValues = {};
// Will hold the fields that need to be watched for changes
const fieldsToWatch = {};
formLayout.sections.forEach(section => {
for (const fieldId of section.fields) {
if (formLayout.fields.has(fieldId)) {
const field = formLayout.fields.get(fieldId);
// Get the value from the incoming values correctly formatted
// If it does not exist the returned value will be the correct default format
const { name, value } = getFieldValue(field, data || {});
defaultValues[name] = value;
// Update the validation schema for this field
// Do not add validations for read only fields
if (!field.render?.readOnly) {
validations[field.id] = field.validations;
}
// If this field exists in the triggerfields we need to watch the form for changes
if (formLayout.triggerFields.has(fieldId)) {
fieldsToWatch[fieldId] = true;
}
}
}
});
return {
defaultValues,
validations: validations,
watchFields: Object.entries(fieldsToWatch).map(([key]) => key),
};
};
/**
* Method to check for and setup conditional rendering
* @function
* @param {Map<string, object>} triggerFields - Map of fields that trigger conditional rendering
* @param {Map<string, object>} fields - Map of all fields
* @param {string} triggerId - ID of the field to check
* @param {any} formValue - Value of the field to check
* @param {object} options - Options for the conditional rendering
* @returns {Array} - Array of fields that need to be updated
*/
const getUpdatedFields = (triggerField, fields, triggerId, formValue, options) => {
const updatedFields = [];
// This is a hack to handle the fact that the form values maybe strings or numbers
// TODO: Add check for field type and coerce for correct check
let usedFormValue = formValue;
let hasIt = triggerField.fieldValues.has(formValue);
// Check again with the string version of the value
if (!hasIt && formValue !== undefined && formValue !== null) {
hasIt = triggerField.fieldValues.has(formValue.toString());
if (hasIt) {
usedFormValue = formValue.toString();
}
}
if (hasIt) {
// Get the fields that need to be updated
let affectedFields = triggerField.fieldValues.get(usedFormValue) || [];
affectedFields.forEach((loadOut, fieldId) => {
const conditional = {
hasAsync: false,
loadedChoices: {},
hasRenderValue: false,
isUpdating: false,
};
// If the field has a remoteUrl, we need to fetch the data
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);
// Note the ID_FIELD and LABEL_FIELD are here different from the useFormLayout hook
// These are the values on this field's CONDITIONAL layout, not the default layout
conditional.asyncLoader = () => fetchChoices(fieldId, remoteUrl, {
mappedId: layout?.get(ID_FIELD),
mappedLabel: layout?.get(LABEL_FIELD),
triggerFieldId: fieldId,
...options
});
}
// If the field has a condtionall dependent renderProperty we need to parse it out
const renderId = layout?.get(CONDITIONAL_RENDER.RENDER_PROPERTY_ID);
if (!isHidden && renderId) {
// Get the choices for the triggering field so we can find the matching selected value
const { render: { choices } } = fields.get(triggerField.id);
const triggerChoice = choices?.find(c => c.id === formValue);
if (triggerChoice) {
// If the renderId is a dot notation, we need to dig into the object
// Otherwise, we can just use the value
const renderValue = objectReducer(triggerChoice, renderId) || '';
if (renderValue !== undefined && renderValue !== null) {
// setValue(fieldId, renderValue);
conditional.hasRenderValue = true;
conditional.renderValue = renderValue;
}
}
}
conditional.isUpdating = true;
conditional.loadOut = loadOut;
updatedFields.push({ id: fieldId, conditional });
});
}
return updatedFields;
};
/**
* Creates a section object for the form
* @function createRenderSection
* @param {object} section - section object from the form layout
* @param {Map<string, Field>} fieldMap -
* @returns {FormSection} - Form section object
*/
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;
};
/**
* Any properties returned here may be applied to the FormContext for use in child components
* @example
* const { useFormObject, formProcessing, sections } = useConfigForm(formLayout, data, options);
* <FormProvider {...useFormObject} sections={sections} formProcessing={formProcessing}>
* @function useConfigForm
* @param {object} formLayout - Form layout object
* @param {object} data - Data to pre-populate the form with
* @param {object} [options] - any other options needed for the form
* @param {function} [addCustomValidations] - function to add custom validations to the form MUST return an object
* @returns {object} - Object containing the useFormObject, formProcessing, and sections
*/
export const useConfigForm = (formLayout, data, options, addCustomValidations) => {
const { defaultValues, watchFields, validations: dynamicValidations } = processDynamicFormLayout(formLayout, data);
const [sections, setSections] = useState([]);
const [validations, setValidations] = useState({});
const [formProcessing, setFormProcessing] = useState(true);
const [readyForWatches, setReadyForWatches] = useState(false);
// update the validation schema hookForm uses when the validation state changes
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: 'onBlur',
defaultValues: defaultValues,
resolver: yupResolver(validationSchema),
shouldUnregister: true
});
// Form object will contain all the properties of useForm (React Hook Form)
const { formState, watch, trigger, reset, resetField, setError, clearErrors } = useFormObject;
useLayoutEffect(() => {
if (!formProcessing) {
setFormProcessing(true);
}
initTheForm({
formLayout,
setSections,
validations: dynamicValidations,
setValidations,
isResetting: false,
watchFields,
setFormProcessing,
setReadyForWatches,
defaultValues,
options: { ...options, setError, clearErrors }
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formLayout]);
// If the schema changes and the form has been submitted, revalidate
useEffect(() => {
if (formState.isSubmitted) {
trigger();
}
// We do not want formState.isSubmitted to be a dependency here, the trigger happens on form submit
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [validationSchema]);
const forceReset = () => {
// Yes we do need to reset the form
reset(defaultValues);
// Then set the formProcessing to true so it will re-render
if (!formProcessing) {
setFormProcessing(true);
}
// Then re-run the initTheForm function otherwise the triggers won't run because watch is dumb
initTheForm({
formLayout,
sections,
setSections,
validations: dynamicValidations,
setValidations,
isResetting: true,
watchFields,
setFormProcessing,
setReadyForWatches,
defaultValues,
options
});
};
// If we have any watchFields, watch them and update the form
useLayoutEffect(() => {
let subscription = null;
if (readyForWatches && !subscription) {
subscription = watch((formValues, { name, type }) => {
let watched = watchFields.includes(name);
// This field is not watched
if (!watched) {
return;
}
if (type !== 'change') {
// check if clusterField
const clusterField = formLayout.fields.get(name);
if (clusterField.type !== FIELD_TYPES.CLUSTER) {
return;
}
}
const finishSetup = ({ renderSections, resetFields, dynValid }) => {
// This will reset any fields that were disabled
// We grab the "empty" value from the field and set it as the default value
const resetValues = {};
for (const field in resetFields) {
const fieldToReset = formLayout.fields.get(field);
const { value } = getFieldValue(fieldToReset, {});
resetValues[field] = value;
resetField(field, { defaultValue: value });
}
setSections(renderSections);
// This will trigger the useMemo to update the validation schema
// That hook will then trigger the useEffect to revalidate the form
setValidations((prevValues) => {
return {
...prevValues,
...dynValid,
};
});
setFormProcessing(false);
};
// Note we pass the reactive sections here AND NOT the formLayout.sections
renderTheSections({
sections,
fields: formLayout.fields,
triggerFields: formLayout.triggerFields,
values: formValues,
watchFields,
options,
fromWatch: true,
finishSetup
});
});
}
return () => {
// TODO: Look into termination of any triggerfield async
subscription?.unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [readyForWatches, watchFields]);
return {
useFormObject,
sections,
formProcessing,
forceReset,
};
};
/**
* Function to initialize the form
* @function initTheForm
* @param {object} props
* @param {object} props.formLayout - Form layout object
* @param {object} props.setSections - React hook to set the sections
* @param {object} props.validations - Dynamic validations
* @param {object} props.setValidations - React hook to set the validations
* @param {boolean} props.isResetting - Whether or not the form is resetting
* @param {array} props.watchFields - Array of fields to watch
* @param {object} props.setFormProcessing - React hook to set the formProcessing state
* @param {object} props.setReadyForWatches - React hook to set the readyForWatches state
* @param {object} props.defaultValues - Default values for the form
* @param {object} props.options - Options object
*/
const initTheForm = ({ formLayout, setSections, validations, setValidations, isResetting, watchFields, setFormProcessing, setReadyForWatches, defaultValues, options }) => {
// Finish setting up the form
// This is done in a separate function so we can await any async calls
const finishSetup = ({ renderSections, dynValid }) => {
setSections(renderSections);
//If we have any dynamic validations, set them
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
});
};
/**
* Renders the sections
* @function renderTheSections
* @param {object} props - Props object
* @param {array} props.sections - Array of sections
* @param {object} props.fields - Map of fields
* @param {object} props.triggerFields - Map of trigger fields
* @param {object} props.values - Form values
* @param {array} props.watchFields - Array of fields to watch
* @param {function} props.finishSetup - Function to finish setting up the form
* @param {object} props.options - Options object
* @param {boolean} props.fromWatch - Whether or not this is being called from a watch
*/
const renderTheSections = ({ sections, fields, triggerFields, values, watchFields, finishSetup, options, fromWatch }) => {
let renderSections = fromWatch ? sections : [];
if (!fromWatch) {
sections.forEach(section => {
renderSections.push(createRenderSection(section, fields));
});
}
const areUpdating = {};
const updatedFields = [];
const asyncLoaders = {};
const loadedChoices = {};
const dynValid = {};
const resetFields = {};
let hasAsync = false;
const updateConditional = (fieldId, conditional) => {
if (conditional.isUpdating) {
areUpdating[fieldId] = true;
if (conditional.hasAsync && conditional.asyncLoader) {
hasAsync = true;
asyncLoaders[fieldId] = conditional.asyncLoader;
}
updatedFields.push({ id: fieldId, type: 'update', ...conditional.loadOut });
}
};
// Loop through all the triggerFields and see if the initial values have caused any fields to be updated
watchFields.forEach((fieldId) => {
// If somehow watching a field that is not in the formLayout, skip it
const triggerField = triggerFields.get(fieldId);
if (!triggerField) {
return;
}
// Get the starting value of the field
const formValue = values[fieldId];
// Loop through all the fields that are dependent on this triggerField
const updated = getUpdatedFields(triggerField, fields, fieldId, formValue, options);
updated.forEach(({ id, conditional }) => {
updateConditional(id, conditional);
});
// A flag to determine if this "onChange" field has a null value. If it does, we need to handle resets for affected fields heavy handedly
let nullChangeValue = false;
// Check for any "onChange" fields
// We have to run a separate loop because conditions could be met for a specific value AND for ANY_VALUE (i.e. not null)
if (triggerField.hasOnChange) {
// If the value is null, we need to handle the reset of the affected fields
if (formValue !== null && formValue !== undefined && formValue !== '' && formValue?.length > 0) {
const anyUpdates = getUpdatedFields(triggerField, fields, fieldId, ANY_VALUE, options);
anyUpdates.forEach(({ id, conditional }) => {
updateConditional(id, conditional);
});
} else {
// Not needed for initial render
nullChangeValue = true;
}
}
// If this update is from a watch, we need to handle the reset of the affected fields
if (fromWatch) {
// touches is a map of all the fields affected by this triggerfield
// fieldId is the key and the value is a map of the field values from this triggerfield that affect it
const touchedFields = triggerField.touches;
// Add a reset to the updatedFields array
const addReset = (fieldId) => {
if (!areUpdating[fieldId]) {
areUpdating[fieldId] = true;
updatedFields.push({ id: fieldId, type: 'reset' });
}
};
// Determine any fields that need to be reset
touchedFields.forEach((value, fieldId) => {
// If this field is not affected by the new value, it needs to be reset (probably)
// We check if the field is already being updated because we don't want to reset a field that is being updated
// This would happen with a field that has a remoteUrl that updates on EVERY triggerfield change.
if (!value.has(formValue)) {
addReset(fieldId);
} else if (value.has(ANY_VALUE) && nullChangeValue) {
// We need to reset any fields that may have been triggered by an ANY_VALUE trigger and allow it to be reset when the triggerfield is null
addReset(fieldId);
}
});
}
});
const hasUpdates = Object.keys(areUpdating).length > 0;
if (hasUpdates) {
// If any of the affected fields have async needs, we need to wait for them to resolve
if (hasAsync) {
// Create an array of promise so we can await all.
const optTypes = Object.keys(asyncLoaders).map((fId) => (
// return Promise that stores the loadedChoices into the correct model
asyncLoaders[fId]().then((loaded) => {
loadedChoices[fId] = loaded;
})
));
// If we have choice loading promises, wait for them all to finish
if (optTypes.length) {
// if (setLoading) {
// setLoading(true);
// }
Promise.all(optTypes).finally(() => {
renderSections = processConditionalUpdate(renderSections, fields, updatedFields, loadedChoices, dynValid, resetFields);
if (finishSetup && typeof finishSetup === 'function') {
finishSetup({ renderSections, dynValid, resetFields });
}
// if (setLoading) {
// setLoading(false);
// }
});
}
} else {
renderSections = processConditionalUpdate(renderSections, fields, updatedFields, null, dynValid, resetFields);
finishSetup({ renderSections, dynValid, resetFields });
}
} else {
finishSetup({ renderSections, dynValid, resetFields });
}
};
/**
* Process the updated fields and update the render sections
* @function processConditionalUpdate
* @param {Array<FormSection>} sections - Array of all the sections in the form
* @param {Map<string, object>} fields - Map of all the fields in the form
* @param {Array<object>} updatedFields - Array of fields that need to be updated
* @param {object} asyncThings - Object of async things that need to be loaded
* @param {object} dynValid - Object of dynamic validations
* @param {object} resetFields - Object of fields that need to be reset
* @returns {Array<object>}
*/
const processConditionalUpdate = (sections, fields, updatedFields, asyncThings = {}, dynValid = {}, resetFields = {}) => {
const revalidates = {};
// Loop through the fields that need to be updated
const layoutSections = sections.map(section => {
updatedFields.forEach(field => {
const fieldObject = fields.get(field.id);
// Check if the field is in this section
const sectionField = section.fields.find(x => x.render.name === fieldObject.id);
if (sectionField) {
revalidates[fieldObject.id] = true;
let { render } = sectionField;
// If the type of update is ...update find the new validations and render bits
if (field.type === 'update') {
if (!fieldObject.render?.readOnly) {
dynValid[fieldObject.id] = field.validation;
}
const updatedLayout = Object.fromEntries(field.layout);
//Check for aysnc things
const choices = asyncThings ? asyncThings[field.id] : null;
// TODO: possibly handle more than just choices
const asyncRender = choices ? { choices } : {};
render = { ...render, ...updatedLayout, ...asyncRender };
} else {
//TODO: Is it possible that reset fields would need to be async?
if (!fieldObject.render?.readOnly) {
dynValid[fieldObject.id] = fieldObject.validations;
}
if (fieldObject.render?.hidden || fieldObject.render?.disabled) {
// New logic actually reset the field value
// Hope past Nathan just missed something and this is not a bad idea
resetFields[fieldObject.id] = true;
}
// If the type of update is ...reset, find the original validations and render bits
// Note that for render properties in the original layout to override the dynamic properties they MUST exist on the original layout even if
// null or empty. This is important for fields that use the "choices" property.
render = { ...render, ...fieldObject.render };
}
sectionField.render = render;
if (sectionField.render.disabled) {
resetFields[fieldObject.id] = true;
}
}
});
let hasVisible = false;
// loop through the fields and break if we find a visible field
for (const field of section.fields) {
if (!field.render.hidden) {
hasVisible = true;
break;
}
}
section.visible = hasVisible;
return section;
});
return layoutSections;
};
/**
* @typedef {Object} FetchChoicesOptions
* @property {string} urlDomain - domain to use when fetching the data
* @property {string} mappedId - property to use when mapping the id
* @property {string} mappedLabel - property to use when mapping the label
* @property {string} triggerFieldId - id of the field that triggered the load
* @property {function} choiceFormatter - function to format the choices
* @property {function} clearErrors - function to clear errors
* @property {function} setError - function to set errors
*/
/**
* Loads the data for the async fields
* @async
* @function
* @param {string} fieldId - id of the field that is being loaded
* @param {string} url - url to load the data from
* @param {FetchChoicesOptions} object - url to load the data from
* @returns {Promise<Array<object>>}
*/
export const fetchChoices = async (fieldId, url, { clearErrors, setError, urlDomain, mappedId, mappedLabel, triggerFieldId, choiceFormatter }) => {
const fetchUrl = urlDomain ? `${urlDomain}${url}` : url;
const things = await axios.get(fetchUrl).then(res => {
// We may need to clear the error in the event that the error was caused by a previous failed attempt
if (clearErrors && typeof clearErrors === 'function') {
clearErrors(fieldId);
}
// If there is a valid choice formatter, use it
if (choiceFormatter && typeof choiceFormatter === 'function') {
// pass along extra options to the choice formatter
return choiceFormatter(fieldId, res, { triggerFieldId, mappedId, mappedLabel });
}
const { data } = res || {};
return data?.map((opt) => {
const id = mappedId && opt[mappedId] ? opt[mappedId] : opt.id || opt.streamID;
const label = mappedLabel && opt[mappedLabel] ? opt[mappedLabel] : opt.name || opt.label;
return { id, label };
});
}
).catch(error => {
if (error.name !== 'CanceledError') {
console.error('\t', fieldId, 'Error fetching data', error);
// We may want to inject an error message into the field
if (setError && typeof setError === 'function') {
setError(fieldId, { type: 'custom', message: 'There was a problem loading the possible choices for this field' });
}
}
});
return things || [];
};
Source