diff --git a/apps/backoffice/src/app/features/certificate-requirements/components/FormSchemaTab.tsx b/apps/backoffice/src/app/features/certificate-requirements/components/FormSchemaTab.tsx
index 6f2933fd6..595816b4b 100644
--- a/apps/backoffice/src/app/features/certificate-requirements/components/FormSchemaTab.tsx
+++ b/apps/backoffice/src/app/features/certificate-requirements/components/FormSchemaTab.tsx
@@ -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 && (
} title={t('certReq.schema.issuesFound', 'Issues found')}>
- {issues.map((issue, i) => (
-
- {issue.path}: {issue.message}
-
- ))}
+ {issues.map((issue, i) => {
+ const shown = describeIssue(issue, sections, localized, t);
+ return (
+
+ {shown.path && {shown.path}: }
+ {shown.message}
+
+ );
+ })}
)}
diff --git a/apps/backoffice/src/app/features/certificate-requirements/config/schema-issues.spec.ts b/apps/backoffice/src/app/features/certificate-requirements/config/schema-issues.spec.ts
new file mode 100644
index 000000000..8df5fca9f
--- /dev/null
+++ b/apps/backoffice/src/app/features/certificate-requirements/config/schema-issues.spec.ts
@@ -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 = {}) =>
+ 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);
+ });
+});
diff --git a/apps/backoffice/src/app/features/certificate-requirements/config/schema-issues.ts b/apps/backoffice/src/app/features/certificate-requirements/config/schema-issues.ts
new file mode 100644
index 000000000..65da0b3d8
--- /dev/null
+++ b/apps/backoffice/src/app/features/certificate-requirements/config/schema-issues.ts
@@ -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,
+): 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 | 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 " ".
+ 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 };
+ });
+}
diff --git a/apps/backoffice/src/app/features/certificate-requirements/hooks/useRequirementActions.ts b/apps/backoffice/src/app/features/certificate-requirements/hooks/useRequirementActions.ts
index e1c2337cd..c82bd96fd 100644
--- a/apps/backoffice/src/app/features/certificate-requirements/hooks/useRequirementActions.ts
+++ b/apps/backoffice/src/app/features/certificate-requirements/hooks/useRequirementActions.ts
@@ -11,12 +11,20 @@ export function useRequirementActions() {
const { t } = useTranslation();
return useCallback(
- async (action: () => Promise, success: string) => {
+ async (
+ action: () => Promise,
+ 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'),
diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts
index 3842528ac..e890f8135 100644
--- a/apps/backoffice/src/app/i18n/locales/am.ts
+++ b/apps/backoffice/src/app/i18n/locales/am.ts
@@ -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: "ክፍል አርትዕ",
diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts
index 454df1fef..7721a6f8d 100644
--- a/apps/backoffice/src/app/i18n/locales/en.ts
+++ b/apps/backoffice/src/app/i18n/locales/en.ts
@@ -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',