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