From 02479246c26abf81450332fedaafc33fdc9771c0 Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Tue, 20 Jan 2026 09:47:16 +0200 Subject: [PATCH 1/2] fix form editor --- web/src/components/FormEditor/FormEditor.tsx | 45 +++++++++++++------ .../forms/components/FormEdit/FormEdit.tsx | 32 +++++++------ 2 files changed, 46 insertions(+), 31 deletions(-) diff --git a/web/src/components/FormEditor/FormEditor.tsx b/web/src/components/FormEditor/FormEditor.tsx index 05c14c00f..1dd2220ab 100644 --- a/web/src/components/FormEditor/FormEditor.tsx +++ b/web/src/components/FormEditor/FormEditor.tsx @@ -21,7 +21,7 @@ import { Button } from '@/components/ui/button'; import { LanguageBadge } from '@/components/ui/language-badge'; import { cn, ensureTranslatedStringCorrectness, isNilOrWhitespace, isNotNilOrWhitespace } from '@/lib/utils'; import { useBlocker } from '@tanstack/react-router'; -import { FC, useEffect, useState } from 'react'; +import { FC, useEffect, useMemo, useState } from 'react'; import { FormFull } from '../../features/forms/models'; import { @@ -205,7 +205,7 @@ interface FormEditorProps { const FormEditor: FC = ({ hasCitizenReportingOption, formData, onSaveForm }) => { const confirm = useConfirm(); const [navigateAwayAfterSave, setNavigateAwayAfterSave] = useState(false); - const editQuestions = formData?.questions.map((question) => { + const editQuestions = useMemo(() => formData?.questions.map((question) => { if (isNumberQuestion(question)) { const numberQuestion: EditNumberQuestionType = { $questionType: QuestionType.NumberQuestionType, @@ -350,7 +350,7 @@ const FormEditor: FC = ({ hasCitizenReportingOption, formData, } return undefined; - }); + }), [formData]); const form = useForm({ resolver: zodResolver(ZEditFormType), @@ -366,21 +366,21 @@ const FormEditor: FC = ({ hasCitizenReportingOption, formData, icon: formData?.icon ?? '', }, mode: 'all', + reValidateMode: 'onChange', }); - useBlocker({ - shouldBlockFn: async () => { - if (!form.formState.isDirty || form.formState.isSubmitting) { - return false; - } + // Subscribe to isDirty by accessing it in component body + // This ensures React tracks form state changes + const isDirty = form.formState.isDirty; - return await confirm({ - title: `Unsaved Changes Detected`, - body: 'You have unsaved changes. If you leave this page, your changes will be lost. Are you sure you want to continue?', - actionButton: 'Leave', - cancelButton: 'Stay', - }); + const { proceed, reset, status } = useBlocker({ + shouldBlockFn: () => { + // Use the ref to get the latest isDirty value + const currentIsDirty = isDirty; + console.log('form.formState.isDirty', currentIsDirty); + return currentIsDirty; }, + withResolver: true, }); const languageCode = useWatch({ @@ -395,6 +395,23 @@ const FormEditor: FC = ({ hasCitizenReportingOption, formData, } }, [form.formState.isSubmitSuccessful, form.reset]); + useEffect(() => { + if (status === 'blocked') { + confirm({ + title: `Unsaved Changes Detected`, + body: 'You have unsaved changes. If you leave this page, your changes will be lost. Are you sure you want to continue?', + actionButton: 'Leave', + cancelButton: 'Stay', + }).then((confirmed) => { + if (confirmed) { + proceed(); + } else { + reset(); + } + }); + } + }, [status, confirm, proceed, reset]); + return (
{ return authApi.put(`/election-rounds/${electionRoundId}/forms/${form.id}`, { ...form, }); }, - onSuccess: async (_, { electionRoundId, shouldNavigateAwayAfterSubmit }) => { + onSuccess: async (_, { electionRoundId }) => { toast({ title: 'Success', description: 'Form updated successfully', @@ -51,17 +50,6 @@ function FormEdit() { await queryClient.invalidateQueries({ queryKey: formsKeys.all(electionRoundId), type: 'all' }); router.invalidate(); - - if (shouldNavigateAwayAfterSubmit) { - if ( - await confirm({ - title: 'Changes made to form template in base language', - body: 'Please note that changes have been made to the form in base language, which can impact the translation(s). All new questions or response options which you have added have been copied to translations but in the base language. Access each translation of the form and manually translate each of the changes.', - }) - ) { - await navigate({ to: '/election-event/$tab', params: { tab: 'observer-forms' } }); - } - } }, onError: () => { @@ -74,7 +62,7 @@ function FormEdit() { }); const saveForm = useCallback( - (formData: EditFormType, shouldNavigateAwayAfterSubmit: boolean) => { + async (formData: EditFormType, shouldNavigateAwayAfterSubmit: boolean) => { const updatedForm: UpdateFormRequest = { id: formId, code: formData.code, @@ -87,13 +75,23 @@ function FormEdit() { questions: formData.questions.map(mapToQuestionRequest), }; - updateFormMutation.mutate({ + await updateFormMutation.mutateAsync({ electionRoundId: currentElectionRoundId, form: updatedForm, - shouldNavigateAwayAfterSubmit, }); + + if (shouldNavigateAwayAfterSubmit) { + if ( + await confirm({ + title: 'Changes made to form template in base language', + body: 'Please note that changes have been made to the form in base language, which can impact the translation(s). All new questions or response options which you have added have been copied to translations but in the base language. Access each translation of the form and manually translate each of the changes.', + }) + ) { + await navigate({ to: '/election-event/$tab', params: { tab: 'observer-forms' } }); + } + } }, - [formId, currentElectionRoundId] + [updateFormMutation, formId, currentElectionRoundId] ); return ( From b2936a72a80b94f80c30ecdc47517411fda4273f Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Tue, 20 Jan 2026 10:10:16 +0200 Subject: [PATCH 2/2] rework blockers --- web/src/components/FormEditor/FormEditor.tsx | 7 +---- .../FormTranslationEditor.tsx | 31 ++++++++++++------- .../SelectQuestionTranslationEditor.tsx | 6 ++-- .../components/Guides/AddGuideForm.tsx | 25 ++++++++++----- .../components/Guides/EditGuideForm.tsx | 25 ++++++++++----- .../FormTranslationEdit.tsx | 27 +++++----------- web/src/features/ngos/components/EditNgo.tsx | 27 ++++++++++------ .../features/ngos/components/NGODetails.tsx | 2 +- .../components/admins/AddNgoAdminDialog.tsx | 27 ++++++++++------ .../ngos/components/admins/EditNgoAdmin.tsx | 27 ++++++++++------ .../components/EditObserver/EditObserver.tsx | 28 +++++++++++------ 11 files changed, 140 insertions(+), 92 deletions(-) diff --git a/web/src/components/FormEditor/FormEditor.tsx b/web/src/components/FormEditor/FormEditor.tsx index 1dd2220ab..cf58bf8c9 100644 --- a/web/src/components/FormEditor/FormEditor.tsx +++ b/web/src/components/FormEditor/FormEditor.tsx @@ -374,12 +374,7 @@ const FormEditor: FC = ({ hasCitizenReportingOption, formData, const isDirty = form.formState.isDirty; const { proceed, reset, status } = useBlocker({ - shouldBlockFn: () => { - // Use the ref to get the latest isDirty value - const currentIsDirty = isDirty; - console.log('form.formState.isDirty', currentIsDirty); - return currentIsDirty; - }, + shouldBlockFn: () => isDirty, withResolver: true, }); diff --git a/web/src/components/FormTranslationEditor/FormTranslationEditor.tsx b/web/src/components/FormTranslationEditor/FormTranslationEditor.tsx index a648589fb..60c4b4a11 100644 --- a/web/src/components/FormTranslationEditor/FormTranslationEditor.tsx +++ b/web/src/components/FormTranslationEditor/FormTranslationEditor.tsx @@ -30,7 +30,7 @@ import { FormTemplateFull } from '@/features/form-templates/models'; import { FormFull } from '@/features/forms/models'; import { cn, ensureTranslatedStringCorrectness } from '@/lib/utils'; import { useBlocker } from '@tanstack/react-router'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { EditFormType, ZEditFormType } from '../FormEditor/FormEditor'; import FormDetailsTranslationEditor from './FormDetailsTranslationEditor'; @@ -50,7 +50,7 @@ export default function FormTranslationEditor({ const confirm = useConfirm(); const [navigateAwayAfterSave, setNavigateAwayAfterSave] = useState(false); - const formQuestions = formData.questions.map((question) => { + const formQuestions = useMemo(() => formData.questions.map((question) => { if (isNumberQuestion(question)) { const numberQuestion: EditNumberQuestionType = { $questionType: QuestionType.NumberQuestionType, @@ -196,7 +196,7 @@ export default function FormTranslationEditor({ } return undefined; - }); + }), [formData]); const form = useForm({ resolver: zodResolver(ZEditFormType), @@ -223,20 +223,29 @@ export default function FormTranslationEditor({ } }, [form.formState.isSubmitSuccessful, form.reset]); - useBlocker({ - shouldBlockFn: async () => { - if (!form.formState.isDirty || form.formState.isSubmitting) { - return false; - } + const isDirty = form.formState.isDirty; + + const { proceed, reset, status } = useBlocker({ + shouldBlockFn: () => isDirty, + withResolver: true, + }); - return await confirm({ + useEffect(() => { + if (status === 'blocked') { + confirm({ title: `Unsaved Changes Detected`, body: 'You have unsaved changes. If you leave this page, your changes will be lost. Are you sure you want to continue?', actionButton: 'Leave', cancelButton: 'Stay', + }).then((confirmed) => { + if (confirmed) { + proceed(); + } else { + reset(); + } }); - }, - }); + } + }, [status, confirm, proceed, reset]); return ( diff --git a/web/src/components/FormTranslationEditor/SelectQuestionTranslationEditor.tsx b/web/src/components/FormTranslationEditor/SelectQuestionTranslationEditor.tsx index 422e342f9..98c6cf09d 100644 --- a/web/src/components/FormTranslationEditor/SelectQuestionTranslationEditor.tsx +++ b/web/src/components/FormTranslationEditor/SelectQuestionTranslationEditor.tsx @@ -76,9 +76,9 @@ function SelectQuestionTranslationEditor({ questionIndex }: SelectQuestionTransl />
diff --git a/web/src/features/election-event/components/Guides/AddGuideForm.tsx b/web/src/features/election-event/components/Guides/AddGuideForm.tsx index 2067a2bfb..a5d3f0fa8 100644 --- a/web/src/features/election-event/components/Guides/AddGuideForm.tsx +++ b/web/src/features/election-event/components/Guides/AddGuideForm.tsx @@ -160,20 +160,29 @@ export default function AddGuideForm({ uploadGuideMutation.mutate({ electionRoundId: currentElectionRoundId, guidePageType, guide }); } - useBlocker({ - shouldBlockFn: async () => { - if (!form.formState.isDirty) { - return false; - } + const isDirty = form.formState.isDirty; + + const { proceed, reset, status } = useBlocker({ + shouldBlockFn: () => isDirty, + withResolver: true, + }); - return await confirm({ + useEffect(() => { + if (status === 'blocked') { + confirm({ title: `Unsaved Changes Detected`, body: 'You have unsaved changes. If you leave this page, your changes will be lost. Are you sure you want to continue?', actionButton: 'Leave', cancelButton: 'Stay', + }).then((confirmed) => { + if (confirmed) { + proceed(); + } else { + reset(); + } }); - }, - }); + } + }, [status, confirm, proceed, reset]); useEffect(() => { if (form.formState.isSubmitSuccessful) { diff --git a/web/src/features/election-event/components/Guides/EditGuideForm.tsx b/web/src/features/election-event/components/Guides/EditGuideForm.tsx index 4ea102932..00c1fc33c 100644 --- a/web/src/features/election-event/components/Guides/EditGuideForm.tsx +++ b/web/src/features/election-event/components/Guides/EditGuideForm.tsx @@ -147,20 +147,29 @@ export default function EditGuideForm({ }, }); - useBlocker({ - shouldBlockFn: async () => { - if (!form.formState.isDirty) { - return false; - } + const isDirty = form.formState.isDirty; + + const { proceed, reset, status } = useBlocker({ + shouldBlockFn: () => isDirty, + withResolver: true, + }); - return await confirm({ + useEffect(() => { + if (status === 'blocked') { + confirm({ title: `Unsaved Changes Detected`, body: 'You have unsaved changes. If you leave this page, your changes will be lost. Are you sure you want to continue?', actionButton: 'Leave', cancelButton: 'Stay', + }).then((confirmed) => { + if (confirmed) { + proceed(); + } else { + reset(); + } }); - }, - }); + } + }, [status, confirm, proceed, reset]); useEffect(() => { if (form.formState.isSubmitSuccessful) { diff --git a/web/src/features/forms/components/FormTranslationEdit/FormTranslationEdit.tsx b/web/src/features/forms/components/FormTranslationEdit/FormTranslationEdit.tsx index 414a4be8d..67cf65880 100644 --- a/web/src/features/forms/components/FormTranslationEdit/FormTranslationEdit.tsx +++ b/web/src/features/forms/components/FormTranslationEdit/FormTranslationEdit.tsx @@ -28,7 +28,6 @@ function FormTranslationEdit() { const navigate = useNavigate(); const router = useRouter(); const { toast } = useToast(); - const confirm = useConfirm(); const updateFormMutation = useMutation({ mutationFn: ({ @@ -37,14 +36,13 @@ function FormTranslationEdit() { }: { electionRoundId: string; form: UpdateFormRequest; - shouldNavigateAwayAfterSubmit: boolean; }) => { return authApi.put(`/election-rounds/${electionRoundId}/forms/${form.id}`, { ...form, }); }, - onSuccess: async (_, { electionRoundId, shouldNavigateAwayAfterSubmit }) => { + onSuccess: async (_, { electionRoundId }) => { toast({ title: 'Success', description: 'Form updated successfully', @@ -52,17 +50,6 @@ function FormTranslationEdit() { await queryClient.invalidateQueries({ queryKey: formsKeys.all(electionRoundId), type: 'all' }); router.invalidate(); - - if (shouldNavigateAwayAfterSubmit) { - if ( - await confirm({ - title: 'Changes made to form in base language', - body: 'Please note that changes have been made to the form in base language, which can impact the translation(s). All new questions or response options which you have added have been copied to translations but in the base language. Access each translation of the form and manually translate each of the changes.', - }) - ) { - await navigate({ to: '/election-event/$tab', params: { tab: 'observer-forms' } }); - } - } }, onError: () => { @@ -75,12 +62,12 @@ function FormTranslationEdit() { }); const saveForm = useCallback( - (formData: EditFormType, shouldNavigateAwayAfterSubmit: boolean) => { + async (formData: EditFormType, shouldNavigateAwayAfterSubmit: boolean) => { const updatedForm: UpdateFormRequest = { id: formId, code: formData.code, name: formData.name, - defaultLanguage: formData.languageCode, + defaultLanguage: form.defaultLanguage, description: formData.description, formType: formData.formType, languages: formData.languages, @@ -88,13 +75,15 @@ function FormTranslationEdit() { questions: formData.questions.map(mapToQuestionRequest), }; - updateFormMutation.mutate({ + await updateFormMutation.mutateAsync({ electionRoundId: currentElectionRoundId, form: updatedForm, - shouldNavigateAwayAfterSubmit, }); + if (shouldNavigateAwayAfterSubmit) { + await navigate({ to: '/election-event/$tab', params: { tab: 'observer-forms' } }); + } }, - [formId, currentElectionRoundId] + [updateFormMutation, formId, currentElectionRoundId] ); return ( diff --git a/web/src/features/ngos/components/EditNgo.tsx b/web/src/features/ngos/components/EditNgo.tsx index cbb7d7075..7990bbf4a 100644 --- a/web/src/features/ngos/components/EditNgo.tsx +++ b/web/src/features/ngos/components/EditNgo.tsx @@ -54,20 +54,29 @@ export const EditNgo: FC = ({ existingData, ngoId }) => { }); }; - useBlocker({ - shouldBlockFn: async () => { - if (!form.formState.isDirty || form.formState.isSubmitting) { - return false; - } + const isDirty = form.formState.isDirty; + + const { proceed, reset, status } = useBlocker({ + shouldBlockFn: () => isDirty, + withResolver: true, + }); - return !(await confirm({ + useEffect(() => { + if (status === 'blocked') { + confirm({ title: `Unsaved Changes Detected`, body: 'You have unsaved changes. If you leave this page, your changes will be lost. Are you sure you want to continue?', actionButton: 'Leave', cancelButton: 'Stay', - })); - }, - }); + }).then((confirmed) => { + if (confirmed) { + proceed(); + } else { + reset(); + } + }); + } + }, [status, confirm, proceed, reset]); return ( = ({ data }) => { } breadcrumbs={<>}> - + Organization details Admin users Monitored elections diff --git a/web/src/features/ngos/components/admins/AddNgoAdminDialog.tsx b/web/src/features/ngos/components/admins/AddNgoAdminDialog.tsx index fd97bef1e..bae8561f0 100644 --- a/web/src/features/ngos/components/admins/AddNgoAdminDialog.tsx +++ b/web/src/features/ngos/components/admins/AddNgoAdminDialog.tsx @@ -48,20 +48,29 @@ function AddNgoAdminDialog({ open, onOpenChange, ngoId }: AddNgoAdminDialogProps [onOpenChange] ); - useBlocker({ - shouldBlockFn: async () => { - if (!form.formState.isDirty) { - return false; - } + const isDirty = form.formState.isDirty; + + const { proceed, reset, status } = useBlocker({ + shouldBlockFn: () => isDirty, + withResolver: true, + }); - return !(await confirm({ + useEffect(() => { + if (status === 'blocked') { + confirm({ title: `Unsaved Changes Detected`, body: 'You have unsaved changes. If you leave this page, your changes will be lost. Are you sure you want to continue?', actionButton: 'Leave', cancelButton: 'Stay', - })); - }, - }); + }).then((confirmed) => { + if (confirmed) { + proceed(); + } else { + reset(); + } + }); + } + }, [status, confirm, proceed, reset]); function onSubmit(values: NgoAdminFormData) { createNgoAdminMutation.mutate({ diff --git a/web/src/features/ngos/components/admins/EditNgoAdmin.tsx b/web/src/features/ngos/components/admins/EditNgoAdmin.tsx index 5eedce642..3686bc234 100644 --- a/web/src/features/ngos/components/admins/EditNgoAdmin.tsx +++ b/web/src/features/ngos/components/admins/EditNgoAdmin.tsx @@ -48,20 +48,29 @@ export const EditNgoAdmin: FC = ({ ngoAdmin }) => { } }, [form.formState.isSubmitSuccessful, form.reset]); - useBlocker({ - shouldBlockFn: async () => { - if (!form.formState.isDirty) { - return false; - } + const isDirty = form.formState.isDirty; + + const { proceed, reset, status } = useBlocker({ + shouldBlockFn: () => isDirty, + withResolver: true, + }); - return !(await confirm({ + useEffect(() => { + if (status === 'blocked') { + confirm({ title: `Unsaved Changes Detected`, body: 'You have unsaved changes. If you leave this page, your changes will be lost. Are you sure you want to continue?', actionButton: 'Leave', cancelButton: 'Stay', - })); - }, - }); + }).then((confirmed) => { + if (confirmed) { + proceed(); + } else { + reset(); + } + }); + } + }, [status, confirm, proceed, reset]); function onSubmit(values: EditNgoAdminFormData) { editNgoAdminMutation.mutate({ adminId, values }); diff --git a/web/src/features/observers/components/EditObserver/EditObserver.tsx b/web/src/features/observers/components/EditObserver/EditObserver.tsx index ee93392de..6e9f1134a 100644 --- a/web/src/features/observers/components/EditObserver/EditObserver.tsx +++ b/web/src/features/observers/components/EditObserver/EditObserver.tsx @@ -15,6 +15,7 @@ import { useBlocker, useNavigate } from '@tanstack/react-router'; import { useForm } from 'react-hook-form'; import { useObserverMutations } from '../../hooks/observers-queries'; import { EditObserverFormData, editObserverFormSchema } from '../../models/observer'; +import { useEffect } from 'react'; export default function EditObserver() { const navigate = useNavigate(); @@ -34,20 +35,29 @@ export default function EditObserver() { }, }); - useBlocker({ - shouldBlockFn: async () => { - if (!form.formState.isDirty) { - return false; - } + const isDirty = form.formState.isDirty; - return !(await confirm({ + const { proceed, reset, status } = useBlocker({ + shouldBlockFn: () => isDirty, + withResolver: true, + }); + + useEffect(() => { + if (status === 'blocked') { + confirm({ title: `Unsaved Changes Detected`, body: 'You have unsaved changes. If you leave this page, your changes will be lost. Are you sure you want to continue?', actionButton: 'Leave', cancelButton: 'Stay', - })); - }, - }); + }).then((confirmed) => { + if (confirmed) { + proceed(); + } else { + reset(); + } + }); + } + }, [status, confirm, proceed, reset]); function onSubmit(values: EditObserverFormData) { editObserverMutation.mutate({