generic and custom per-field validation

This commit is contained in:
Josh Hawkins 2026-01-25 18:20:30 -06:00
parent f7cc87e8ce
commit dd0c497fd3
3 changed files with 130 additions and 18 deletions

View File

@ -0,0 +1,20 @@
{
"minimum": "Must be at least {{limit}}",
"maximum": "Must be at most {{limit}}",
"exclusiveMinimum": "Must be greater than {{limit}}",
"exclusiveMaximum": "Must be less than {{limit}}",
"minLength": "Must be at least {{limit}} character(s)",
"maxLength": "Must be at most {{limit}} character(s)",
"minItems": "Must have at least {{limit}} items",
"maxItems": "Must have at most {{limit}} items",
"pattern": "Invalid format",
"required": "This field is required",
"type": "Invalid value type",
"enum": "Must be one of the allowed values",
"const": "Value does not match expected constant",
"uniqueItems": "All items must be unique",
"format": "Invalid format",
"additionalProperties": "Unknown property is not allowed",
"oneOf": "Must match exactly one of the allowed schemas",
"anyOf": "Must match at least one of the allowed schemas"
}

View File

@ -68,7 +68,11 @@ export function ConfigForm({
formContext, formContext,
i18nNamespace, i18nNamespace,
}: ConfigFormProps) { }: ConfigFormProps) {
const { t } = useTranslation([i18nNamespace || "common", "views/settings"]); const { t } = useTranslation([
i18nNamespace || "common",
"views/settings",
"validation",
]);
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false);
// Determine which fields to hide based on advanced toggle // Determine which fields to hide based on advanced toggle
@ -113,7 +117,7 @@ export function ConfigForm({
); );
// Create error transformer for user-friendly error messages // Create error transformer for user-friendly error messages
const errorTransformer = useMemo(() => createErrorTransformer(), []); const errorTransformer = useMemo(() => createErrorTransformer(t), [t]);
const handleChange = useCallback( const handleChange = useCallback(
(e: IChangeEvent) => { (e: IChangeEvent) => {

View File

@ -2,6 +2,7 @@
// Maps JSON Schema validation keywords to user-friendly messages // Maps JSON Schema validation keywords to user-friendly messages
import type { ErrorTransformer } from "@rjsf/utils"; import type { ErrorTransformer } from "@rjsf/utils";
import type { TFunction } from "i18next";
export interface ErrorMessageMap { export interface ErrorMessageMap {
[keyword: string]: string | ((params: Record<string, unknown>) => string); [keyword: string]: string | ((params: Record<string, unknown>) => string);
@ -30,7 +31,13 @@ export const defaultErrorMessages: ErrorMessageMap = {
}; };
return formatLabels[format] || `Invalid ${format} format`; return formatLabels[format] || `Invalid ${format} format`;
}, },
enum: "Must be one of the allowed values", enum: (params) => {
const allowedValues = params.allowedValues as unknown;
if (Array.isArray(allowedValues)) {
return `Must be one of: ${allowedValues.join(", ")}`;
}
return "Must be one of the allowed values";
},
const: "Value does not match expected constant", const: "Value does not match expected constant",
uniqueItems: "All items must be unique", uniqueItems: "All items must be unique",
minItems: (params) => `Must have at least ${params.limit} items`, minItems: (params) => `Must have at least ${params.limit} items`,
@ -44,25 +51,107 @@ export const defaultErrorMessages: ErrorMessageMap = {
* Creates an error transformer function for RJSF * Creates an error transformer function for RJSF
* Transforms technical JSON Schema errors into user-friendly messages * Transforms technical JSON Schema errors into user-friendly messages
*/ */
export function createErrorTransformer( export function createErrorTransformer(t: TFunction): ErrorTransformer {
customMessages: ErrorMessageMap = {}, const getDefaultMessage = (
): ErrorTransformer { errorType: string,
const messages = { ...defaultErrorMessages, ...customMessages }; params: Record<string, unknown>,
): string | undefined => {
const template = defaultErrorMessages[errorType];
return (errors) => { if (!template) {
return errors.map((error) => { return undefined;
const keyword = error.name || ""; }
const messageTemplate = messages[keyword];
if (!messageTemplate) { if (typeof template === "function") {
return template(params);
}
return template;
};
const normalizeParams = (
params: Record<string, unknown> | undefined,
): Record<string, unknown> => {
if (!params) {
return {};
}
const allowedValues = params.allowedValues as unknown;
return {
...params,
allowedValues: Array.isArray(allowedValues)
? allowedValues.join(", ")
: allowedValues,
};
};
const getFieldPathFromProperty = (
property: string | undefined,
params: Record<string, unknown>,
errorType: string,
): string => {
const basePath = (property || "").replace(/^\./, "").trim();
const missingProperty = params.missingProperty as string | undefined;
if (errorType === "required" && missingProperty) {
return basePath ? `${basePath}.${missingProperty}` : missingProperty;
}
return basePath;
};
return (errors) =>
errors.map((error) => {
const errorType = error.name || "";
if (!errorType) {
return error; return error;
} }
let message: string; const normalizedParams = normalizeParams(error.params);
if (typeof messageTemplate === "function") { const fieldPath = getFieldPathFromProperty(
message = messageTemplate(error.params || {}); error.property,
} else { normalizedParams,
message = messageTemplate; errorType,
);
let message: string | undefined;
const missingTranslation = "__missing_translation__";
// Try field-specific validation message first
if (fieldPath) {
const fieldKey = `${fieldPath}.validation.${errorType}`;
const translated = t(fieldKey, {
...normalizedParams,
ns: ["config"],
defaultValue: missingTranslation,
});
if (translated !== fieldKey && translated !== missingTranslation) {
message = translated;
}
}
// Fall back to generic validation message
if (!message) {
const genericKey = errorType;
const translated = t(genericKey, {
...normalizedParams,
ns: ["validation"],
defaultValue: missingTranslation,
});
if (translated !== genericKey && translated !== missingTranslation) {
message = translated;
}
}
// Fall back to English defaults
if (!message) {
message = getDefaultMessage(errorType, normalizedParams);
}
if (!message) {
return error;
} }
return { return {
@ -70,7 +159,6 @@ export function createErrorTransformer(
message, message,
}; };
}); });
};
} }
/** /**