mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-09 08:18:22 +00:00
feat: parse and human-readably display form schema validation errors from server 400 responses
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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: "ክፍል አርትዕ",
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user