/** @module formHelpers */
import '../models/form';
import { DATE_MSG, FIELD_TYPES as FIELDS, VALIDATIONS } from '../constants.js';
import { functionOrDefault, isObject } from './helpers.js';
import {
string, array, date, number, object
} from 'yup';
import axios from 'axios';
import { useSnackbar } from 'notistack';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
/**
* @constant {Object} VALID_PATTERNS - regex patterns for validating fields
* @property {RegExp} PHONE - regex pattern for validating phone numbers
* @property {RegExp} EMAIL - regex pattern for validating email addresses
* @property {RegExp} ZIP - regex pattern for validating zip codes
*/
export 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})?$/,
});
/**
* Create a yup schema for a string field
* @param {string} label
* @param {boolean} [isRequired]
* @param {string} [reqMessage]
* @returns {YupSchema} - yup schema for the field
*/
export function yupString(label, isRequired = true, reqMessage) {
const schema = string().label(label || 'This field');
return isRequired ? schema.required(reqMessage) : schema;
}
/**
* Create a yup schema for a date field
* @param {string} label - label for the field
* @param {boolean} [isRequired] - is the field required
* @param {string} [msg] - message to display if the field is not a valid date
* @param {string} [reqMessage] - message to display if the field is required
* @returns {YupSchema} - yup schema for a date field
*/
export function yupDate(label, isRequired = false, msg = DATE_MSG, reqMessage) {
// If you can't figure out why date validation is not working remove the "typeError(msg)" this will spit out more detail
const schema = date()
.transform((curr, orig) => (orig === '' ? null : curr))
.default(undefined)
.typeError(msg)
// value is the user's input as a date object
// .test('formatted', msg, (value, context) => (
// !context || !context.originalValue ? true : validDateFormat(context.originalValue)
// ))
.nullable().label(label);
return isRequired ? schema.required(reqMessage) : schema;
}
/**
* Test the value to see if it is a valid date
* @param {string} value
* @returns {boolean} - true if the value is a valid date
*/
export function validDateFormat(value) {
return new RegExp(/^\d{2}\/\d{2}\/\d{4}$/).test(value);
}
/**
* Covert multiselect form input into an api ready array
* @param {Array<object>} [selections] - array of selected items
* @returns {Array<{id: number}>} - array of selected items with id
*/
export function multiToPayload(selections) {
return Array.isArray(selections) ? selections.map((id) => ({ id: parseInt(id, 10) })) : [];
}
/**
* Create a yup schema for a typeahead field
* @param {string} label - label for the field
* @param {boolean} [isRequired] - is the field required
* @param {string} [reqMessage] - message to display if the field is required
* @returns {YupSchema} - yup schema for the field
*/
export function yupTypeAhead(label, isRequired = true, reqMessage) {
return yupString(label, isRequired, reqMessage).nullable();
}
/**
* Create a yup schema for a string field that ensures the value is trimmed
* @param {string} label - label for the field
* @param {boolean} [isRequired] - is the field required
* @param {string} [trimMsg] - message to display if the field is not trimmed
* @param {string} [reqMessage] - message to display if the field is required
* @returns {YupSchema} - yup schema for the field
*/
export function yupTrimString(label, isRequired = true, trimMsg, reqMessage) {
return yupString(label, isRequired, reqMessage).trim(trimMsg || 'Remove leading and/or trailing spaces').strict(true);
}
/**
* Create a yup schema for an integer field that checks max/min length
* @param {string} label - label for the field
* @param {boolean} [isRequired] - is the field required
* @param {number} [maxLength] - max length of the field
* @param {string} [msg] - message to display if the field is not an integer
* @param {string} [reqMessage] - message to display if the field is required
* @param {number} [minLength] - min length of the field
* @returns {YupSchema}
*/
export function yupInt(label, isRequired = true, maxLength, msg, reqMessage, minLength, minValue, maxValue) {
let schema = number().integer().nullable().label(label)
.transform((curr, orig) => (orig === '' ? null : curr))
.typeError(msg);
// Check for and add tests max/min Length if needed
schema = addMaxLength(schema, label, maxLength);
schema = addMinLength(schema, label, minLength);
// Make sure we pass in true for the isInt flag
schema = addMaxValue(schema, label, maxValue, true);
schema = addMinValue(schema, label, minValue, true);
return isRequired ? schema.required(reqMessage) : schema;
}
/**
* Create a yup schema for a float field that checks max/min length
* @param {string} label - label for the field
* @param {boolean} [isRequired] - is the field required
* @param {number} [int] - max number of integers
* @param {number} [frac] - max number of decimals
* @param {number} [maxLength] - max length of the field
* @param {string} [msg] - message to display if the field is not in the correct format
* @param {string} [maxValue] - max value of the field
* @param {string} [reqMessage] - message to display if the field is required
* @param {number} [minLength] - min length of the field
* @param {number} [minValue] - min value of the field
* @returns {YupSchema}
*/
export function yupFloat(label, isRequired = true, int = null, frac = null, maxLength, msg, maxValue, reqMessage, minLength, minValue) {
let formatMessage = isNaN(parseInt(int)) && isNaN(parseInt(frac)) ? 'Invalid number format' : msg;
let schema = number().nullable().label(label)
.transform((curr, orig) => (orig === '' ? null : curr))
.typeError(formatMessage)
.test('formatted', formatMessage, (value, context) => (
!context || !context.originalValue ? true : validDoubleFormat(context.originalValue, int, frac)
));
// Check for and add tests max/min Length if needed
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;
}
/**
* Add a test to a yup schema to check the max value of a field
* @param {YupSchema} schema - the schema to add the test to
* @param {string} label - label for the field
* @param {string|number}[maxValue - the max value the field can be
* @param {boolean} [isInt] - should the test be for an integer or a float
* @function
* @returns {YupSchema}
*/
const addMaxValue = (schema, label, maxValue, isInt) => {
// Check if maxValue is a number
const parsedMax = isInt ? parseInt(maxValue) : parseFloat(maxValue);
const isNaNMax = isNaN(parsedMax);
// maxValue seems to functionally be a boolean that flags that the value must be less than 1. Maybe we should rename this value? I thought it was a number we set saying the value can't be greater than it at first - Eric Schmiel 1/19/23
if (!isNaNMax) {
schema = schema.test('maxValue', `${label} cannot be greater than ${parsedMax}`, (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;
};
/**
* Add a test to a yup schema to check the min value of a field
* @param {YupSchema} schema - the schema to add the test to
* @param {string} label - label for the field
* @param {string|number} minValue - the max value the field can be
* @param {boolean} [isInt] - should the test be for an integer or a float
* @function
* @returns {YupSchema}
*/
const addMinValue = (schema, label, minValue, isInt) => {
// Check if minValue is a number
const parsedMin = isInt ? parseInt(minValue) : parseFloat(minValue);
const isNaNMin = isNaN(parsedMin);
if (!isNaNMin) {
schema = schema.test('minValue', `${label} cannot be less than ${parsedMin}`, (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;
};
/**
* Create a yup schema for a currency field that checks max/min length
* @param {string} label - label for the field
* @param {boolean} [isRequired] - is the field required
* @param {number} [maxLength] - max length of the field
* @param {string} [msg] - message to display if the field is not formatted correctly
* @param {string} [reqMessage] - message to display if the field is required
* @param {number} [minLength] - min length of the field
* @returns {YupSchema} - yup schema for a currency field
*/
export function yupCurrency(label, isRequired = true, maxLength, msg, reqMessage, minLength, maxValue, minValue) {
let schema = number().nullable().label(label)
.transform((curr, orig) => (orig === '' ? null : curr))
.typeError(msg)
.test('formatted', msg, (value, context) => (
!context || !context.originalValue ? true : validCurrencyFormat(context.originalValue)
));
// Check for and add tests max/min Length if needed
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;
}
/**
* Add a min length test to a yup schema
* @param {YupSchema} schema
* @param {string} label
* @param {number} minLength
* @returns {YupSchema} - yup schema with a min length test
*/
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;
}
/**
* Add a max length test to a yup schema
* @param {YupSchema} schema
* @param {string} label
* @param {number} maxLength
* @returns {YupSchema} - yup schema with a max length test
*/
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;
}
/**
* Trim a string and check max/min and remove any leading or trailing spaces
* @param {string} label - label for the field
* @param {boolean} isRequired - is the field required
* @param {number} maxLength - max length of the field
* @param {string} msg - message to display if the field is not formatted correctly
* @param {string} reqMessage - message to display if the field is required
* @param {number} minLength - min length of the field
* @returns {YupSchema} - yup schema for a string field
*/
export function yupTrimStringMax(label, isRequired = true, maxLength, msg, reqMessage, minLength) {
let schema = yupTrimString(label, isRequired, msg, reqMessage);
// Check for and add tests max/min Length if needed
schema = addMaxLength(schema, label, maxLength);
schema = addMinLength(schema, label, minLength);
return schema;
}
/**
* Create a yup schema for a multiselect field
* @param {string} label - label for the field
* @param {boolean} isRequired - is the field required
* @param {string} reqMessage - message to display if the field is required
* @returns {YupSchema} - yup schema for a string field
*/
export function yupMultiselect(label, isRequired = true, 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;
}
/**
* Extract the id of the selected item from the data
* @param {boolean} multiple - is the field a multiselect
* @param {object} inData - data to be extracted and formatted
* @returns {string} - string of the id of the selected item or array of ids of selected items
*/
export 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() || '';
}
/**
* Create a yup schema for a generic or custom object field
* @param {string} label - label for the field
* @param {boolean} isRequired - is the field required
* @param {string} reqMessage - message to display if the field is required
* @returns {YupSchema} - yup schema for an object field
*/
export function yupObject(label, isRequired = false, reqMessage) {
// Need nullable to avoid type error on 'object' even if required
const schema = object().label(label).nullable();
return isRequired ? schema.required(reqMessage) : schema;
}
/**
* Return whether or not the value is a valid currency format
* @param {string} value
* @returns {boolean} - true if the value is a valid currency format
*/
export function validCurrencyFormat(value) {
return new RegExp(/^-?\d+\.?\d{0,2}$/).test(value);
}
/**
* Format validation for a double value
* if i and f are not passed in, the default is 4 digits before and 4 digits after the decimal
* if i or f are passed in and are not numbers, the default is 999999999 digits before and after the decimal
* @param {number} value
* @param {number} i - number of digits before decimal
* @param {number} f - number of digits after decimal
* @returns {boolean} - true if the value is a valid double format
*/
export function validDoubleFormat(value, i = 4, f = 4) {
const pInt = parseInt(i);
const pFrac = parseInt(f);
// If one or both of the values are not numbers, set them to an arbitrary digit value
const int = isNaN(pInt) ? 999999999 : pInt;
const frac = isNaN(pFrac) ? 999999999 : pFrac;
const re = new RegExp(`^\\d{0,${int}}(\\.\\d{0,${frac}})?$`).test(value);
return re;
}
/**
* Create a yup schema for a given field type
* @param {number} type
* @param {string} label
* @param {Map} validationMap
* @param {object} field
* @returns {YupSchema} - yup schema for a field
*/
export 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?.requiredErrorText;
const disableFutureErrorText = field?.render?.disableFutureErrorText;
switch (type) {
case FIELDS.LONG_TEXT:
case FIELDS.TEXT: {
validation = yupTrimStringMax(label, required, maxLength, null, reqMessage, minLength);
const regexProps = validationMap.get(VALIDATIONS.REGEXP_VALIDATION);
if (regexProps) {
const { pattern, flags, errorMessage } = regexProps;
if (pattern) {
const regexp = new RegExp(pattern, flags);
validation = validation.matches(regexp, errorMessage || `Please enter a value that matches the regular expression: ${regexp}`);
}
}
const isEmail = !!validationMap.get(VALIDATIONS.EMAIL);
if (isEmail) {
validation = validation.email('Please enter a valid email address');
}
// TODO: Rework to allow for custom regex via a field property
// like field.render.validationRegex and field.render.validationMessage
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 FIELDS.LINK: {
validation = yupTrimStringMax(label, required, maxLength, null, reqMessage, minLength);
break;
}
case FIELDS.INT:
validation = yupInt(
label,
required,
maxLength,
'Please enter an integer',
reqMessage,
minLength,
minValue,
maxValue
);
break;
case FIELDS.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
);
break;
}
case FIELDS.CURRENCY: {
const maxValue = validationMap.get(VALIDATIONS.MAX_VALUE);
const minValue = validationMap.get(VALIDATIONS.MIN_VALUE);
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 FIELDS.DATE: {
validation = yupDate(label, required, null, reqMessage);
const disableFutureDates = !!validationMap.get(VALIDATIONS.DISABLE_FUTURE);
if (disableFutureDates) {
const today = new Date().toDateString();
validation = validation.max(today, disableFutureErrorText);
}
break;
}
// NOTE that Checkboxes are multi-selects, so we use the same validation
case FIELDS.CHOICE:
case FIELDS.OBJECT: {
validation = (field?.render?.multiple ? yupMultiselect : yupTypeAhead)(label, required, reqMessage).nullable();
break;
}
// case FIELDS.FLAG: {
// dynField.render.is = 'CheckBox';
// break;
// }
// TODO: Yes clusterfield I know you are here, but I don't know what to do with you yet
case FIELDS.CLUSTER: {
const subFieldValidations = {};
// Loop through and extract the validations for each subfield
field.subFields?.forEach((subF) => {
subFieldValidations[subF.render?.name] = subF.validations;
});
// Create the validation for the cluster field which is an array of objects
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;
}
default:
break;
}
return validation;
}
/**
* Method to reduce the amount of boilerplate code needed to submit a form
* @function
* @async
* @param {object} formData - data to submit
* @param {boolean} isEdit - true if editing, false if creating
* @param {SubmitOptions} options - options object
* @returns {Promise<void>} - promise that resolves when the submit is complete
*/
export 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, unitLabel, serverError }) => {
return serverError && result?.response?.data?.error ? result?.response?.data?.error : `Error ${isEdit ? 'updating' : 'creating'} ${unitLabel}`;
}
);
successSnackMessage = functionOrDefault(formatSubmitMessage, (result, { isEdit, unitLabel }) => `${unitLabel} successfully ${isEdit ? '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 we have an onSuccess callback, call it otherwise navigate to the successUrl
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) {
// Sending a true flag to formatSubmitError indicates that the error came from the server
const errorMsg = errorSnackMessage(error, { isEdit, unitLabel, serverError: true });
queueSnack(errorMsg, { variant: 'error' });
}
if (errorCall) {
errorCall(error);
} else if (modifier) {
modifier(false);
}
}
};
/**
* Helper to create hook bits for form submit
* @function
* @returns {FormSubmitOptions}
* @example
* const { modifying, setModifying, nav, enqueueSnackbar } = useFormSubmit();
*/
export const useFormSubmit = () => {
const { enqueueSnackbar } = useSnackbar();
const nav = useNavigate();
const [modifying, setModifying] = useState(false);
return { modifying, setModifying, nav, enqueueSnackbar };
};
/**
* Give an array of fields, create an array of rows with the fields give a column count
* @param {ParsedField[]} fields
* @param {number} columnCount
* @returns {RowFields[]}
*/
export const createRowFields = (fields, columnCount, isInline) => {
const rows = [];
const cols = isInline ? 12 : columnCount || 1;
let col = 1;
let row = 1;
//Create the rows
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] === undefined) {
rows[row] = { fields: [], maxColumns: cols };
}
rows[row].fields.push(field);
col++;
if (col > cols) {
col = 1;
row++;
}
});
return rows;
};
Source