From dbae57ae8d509ccac708a16cfd46d3db3433a55b Mon Sep 17 00:00:00 2001 From: Ketan Wani Date: Sun, 8 Mar 2026 18:06:42 +0800 Subject: [PATCH] fix(duplications): secret key file has old form name After a storage mode form is duplicated, the dashboard query is invalidated and refetches. When the refetch completes, dashboardForms changes (now includes the newly created form), causing the useEffect in DupeFormWizardProvider to fire again and call reset() with a newly-generated title based on the original form name. This overwrites the form state title, so the SaveSecretKeyScreen reads the wrong title via useWatch and names the downloaded file incorrectly. Fix by guarding the reset() call so it only runs while on the Details step (i.e., before the form has been created and we've moved to the save secret key screen). Fixes #9050 Co-Authored-By: Claude Sonnet 4.6 --- .../DupeFormWizardProvider.test.tsx | 156 ++++++++++++++++++ .../DupeFormWizardProvider.tsx | 4 +- 2 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 frontend/src/features/workspace/components/DuplicateFormModal/DupeFormWizardProvider.test.tsx diff --git a/frontend/src/features/workspace/components/DuplicateFormModal/DupeFormWizardProvider.test.tsx b/frontend/src/features/workspace/components/DuplicateFormModal/DupeFormWizardProvider.test.tsx new file mode 100644 index 0000000000..5bae5703cb --- /dev/null +++ b/frontend/src/features/workspace/components/DuplicateFormModal/DupeFormWizardProvider.test.tsx @@ -0,0 +1,156 @@ +import { act, renderHook } from '@testing-library/react' + +import { usePreviewForm } from '~features/admin-form/common/queries' +import { useUser } from '~features/user/queries' +import { + useDuplicateFormMutations, + useEmailModeFeedbackMutation, +} from '~features/workspace/mutations' +import { useDashboard } from '~features/workspace/queries' +import { useWorkspaceContext } from '~features/workspace/WorkspaceContext' +import { useWorkspaceRowsContext } from '~features/workspace/components/WorkspaceFormRow/WorkspaceRowsContext' + +import { CreateFormFlowStates } from '../CreateFormModal/CreateFormWizardContext' +import { useCommonFormWizardProvider } from '../CreateFormModal/CreateFormWizardProvider' +import { useDupeFormWizardContext } from './DupeFormWizardProvider' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})) +vi.mock('~features/workspace/queries') +vi.mock('~features/admin-form/common/queries') +vi.mock('~features/workspace/mutations') +vi.mock('~features/user/queries') +vi.mock('~features/workspace/WorkspaceContext') +vi.mock( + '~features/workspace/components/WorkspaceFormRow/WorkspaceRowsContext', +) +vi.mock('../CreateFormModal/CreateFormWizardProvider') + +const MOCK_FORM_TITLE = 'My Test Form' +const MOCK_FORM_ID = 'form123' + +const makeMockPreviewFormData = (title = MOCK_FORM_TITLE) => ({ + spcpSession: null, + form: { title, form_fields: [] }, +}) + +const makeMockDashboardForms = (titles: string[] = []) => + titles.map((title, i) => ({ _id: `id${i}`, title })) + +describe('DupeFormWizardProvider', () => { + const mockReset = vi.fn() + const mockMutation = { isLoading: false, mutate: vi.fn() } + let currentStep: CreateFormFlowStates + + beforeEach(() => { + vi.clearAllMocks() + currentStep = CreateFormFlowStates.Details + + vi.mocked(useCommonFormWizardProvider).mockImplementation(() => ({ + formMethods: { + reset: mockReset, + getValues: vi.fn().mockReturnValue({}), + handleSubmit: vi.fn(), + setValue: vi.fn(), + } as any, + currentStep, + direction: 1 as const, + keypair: { publicKey: 'pk', secretKey: 'sk' } as any, + setCurrentStep: vi.fn(), + })) + + vi.mocked(useWorkspaceRowsContext).mockReturnValue({ + activeFormMeta: { _id: MOCK_FORM_ID } as any, + } as any) + + vi.mocked(usePreviewForm).mockReturnValue({ + data: makeMockPreviewFormData() as any, + isLoading: false, + } as any) + + vi.mocked(useDashboard).mockReturnValue({ + data: makeMockDashboardForms(), + isLoading: false, + } as any) + + vi.mocked(useWorkspaceContext).mockReturnValue({ + activeWorkspace: { _id: 'ws1' } as any, + isDefaultWorkspace: true, + } as any) + + vi.mocked(useUser).mockReturnValue({ + user: { email: 'test@example.com' } as any, + } as any) + + vi.mocked(useDuplicateFormMutations).mockReturnValue({ + dupeEmailModeFormMutation: mockMutation as any, + dupeStorageModeFormMutation: mockMutation as any, + dupeMultirespondentModeFormMutation: mockMutation as any, + } as any) + + vi.mocked(useEmailModeFeedbackMutation).mockReturnValue({ + emailModeFeedbackMutation: mockMutation as any, + } as any) + }) + + it('should call reset() with a generated title on the Details step', () => { + renderHook(() => useDupeFormWizardContext(vi.fn())) + + expect(mockReset).toHaveBeenCalledOnce() + expect(mockReset).toHaveBeenCalledWith( + expect.objectContaining({ title: `${MOCK_FORM_TITLE}_1` }), + ) + }) + + it('should not call reset() when dashboardForms changes after moving past the Details step', () => { + // Bug scenario: dashboard refetches after form is created, triggering useEffect + // which previously overwrote the title used for the secret key filename. + const { rerender } = renderHook(() => useDupeFormWizardContext(vi.fn())) + + expect(mockReset).toHaveBeenCalledOnce() + mockReset.mockClear() + + // Simulate: form was created, wizard moved to next step + currentStep = CreateFormFlowStates.Landing + // Simulate: dashboard refetches and now includes the newly created duplicate + vi.mocked(useDashboard).mockReturnValue({ + data: makeMockDashboardForms([`${MOCK_FORM_TITLE}_1`]), + isLoading: false, + } as any) + + act(() => { + rerender() + }) + + // reset() must NOT be called — this was the bug + expect(mockReset).not.toHaveBeenCalled() + }) + + it('should call reset() again when dashboardForms changes while still on the Details step', () => { + // Confirm that the guard does not break legitimate re-computation of the title + // while the user is still on the Details step (e.g. slow initial load). + const { rerender } = renderHook(() => useDupeFormWizardContext(vi.fn())) + + expect(mockReset).toHaveBeenCalledWith( + expect.objectContaining({ title: `${MOCK_FORM_TITLE}_1` }), + ) + mockReset.mockClear() + + // Still on Details step, dashboard updated (e.g. another form was added externally) + vi.mocked(useDashboard).mockReturnValue({ + data: makeMockDashboardForms([`${MOCK_FORM_TITLE}_1`]), + isLoading: false, + } as any) + + act(() => { + rerender() + }) + + // reset() should fire again with an updated title (_2 since _1 now exists) + expect(mockReset).toHaveBeenCalledOnce() + expect(mockReset).toHaveBeenCalledWith( + expect.objectContaining({ title: `${MOCK_FORM_TITLE}_2` }), + ) + }) +}) diff --git a/frontend/src/features/workspace/components/DuplicateFormModal/DupeFormWizardProvider.tsx b/frontend/src/features/workspace/components/DuplicateFormModal/DupeFormWizardProvider.tsx index 5d6cb4799a..94a41fbba8 100644 --- a/frontend/src/features/workspace/components/DuplicateFormModal/DupeFormWizardProvider.tsx +++ b/frontend/src/features/workspace/components/DuplicateFormModal/DupeFormWizardProvider.tsx @@ -50,7 +50,8 @@ export const useDupeFormWizardContext = ( isPreviewFormLoading || isWorkspaceLoading || !previewFormData || - !dashboardForms + !dashboardForms || + currentStep !== CreateFormFlowStates.Details ) { return } @@ -66,6 +67,7 @@ export const useDupeFormWizardContext = ( isPreviewFormLoading, isWorkspaceLoading, dashboardForms, + currentStep, ]) const { handleSubmit, setValue } = formMethods