From e71516023538ce29253a71a0505c3575ad5b92ab Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 30 Sep 2026 00:40:21 +0200 Subject: [PATCH] fix: preserve folder rename drafts until save --- .../settings/BudgetFoldersSection.test.tsx | 36 ++++++++++++++++++- .../routes/settings/BudgetFoldersSection.tsx | 31 +++++++++++++--- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/ui/routes/settings/BudgetFoldersSection.test.tsx b/src/ui/routes/settings/BudgetFoldersSection.test.tsx index b68aae2..5f1cc66 100644 --- a/src/ui/routes/settings/BudgetFoldersSection.test.tsx +++ b/src/ui/routes/settings/BudgetFoldersSection.test.tsx @@ -43,7 +43,9 @@ test("nested folder can be created and renamed", async () => setup(async (contai await act(async () => { Array.from(container.querySelectorAll("button")).find(b => b.textContent === "Add folder")!.click(); }); const services = store.get().folders.find(f => f.name === "Services")!; expect(services.parentId).toBe(housing.id); - await act(async () => { input(container, "Name for Services", "Household"); }); + const field = container.querySelector('input[aria-label="Name for Services"]') as HTMLInputElement; + act(() => { field.focus(); input(container, "Name for Services", "Household"); }); + await act(async () => field.blur()); expect(store.get().folders.find(f => f.id === services.id)!.name).toBe("Household"); await act(async () => { select(container, "Folder for Utilities", ""); }); expect(store.get().folders[1]!.parentId).toBeNull(); @@ -67,3 +69,35 @@ test("failed move reports error", async () => setup(async (container, data) => { expect(container.querySelector('[role="alert"]')?.textContent).toContain("Disk full"); expect(JSON.stringify(store.get())).toBe(before); }, true)); + + +test("folder rename keeps multiple keystrokes until explicit save", async () => setup(async (container, data) => { + const persist = store.mutate; + const pending: Array<() => Promise> = []; + store.mutate = change => new Promise((resolve, reject) => { + pending.push(async () => { try { await persist(change); resolve(); } catch (error) { reject(error); } }); + }); + const field = container.querySelector('input[aria-label="Name for Housing"]') as HTMLInputElement; + act(() => field.focus()); + for (const value of ["H", "Ho", "Home", "Home ", "Home bills"]) { + act(() => input(container, "Name for Housing", value)); + expect(field.value).toBe(value); + expect(store.get().folders[0]!.name).toBe("Housing"); + } + expect(pending.length).toBe(0); + act(() => field.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))); + expect(pending.length).toBe(1); + expect(store.get().folders[0]!.name).toBe("Housing"); + await act(async () => { await pending.shift()!(); }); + expect(store.get().folders[0]!.name).toBe("Home bills"); + expect(store.get().folders[0]!.id).toBe(data.folders[0]!.id); +})); + +test("failed folder rename retains draft for retry", async () => setup(async (container) => { + const field = container.querySelector('input[aria-label="Name for Housing"]') as HTMLInputElement; + act(() => { field.focus(); input(container, "Name for Housing", "Home bills"); }); + await act(async () => field.blur()); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Disk full"); + expect(store.get().folders[0]!.name).toBe("Housing"); + expect(field.value).toBe("Home bills"); +}, true)); diff --git a/src/ui/routes/settings/BudgetFoldersSection.tsx b/src/ui/routes/settings/BudgetFoldersSection.tsx index 90aef2f..cf1511b 100644 --- a/src/ui/routes/settings/BudgetFoldersSection.tsx +++ b/src/ui/routes/settings/BudgetFoldersSection.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from "react"; +import { useRef, useState, type ReactNode } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { NativeSelect } from "@/components/ui/native-select"; @@ -13,6 +13,24 @@ export function BudgetFoldersSection() { const [name, setName] = useState(""); const [parent, setParent] = useState(null); + const [folderNames, setFolderNames] = useState>({}); + const savingNames = useRef(new Set()); + + function saveFolderName(id: string, savedName: string) { + const typed = folderNames[id]; + if (typed === undefined || typed === savedName || savingNames.current.has(id)) return; + savingNames.current.add(id); + void mutate(draft => renameBudgetFolder(draft, id, typed)).then(saved => { + savingNames.current.delete(id); + if (saved) setFolderNames(names => { + if (names[id] !== typed) return names; + const next = { ...names }; + delete next[id]; + return next; + }); + }); + } + function folderPath(id: string): string { const folder = dataset.folders.find(folder => folder.id === id)!; return folder.parentId === null ? folder.name : `${folderPath(folder.parentId)} / ${folder.name}`; @@ -34,9 +52,14 @@ export function BudgetFoldersSection() { return
  • - {key.kind === "folder" ? { - const name = event.currentTarget.value; - if (name.trim()) void mutate(draft => renameBudgetFolder(draft, key.id, name)); + {key.kind === "folder" ? { + const typed = event.currentTarget.value; + setFolderNames(names => ({ ...names, [key.id]: typed })); + }} onBlur={() => saveFolderName(key.id, item.name)} onKeyDown={event => { + if (event.key === "Enter") { + event.preventDefault(); + saveFolderName(key.id, item.name); + } }} /> : {item.name}} {key.kind === "folder" ? "Folder" : key.kind === "post" ? "Post" : "Bill"}