diff --git a/web/public/locales/en/validation.json b/web/public/locales/en/validation.json new file mode 100644 index 000000000..6684107ac --- /dev/null +++ b/web/public/locales/en/validation.json @@ -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" +} diff --git a/web/src/components/config-form/ConfigForm.tsx b/web/src/components/config-form/ConfigForm.tsx index 2380d6df7..da0af36bf 100644 --- a/web/src/components/config-form/ConfigForm.tsx +++ b/web/src/components/config-form/ConfigForm.tsx @@ -68,7 +68,11 @@ export function ConfigForm({ formContext, i18nNamespace, }: ConfigFormProps) { - const { t } = useTranslation([i18nNamespace || "common", "views/settings"]); + const { t } = useTranslation([ + i18nNamespace || "common", + "views/settings", + "validation", + ]); const [showAdvanced, setShowAdvanced] = useState(false); // Determine which fields to hide based on advanced toggle @@ -113,7 +117,7 @@ export function ConfigForm({ ); // Create error transformer for user-friendly error messages - const errorTransformer = useMemo(() => createErrorTransformer(), []); + const errorTransformer = useMemo(() => createErrorTransformer(t), [t]); const handleChange = useCallback( (e: IChangeEvent) => { diff --git a/web/src/lib/config-schema/errorMessages.ts b/web/src/lib/config-schema/errorMessages.ts index af558c749..def4e6517 100644 --- a/web/src/lib/config-schema/errorMessages.ts +++ b/web/src/lib/config-schema/errorMessages.ts @@ -2,6 +2,7 @@ // Maps JSON Schema validation keywords to user-friendly messages import type { ErrorTransformer } from "@rjsf/utils"; +import type { TFunction } from "i18next"; export interface ErrorMessageMap { [keyword: string]: string | ((params: Record) => string); @@ -30,7 +31,13 @@ export const defaultErrorMessages: ErrorMessageMap = { }; 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", uniqueItems: "All items must be unique", minItems: (params) => `Must have at least ${params.limit} items`, @@ -44,25 +51,107 @@ export const defaultErrorMessages: ErrorMessageMap = { * Creates an error transformer function for RJSF * Transforms technical JSON Schema errors into user-friendly messages */ -export function createErrorTransformer( - customMessages: ErrorMessageMap = {}, -): ErrorTransformer { - const messages = { ...defaultErrorMessages, ...customMessages }; +export function createErrorTransformer(t: TFunction): ErrorTransformer { + const getDefaultMessage = ( + errorType: string, + params: Record, + ): string | undefined => { + const template = defaultErrorMessages[errorType]; - return (errors) => { - return errors.map((error) => { - const keyword = error.name || ""; - const messageTemplate = messages[keyword]; + if (!template) { + return undefined; + } - if (!messageTemplate) { + if (typeof template === "function") { + return template(params); + } + + return template; + }; + + const normalizeParams = ( + params: Record | undefined, + ): Record => { + 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, + 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; } - let message: string; - if (typeof messageTemplate === "function") { - message = messageTemplate(error.params || {}); - } else { - message = messageTemplate; + const normalizedParams = normalizeParams(error.params); + const fieldPath = getFieldPathFromProperty( + error.property, + normalizedParams, + 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 { @@ -70,7 +159,6 @@ export function createErrorTransformer( message, }; }); - }; } /**