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 }