feat: parse and human-readably display form schema validation errors from server 400 responses

This commit is contained in:
estifanos
2026-09-08 06:54:09 +00:00
parent e1150cab63
commit 0f81f1f199
6 changed files with 216 additions and 7 deletions

View File

@@ -33,6 +33,7 @@ import {
type SchemaIssue,
} from '@ema-platform/api';
import { collectConditionTargets } from '../config/schema-paths';
import { describeIssue, issuesFromValidationError } from '../config/schema-issues';
import { useRequirementActions } from '../hooks/useRequirementActions';
import { FieldEditorDrawer } from './FieldEditorDrawer';
import { SectionEditorDrawer } from './SectionEditorDrawer';
@@ -136,6 +137,19 @@ export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) {
setDeleteField(null);
}
/**
* A body the DTO refuses never reaches the lint, so its problems arrive as
* a 400 instead of an `issues` list. Shown in the same place, addressed the
* same way — an admin should not have to decode which layer objected.
*/
function showRejection(err: unknown): boolean {
const rejected = issuesFromValidationError(err);
if (!rejected) return false;
setIssues(rejected);
notify.error(t('certReq.schema.rejected', 'The schema was not saved — see the issues listed above the sections'));
return true;
}
async function checkForErrors() {
try {
const result = await validateSchema({
@@ -145,7 +159,7 @@ export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) {
setIssues(result.issues);
if (result.valid) notify.success(t('certReq.schema.noIssues', 'No issues found'));
} catch (err) {
notify.error(extractErrorMessage(err));
if (!showRejection(err)) notify.error(extractErrorMessage(err));
}
}
@@ -153,6 +167,7 @@ export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) {
const ok = await run(
() => saveSchema({ id: licenseType.id, formSchema: { sections } }).unwrap(),
t('certReq.schema.saved', 'Form schema saved'),
{ onError: showRejection },
);
if (ok) {
setDirty(false);
@@ -189,11 +204,15 @@ export function FormSchemaTab({ licenseType }: { licenseType: LicenseType }) {
{issues !== null && issues.length > 0 && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t('certReq.schema.issuesFound', 'Issues found')}>
<Stack gap={4}>
{issues.map((issue, i) => (
<Text key={i} fz="xs">
<Text span fw={600}>{issue.path}</Text>: {issue.message}
</Text>
))}
{issues.map((issue, i) => {
const shown = describeIssue(issue, sections, localized, t);
return (
<Text key={i} fz="xs" title={issue.path || undefined}>
{shown.path && <Text span fw={600}>{shown.path}: </Text>}
{shown.message}
</Text>
);
})}
</Stack>
</Alert>
)}

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import type { Bilingual, FormSectionConfig } from '@ema-platform/api';
import { describeIssue, issuesFromValidationError } from './schema-issues';
const en = (v?: Bilingual) => v?.en ?? '';
const t = (_key: string, fallback: string, opts: Record<string, string> = {}) =>
fallback.replace(/\{\{(\w+)\}\}/g, (_, k) => opts[k] ?? '');
const sections: FormSectionConfig[] = [
{ key: 'a', title: { en: 'A' }, fields: [] },
{ key: 'b', title: { en: 'B' }, fields: [] },
{
key: 'applicant',
title: { en: 'Applicant details' },
fields: [
...Array.from({ length: 6 }, (_, i) => ({ key: `f${i}`, label: { en: `F${i}` }, type: 'TEXT' as const })),
{ key: 'nationality', label: { en: 'Nationality' }, type: 'NATIONALITY' as const },
],
},
];
const ENUM_LINE =
'formSchema.sections.2.fields.6.type must be one of the following values: TEXT, TEXTAREA, NUMBER, MONEY, DATE, SELECT, BOOLEAN, EMAIL, PHONE, TIN';
describe('issuesFromValidationError', () => {
it('turns a class-validator 400 into lint-style issues', () => {
const issues = issuesFromValidationError({ status: 400, data: { statusCode: 400, message: [ENUM_LINE] } });
expect(issues).toEqual([
{
path: 'formSchema.sections.2.fields.6.type',
message: 'must be one of the following values: TEXT, TEXTAREA, NUMBER, MONEY, DATE, SELECT, BOOLEAN, EMAIL, PHONE, TIN',
},
]);
});
it('keeps a line it cannot address, without a path', () => {
const issues = issuesFromValidationError({ status: 400, data: { message: ['formSchema should not be empty'] } });
expect(issues).toEqual([{ path: '', message: 'formSchema should not be empty' }]);
});
it('leaves every other error to the caller', () => {
expect(issuesFromValidationError({ status: 403, data: { message: ['Forbidden'] } })).toBeNull();
expect(issuesFromValidationError({ status: 400, data: { message: 'plain string' } })).toBeNull();
expect(issuesFromValidationError(new Error('network'))).toBeNull();
});
});
describe('describeIssue', () => {
it('names the section and field, and words the enum constraint as a sentence', () => {
const [issue] = issuesFromValidationError({ status: 400, data: { message: [ENUM_LINE] } }) ?? [];
expect(describeIssue(issue, sections, en, t)).toEqual({
path: 'Section "Applicant details" field "Nationality" type',
message:
'"NATIONALITY" is not accepted here — allowed values: TEXT, TEXTAREA, NUMBER, MONEY, DATE, SELECT, BOOLEAN, EMAIL, PHONE, TIN',
});
});
it('reads the lint path form too', () => {
const shown = describeIssue(
{ path: 'sections[2].fields[6].options', message: 'options are only rendered for SELECT, not NATIONALITY' },
sections,
en,
t,
);
expect(shown.path).toBe('Section "Applicant details" field "Nationality" options');
expect(shown.message).toBe('options are only rendered for SELECT, not NATIONALITY');
});
it('falls back to a 1-based position when the index is out of range', () => {
const shown = describeIssue({ path: 'sections[9].fields[3].key', message: 'x' }, sections, en, t);
expect(shown.path).toBe('Section #10 field #4 key');
});
it('passes an unrecognised path through untouched', () => {
const issue = { path: 'staffRoles.0', message: 'x' };
expect(describeIssue(issue, sections, en, t)).toBe(issue);
});
});

View File

@@ -0,0 +1,88 @@
import type { Bilingual, FormSectionConfig, SchemaIssue } from '@ema-platform/api';
/**
* Both ways a problem in a form schema is addressed by the server, in one
* capture group layout: `sections[2].fields[6].type` from the lint, and
* `formSchema.sections.2.fields.6.type` from class-validator when the DTO
* itself rejects the body.
*/
const ISSUE_PATH = /^(?:formSchema\.)?sections(?:\[(\d+)\]|\.(\d+))(?:\.fields(?:\[(\d+)\]|\.(\d+)))?(?:\.(.+))?$/;
/** class-validator's `@IsEnum` wording, so it can be turned into a sentence. */
const ENUM_CONSTRAINT = /^must be one of the following values: (.+)$/;
function describeName(name: (v?: Bilingual) => string, value: Bilingual | undefined, key: string): string {
return name(value) || key;
}
/**
* The same issue, addressed the way the author sees the schema: by section
* title and field label instead of array indices — "sections[2].fields[6]"
* tells an admin nothing about which drawer to open.
*
* Unrecognised paths pass through unchanged rather than being hidden.
*/
export function describeIssue(
issue: SchemaIssue,
sections: FormSectionConfig[],
name: (v?: Bilingual) => string,
t: (key: string, fallback: string, opts?: Record<string, string>) => string,
): SchemaIssue {
const match = ISSUE_PATH.exec(issue.path);
if (!match) return issue;
const [, s1, s2, f1, f2, property] = match;
const section = sections[Number(s1 ?? s2)];
const fieldIndex = f1 ?? f2;
const field = fieldIndex === undefined ? undefined : section?.fields?.[Number(fieldIndex)];
const parts: string[] = [];
if (section) {
parts.push(t('certReq.issue.section', 'Section "{{name}}"', { name: describeName(name, section.title, section.key) }));
} else {
parts.push(t('certReq.issue.sectionAt', 'Section #{{index}}', { index: String(Number(s1 ?? s2) + 1) }));
}
if (fieldIndex !== undefined) {
parts.push(
field
? t('certReq.issue.field', 'field "{{name}}"', { name: describeName(name, field.label, field.key) })
: t('certReq.issue.fieldAt', 'field #{{index}}', { index: String(Number(fieldIndex) + 1) }),
);
}
if (property) parts.push(property);
let message = issue.message;
const enumMatch = ENUM_CONSTRAINT.exec(message);
if (enumMatch) {
const holder = (field ?? section) as unknown as Record<string, unknown> | undefined;
const current = property && holder ? holder[property] : undefined;
message = t('certReq.issue.notAccepted', '"{{value}}" is not accepted here — allowed values: {{allowed}}', {
value: typeof current === 'string' ? current : '',
allowed: enumMatch[1],
});
}
return { path: parts.join(' '), message };
}
/**
* Lint-style issues out of a `400` the form-schema DTO threw, so the save and
* "Check for errors" can show them in the same list as the server's own
* lint. `null` when the error is anything else (a 403, a network failure…),
* leaving the caller's ordinary error toast in charge.
*/
export function issuesFromValidationError(err: unknown): SchemaIssue[] | null {
const data = (err as { status?: unknown; data?: { statusCode?: unknown; message?: unknown } })?.data;
const status = (err as { status?: unknown })?.status ?? data?.statusCode;
if (status !== 400 || !Array.isArray(data?.message)) return null;
return (data.message as unknown[]).filter((m): m is string => typeof m === 'string').map((line) => {
// class-validator formats each line as "<path> <constraint>".
const split = line.indexOf(' ');
if (split === -1) return { path: '', message: line };
const path = line.slice(0, split);
return ISSUE_PATH.test(path)
? { path, message: line.slice(split + 1) }
: { path: '', message: line };
});
}

View File

@@ -11,12 +11,20 @@ export function useRequirementActions() {
const { t } = useTranslation();
return useCallback(
async (action: () => Promise<unknown>, success: string) => {
async (
action: () => Promise<unknown>,
success: string,
opts: {
/** Return true to take over reporting — the generic toast is skipped. */
onError?: (err: unknown) => boolean;
} = {},
) => {
try {
await action();
notifications.show({ color: 'teal', title: success, message: '' });
return true;
} catch (err) {
if (opts.onError?.(err)) return false;
notifications.show({
color: 'red',
title: t('certReq.actionFailed', 'Action failed'),

View File

@@ -1913,9 +1913,17 @@ export const am: Translations = {
issuesFound: "ችግሮች ተገኝተዋል",
save: "ቅንብር አስቀምጥ",
saved: "የቅጽ ቅንብር ተቀምጧል",
rejected: "ቅንብሩ አልተቀመጠም — ከክፍሎቹ በላይ የተዘረዘሩትን ችግሮች ይመልከቱ",
empty: "እስካሁን ክፍል የለም",
emptyBody: "ለዚህ የፈቃድ ዓይነት ቅጽ ለመገንባት ክፍል ይጨምሩ።",
},
issue: {
section: 'ክፍል "{{name}}"',
sectionAt: "ክፍል #{{index}}",
field: 'መስክ "{{name}}"',
fieldAt: "መስክ #{{index}}",
notAccepted: '"{{value}}" እዚህ ተቀባይነት የለውም — የሚፈቀዱ ዋጋዎች፦ {{allowed}}',
},
section: {
add: "ክፍል ጨምር",
edit: "ክፍል አርትዕ",

View File

@@ -1928,9 +1928,17 @@ export const en = {
issuesFound: 'Issues found',
save: 'Save schema',
saved: 'Form schema saved',
rejected: 'The schema was not saved — see the issues listed above the sections',
empty: 'No sections yet',
emptyBody: "Add a section to start building this licence type's form.",
},
issue: {
section: 'Section "{{name}}"',
sectionAt: 'Section #{{index}}',
field: 'field "{{name}}"',
fieldAt: 'field #{{index}}',
notAccepted: '"{{value}}" is not accepted here — allowed values: {{allowed}}',
},
section: {
add: 'Add section',
edit: 'Edit section',