Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 17 additions & 13 deletions web/src/components/FormEditor/FormEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.'),
Expand Down Expand Up @@ -237,8 +238,8 @@ const FormEditor: FC<FormEditorProps> = ({ 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,
Expand All @@ -259,8 +260,8 @@ const FormEditor: FC<FormEditorProps> = ({ 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,
Expand All @@ -281,8 +282,8 @@ const FormEditor: FC<FormEditorProps> = ({ 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,
Expand All @@ -303,8 +304,8 @@ const FormEditor: FC<FormEditorProps> = ({ 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,
Expand Down Expand Up @@ -334,8 +335,8 @@ const FormEditor: FC<FormEditorProps> = ({ 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,
Expand Down Expand Up @@ -363,8 +364,8 @@ const FormEditor: FC<FormEditorProps> = ({ 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,
Expand Down Expand Up @@ -428,7 +429,10 @@ const FormEditor: FC<FormEditorProps> = ({ hasCitizenReportingOption, formData,
<Form {...form}>
<form
className='flex flex-col flex-1'
onSubmit={form.handleSubmit((values) => save(values))}>
onSubmit={form.handleSubmit(
(values) => save(values),
(errors) => toastFormValidationErrors(errors, form.getValues())
)}>
<Tabs className='flex flex-col flex-1' defaultValue='form-details'>
<TabsList className='grid grid-cols-2 bg-gray-200 w-[400px] mb-4'>
<TabsTrigger
Expand Down
24 changes: 17 additions & 7 deletions web/src/components/FormTranslationEditor/FormTranslationEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { LanguageBadge } from '@/components/ui/language-badge';
import { FormTemplateFull } from '@/features/form-templates/models';
import { FormFull } from '@/features/forms/models';
import { cn, ensureTranslatedStringCorrectness } from '@/lib/utils';
import { toastFormValidationErrors } from '@/lib/form-validation-toast';
import { useBlocker } from '@tanstack/react-router';
import { useEffect, useMemo, useState } from 'react';
import { EditFormType, ZEditFormType } from '../FormEditor/FormEditor';
Expand Down Expand Up @@ -73,7 +74,8 @@ export default function FormTranslationEditor({

parentQuestionId: question.displayLogic?.parentQuestionId,
condition: question.displayLogic?.condition,
value: question.displayLogic?.value,
value: question.displayLogic?.value ?? undefined,
optionIds: question.displayLogic?.optionIds ?? undefined,

code: question.code,
defaultLanguage: formData.defaultLanguage,
Expand All @@ -95,7 +97,8 @@ export default function FormTranslationEditor({

parentQuestionId: question.displayLogic?.parentQuestionId,
condition: question.displayLogic?.condition,
value: question.displayLogic?.value,
value: question.displayLogic?.value ?? undefined,
optionIds: question.displayLogic?.optionIds ?? undefined,

code: question.code,
defaultLanguage: formData.defaultLanguage,
Expand All @@ -116,7 +119,8 @@ export default function FormTranslationEditor({

parentQuestionId: question.displayLogic?.parentQuestionId,
condition: question.displayLogic?.condition,
value: question.displayLogic?.value,
value: question.displayLogic?.value ?? undefined,
optionIds: question.displayLogic?.optionIds ?? undefined,

code: question.code,
defaultLanguage: formData.defaultLanguage,
Expand All @@ -137,7 +141,8 @@ export default function FormTranslationEditor({

parentQuestionId: question.displayLogic?.parentQuestionId,
condition: question.displayLogic?.condition,
value: question.displayLogic?.value,
value: question.displayLogic?.value ?? undefined,
optionIds: question.displayLogic?.optionIds ?? undefined,

code: question.code,
lowerLabel: ensureTranslatedStringCorrectness(question.lowerLabel, formData.languages),
Expand Down Expand Up @@ -167,7 +172,8 @@ export default function FormTranslationEditor({

parentQuestionId: question.displayLogic?.parentQuestionId,
condition: question.displayLogic?.condition,
value: question.displayLogic?.value,
value: question.displayLogic?.value ?? undefined,
optionIds: question.displayLogic?.optionIds ?? undefined,

code: question.code,
defaultLanguage: formData.defaultLanguage,
Expand Down Expand Up @@ -195,7 +201,8 @@ export default function FormTranslationEditor({

parentQuestionId: question.displayLogic?.parentQuestionId,
condition: question.displayLogic?.condition,
value: question.displayLogic?.value,
value: question.displayLogic?.value ?? undefined,
optionIds: question.displayLogic?.optionIds ?? undefined,

code: question.code,
defaultLanguage: formData.defaultLanguage,
Expand Down Expand Up @@ -246,7 +253,10 @@ export default function FormTranslationEditor({
<Form {...form}>
<form
className='flex flex-col flex-1'
onSubmit={form.handleSubmit((data) => onSaveForm(data, navigateAwayAfterSave))}>
onSubmit={form.handleSubmit(
(data) => onSaveForm(data, navigateAwayAfterSave),
(errors) => toastFormValidationErrors(errors, form.getValues())
)}>
<Tabs className='flex flex-col flex-1' defaultValue='form-details'>
<TabsList className='grid grid-cols-2 bg-gray-200 w-[400px] mb-4'>
<TabsTrigger
Expand Down
92 changes: 92 additions & 0 deletions web/src/lib/form-validation-toast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { FieldErrors } from 'react-hook-form';
import { toast } from 'sonner';
import type { EditFormType } from '@/components/FormEditor/FormEditor';

const MAX_LISTED_ERRORS = 5;

const ERROR_METADATA_KEYS = new Set(['message', 'type', 'types', 'ref', 'root']);

const FIELD_LABELS: Record<string, string> = {
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<string, unknown>;
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<EditFormType>, 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' },
});
}
Loading