From 578acc17a772534481f6d6997bc2e35f62158c78 Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Mon, 19 Oct 2026 12:17:36 +0300 Subject: [PATCH] Fix null display logic validation error and show validation errors on save - Normalize null display logic value/optionIds returned by the API to undefined, so existing questions no longer fail validation with "Expected array, received null". - Load display logic optionIds in the translation editor so AnyOf/All conditions are not dropped when saving translations. - Show a toast listing validation errors when saving an invalid form. --- web/src/components/FormEditor/FormEditor.tsx | 30 +++--- .../FormTranslationEditor.tsx | 24 +++-- web/src/lib/form-validation-toast.ts | 92 +++++++++++++++++++ 3 files changed, 126 insertions(+), 20 deletions(-) create mode 100644 web/src/lib/form-validation-toast.ts diff --git a/web/src/components/FormEditor/FormEditor.tsx b/web/src/components/FormEditor/FormEditor.tsx index c5322ef12..192f7f6ae 100644 --- a/web/src/components/FormEditor/FormEditor.tsx +++ b/web/src/components/FormEditor/FormEditor.tsx @@ -44,6 +44,7 @@ import { } from "@/components/ui/alert-dialog"; import { FormTemplateFull } from '@/features/form-templates/models'; import EditFormDetails from './FormDetailEditor'; +import { toastFormValidationErrors } from '@/lib/form-validation-toast'; export const ZEditFormType = z .object({ languageCode: z.string().trim().min(1, 'Language code is required.'), @@ -237,8 +238,8 @@ const FormEditor: FC = ({ hasCitizenReportingOption, formData, parentQuestionId: question.displayLogic?.parentQuestionId, condition: question.displayLogic?.condition, - value: question.displayLogic?.value, - optionIds: question.displayLogic?.optionIds, + value: question.displayLogic?.value ?? undefined, + optionIds: question.displayLogic?.optionIds ?? undefined, code: question.code, languageCode: formData.defaultLanguage, @@ -259,8 +260,8 @@ const FormEditor: FC = ({ hasCitizenReportingOption, formData, parentQuestionId: question.displayLogic?.parentQuestionId, condition: question.displayLogic?.condition, - value: question.displayLogic?.value, - optionIds: question.displayLogic?.optionIds, + value: question.displayLogic?.value ?? undefined, + optionIds: question.displayLogic?.optionIds ?? undefined, code: question.code, languageCode: formData.defaultLanguage, @@ -281,8 +282,8 @@ const FormEditor: FC = ({ hasCitizenReportingOption, formData, parentQuestionId: question.displayLogic?.parentQuestionId, condition: question.displayLogic?.condition, - value: question.displayLogic?.value, - optionIds: question.displayLogic?.optionIds, + value: question.displayLogic?.value ?? undefined, + optionIds: question.displayLogic?.optionIds ?? undefined, code: question.code, languageCode: formData.defaultLanguage, @@ -303,8 +304,8 @@ const FormEditor: FC = ({ hasCitizenReportingOption, formData, parentQuestionId: question.displayLogic?.parentQuestionId, condition: question.displayLogic?.condition, - value: question.displayLogic?.value, - optionIds: question.displayLogic?.optionIds, + value: question.displayLogic?.value ?? undefined, + optionIds: question.displayLogic?.optionIds ?? undefined, code: question.code, languageCode: formData.defaultLanguage, @@ -334,8 +335,8 @@ const FormEditor: FC = ({ hasCitizenReportingOption, formData, parentQuestionId: question.displayLogic?.parentQuestionId, condition: question.displayLogic?.condition, - value: question.displayLogic?.value, - optionIds: question.displayLogic?.optionIds, + value: question.displayLogic?.value ?? undefined, + optionIds: question.displayLogic?.optionIds ?? undefined, code: question.code, languageCode: formData.defaultLanguage, @@ -363,8 +364,8 @@ const FormEditor: FC = ({ hasCitizenReportingOption, formData, parentQuestionId: question.displayLogic?.parentQuestionId, condition: question.displayLogic?.condition, - value: question.displayLogic?.value, - optionIds: question.displayLogic?.optionIds, + value: question.displayLogic?.value ?? undefined, + optionIds: question.displayLogic?.optionIds ?? undefined, code: question.code, languageCode: formData.defaultLanguage, @@ -428,7 +429,10 @@ const FormEditor: FC = ({ hasCitizenReportingOption, formData,
save(values))}> + onSubmit={form.handleSubmit( + (values) => save(values), + (errors) => toastFormValidationErrors(errors, form.getValues()) + )}> onSaveForm(data, navigateAwayAfterSave))}> + onSubmit={form.handleSubmit( + (data) => onSaveForm(data, navigateAwayAfterSave), + (errors) => toastFormValidationErrors(errors, form.getValues()) + )}> = { + name: 'Name', + description: 'Description', + code: 'Code', + languages: 'Languages', + languageCode: 'Language', + defaultLanguage: 'Default language', + text: 'Question text', + helptext: 'Help text', + inputPlaceholder: 'Placeholder', + lowerLabel: 'Lower label', + upperLabel: 'Upper label', + options: 'Options', + parentQuestionId: 'Display logic question', + condition: 'Display logic condition', + value: 'Display logic value', + optionIds: 'Display logic options', +}; + +function collectErrorMessages(errors: unknown, path: string[], result: { path: string[]; message: string }[]) { + if (!errors || typeof errors !== 'object') { + return; + } + + const error = errors as Record; + if (typeof error['message'] === 'string' && error['message']) { + result.push({ path, message: error['message'] }); + } + + if (error['root']) { + collectErrorMessages(error['root'], path, result); + } + + Object.entries(error) + .filter(([key]) => !ERROR_METADATA_KEYS.has(key)) + .forEach(([key, value]) => collectErrorMessages(value, [...path, key], result)); +} + +function describePath(path: string[], values: EditFormType): string { + const [first, index, ...rest] = path; + + if (first === 'questions' && index !== undefined) { + const questionCode = values.questions[Number(index)]?.code; + const question = `Question ${Number(index) + 1}${questionCode ? ` (${questionCode})` : ''}`; + return [question, ...describeFields(rest)].join(' › '); + } + + return describeFields(path).join(' › '); +} + +function describeFields(path: string[]): string[] { + return path.map((segment, i) => { + // Numeric segments after options refer to an option position. + if (/^\d+$/.test(segment)) { + return path[i - 1] === 'options' ? `#${Number(segment) + 1}` : segment; + } + + return FIELD_LABELS[segment] ?? segment; + }); +} + +/** + * Shows an error toast listing the validation errors that prevented the form from being saved. + */ +export function toastFormValidationErrors(errors: FieldErrors, values: EditFormType): void { + const messages: { path: string[]; message: string }[] = []; + collectErrorMessages(errors, [], messages); + + const lines = messages.map(({ path, message }) => { + const location = describePath(path, values); + return location ? `${location}: ${message}` : message; + }); + const uniqueLines = [...new Set(lines)]; + const listed = uniqueLines.slice(0, MAX_LISTED_ERRORS); + const remaining = uniqueLines.length - listed.length; + + toast.error('Form could not be saved. Please fix the following errors:', { + description: ( + [...listed, ...(remaining > 0 ? [`…and ${remaining} more`] : [])].join('\n') + ), + duration: 10000, + classNames: { description: 'whitespace-pre-line' }, + }); +}