UNPKG

mui-validate

Version:

Validation tools for Material UI components and component groups

444 lines (432 loc) 20 kB
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 };