From 814c64c6399c62ce4290e1c490459c1d0124076f Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Mon, 3 Aug 2026 14:14:02 +0200 Subject: [PATCH] feat(formplayer): Better display of validation errors on finalize screen --- .../src/renderers/FinalizeRenderer.tsx | 29 +++++---- .../src/utils/errorPageNavigation.test.ts | 59 +++++++++++++++++++ .../src/utils/errorPageNavigation.ts | 15 ++++- .../src/utils/validationNavigation.test.ts | 31 +++++++++- .../src/utils/validationNavigation.ts | 38 ++++++++++-- 5 files changed, 150 insertions(+), 22 deletions(-) diff --git a/formulus-formplayer/src/renderers/FinalizeRenderer.tsx b/formulus-formplayer/src/renderers/FinalizeRenderer.tsx index ba02193a6..320f88af4 100644 --- a/formulus-formplayer/src/renderers/FinalizeRenderer.tsx +++ b/formulus-formplayer/src/renderers/FinalizeRenderer.tsx @@ -13,7 +13,8 @@ import { formatDurationHuman } from '../components/duration/durationFormat'; import { useOdeT } from '../i18n/useOdeT'; import { translateAjvError } from '../i18n/createOdeI18n'; import { FormplayerLocaleContext } from '../i18n/FormplayerLocaleContext'; -import { titleForErrorPath } from '../utils/errorPageNavigation'; +import { titleForAjvError } from '../utils/errorPageNavigation'; +import { instancePathForAjvError } from '../utils/validationNavigation'; import { resolveFieldLabel } from '../utils/controlDisplayText'; import type { JsonSchema7 } from '@jsonforms/core'; @@ -287,8 +288,8 @@ const FinalizeRenderer = ({ data }: ControlProps) => { }, [fullSchema, data, findFieldPageMemo, getFieldLabel]); const formatErrorMessage = (error: ErrorObject) => { - const title = titleForErrorPath( - error.instancePath, + const title = titleForAjvError( + error, fullSchema as JsonSchema7 | undefined, localizedUiSchema, ); @@ -298,12 +299,18 @@ const FinalizeRenderer = ({ data }: ControlProps) => { const hasErrors = Array.isArray(errors) && errors.length > 0; - const handleErrorClick = (path: string) => { - // Dispatch a custom event that SwipeLayoutRenderer will listen for - const event = new CustomEvent('navigateToError', { - detail: { path }, - }); - window.dispatchEvent(event); + const navigateToPath = (path: string) => { + if (!path) return; + window.dispatchEvent( + new CustomEvent('navigateToError', { + detail: { path }, + }), + ); + }; + + const handleErrorClick = (error: ErrorObject) => { + const path = instancePathForAjvError(error); + if (path) navigateToPath(path); }; const handleFieldEdit = (item: SummaryItem) => { @@ -315,7 +322,7 @@ const FinalizeRenderer = ({ data }: ControlProps) => { window.dispatchEvent(navigateEvent); } else { // Fallback: try to navigate using the field path - handleErrorClick(item.path); + navigateToPath(item.path); } }; @@ -364,7 +371,7 @@ const FinalizeRenderer = ({ data }: ControlProps) => { key={index} variant="danger" size="medium" - onPress={() => handleErrorClick(error.instancePath)} + onPress={() => handleErrorClick(error)} style={{ width: '100%', whiteSpace: 'normal', diff --git a/formulus-formplayer/src/utils/errorPageNavigation.test.ts b/formulus-formplayer/src/utils/errorPageNavigation.test.ts index 2a93b1cc0..56e7b6591 100644 --- a/formulus-formplayer/src/utils/errorPageNavigation.test.ts +++ b/formulus-formplayer/src/utils/errorPageNavigation.test.ts @@ -5,6 +5,7 @@ import { instancePathMatchesControlScope, normalizeErrorInstancePath, resolveErrorPageIndex, + titleForAjvError, } from './errorPageNavigation'; const nestedGroupLayout = { @@ -101,6 +102,7 @@ describe('formatBlockingErrorSummary', () => { properties: { validar_cama: { type: 'string', title: 'A cama é válida' }, viutenda: { type: 'string', title: 'Viu/tem tenda?' }, + nome_chefe: { type: 'string', title: 'Nome do Chefe/Referência' }, }, }; @@ -112,4 +114,61 @@ describe('formatBlockingErrorSummary', () => { expect(message).toContain('A cama é válida'); expect(message).toContain('Tap Done to review'); }); + + it('resolves titles for root required errors with empty instancePath', () => { + const message = formatBlockingErrorSummary( + [ + { + instancePath: '', + keyword: 'required', + params: { missingProperty: 'nome_chefe' }, + }, + ], + schema, + ); + expect(message).toContain('Nome do Chefe/Referência'); + }); +}); + +describe('titleForAjvError', () => { + const schema = { + properties: { + nome_chefe: { type: 'string', title: 'Nome do Chefe/Referência' }, + pessoas: { + type: 'array', + items: { + type: 'object', + properties: { + sexo: { type: 'string', title: 'Sexo' }, + }, + }, + }, + }, + }; + + it('titles root required errors via missingProperty', () => { + expect( + titleForAjvError( + { + instancePath: '', + keyword: 'required', + params: { missingProperty: 'nome_chefe' }, + }, + schema, + ), + ).toBe('Nome do Chefe/Referência'); + }); + + it('titles nested required errors under a parent instancePath', () => { + expect( + titleForAjvError( + { + instancePath: '/pessoas/0', + keyword: 'required', + params: { missingProperty: 'sexo' }, + }, + schema, + ), + ).toBe('Sexo'); + }); }); diff --git a/formulus-formplayer/src/utils/errorPageNavigation.ts b/formulus-formplayer/src/utils/errorPageNavigation.ts index 0d2429738..5ebed131d 100644 --- a/formulus-formplayer/src/utils/errorPageNavigation.ts +++ b/formulus-formplayer/src/utils/errorPageNavigation.ts @@ -8,6 +8,7 @@ import { type UISchemaElement, } from '@jsonforms/core'; import type { BlockingValidationError } from './validationNavigation'; +import { instancePathForAjvError } from './validationNavigation'; import { resolveFieldLabel } from './controlDisplayText'; function escapeRegex(segment: string): string { @@ -200,6 +201,16 @@ export function titleForErrorPath( return titleAtSchemaPath(schema, propertyPath); } +/** Field title for an AJV/custom error, including root `required` failures. */ +export function titleForAjvError( + error: BlockingValidationError, + schema: JsonSchema7 | undefined, + uischema?: UISchemaElement, +): string | null { + const path = instancePathForAjvError(error); + return path ? titleForErrorPath(path, schema, uischema) : null; +} + /** Human-readable summary for skipFinalize Done alert (field titles, not count only). */ export type OdeTranslateFn = ( key: string, @@ -219,9 +230,7 @@ export function formatBlockingErrorSummary( const titles: string[] = []; for (const err of errors) { - const path = - err.instancePath ?? (typeof err.path === 'string' ? err.path : undefined); - const title = path ? titleForErrorPath(path, schema, uischema) : null; + const title = titleForAjvError(err, schema, uischema); const label = title || err.message; if (label && !titles.includes(label)) titles.push(label); if (titles.length >= maxTitles) break; diff --git a/formulus-formplayer/src/utils/validationNavigation.test.ts b/formulus-formplayer/src/utils/validationNavigation.test.ts index 1123decbb..7b1ee54c6 100644 --- a/formulus-formplayer/src/utils/validationNavigation.test.ts +++ b/formulus-formplayer/src/utils/validationNavigation.test.ts @@ -4,7 +4,10 @@ import { coerceSchemaRootIntegers, prepareRootObservationData, } from './formObservationData'; -import { firstBlockingErrorInstancePath } from './validationNavigation'; +import { + firstBlockingErrorInstancePath, + instancePathForAjvError, +} from './validationNavigation'; describe('coerceSchemaIntegerValue', () => { it('coerces numeric strings to integers', () => { @@ -56,9 +59,31 @@ describe('firstBlockingErrorInstancePath', () => { ).toBe('/quarto_num'); }); - it('falls back to custom validator path', () => { + it('falls back to custom validator path (normalized)', () => { expect( firstBlockingErrorInstancePath([{ path: '#/properties/validar_cama' }]), - ).toBe('#/properties/validar_cama'); + ).toBe('/validar_cama'); + }); + + it('resolves AJV required missingProperty at root', () => { + expect( + firstBlockingErrorInstancePath([ + { + instancePath: '', + keyword: 'required', + params: { missingProperty: 'nome_chefe' }, + }, + ]), + ).toBe('/nome_chefe'); + }); + + it('resolves AJV required missingProperty under a parent object', () => { + expect( + instancePathForAjvError({ + instancePath: '/pessoas/0', + keyword: 'required', + params: { missingProperty: 'sexo' }, + }), + ).toBe('/pessoas/0/sexo'); }); }); diff --git a/formulus-formplayer/src/utils/validationNavigation.ts b/formulus-formplayer/src/utils/validationNavigation.ts index cc482e477..6a4fe34df 100644 --- a/formulus-formplayer/src/utils/validationNavigation.ts +++ b/formulus-formplayer/src/utils/validationNavigation.ts @@ -6,18 +6,46 @@ export type BlockingValidationError = { instancePath?: string; schemaPath?: string; path?: string; + keyword?: string; + params?: { missingProperty?: string; [key: string]: unknown }; }; +/** + * AJV `required` errors use an empty `instancePath` and put the field name in + * `params.missingProperty`. Resolve a navigable/display path for any AJV-like error. + */ +export function instancePathForAjvError( + error: BlockingValidationError, +): string | null { + const missing = + error.keyword === 'required' ? error.params?.missingProperty : undefined; + if (typeof missing === 'string' && missing.length > 0) { + const parent = error.instancePath ?? ''; + return parent ? `${parent}/${missing}` : `/${missing}`; + } + + if (error.instancePath) return error.instancePath; + if (typeof error.path === 'string' && error.path.length > 0) { + // Mirror normalizeErrorInstancePath for #/properties/… custom-validator paths. + if (error.path.startsWith('#/properties/')) { + const tail = error.path + .replace(/^#\/properties\//, '') + .replace(/\/items\/properties\//g, '/') + .replace(/\/items$/, ''); + return `/${tail}`; + } + return error.path.startsWith('/') ? error.path : `/${error.path}`; + } + + return null; +} + export function firstBlockingErrorInstancePath( errors: ReadonlyArray, ): string | null { const first = errors[0]; if (!first) return null; - if (first.instancePath) return first.instancePath; - if (typeof first.path === 'string' && first.path.length > 0) { - return first.path; - } - return null; + return instancePathForAjvError(first); } /** Switch to ValidateAndShow and jump to the first blocking field when possible. */