/** @module useDynamicForm */
//Third party bits
import { useEffect, useMemo, useState } from 'react';
import { flushSync } from 'react-dom';
import { useForm } from 'react-hook-form';
import { object } from 'yup';
import { yupResolver } from '@hookform/resolvers/yup';
// Internal bits
import { getFieldValue, useFormLayout } from './useFormLayout';
import axios from 'axios';
import {
ID_FIELD,
LABEL_FIELD,
CONDITIONAL_RENDER
} from '../constants';
import { objectReducer } from '../helpers';
/**
* @typedef {Object} UseDynamicFormReturn
* @property {Array<object>} sections - Array of the sections of the form.
* @property {boolean} layoutLoading - the layout object
* @property {UseFormReturn} ...rest - all the properties of useForm
*
*/
/**
* useDynamicForm is a hook that handles the fields and validations for a dynamic form.
* @function useDynamicForm
* @param {string} layoutOptions.type - object type for standard get layout endpoint
* @param {string} layoutOptions.key - layout key for standard get layout endpoint
* @param {string} layoutOptions.url - url if you are not using the standard endpoint (optional)
* @param {object} layoutOptions.layout - a layout object to use to build the dynamic form instead of pulling from an url
* @param {object} incomingValues - object of the defaut or initial values for the form (optional)
* @param {string} urlDomain - domain to use for the url (optional)
* @param {function?} setLoading - function to set the loading state of the form for async conditional items (optional)
* @param {object?} asyncOptions - options for async operations (optional)
* @returns {UseDynamicFormReturn} - all the properties of useFom, an array of the sections, a loading boolean
*/
export const useDynamicForm = (layoutOptions = {}, incomingValues = {}, urlDomain, setLoading, asyncOptions) => {
const [parsedLayout, layoutLoading] = useFormLayout(layoutOptions?.type, layoutOptions?.key, layoutOptions?.url, urlDomain, asyncOptions, layoutOptions?.layout);
const [sections, setSections] = useState([]);
const [hasWatches, setHasWatches] = useState(false);
const [validations, setValidations] = useState({});
// update the validation schema hookForm uses when the validation state changes
const validationSchema = useMemo(
() => {
return object({ ...validations });
},
[validations]
);
// 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 useFormObject = useForm({
mode: 'onBlur',
resolver: yupResolver(validationSchema),
shouldUnregister: true
});
// Form object will contain all the properties of useForm (React Hook Form)
const { formState, watch, trigger, setValue, reset, resetField, setError, clearErrors } = useFormObject;
useEffect(() => {
if (layoutLoading) {
return;
}
// Will hold the validation schema for the form
const dynValid = {};
// flag to see if we need any watches (triggered by a field value changing)
let watchMe = false;
// Will hold the correctly formatted field values for the form
const dynValues = {};
parsedLayout.sections.forEach(section => {
for (const fieldId of section.fields) {
if (parsedLayout.fields.has(fieldId)) {
const field = parsedLayout.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, incomingValues || {});
dynValues[name] = value;
// Update the validation schema for this field
// Do not add validations for read only fields
if (!field.render?.readOnly) {
dynValid[field.id] = field.validations;
}
// If this field exists in the triggerfields we need to watch the form for changes
if (parsedLayout.triggerFields.has(fieldId)) {
watchMe = true;
}
}
}
});
// TODO figure out a way to only watch the fields that are needed
if (watchMe) {
setHasWatches(watchMe);
}
//If we have any dynamic validations, set them
if (Object.keys(dynValid).length > 0) {
setValidations(() => {
return {
...dynValid,
};
});
}
// Create our renderable sections
const renderSections = [];
parsedLayout.sections.forEach(section => {
const formSection = {
name: section.name || section.title,
fields: [],
visible: false
};
let visibleCount = 0;
section.fields.forEach((fieldPath) => {
const field = parsedLayout.fields.get(fieldPath) || {};
const { render } = field || {};
if (!render.hidden) {
visibleCount++;
}
formSection.fields.push({ render: { ...render } });
// TODO: Nuke this when defaultValue is implemented
// THIS should move to getFieldValue method
// Preselect the first option if there is only one option
if (render.choices?.length === 1) {
setValue(render.name, render.choices[0]);
}
});
formSection.visible = visibleCount > 0;
renderSections.push(formSection);
});
setSections(renderSections);
// We do this to cause any watched fields to fire on initial load
// This will also set the sections.
reset(dynValues);
// }
// We really only want to run this on layoutLoading changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutLoading]);
useEffect(() => {
let subscription = null;
// If we have watched fields, we need to watch them
if (hasWatches && !subscription) {
/**
* Method to complete the watch logic and update state.
* @function
* @param {array} updatedFields - array of field ids that need to be modified and if it was an "update" or "reset"
* @param {object} asyncThings - object of async render bits that should be folded into the layouts
*
*/
const finishWatch = (updatedFields, asyncThings = {}) => {
const dynValid = {};
const resetFields = {};
const revalidates = {};
// Loop through the fields that need to be updated
const layoutSections = sections.map(section => {
updatedFields.forEach(field => {
const fieldObject = parsedLayout.fields.get(field.id);
const fndField = section.fields.find(x => x.render.name === fieldObject.id);
if (fndField) {
revalidates[fieldObject.id] = true;
let { render } = fndField;
// 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;
}
// 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 };
}
fndField.render = render;
if (fndField.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;
});
// Update the sections
setSections(layoutSections);
// 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,
};
});
// This will reset any fields that were disabled
// We need to bypass the batch updates here or the disabled fields may trigger their own validation before the schema update occurs
// and out validation appearance will be out of sync with the schema
flushSync(() => {
for (const field in resetFields) {
resetField(field);
}
});
};
/**
* Loads the data for the async fields
* @function
* @async
* @param {string} fieldId - id of the field that is being loaded
* @param {string} url - url to load the data from
* @param {string} mappedId - property to use when mapping the id
* @param {string} mappedLabel - property to use when mapping the label
* @param {string} triggerFieldId - id of the field that triggered the load
* @returns {Promise<Object[]>} - array of objects with id and label properties(by default)
*/
const fetchData = async (fieldId, url, mappedId, mappedLabel, triggerFieldId) => {
const fetchUrl = urlDomain ? `${urlDomain}${url}` : url;
const things = await axios.get(fetchUrl).then(res => {
// We need to clear the error in the event that the error was caused by a previous failed attempt
const { data } = res || {};
clearErrors(fieldId);
// If there is a valid choice formatter, use it
if (asyncOptions?.choiceFormatter && typeof asyncOptions?.choiceFormatter === 'function') {
// pass along extra options to the choice formatter
return asyncOptions.choiceFormatter(fieldId, res, { triggerFieldId, mappedId, mappedLabel });
} else
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') {
// Inject an error message into the field
setError(fieldId, { type: 'custom', message: 'There was a problem loading the possible choices for this field' });
}
});
return things || [];
};
// There may be a way to dynamically watch just the needed fields, they all seem hacky
// This subscription will fire on every change
/**
* value is the value of the watched field
* name is the name of the watched field
* type is what happened. We only care about 'change'
**/
subscription = watch((value, { name, type }) => {
// const triggerField = parsedLayout.triggerFields.get(`fields.${name}`);
const triggerField = parsedLayout.triggerFields.get(name);
if (!triggerField || type !== 'change') {
return;
}
let formValue = value[name];
// This is old logic when we were an object {id: valueHere} instead of the value directly
// const triggerFieldType = parsedLayout.fields.get(triggerField.id).type;
// if (triggerFieldType === FIELDS.Object || triggerFieldType === FIELDS.CHOICE) {
// formValue = formValue?.id;
// }
// Array of fields that were affected by the change
const updatedFields = [];
// Conditional async rendering
// Flag to indicate if any fields triggered async rendering
let hasAsync = false;
// Object where eached key is the field path and value is a promise that resolves to the choices
const asyncLoaders = {};
// Object where each key is the field path and value an array of the choices
const loadedChoices = {};
// Track if it is already updating. Things get weird if we try to update and reset the same field at the same time
const areUpdating = {};
const updateLoop = (fValue) => {
// See if the new value matches any of the trigger values
// fieldValues is a map. Each value is an array of fields that are affected by that value
if (triggerField.fieldValues.has(fValue)) {
// Get the fields that need to be updated
let affectedFields = triggerField.fieldValues.get(fValue) || [];
affectedFields.forEach((loadOut, fieldId) => {
// 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')) {
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
asyncLoaders[fieldId] = () => fetchData(fieldId, remoteUrl, layout?.get(ID_FIELD), layout?.get(LABEL_FIELD), name);
}
// 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 } } = parsedLayout.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);
}
}
}
areUpdating[fieldId] = true;
updatedFields.push({ id: fieldId, type: 'update', ...loadOut });
});
}
};
// Check for exact form value match
updateLoop(formValue);
// 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
if (triggerField.hasOnChange) {
if (formValue !== null && formValue !== undefined && formValue !== '') {
updateLoop('anyValue');
} else {
nullChangeValue = true;
}
}
// 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('anyValue') && nullChangeValue) {
// We need to reset any fields that may have been triggered by an "anyValue" trigger and allow it to be reset when the triggerfield is null
addReset(fieldId);
}
});
// 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((typeId) => (
// return Promise that stores the loadedChoices into the correct model
asyncLoaders[typeId]().then((loaded) => {
loadedChoices[typeId] = loaded;
})
));
// If we have choice loading promises, wait for them all to finish
if (optTypes.length) {
if (setLoading) {
setLoading(true);
}
Promise.all(optTypes).finally(() => {
finishWatch(updatedFields, loadedChoices);
if (setLoading) {
setLoading(false);
}
});
}
} else {
finishWatch(updatedFields);
}
});
}
return () => {
// TODO: Look into termination of any triggerfield async
subscription?.unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasWatches]);
return {
...useFormObject,
sections,
layoutLoading,
};
};
Source