UNPKG

@timmons-group/config-form

Version:

React Components and helpers to build a form via configuration with react-hook-form and MUI

460 lines (457 loc) 18.1 kB
import { isObject, isEmpty, dateStringNormalizer, functionOrDefault } from '@timmons-group/shared-react-components'; import { DATE_MSG, CONDITIONAL_RENDER, VALIDATIONS, FIELD_TYPES } from '../constants.js'; import { string, date, number, array, object } from 'yup'; import axios from 'axios'; import { useSnackbar } from 'notistack'; import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; const VALID_PATTERNS = Object.freeze({ PHONE: /^$|^\d{3}-\d{3}-\d{4}$/, EMAIL: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, ZIP: /^$|^\d{5}(-\d{4})?$/ }); function yupString(label, isRequired, reqMessage) { const schema = string().label(label || "This field"); return isRequired ? schema.required(reqMessage) : schema.nullable(); } function yupDate(label, isRequired, msg = DATE_MSG, reqMessage) { const schema = date().transform((curr, orig) => orig === "" ? null : curr).default(void 0).typeError(msg ?? DATE_MSG).label(label); return isRequired ? schema.required(reqMessage) : schema.nullable(); } function validDateFormat(value) { return new RegExp(/^\d{2}\/\d{2}\/\d{4}$/).test(value); } function multiToPayload(selections) { return Array.isArray(selections) ? selections.map((id) => ({ id: parseInt(id, 10) })) : []; } function yupTypeAhead(label, isRequired, reqMessage) { return yupString(label, isRequired, reqMessage); } function yupTrimString(label, isRequired, trimMsg, reqMessage, moreThings) { let schema = yupString(label, isRequired, reqMessage); if (!!moreThings?.[CONDITIONAL_RENDER.NO_TRIM]) { return schema; } schema = schema.trim(trimMsg || "Remove leading and/or trailing spaces"); if (!!moreThings?.[CONDITIONAL_RENDER.TRIM_STRICT]) { schema = schema.strict(true); } return schema; } function yupInt(label, isRequired, maxLength, msg, reqMessage, minLength, minValue, maxValue, options = {}) { let schema = number().integer().label(label).transform((curr, orig) => orig === "" ? null : curr).typeError(msg); schema = addMaxLength(schema, label, maxLength); schema = addMinLength(schema, label, minLength); schema = addMaxValue(schema, label, maxValue, true, options?.maxValueErrorText); schema = addMinValue(schema, label, minValue, true, options?.minValueErrorText); return isRequired ? schema.required(reqMessage) : schema.nullable(); } function yupFloat(label, isRequired, int = null, frac = null, maxLength, msg, maxValue, reqMessage, minLength, minValue, options = {}) { let formatMessage = isNaN(parseInt(int)) && isNaN(parseInt(frac)) ? "Invalid number format" : msg; let schema = number().label(label).transform((curr, orig) => orig === "" ? null : curr).typeError(formatMessage).test("formatted", formatMessage, (value, context) => !context?.originalValue ? true : validDoubleFormat(context.originalValue, int, frac)); schema = addMaxLength(schema, label, maxLength); schema = addMinLength(schema, label, minLength); schema = addMaxValue(schema, label, maxValue, false, options?.maxValueErrorText); schema = addMinValue(schema, label, minValue, false, options?.minValueErrorText); return isRequired ? schema.required(reqMessage) : schema.nullable(); } const addMaxValue = (schema, label, maxValue, isInt, errorMessage) => { const parsedMax = isInt ? parseInt(maxValue) : parseFloat(maxValue); const isNaNMax = isNaN(parsedMax); let message = errorMessage || `${label} cannot be greater than ${parsedMax}`; if (!isNaNMax) { schema = schema.test("maxValue", message, (value, context) => { if (!context || !context.originalValue) { return true; } const ogValue = context.originalValue.toString(); const currentValue = isInt ? parseInt(ogValue) : parseFloat(ogValue); return currentValue <= parsedMax; }); } return schema; }; const addMinValue = (schema, label, minValue, isInt, errorMessage) => { const parsedMin = isInt ? parseInt(minValue) : parseFloat(minValue); const isNaNMin = isNaN(parsedMin); const message = errorMessage || `${label} cannot be less than ${parsedMin}`; if (!isNaNMin) { schema = schema.test("minValue", message, (value, context) => { if (!context || !context.originalValue) { return true; } const ogValue = context.originalValue.toString(); const currentValue = isInt ? parseInt(ogValue) : parseFloat(ogValue); return currentValue >= parsedMin; }); } return schema; }; function yupCurrency(label, isRequired, maxLength, msg, reqMessage, minLength, maxValue, minValue) { let schema = number().label(label).transform((curr, orig) => orig === "" ? null : curr).typeError(msg).test("formatted", msg, (value, context) => ( // If the originalValue is empty or null return true required or nullable will handle the rest // otherwise pass the transformed value to the validCurrencyFormat function !context || !context.originalValue ? true : validCurrencyFormat(value) )); schema = addMaxLength(schema, label, maxLength); schema = addMinLength(schema, label, minLength); schema = addMaxValue(schema, label, maxValue); schema = addMinValue(schema, label, minValue); return isRequired ? schema.required(reqMessage) : schema.nullable(); } function addMinLength(schema, label, minLength) { const pMin = parseInt(minLength); if (!isNaN(pMin)) { return schema.test("minLength", `${label} cannot be less than ${pMin} characters`, (value, context) => !context || !context.originalValue ? true : context.originalValue.toString().length >= pMin); } return schema; } function addMaxLength(schema, label, maxLength) { const pMax = parseInt(maxLength); if (!isNaN(pMax)) { return schema.test("maxLength", `${label} cannot be more than ${pMax} characters`, (value, context) => !context || !context.originalValue ? true : context.originalValue.toString().length <= pMax); } return schema; } function yupTrimStringMax(label, isRequired, maxLength, msg, reqMessage, minLength, moreThings) { let schema = yupTrimString(label, isRequired, msg, reqMessage, moreThings); schema = addMaxLength(schema, label, maxLength); schema = addMinLength(schema, label, minLength); return schema; } function yupMultiselect(label, isRequired, reqMessage) { const message = reqMessage || "Please select at least one item"; const schema = array().label(label || "This field"); return isRequired ? schema.required(message).min(1, message) : schema; } function getSelectValue(multiple, inData) { if (multiple) { if (!Array.isArray(inData)) { return []; } return inData.map((con) => { if (isObject(con)) { return con?.id; } return con; }); } if (isObject(inData)) { return inData?.id?.toString() || ""; } return inData?.toString() || ""; } function yupObject(label, isRequired = false, reqMessage) { const schema = object().label(label); return isRequired ? schema.required(reqMessage) : schema.nullable(); } function validCurrencyFormat(value) { return new RegExp(/^-?\d+\.?\d{0,2}$/).test(value); } function validDoubleFormat(value, i = 4, f = 4) { const pInt = parseInt(i); const pFrac = parseInt(f); const int = isNaN(pInt) ? 999999999 : pInt; const frac = isNaN(pFrac) ? 999999999 : pFrac; const absValue = Math.abs(value).toString(); const re = new RegExp(`^\\d{0,${int}}(\\.\\d{0,${frac}})?$`).test(absValue); return re; } function createFieldValidation(type, label, validationMap, field) { let validation = null; const required = validationMap.get(VALIDATIONS.REQUIRED); const maxLength = validationMap.get(VALIDATIONS.MAX_LENGTH); const minLength = validationMap.get(VALIDATIONS.MIN_LENGTH); const maxValue = validationMap.get(VALIDATIONS.MAX_VALUE); const minValue = validationMap.get(VALIDATIONS.MIN_VALUE); const reqMessage = field?.render?.[CONDITIONAL_RENDER.REQ_TEXT]; const disableFutureErrorText = field?.render?.[CONDITIONAL_RENDER.DISABLE_FUTURE_ERROR_TEXT]; const maxValueErrorText = field?.render?.[CONDITIONAL_RENDER.MAX_VALUE_ERROR_TEXT]; const minValueErrorText = field?.render?.[CONDITIONAL_RENDER.MIN_VALUE_ERROR_TEXT]; const enforceTrim = validationMap.get(VALIDATIONS.TRIM_STRICT); switch (type) { case FIELD_TYPES.LONG_TEXT: case FIELD_TYPES.TEXT: { const moreThings = { [CONDITIONAL_RENDER.NO_TRIM]: validationMap.get(VALIDATIONS.NO_TRIM), [CONDITIONAL_RENDER.TRIM_STRICT]: enforceTrim }; validation = yupTrimStringMax(label, required, maxLength, null, reqMessage, minLength, moreThings); const regexProps = validationMap.get(VALIDATIONS.REGEXP_VALIDATION); if (regexProps) { const { pattern, flags, errorMessage } = regexProps; if (pattern) { const regexp = new RegExp(pattern, flags); const matchOptions = { message: errorMessage || `Please enter a value that matches the regular expression: ${regexp}`, excludeEmptyString: true }; validation = validation.matches(regexp, matchOptions); } } const isEmail = !!validationMap.get(VALIDATIONS.EMAIL); if (isEmail) { validation = validation.email("Please enter a valid email address"); } const isZip = !!validationMap.get(VALIDATIONS.ZIP); if (isZip) { validation = validation.matches(VALID_PATTERNS.ZIP, "Please enter a valid zip code in the format of xxxxx or xxxxx-xxxx"); } const isPhone = !!validationMap.get(VALIDATIONS.PHONE); if (isPhone) { validation = validation.matches(VALID_PATTERNS.PHONE, "Please enter a valid phone number in the format of xxx-xxx-xxxx"); } break; } case FIELD_TYPES.LINK: { validation = yupTrimStringMax(label, required, maxLength, null, reqMessage, minLength, { [CONDITIONAL_RENDER.TRIM_STRICT]: true }); break; } case FIELD_TYPES.INT: validation = yupInt( label, required, maxLength, "Please enter an integer", reqMessage, minLength, minValue, maxValue, { maxValueErrorText, minValueErrorText } ); break; case FIELD_TYPES.FLOAT: { const intD = validationMap.get(VALIDATIONS.INTEGER_DIGITS); const fracD = validationMap.get(VALIDATIONS.FRACTIONAL_DIGITS); validation = yupFloat( label, required, intD, fracD, maxLength, intD ? `Please enter a number, with up to ${intD} digits and an optional decimal of up to ${fracD} digits` : `Please enter a decimal of up to ${fracD} digits`, maxValue, reqMessage, minLength, minValue, { maxValueErrorText, minValueErrorText } ); break; } case FIELD_TYPES.CURRENCY: { validation = yupCurrency( label, required, maxLength, "Please enter a valid dollar amount, with an optional decimal of up to two digits for cents; e.g., 1234.56", reqMessage, minLength, maxValue, minValue ); break; } case FIELD_TYPES.DATE: { validation = yupDate(label, required, null, reqMessage); const disableFutureDates = !!validationMap.get(VALIDATIONS.DISABLE_FUTURE); if (disableFutureDates) { const today = /* @__PURE__ */ new Date(); validation = validation.max(today.toISOString(), disableFutureErrorText); } else if (!isEmpty(maxValue)) { const maxValueDate = new Date(dateStringNormalizer(maxValue)); validation = validation.max(maxValueDate.toISOString(), maxValueErrorText ?? `Date cannot be after ${maxValueDate.toDateString()}`); } if (!isEmpty(minValue)) { const minValueDate = new Date(dateStringNormalizer(minValue)); validation = validation.min(minValueDate.toISOString(), minValueErrorText ?? `Date cannot be before ${minValueDate.toDateString()}`); } break; } case FIELD_TYPES.CHOICE: case FIELD_TYPES.OBJECT: { validation = (field?.render?.multiple ? yupMultiselect : yupTypeAhead)(label, required, reqMessage); break; } case FIELD_TYPES.CLUSTER: { const subFieldValidations = {}; field.subFields?.forEach((subF) => { subFieldValidations[subF.render?.name] = subF.validations; }); validation = array().label(label).of(object().shape(subFieldValidations).strict()); if (required) { const minMessage = reqMessage ? reqMessage : `${label} must have at least one item`; validation = validation.min(1, minMessage); } break; } } return validation; } const attemptFormSubmit = async (formData, isEdit, { enqueueSnackbar, nav, onSuccess, onError, formatSubmitError, checkSuccess, unitLabel = "Item", successUrl, submitUrl, setModifying, formatSubmitMessage, suppressSuccessToast, suppressErrorToast }) => { if (!submitUrl) { console.warn("No submit url provided. Data to submit:", formData); return; } const successCheck = functionOrDefault(checkSuccess, (result) => result?.data?.streamID); const modifier = functionOrDefault(setModifying, null); const queueSnack = functionOrDefault(enqueueSnackbar, null); const successCall = functionOrDefault(onSuccess, null); const errorCall = functionOrDefault(onError, null); let errorSnackMessage = null; let successSnackMessage = null; if (queueSnack) { errorSnackMessage = functionOrDefault( formatSubmitError, (result, { isEdit: isEdit2, unitLabel: unitLabel2, serverError }) => { let serverErrorMessage = result?.response?.data?.error; if (isObject(serverErrorMessage)) { serverErrorMessage = Object.values(serverErrorMessage).join(", "); } return serverError && serverErrorMessage ? serverErrorMessage : `Error ${isEdit2 ? "updating" : "creating"} ${unitLabel2}`; } ); successSnackMessage = functionOrDefault(formatSubmitMessage, (result, { isEdit: isEdit2, unitLabel: unitLabel2 }) => `${unitLabel2} successfully ${isEdit2 ? "updated" : "created"}`); } if (modifier) { modifier(true); } try { const result = await axios.post(submitUrl, formData); if (successCheck(result)) { if (!suppressSuccessToast && queueSnack) { queueSnack(successSnackMessage(result, { isEdit, unitLabel }), { variant: "success" }); } if (successCall) { successCall(result); } else { nav(successUrl); if (modifier) { modifier(false); } nav(successUrl); } } else { if (errorCall) { errorCall(result); } else if (modifier) { modifier(false); } if (!suppressErrorToast && queueSnack) { queueSnack(errorSnackMessage(result, { isEdit, unitLabel }), { variant: "error" }); } } } catch (error) { if (!suppressErrorToast && queueSnack) { const errorMsg = errorSnackMessage(error, { isEdit, unitLabel, serverError: true }); queueSnack(errorMsg, { variant: "error" }); } if (errorCall) { errorCall(error); } else if (modifier) { modifier(false); } } }; const useFormSubmit = () => { const { enqueueSnackbar } = useSnackbar(); const nav = useNavigate(); const [modifying, setModifying] = useState(false); return { modifying, setModifying, nav, enqueueSnackbar }; }; const createRowFields = (fields, columnCount, isInline) => { const rows = []; const cols = isInline ? 12 : columnCount || 1; let col = 1; let row = 1; fields.forEach((field) => { if (field.render.solitary && !isInline) { const rowObject = { fields: [field], solitary: true, size: field.render.singleColumnSize || 12, maxColumns: cols, isInline }; rows.push(rowObject); row = rows.length; col = 1; return; } if (rows[row] === void 0) { rows[row] = { fields: [], maxColumns: cols }; } rows[row].fields.push(field); col++; if (col > cols) { col = 1; row++; } }); return rows; }; const checkConditional = (condition, fieldValue) => { const { value, operation /* , fieldId */ } = condition; switch (operation) { case "eq": return fieldValue === value; case "neq": return fieldValue !== value; case "gt": return parseFloat(fieldValue) > parseFloat(value); case "gte": return parseFloat(fieldValue) >= parseFloat(value); case "lt": return parseFloat(fieldValue) < parseFloat(value); case "lte": return parseFloat(fieldValue) <= parseFloat(value); case "contains": return fieldValue?.includes(value); case "notContains": return !fieldValue?.includes(value); case "startsWith": return fieldValue?.startsWith(value); case "endsWith": return fieldValue?.endsWith(value); case "regex": return new RegExp(value).test(fieldValue); case "notRegex": return !new RegExp(value).test(fieldValue); case "isNull": return fieldValue === null; case "isNotNull": return fieldValue !== null; } }; function defaultChoiceFormatter(item, options) { const opt = item || {}; const { mappedId, mappedLabel } = options || {}; const id = mappedId && opt[mappedId] ? opt[mappedId] : opt.id || opt.streamID; const label = mappedLabel && opt[mappedLabel] ? opt[mappedLabel] : opt.name || opt.label; return { ...item, id, label }; } function defaultChoiceMapper(res, options) { const { data } = res || {}; return data?.map((opt) => { return defaultChoiceFormatter(opt, options); }); } export { VALID_PATTERNS, attemptFormSubmit, checkConditional, createFieldValidation, createRowFields, defaultChoiceFormatter, defaultChoiceMapper, getSelectValue, multiToPayload, useFormSubmit, validCurrencyFormat, validDateFormat, validDoubleFormat, yupCurrency, yupDate, yupFloat, yupInt, yupMultiselect, yupObject, yupString, yupTrimString, yupTrimStringMax, yupTypeAhead }; //# sourceMappingURL=formHelpers.js.map