fix: preserve folder rename drafts until save

This commit is contained in:
Lucas Winther committed 2026-09-30 00:40:21 +02:00
1 parent 725c836788
commit e715160235
2 files changed
+62 -5

No files matched your search

@@ -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<void>> = [];
store.mutate = change => new Promise<void>((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));
@@ -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<string | null>(null);
const [folderNames, setFolderNames] = useState<Record<string, string>>({});
const savingNames = useRef(new Set<string>());
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 <li key={`${key.kind}-${key.id}`}>
<div data-budget-item={key.id} className="flex flex-wrap items-center gap-2 border-b border-budget-rule py-2">
<div className="min-w-0 flex-1 basis-40">
{key.kind === "folder" ? <Input className="h-8 w-full max-w-64" aria-label={`Name for ${item.name}`} value={item.name} onInput={event => {
const name = event.currentTarget.value;
if (name.trim()) void mutate(draft => renameBudgetFolder(draft, key.id, name));
{key.kind === "folder" ? <Input className="h-8 w-full max-w-64" aria-label={`Name for ${item.name}`} value={folderNames[key.id] ?? item.name} onInput={event => {
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);
}
}} /> : <span className="break-words text-sm">{item.name}</span>}
<span className="mt-1 block text-xs text-budget-ink-muted">{key.kind === "folder" ? "Folder" : key.kind === "post" ? "Post" : "Bill"}</span>
</div>