mui-validate
Version:
Validation tools for Material UI components and component groups
444 lines (432 loc) • 20 kB
JavaScript
import React, { useContext, useState, useMemo, useEffect, useLayoutEffect, useImperativeHandle } from 'react';
import { jsx, jsxs } from 'react/jsx-runtime';
import { FormHelperText, Box, FormControl, Typography } from '@mui/material';
// eslint-disable-next-line import/no-unresolved
const context = React.createContext({
validations: {},
setValidations: () => { },
updateValidation: () => { },
removeValidation: () => { },
allValid: true,
initialValidation: 'silent',
validation: 'noisy',
initialState: {},
autoDisablersWereTriggered: false,
setAutoDisablersWereTriggered: () => { },
});
context.displayName = 'ValidationContext';
const useValidation = () => useContext(context);
const validateAll = (validation) => !Object.values(validation).some((field) => !field.valid);
const ValidationGroup = ({ children, initialValidation = 'silent', validation = 'noisy', initialState = {}, }) => {
const [validations, setValidations] = useState(initialState);
const [autoDisablersWereTriggered, setAutoDisablersWereTriggered] = useState(false);
const allValid = validateAll(validations);
const updateValidation = (key, val) => {
setValidations((prevValidations) => (Object.assign(Object.assign({}, prevValidations), { [key]: val })));
};
const removeValidation = (key) => {
setValidations((prevValidations) => {
const newValidations = JSON.parse(JSON.stringify(prevValidations));
delete newValidations[key];
return Object.assign({}, newValidations);
});
};
const validationContextVaule = useMemo(() => ({
validations,
setValidations,
allValid,
initialValidation,
validation,
updateValidation,
initialState,
autoDisablersWereTriggered,
setAutoDisablersWereTriggered,
removeValidation,
}), [validations, allValid, autoDisablersWereTriggered]);
return (jsx(context.Provider, { value: validationContextVaule, children: children }));
};
ValidationGroup.displayName = 'ValidationGroup';
var ERROR_MESSAGE = {
REQUIRED: 'Please fill in this field.',
UNIQUE: 'Please choose a unique value.',
REGEX: 'Input does not match the required pattern.',
CUSTOM: 'The input is invalid.',
};
const required = (value) => value !== '' && value !== null && value !== undefined;
const unique = (value, compareList) => !compareList.map((val) => val.toLowerCase()).includes(value.toLowerCase());
const regex = (value, regexp) => regexp.test(value);
const custom = (value, fn) => fn(value);
var validator = {
required: {
test: required,
errorMessage: ERROR_MESSAGE.REQUIRED,
},
unique: {
test: unique,
errorMessage: ERROR_MESSAGE.UNIQUE,
},
regex: {
test: regex,
errorMessage: ERROR_MESSAGE.REGEX,
},
custom: {
test: custom,
errorMessage: ERROR_MESSAGE.CUSTOM,
},
};
const validate = (value, rules = {}) => {
const validation = { valid: true, messages: [], display: true };
const rulesIncluded = Object.keys(rules);
if (rulesIncluded.includes('required') && !validator.required.test(value)) {
validation.messages.push({
type: 'required',
text: (Array.isArray(rules.required) && rules.required[1]) || validator.required.errorMessage,
});
}
if (rulesIncluded.includes('unique') && rules.unique && !validator.unique.test(value,
// eslint-disable-next-line
// @ts-ignore:next-line
Array.isArray(rules.unique[0]) ? rules.unique[0] : rules.unique)) {
validation.messages.push({
type: 'unique',
text: (Array.isArray(rules.unique[0]) && rules.unique[1]) || validator.unique.errorMessage,
});
}
if (rulesIncluded.includes('regex') && rules.regex && !validator.regex.test(value, Array.isArray(rules.regex) ? rules.regex[0] : rules.regex)) {
validation.messages.push({
type: 'regex',
text: (Array.isArray(rules.regex) && rules.regex[1]) || validator.regex.errorMessage,
});
}
if (rulesIncluded.includes('custom') && rules.custom) {
// check if signle or multiple custom rules
const isSingleRule = !Array.isArray(rules.custom) || typeof rules.custom[1] === 'string';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const customRulesToCheck = isSingleRule ? [rules.custom] : rules.custom;
customRulesToCheck.every((rule) => {
if (!validator.custom.test(value, Array.isArray(rule) ? rule[0] : rule)) {
validation.messages.push({
type: 'custom',
text: (Array.isArray(rule) && rule[1]) || validator.custom.errorMessage,
});
// break the circuit and stop further evaluation
return false;
}
return true;
});
}
validation.valid = validation.messages.length === 0;
return validation;
};
// eslint-disable-next-line
const detectAutocomplete = (props) => typeof props.autoComplete === 'boolean' || props.getOptionLabel !== undefined;
// eslint-disable-next-line
const detectPickerV5 = (props) => [
props.allowKeyboardControl, props.KeyboardButtonProps, props.inputFormat,
props.mask, props.disableMaskedInput, props.allowSameDateSelection,
props.OpenPickerButtonProps, props.renderDay,
props.showTodayButton, props.todayText,
].some((val) => val !== undefined);
// eslint-disable-next-line
const detectPickerV6 = (props) => [
props.slots, props.dayOfWeekFormatter, props.defaultCalendarMonth, props.desktopModeMediaQuery,
props.disableFuture, props.disableHighlightToday, props.closeOnSelect, props.disableOpenPicker,
props.disablePast, props.displayWeekNumber, props.fixedWeekNumber, props.format, props.formatDensity,
props.localeText, props.minDate, props.maxDate, props.monthsPerRow, props.onMonthChange,
props.onSelectedSectionsChange, props.onViewChange, props.onYearChange, props.openTo, props.reduceAnimations,
props.selectedSections, props.shouldDisableDate, props.shouldDisableYear, props.shouldDisableMonth,
props.showDaysOutsideCurrentMonth, props.slotProps, props.timezone, props.yearsPerRow,
].some((val) => val !== undefined);
// eslint-disable-next-line
const detectInputType = (props) => {
if (detectAutocomplete(props)) {
return 'autocomplete';
}
if (detectPickerV6(props)) {
return 'datepicker';
}
if (detectPickerV5(props)) {
return 'picker';
}
// select and textfield remain but have the same behavior in the lib
return 'textfield';
};
// eslint-disable-next-line
const getValueFromAutocomplete = (option, children) => {
var _a;
let value;
if (!option) {
value = '';
}
else {
// eslint-disable-next-line
// @ts-ignore-next-line
value = ((_a = children === null || children === void 0 ? void 0 : children.props) === null || _a === void 0 ? void 0 : _a.getOptionLabel) ? children.props.getOptionLabel(option) : option;
}
return value;
};
const Validate = ({ children, name, required, unique, regex, custom, after, before, triggers = [], classes = {}, initialValidation, validation, inputType = 'detect', id, reference, }) => {
// val reflects the actual value, which is updated on every change event
// it needs to be persisted so that cross-triggers do have a calculation base
const [val, setVal] = useState(children.props.value || '');
// state required for judging if initial validation is passed
const [initialValidationPassed, setInitialValidationPassed] = useState(false);
// visualization state used to render the visual elements
const [validationState, setValidationState] = useState({
hasError: false,
displayError: false,
message: '',
});
// eslint-disable-next-line
const { validations, updateValidation, initialValidation: initialValidationSetting, validation: validationSetting, removeValidation, } = useValidation();
const initialValidationDerrived = initialValidation || initialValidationSetting;
const validationDerrived = validation || validationSetting;
const detectedInputType = inputType === 'detect' ? detectInputType(children.props) : inputType;
const ROOT_CLASS_NAME = `mui-validate__validate-root${(classes === null || classes === void 0 ? void 0 : classes.root) ? ` ${classes.root}` : ''}`;
const MESSAGE_CLASS_NAME = `mui-validate__validate-message${(classes === null || classes === void 0 ? void 0 : classes.message) ? ` ${classes.message}` : ''}`;
// wheneever ther incoming value changes the most recent value needs to be persisted into val
useEffect(() => {
if (children.props.value !== undefined) {
let value = '';
if (detectedInputType === 'autocomplete') {
value = getValueFromAutocomplete(children.props.value, children);
// eslint-disable-next-line
}
// picker (v5) || datepicker (v6)
else if (detectedInputType === 'picker' || detectedInputType === 'datepicker') {
if (children.props.value) {
try {
value = new Date(children.props.value).toISOString();
}
catch (e) {
value = '';
}
}
// eslint-disable-next-line
}
// textfield and select
else if (['textfield', 'select'].includes(detectedInputType)) {
value = children.props.value;
}
setVal(value);
}
}, [children.props.value]);
// Validation rules which will be applied
const validationRules = {};
if (required) {
validationRules.required = required;
}
if (unique !== undefined) {
validationRules.unique = unique;
}
if (regex !== undefined) {
validationRules.regex = regex;
}
if (custom !== undefined) {
validationRules.custom = custom;
}
// Initial validations before first child component rendering
// all supported child types (so far) define an initial value in the component attribut 'value'
useLayoutEffect(() => {
if (validations[name] === undefined && Object.keys(validationRules).length > 0) {
let value;
if (detectedInputType === 'autocomplete') {
value = getValueFromAutocomplete(children.props.value, children);
}
else {
value = children.props.value || '';
}
const validationResult = validate(value, validationRules);
if (initialValidationDerrived === 'silent') {
validationResult.display = false;
}
updateValidation(name, validationResult);
}
});
const triggerCrossValidations = () => {
// map triggers into array if not already one
const triggerRefsArray = Array.isArray(triggers) ? triggers : [triggers];
// trigger validations of linked validates
// we give us a little buffer time before the trigger so that all external value changhes
// have already been processed before the -re-validation
// eslint-disable-next-line
// @ts-ignore
setTimeout(() => triggerRefsArray.forEach((tRef) => {
if (tRef.current && tRef.current.validate) {
tRef.current.validate();
}
}), 50);
};
// this is triggered on unmount and will unregister the validation from the validation group
useEffect(() => () => {
removeValidation(name);
triggerCrossValidations();
}, []);
// validate and return validation result
const doValidation = () => {
const validationResult = validate(val, validationRules);
if (validationDerrived === 'silent' || (initialValidationDerrived === 'silent' && !initialValidationPassed)) {
validationResult.display = false;
}
updateValidation(name, validationResult);
// set initialValidationPassed if nort yet done
if (!initialValidationPassed) {
setInitialValidationPassed(true);
}
return validationResult;
};
// extract value from event paload
// eslint-disable-next-line
const getValue = (args) => {
// value to be found from underlying component
let value = '';
// autocomplete sends the attached option in the second parameter
// in case no option is selected null is sent instead
if (detectedInputType === 'autocomplete') {
value = getValueFromAutocomplete(args[1], children);
// eslint-disable-next-line
}
// picker send a date object or 'Invalid Date' as the first parameter
else if (detectedInputType === 'picker') {
if (args[0]) {
try {
value = new Date(args[0]).toISOString();
}
catch (e) {
value = '';
}
}
// eslint-disable-next-line
}
// textfield and select send a regular event as first parameter
else if (['textfield', 'select'].includes(detectedInputType)) {
const { value: eventValue = '' } = args[0].target;
value = eventValue;
}
return value;
};
// eslint-disable-next-line
const onChange = (...args) => {
if (children.props.onChange) {
children.props.onChange(...args);
}
// before hook operations
if (before) {
before();
}
setVal(getValue(args));
};
// validate on every change of val
// this appears after change event has been fired
// or value is changed from outside for controlled components
useEffect(() => {
if (val === undefined) {
return;
}
const validationResult = doValidation();
// after hook operations
if (after) {
after(validationResult);
}
triggerCrossValidations();
}, [val]);
// enrich passed in reference object to make revalidation available
useImperativeHandle(reference, () => ({
validate: () => { doValidation(); },
name,
// eslint-disable-next-line
// @ts-ignore
value: children.props.value,
}));
const addedProps = {
onChange,
};
// update visualization state on validation result change
useEffect(() => {
var _a, _b;
// lookup if error exists
const hasError = ((_a = validations[name]) === null || _a === void 0 ? void 0 : _a.valid) === false;
// lookup if error exists and shall be displayed
const displayError = hasError && ((_b = validations[name]) === null || _b === void 0 ? void 0 : _b.display);
// calculate the message to be displayed
const message = displayError ? validations[name].messages[0].text : '';
setValidationState({ hasError, displayError, message });
}, [validations]);
// read the visualization state
const { hasError, displayError, message } = validationState;
// This block is specifically for TextFields
if (displayError) {
addedProps.helperText = undefined;
addedProps.error = true;
}
// in case there is a labelId set on the validation child, we can assume
// that it is inside a form control, thus we cannot wrap with an own control
// but must reuse the existing one
const { labelId } = children.props;
const wrapperProps = {
error: labelId ? undefined : displayError,
style: {
width: children.props.fullWidth === true ? '100%' : undefined,
display: labelId ? 'inline-block' : undefined,
},
'data-has-error': hasError.toString(),
'data-has-message': message !== '',
id,
className: ROOT_CLASS_NAME,
};
// Form control needs to always be present so that the alignment of the
// helper text is correct
const Wrapper = labelId ? Box : FormControl;
return (jsxs(Wrapper, Object.assign({}, wrapperProps, { children: [React.cloneElement(children, addedProps), displayError && jsx(FormHelperText, { error: true, className: MESSAGE_CLASS_NAME, children: message })] })));
};
Validate.displayName = 'Validate';
// eslint-disable-next-line import/no-unresolved
const AutoDisabler = ({ children, firstDisplayErrors = false }) => {
const { allValid, validations, setValidations, autoDisablersWereTriggered, setAutoDisablersWereTriggered, } = useValidation();
const calculatedDisabled = !allValid // precondition is that there is at least 1 error
&& ( // additionally
!firstDisplayErrors // firstDisplayErrors is not set to true
|| (firstDisplayErrors && ( // or it is set true
autoDisablersWereTriggered // and a disabler button was already hit
|| Object.values(validations).some((validation) => validation.valid === false && validation.display === true) // or any error is visible
)));
return React.cloneElement(children, {
onClick: !autoDisablersWereTriggered ? () => {
// if firstDisplayErrors set display all error messages
if (firstDisplayErrors) {
setValidations((prevValidations) => {
const newValidations = Object.assign({}, prevValidations);
Object.keys(newValidations).forEach((key) => {
newValidations[key].display = true;
});
return newValidations;
});
}
setAutoDisablersWereTriggered(true);
// if allValid then trigger the actual onClick event if present
if (allValid && children.props.onClick) {
children.props.onClick();
}
} : children.props.onClick,
disabled: calculatedDisabled || children.props.disabled,
});
};
AutoDisabler.displayName = 'AutoDisabler';
const AutoHide = ({ children, validationName }) => {
const { validations } = useValidation();
const display = validationName && validations[validationName] && (validations[validationName].valid || !validations[validationName].display);
if (display) {
return children;
}
return null;
};
AutoHide.displayName = 'AutoHide';
const defaultErrorMessageRendering = (validationName, errorMessage) => `${validationName}: ${errorMessage}`;
const ErrorList = ({ title, alwaysVisible = false, noErrorsText = 'No errors detected', titleVariant = 'subtitle1', errorVariant = 'caption', titleColor = 'inherit', errorColor = 'error', renderErrorMessage = defaultErrorMessageRendering, }) => {
const { validations } = useValidation();
const errors = Object.entries(validations).filter((dataset) => !dataset[1].valid && dataset[1].display);
return (jsxs("div", { className: "error-list", "data-error-count": errors.length, children: [(errors.length > 0 || alwaysVisible) && jsx(Typography, { variant: titleVariant, className: "error-list__title", color: titleColor, children: title }), errors.map(([name, validation]) => (validation.messages.map((message) => (jsx(Typography, { component: "p", className: "error-list__error-message", color: errorColor, variant: errorVariant, children: renderErrorMessage(name, message.text) }, name))))), alwaysVisible && errors.length === 0 && jsx(Typography, { component: "p", className: "error-list__no-errors-message", color: titleColor, variant: errorVariant, children: noErrorsText })] }));
};
ErrorList.displayName = 'ErrorList';
export { AutoDisabler, AutoHide, ErrorList, Validate, context as ValidationContext, ValidationGroup, useValidation };