From 8cf377231f55be61dce8f6c0e5452ebb2ca77f95 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Fri, 9 Oct 2026 02:08:20 +0200 Subject: [PATCH] Keep the chosen folder showing in the form's Folder dropdown (#537) Co-Authored-By: Claude Sonnet 5.5 --- .../components/FormFolderSelector.test.tsx | 87 +++++++++++++++++++ src/forms/components/FormFolderSelector.tsx | 26 +++++- 2 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 src/forms/components/FormFolderSelector.test.tsx diff --git a/src/forms/components/FormFolderSelector.test.tsx b/src/forms/components/FormFolderSelector.test.tsx new file mode 100644 index 00000000..d4888811 --- /dev/null +++ b/src/forms/components/FormFolderSelector.test.tsx @@ -0,0 +1,87 @@ +import React from "react" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react" + +const saveMeta = vi.fn() +const createFolderFn = vi.fn() +const toastError = vi.fn() +let folders = [ + { id: 1, name: "Surveys" }, + { id: 2, name: "Experiments" }, +] +const refetch = vi.fn(async () => undefined) + +vi.mock("@blitzjs/rpc", () => ({ + useQuery: () => [folders, { refetch }], + // the first useMutation in the component is updateFormMeta, the second createFolder + useMutation: (mutation: { name?: string }) => [ + (...args: unknown[]) => + mutation.name === "createFolder" ? createFolderFn(...args) : saveMeta(...args), + ], +})) +vi.mock("src/folders/queries/getFolders", () => ({ default: {} })) +vi.mock("src/folders/mutations/createFolder", () => ({ default: { name: "createFolder" } })) +vi.mock("src/forms/mutations/updateFormMeta", () => ({ default: { name: "updateFormMeta" } })) +vi.mock("react-hot-toast", () => ({ default: { error: (...a: unknown[]) => toastError(...a) } })) + +import FormFolderSelector from "./FormFolderSelector" + +const dropdown = () => screen.getByRole("combobox") as HTMLSelectElement + +describe("FormFolderSelector", () => { + beforeEach(() => { + vi.clearAllMocks() + saveMeta.mockResolvedValue({}) + }) + afterEach(cleanup) + + test("shows the folder the form is in", () => { + render() + expect(dropdown().value).toBe("2") + }) + + test("keeps showing the folder you chose, even though the page never passes the new one back", async () => { + render() + fireEvent.change(dropdown(), { target: { value: "2" } }) + + await waitFor(() => expect(saveMeta).toHaveBeenCalledWith({ id: 5, folderId: 2 })) + expect(dropdown().value).toBe("2") + }) + + test("choosing No folder is shown and saved as no folder", async () => { + render() + fireEvent.change(dropdown(), { target: { value: "" } }) + + await waitFor(() => expect(saveMeta).toHaveBeenCalledWith({ id: 5, folderId: null })) + expect(dropdown().value).toBe("") + }) + + test("goes back to the previous folder and says so if saving fails", async () => { + saveMeta.mockRejectedValue(new Error("offline")) + render() + fireEvent.change(dropdown(), { target: { value: "2" } }) + + await waitFor(() => expect(toastError).toHaveBeenCalled()) + expect(dropdown().value).toBe("1") + }) + + test("follows the page if it does pass a different folder later", () => { + const { rerender } = render() + rerender() + expect(dropdown().value).toBe("2") + }) + + test("a newly created folder is selected", async () => { + createFolderFn.mockImplementation(async () => { + folders = [...folders, { id: 3, name: "New one" }] + return { id: 3, name: "New one" } + }) + render() + fireEvent.click(screen.getByText("+ New folder")) + fireEvent.change(screen.getByPlaceholderText("Folder name"), { target: { value: "New one" } }) + fireEvent.click(screen.getByText("Create")) + + await waitFor(() => expect(saveMeta).toHaveBeenCalledWith({ id: 5, folderId: 3 })) + await waitFor(() => expect(dropdown().value).toBe("3")) + }) +}) diff --git a/src/forms/components/FormFolderSelector.tsx b/src/forms/components/FormFolderSelector.tsx index 5fe86d28..1ad7a5a7 100644 --- a/src/forms/components/FormFolderSelector.tsx +++ b/src/forms/components/FormFolderSelector.tsx @@ -1,5 +1,6 @@ -import { useState } from "react" +import { useEffect, useState } from "react" import { useQuery, useMutation } from "@blitzjs/rpc" +import toast from "react-hot-toast" import getFolders from "src/folders/queries/getFolders" import createFolder from "src/folders/mutations/createFolder" import updateFormMeta from "src/forms/mutations/updateFormMeta" @@ -16,18 +17,35 @@ export default function FormFolderSelector({ formId, currentFolderId, onUpdate } const [createFolderMutation] = useMutation(createFolder) const [newFolderName, setNewFolderName] = useState("") const [showNewFolder, setShowNewFolder] = useState(false) + // The dropdown shows its own selection. The page only tells us the folder the form had when it + // loaded and doesn't pass it back after a change, so reading the value from the prop alone + // snapped the dropdown back to the old folder even though the change had been saved. + const [selectedFolderId, setSelectedFolderId] = useState(currentFolderId) + + useEffect(() => { + setSelectedFolderId(currentFolderId) + }, [currentFolderId]) const handleChange = async (e: React.ChangeEvent) => { const val = e.target.value const folderId = val === "" ? null : parseInt(val, 10) - await updateMeta({ id: formId, folderId }) - onUpdate?.(folderId) + const previous = selectedFolderId + setSelectedFolderId(folderId) + try { + await updateMeta({ id: formId, folderId }) + onUpdate?.(folderId) + } catch (error) { + // not saved, so don't pretend it was + setSelectedFolderId(previous) + toast.error("The folder could not be saved. Please try again.") + } } const handleCreateFolder = async () => { if (!newFolderName.trim()) return const folder = await createFolderMutation({ name: newFolderName.trim() }) await updateMeta({ id: formId, folderId: folder.id }) + setSelectedFolderId(folder.id) setNewFolderName("") setShowNewFolder(false) await refetch() @@ -38,7 +56,7 @@ export default function FormFolderSelector({ formId, currentFolderId, onUpdate }