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',