Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions formulus-formplayer/src/renderers/FinalizeRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
);
Expand All @@ -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) => {
Expand All @@ -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);
}
};

Expand Down Expand Up @@ -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',
Expand Down
59 changes: 59 additions & 0 deletions formulus-formplayer/src/utils/errorPageNavigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
instancePathMatchesControlScope,
normalizeErrorInstancePath,
resolveErrorPageIndex,
titleForAjvError,
} from './errorPageNavigation';

const nestedGroupLayout = {
Expand Down Expand Up @@ -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' },
},
};

Expand All @@ -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');
});
});
15 changes: 12 additions & 3 deletions formulus-formplayer/src/utils/errorPageNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down
31 changes: 28 additions & 3 deletions formulus-formplayer/src/utils/validationNavigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
38 changes: 33 additions & 5 deletions formulus-formplayer/src/utils/validationNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BlockingValidationError>,
): 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. */
Expand Down
Loading