diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index 07da65418..df5ee50ba 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -115,6 +115,7 @@ import ShellInputControl, { } from './jsonforms/ShellInputControl'; import { applyClearOnHideToRenderers } from './jsonforms/applyClearOnHideToRenderers'; import type { KeyboardPrimaryEnterKeyHint } from './utils/keyboardEnterKeyHint'; +import { additionalErrorsForDisplay } from './utils/additionalErrorsForDisplay'; import ErrorBoundary from './components/ErrorBoundary'; import { draftService } from './services/DraftService'; @@ -460,9 +461,10 @@ function App() { // Deferred validation: new forms start hidden (no red errors on first paint), // then switch to ValidateAndShow on first forward navigation / finalize. Edits // and draft resumes start shown. Host can override via params.validationMode. + // Default Hide so pre-init flash cannot paint Show before FormInitData arrives. const [validationMode, setValidationMode] = useState< 'ValidateAndShow' | 'ValidateAndHide' | 'NoValidation' - >('ValidateAndShow'); + >('ValidateAndHide'); const [uiLocale, setUiLocale] = useState('en'); const uiLocaleRef = useRef(uiLocale); uiLocaleRef.current = uiLocale; @@ -1572,7 +1574,10 @@ function App() { onChange={handleDataChange} validationMode={validationMode} ajv={ajv} - additionalErrors={customValidatorErrors} + additionalErrors={additionalErrorsForDisplay( + validationMode, + customValidatorErrors, + )} /> {/* Success Snackbar */} diff --git a/formulus-formplayer/src/DynamicEnumControl.tsx b/formulus-formplayer/src/DynamicEnumControl.tsx index 29e6ae91d..67edf287c 100644 --- a/formulus-formplayer/src/DynamicEnumControl.tsx +++ b/formulus-formplayer/src/DynamicEnumControl.tsx @@ -21,6 +21,7 @@ import { } from '@mui/material'; import QuestionShell from './components/QuestionShell'; import { useClearOnHide } from './jsonforms/useClearOnHide'; +import { useOdeT } from './i18n/useOdeT'; /** * Interface for x-dynamicEnum configuration @@ -129,6 +130,7 @@ const DynamicEnumControl: React.FC = ({ }) => { const { functions } = useFormEvaluation(); const ctx = useJsonForms(); + const t = useOdeT(); useClearOnHide({ visible, path, data, handleChange }); @@ -158,9 +160,11 @@ const DynamicEnumControl: React.FC = ({ [handleChange, path], ); - // Find selected option based on current data value - must be before early returns + // Find selected option based on current data value - must be before early returns. + // Coerce so number/string mismatches from saved observations still resolve. const selectedOption = useMemo(() => { - return choices.find(opt => opt.const === data) || null; + if (data == null || data === '') return null; + return choices.find(opt => String(opt.const) === String(data)) || null; }, [choices, data]); // Get display label from schema or uischema - computed before early returns @@ -187,13 +191,20 @@ const DynamicEnumControl: React.FC = ({ // Load choices when component mounts or params change const loadChoices = useCallback(async () => { if (!dynamicConfig) { - setError('x-dynamicEnum configuration is missing'); + setError( + t( + 'dynamicEnum.configMissing', + 'x-dynamicEnum configuration is missing', + ), + ); return; } // Validate configuration if (!dynamicConfig.query) { - setError('x-dynamicEnum: query is required'); + setError( + t('dynamicEnum.queryRequired', 'x-dynamicEnum: query is required'), + ); return; } @@ -203,7 +214,14 @@ const DynamicEnumControl: React.FC = ({ if (!func) { const availableFunctions = Array.from(functions.keys()).join(', '); setError( - `Function "${functionName}" not found. Available: ${availableFunctions || 'none'}.`, + t( + 'dynamicEnum.functionNotFound', + 'Function "{{functionName}}" not found. Available: {{available}}.', + { + functionName, + available: availableFunctions || 'none', + }, + ), ); return; } @@ -251,13 +269,15 @@ const DynamicEnumControl: React.FC = ({ }; setLocalSchema(updatedSchema); } catch (err: any) { - const errorMessage = err?.message || 'Failed to load dynamic choices'; + const errorMessage = + err?.message || + t('dynamicEnum.loadFailed', 'Failed to load dynamic choices'); setError(`${errorMessage}`); console.error(`Error loading dynamic choices for ${path}:`, err); } finally { setLoading(false); } - }, [dynamicConfig, functions, path, localSchema, currentFormData]); // Use currentFormData instead + }, [dynamicConfig, functions, path, localSchema, currentFormData, t]); // Use currentFormData instead // Load choices on mount, when config changes, and when form data changes (for cascading filters) // currentFormData must be in deps so fields that use {{data.field}} templates reload @@ -311,7 +331,12 @@ const DynamicEnumControl: React.FC = ({ {description} )} - x-dynamicEnum configuration is missing + + {t( + 'dynamicEnum.configMissing', + 'x-dynamicEnum configuration is missing', + )} + ); } @@ -326,7 +351,7 @@ const DynamicEnumControl: React.FC = ({ - Loading choices... + {t('dynamicEnum.loading', 'Loading choices...')} ) : error ? ( @@ -339,12 +364,12 @@ const DynamicEnumControl: React.FC = ({ color="primary" sx={{ cursor: 'pointer', textDecoration: 'underline' }} onClick={loadChoices}> - Retry + {t('dynamicEnum.retry', 'Retry')} ) : choices.length === 0 ? ( - No options available + {t('dynamicEnum.noOptions', 'No options available')} ) : ( = ({ onChange={handleValueChange} options={choices} getOptionLabel={option => option.title || String(option.const)} - isOptionEqualToValue={(option, value) => option.const === value.const} + isOptionEqualToValue={(option, value) => + String(option.const) === String(value.const) + } disabled={!enabled} sx={{ mt: 1 }} renderInput={params => ( )} /> diff --git a/formulus-formplayer/src/locales/en.json b/formulus-formplayer/src/locales/en.json index cacb2df77..99b4e5924 100644 --- a/formulus-formplayer/src/locales/en.json +++ b/formulus-formplayer/src/locales/en.json @@ -82,7 +82,24 @@ "media.qrCode": "QR Code", "media.approximateDate": "Approximate Date", "media.takePhoto": "Take photo", + "media.openingCamera": "Opening camera...", + "media.retakePhoto": "Retake photo", + "media.deletePhoto": "Delete photo", + "media.captureHint": "Capture a clear photo.", + "media.capturedPhotoAlt": "Captured photo", + "media.invalidFilename": "Invalid photo filename from camera.", + "media.cameraError": "Camera error occurred", + "media.unknownCameraError": "Unknown camera error", + "media.captureFailed": "Failed to capture photo. Please try again.", "media.recording": "Recording...", + "dynamicEnum.selectOption": "Select an option...", + "dynamicEnum.loading": "Loading choices...", + "dynamicEnum.retry": "Retry", + "dynamicEnum.noOptions": "No options available", + "dynamicEnum.configMissing": "x-dynamicEnum configuration is missing", + "dynamicEnum.queryRequired": "x-dynamicEnum: query is required", + "dynamicEnum.functionNotFound": "Function \"{{functionName}}\" not found. Available: {{available}}.", + "dynamicEnum.loadFailed": "Failed to load dynamic choices", "cqt.errorTitle": "Custom Question Type Error", "cqt.errorBody": "The custom question type \"{{format}}\" encountered an error and could not be rendered.", "cqt.errorDetails": "Error Details (click to expand)", diff --git a/formulus-formplayer/src/locales/fr.json b/formulus-formplayer/src/locales/fr.json index 44afd1d1f..b5369225f 100644 --- a/formulus-formplayer/src/locales/fr.json +++ b/formulus-formplayer/src/locales/fr.json @@ -82,7 +82,24 @@ "media.qrCode": "Code QR", "media.approximateDate": "Date approximative", "media.takePhoto": "Prendre une photo", + "media.openingCamera": "Ouverture de l'appareil photo...", + "media.retakePhoto": "Reprendre la photo", + "media.deletePhoto": "Supprimer la photo", + "media.captureHint": "Prenez une photo nette.", + "media.capturedPhotoAlt": "Photo capturée", + "media.invalidFilename": "Nom de fichier photo invalide provenant de l'appareil photo.", + "media.cameraError": "Une erreur de l'appareil photo s'est produite", + "media.unknownCameraError": "Erreur inconnue de l'appareil photo", + "media.captureFailed": "Échec de la capture de la photo. Veuillez réessayer.", "media.recording": "Enregistrement...", + "dynamicEnum.selectOption": "Sélectionnez une option...", + "dynamicEnum.loading": "Chargement des options...", + "dynamicEnum.retry": "Réessayer", + "dynamicEnum.noOptions": "Aucune option disponible", + "dynamicEnum.configMissing": "Configuration x-dynamicEnum manquante", + "dynamicEnum.queryRequired": "x-dynamicEnum : la requête est obligatoire", + "dynamicEnum.functionNotFound": "Fonction « {{functionName}} » introuvable. Disponibles : {{available}}.", + "dynamicEnum.loadFailed": "Échec du chargement des options dynamiques", "cqt.errorTitle": "Erreur du type de question personnalisé", "cqt.errorBody": "Le type de question personnalisé « {{format}} » a rencontré une erreur et n'a pas pu être affiché.", "cqt.errorDetails": "Détails de l'erreur (cliquer pour développer)", diff --git a/formulus-formplayer/src/locales/pt.json b/formulus-formplayer/src/locales/pt.json index f81439b8e..0b09f968a 100644 --- a/formulus-formplayer/src/locales/pt.json +++ b/formulus-formplayer/src/locales/pt.json @@ -82,7 +82,24 @@ "media.qrCode": "Código QR", "media.approximateDate": "Data aproximada", "media.takePhoto": "Tirar fotografia", + "media.openingCamera": "A abrir a câmara...", + "media.retakePhoto": "Repetir fotografia", + "media.deletePhoto": "Eliminar fotografia", + "media.captureHint": "Capture uma fotografia nítida.", + "media.capturedPhotoAlt": "Fotografia capturada", + "media.invalidFilename": "Nome de ficheiro de fotografia inválido da câmara.", + "media.cameraError": "Ocorreu um erro da câmara", + "media.unknownCameraError": "Erro desconhecido da câmara", + "media.captureFailed": "Falha ao capturar a fotografia. Tente novamente.", "media.recording": "A gravar...", + "dynamicEnum.selectOption": "Selecione uma opção...", + "dynamicEnum.loading": "A carregar opções...", + "dynamicEnum.retry": "Tentar novamente", + "dynamicEnum.noOptions": "Nenhuma opção disponível", + "dynamicEnum.configMissing": "Configuração x-dynamicEnum em falta", + "dynamicEnum.queryRequired": "x-dynamicEnum: a consulta é obrigatória", + "dynamicEnum.functionNotFound": "Função \"{{functionName}}\" não encontrada. Disponíveis: {{available}}.", + "dynamicEnum.loadFailed": "Falha ao carregar opções dinâmicas", "cqt.errorTitle": "Erro no tipo de pergunta personalizado", "cqt.errorBody": "O tipo de pergunta personalizado \"{{format}}\" encontrou um erro e não pôde ser apresentado.", "cqt.errorDetails": "Detalhes do erro (clique para expandir)", diff --git a/formulus-formplayer/src/renderers/FileQuestionRenderer.tsx b/formulus-formplayer/src/renderers/FileQuestionRenderer.tsx index 8dce9f985..56465b64b 100644 --- a/formulus-formplayer/src/renderers/FileQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/FileQuestionRenderer.tsx @@ -25,6 +25,7 @@ import { attachmentBasenameFromObservation, } from '../utils/attachmentBasename'; import FormulusClient from '../services/FormulusInterface'; +import { formatControlErrors } from '../utils/formatControlErrors'; const parsePx = (value: string): number => parseInt(value.replace('px', ''), 10); @@ -183,8 +184,7 @@ const FileQuestionRenderer: React.FC = ({ const obs = fileObservationRecord(data); const hasData = obs !== null; const displayName = displayFilenameForFileObservation(obs); - const validationError = - errors && errors.length > 0 ? String(errors[0]) : null; + const validationError = formatControlErrors(errors); const label = (uischema as { label?: string }).label ?? schema.title; const description = schema.description; diff --git a/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx b/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx index c999c45f8..39658a864 100644 --- a/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx @@ -28,6 +28,12 @@ import { attachmentBasenameFromFilename, attachmentBasenameFromObservation, } from '../utils/attachmentBasename'; +import { formatControlErrors } from '../utils/formatControlErrors'; +import { + resolveControlDescription, + resolveControlLabel, +} from '../utils/controlDisplayText'; +import { useOdeT } from '../i18n/useOdeT'; // Helper to parse pixel values from tokens const parsePx = (value: string): number => { @@ -68,36 +74,41 @@ interface PhotoQuestionProps extends ControlProps { // Additional props specific to photo questions can be added here } -const PhotoQuestionRenderer: React.FC = ({ - data, - handleChange, - path, - errors, - schema, - uischema, - enabled = true, - visible = true, -}) => { +const PhotoQuestionRenderer: React.FC = props => { + const { + data, + handleChange, + path, + errors, + schema, + enabled = true, + visible = true, + } = props; + + const t = useOdeT(); const [isLoading, setIsLoading] = useState(false); const [photoUrl, setPhotoUrl] = useState(null); const [error, setError] = useState(null); // Safe error setter to prevent corruption - const setSafeError = useCallback((errorMessage: string | null) => { - if (errorMessage === null || errorMessage === undefined) { - setError(null); - } else if (typeof errorMessage === 'string' && errorMessage.length > 0) { - setError(errorMessage); - } else { - console.warn( - 'Invalid error message detected:', - errorMessage, - 'Type:', - typeof errorMessage, - ); - setError('An unknown error occurred'); - } - }, []); + const setSafeError = useCallback( + (errorMessage: string | null) => { + if (errorMessage === null || errorMessage === undefined) { + setError(null); + } else if (typeof errorMessage === 'string' && errorMessage.length > 0) { + setError(errorMessage); + } else { + console.warn( + 'Invalid error message detected:', + errorMessage, + 'Type:', + typeof errorMessage, + ); + setError(t('cqt.unknownError', 'Unknown error')); + } + }, + [t], + ); const formulusClient = useRef(FormulusClient.getInstance()); // Extract field ID from the path for use with the camera interface @@ -152,7 +163,9 @@ const PhotoQuestionRenderer: React.FC = ({ cameraResult.data.filename, ); if (!storedBasename) { - setSafeError('Invalid photo filename from camera.'); + setSafeError( + t('media.invalidFilename', 'Invalid photo filename from camera.'), + ); return; } @@ -196,24 +209,29 @@ const PhotoQuestionRenderer: React.FC = ({ console.log('Camera operation cancelled by user'); setSafeError(null); } else if (cameraError.status === 'error') { - const errorMessage = cameraError.message || 'Camera error occurred'; + const errorMessage = + cameraError.message || + t('media.cameraError', 'Camera error occurred'); console.log('Setting camera error message:', errorMessage); setSafeError(errorMessage); } else { - setSafeError('Unknown camera error'); + setSafeError(t('media.unknownCameraError', 'Unknown camera error')); } } else { const errorMessage = err?.message || err?.toString() || - 'Failed to capture photo. Please try again.'; + t( + 'media.captureFailed', + 'Failed to capture photo. Please try again.', + ); console.log('Setting error message:', errorMessage); setSafeError(errorMessage); } } finally { setIsLoading(false); } - }, [fieldId, enabled, handleChange, path, setSafeError]); + }, [fieldId, enabled, handleChange, path, setSafeError, t]); // Handle photo deletion const handleDeletePhoto = useCallback(() => { @@ -225,17 +243,15 @@ const PhotoQuestionRenderer: React.FC = ({ console.log('Photo deleted for field:', fieldId); }, [fieldId, handleChange, path, enabled, setSafeError]); - // Get display label from schema or uischema - const label = (uischema as any)?.label || schema.title || 'Photo'; - const description = schema.description; + const label = resolveControlLabel(props) || t('media.photo', 'Photo'); + const description = resolveControlDescription(props) ?? schema.description; const isRequired = Boolean( - (uischema as any)?.options?.required ?? + (props.uischema as any)?.options?.required ?? (schema as any)?.options?.required ?? false, ); - const validationError = - errors && errors.length > 0 ? String(errors[0]) : null; + const validationError = formatControlErrors(errors); const displayBasename = attachmentBasenameFromObservation( currentPhotoData as Record | null, @@ -252,7 +268,9 @@ const PhotoQuestionRenderer: React.FC = ({ required={isRequired} error={error || validationError} helperText={ - displayBasename ? `File: ${displayBasename}` : 'Capture a clear photo.' + displayBasename + ? undefined + : t('media.captureHint', 'Capture a clear photo.') } metadata={ process.env.NODE_ENV === 'development' ? ( @@ -301,7 +319,7 @@ const PhotoQuestionRenderer: React.FC = ({ component="img" height="200" image={photoUrl} - alt="Captured photo" + alt={t('media.capturedPhotoAlt', 'Captured photo')} sx={{ objectFit: 'cover' }} /> @@ -323,7 +341,7 @@ const PhotoQuestionRenderer: React.FC = ({ disabled={!enabled || isLoading} color="primary" size="small" - aria-label="Retake photo"> + aria-label={t('media.retakePhoto', 'Retake photo')}> = ({ disabled={!enabled} color="error" size="small" - aria-label="Delete photo"> + aria-label={t('media.deletePhoto', 'Delete photo')}> @@ -366,14 +384,16 @@ const PhotoQuestionRenderer: React.FC = ({ color: 'action.disabled', }, }} - aria-label="Take photo"> + aria-label={t('media.takePhoto', 'Take photo')}> - {isLoading ? 'Opening camera...' : 'Tap to capture photo'} + {isLoading + ? t('media.openingCamera', 'Opening camera...') + : t('media.photoTap', 'Tap to capture photo')} )} diff --git a/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx b/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx index bb966f1c8..825e7f63d 100644 --- a/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx +++ b/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx @@ -103,6 +103,7 @@ const SwipeLayoutRenderer = ({ const t = useOdeT(); const parentFormContext = useFormContext(); const { formInitData } = parentFormContext; + const showValidationErrors = core?.validationMode === 'ValidateAndShow'; const fallbackAjv = useMemo(() => createAjv(), []); const ajv = core?.ajv ?? fallbackAjv; @@ -683,15 +684,18 @@ const SwipeLayoutRenderer = ({ )} - {skipFinalize && isLastContentPage && validationErrorCount > 0 && ( - - {validationAlertMessage} - - )} + {skipFinalize && + isLastContentPage && + showValidationErrors && + validationErrorCount > 0 && ( + + {validationAlertMessage} + + )} {snackbarOpen && typeof document !== 'undefined' && diff --git a/formulus-formplayer/src/theme/material-wrappers.tsx b/formulus-formplayer/src/theme/material-wrappers.tsx index fc8f256aa..5935c35ce 100644 --- a/formulus-formplayer/src/theme/material-wrappers.tsx +++ b/formulus-formplayer/src/theme/material-wrappers.tsx @@ -221,6 +221,8 @@ const SelectOneOfEnumControl = (props: ControlProps & OwnPropsOfEnum) => { typeof (uischema as any)?.options?.placeholder === 'string' ? (uischema as any).options.placeholder : '—'; + // Coerce so option values (always strings) match saved data (number/boolean). + const selectValue = data == null || data === '' ? '' : String(data); return ( { disabled={!enabled}>