From a2ade7be0f2ceeabc9a34cd22b036c03b01129a3 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Thu, 6 Aug 2026 13:01:43 +0200 Subject: [PATCH 1/4] fix(formplayer): Update validation error display logic in SwipeLayoutRenderer --- .../src/renderers/SwipeLayoutRenderer.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) 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' && From 611244f4757fbbc62fa9d312e074a6244ed89924 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Thu, 6 Aug 2026 13:02:30 +0200 Subject: [PATCH 2/4] fix(formplayer): Improve validation error handling in SwipeLayoutRenderer --- formulus-formplayer/src/App.tsx | 9 +- .../src/DynamicEnumControl.tsx | 12 +- formulus-formplayer/src/locales/en.json | 9 ++ formulus-formplayer/src/locales/fr.json | 9 ++ formulus-formplayer/src/locales/pt.json | 9 ++ .../src/renderers/FileQuestionRenderer.tsx | 4 +- .../src/renderers/PhotoQuestionRenderer.tsx | 104 +++++++++++------- .../src/theme/material-wrappers.tsx | 9 +- .../utils/additionalErrorsForDisplay.test.ts | 31 ++++++ .../src/utils/additionalErrorsForDisplay.ts | 14 +++ 10 files changed, 157 insertions(+), 53 deletions(-) create mode 100644 formulus-formplayer/src/utils/additionalErrorsForDisplay.test.ts create mode 100644 formulus-formplayer/src/utils/additionalErrorsForDisplay.ts 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..e87f44a49 100644 --- a/formulus-formplayer/src/DynamicEnumControl.tsx +++ b/formulus-formplayer/src/DynamicEnumControl.tsx @@ -158,9 +158,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 @@ -352,14 +354,16 @@ const DynamicEnumControl: React.FC = ({ 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..533a9b7e4 100644 --- a/formulus-formplayer/src/locales/en.json +++ b/formulus-formplayer/src/locales/en.json @@ -82,6 +82,15 @@ "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...", "cqt.errorTitle": "Custom Question Type Error", "cqt.errorBody": "The custom question type \"{{format}}\" encountered an error and could not be rendered.", diff --git a/formulus-formplayer/src/locales/fr.json b/formulus-formplayer/src/locales/fr.json index 44afd1d1f..7f553586a 100644 --- a/formulus-formplayer/src/locales/fr.json +++ b/formulus-formplayer/src/locales/fr.json @@ -82,6 +82,15 @@ "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...", "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é.", diff --git a/formulus-formplayer/src/locales/pt.json b/formulus-formplayer/src/locales/pt.json index f81439b8e..9a34c4463 100644 --- a/formulus-formplayer/src/locales/pt.json +++ b/formulus-formplayer/src/locales/pt.json @@ -82,6 +82,15 @@ "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...", "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.", 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/theme/material-wrappers.tsx b/formulus-formplayer/src/theme/material-wrappers.tsx index fc8f256aa..e72419039 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}>