diff --git a/src/ui/routes/SettingsRoute.tsx b/src/ui/routes/SettingsRoute.tsx index cb3becf..83928a6 100644 --- a/src/ui/routes/SettingsRoute.tsx +++ b/src/ui/routes/SettingsRoute.tsx @@ -1,4 +1,5 @@ import { CurrencySection } from "./settings/CurrencySection.tsx"; +import { BudgetFoldersSection } from "./settings/BudgetFoldersSection.tsx"; import { PostsSection } from "./settings/PostsSection.tsx"; import { RecurringSection } from "./settings/RecurringSection.tsx"; import { DataSection } from "./settings/DataSection.tsx"; @@ -21,6 +22,7 @@ export function SettingsRoute() {

Settings

+ diff --git a/src/ui/routes/settings/BudgetFoldersSection.test.tsx b/src/ui/routes/settings/BudgetFoldersSection.test.tsx new file mode 100644 index 0000000..b68aae2 --- /dev/null +++ b/src/ui/routes/settings/BudgetFoldersSection.test.tsx @@ -0,0 +1,69 @@ +import { expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register(); +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { createSeedDataset } from "../../../domain/seed.ts"; +import { store } from "../../../store/index.ts"; +import { addBudgetFolder, addRecurringCost } from "../../../store/actions.ts"; +import { BudgetFoldersSection } from "./BudgetFoldersSection.tsx"; + +async function setup(run: (container: HTMLDivElement, data: ReturnType) => Promise, fail = false) { + let data = createSeedDataset("2026-09"); + const housing = addBudgetFolder(data, "Housing", null); + addBudgetFolder(data, "Utilities", housing.id); + addRecurringCost(data, { name: "Phone", folderId: null, archived: false, amount: { amount: 30, currency: "USD" }, startDate: "2026-09", recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }] }); + data.fxRates = []; + const original = { get: store.get, subscribe: store.subscribe, mutate: store.mutate }; + const listeners = new Set<() => void>(); + store.get = () => data; + store.subscribe = fn => { listeners.add(fn); return () => listeners.delete(fn); }; + store.mutate = async change => { if (fail) throw new Error("Disk full"); const draft = structuredClone(data); change(draft); data = draft; listeners.forEach(fn => fn()); }; + const container = document.createElement("div"); document.body.append(container); + const root = createRoot(container); + try { act(() => root.render()); await run(container, data); } + finally { act(() => root.unmount()); container.remove(); Object.assign(store, original); } +} +function select(container: HTMLDivElement, label: string, value: string) { + const field = container.querySelector(`select[aria-label="${label}"]`) as HTMLSelectElement; + field.value = value; field.dispatchEvent(new Event("change", { bubbles: true })); +} +function input(container: HTMLDivElement, label: string, value: string) { + const field = container.querySelector(`input[aria-label="${label}"]`) as HTMLInputElement; + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(field, value); + field.dispatchEvent(new InputEvent("input", { bubbles: true })); +} +test("nested folder can be created and renamed", async () => setup(async (container, data) => { + const housing = data.folders[0]!; + const parent = container.querySelector('select[aria-label="Folder for Housing"]')!; + expect(parent.querySelector(`option[value="${housing.id}"]`)).toBeNull(); + expect(parent.querySelector(`option[value="${data.folders[1]!.id}"]`)).toBeNull(); + act(() => { input(container, "New folder name", "Services"); select(container, "New folder parent", housing.id); }); + 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"); }); + 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(); +})); +test("post and bill move independently", async () => setup(async (container, data) => { + const housing = data.folders[0]!, utilities = data.folders[1]!; + const post = data.posts[0]!, bill = data.recurring[0]!; + await act(async () => { select(container, `Folder for ${post.name}`, housing.id); }); + await act(async () => { select(container, "Folder for Phone", utilities.id); }); + expect(store.get().posts[0]!.folderId).toBe(housing.id); + expect(store.get().recurring[0]!.folderId).toBe(utilities.id); + expect(store.get().recurring[0]!.splits).toEqual(bill.splits); + await act(async () => { (container.querySelector(`button[aria-label="Move ${post.name} up"]`) as HTMLButtonElement).click(); }); + expect(store.get().posts[0]!.order).toBe(0); + const rows = Array.from(container.querySelectorAll("[data-budget-item]")); + expect(rows.findIndex(row => row.getAttribute("data-budget-item") === post.id)).toBeLessThan(rows.findIndex(row => row.getAttribute("data-budget-item") === utilities.id)); +})); +test("failed move reports error", async () => setup(async (container, data) => { + const before = JSON.stringify(data); + await act(async () => { select(container, `Folder for ${data.posts[0]!.name}`, data.folders[0]!.id); }); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Disk full"); + expect(JSON.stringify(store.get())).toBe(before); +}, true)); diff --git a/src/ui/routes/settings/BudgetFoldersSection.tsx b/src/ui/routes/settings/BudgetFoldersSection.tsx new file mode 100644 index 0000000..90aef2f --- /dev/null +++ b/src/ui/routes/settings/BudgetFoldersSection.tsx @@ -0,0 +1,81 @@ +import { useState, type ReactNode } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { NativeSelect } from "@/components/ui/native-select"; +import { Section } from "../../components/Section.tsx"; +import { useDataset } from "../../hooks/useDataset.ts"; +import { useMutate } from "../../hooks/useMutate.ts"; +import { addBudgetFolder, budgetSiblings, placeBudgetItem, renameBudgetFolder, type BudgetItemKey } from "../../../store/actions.ts"; + +export function BudgetFoldersSection() { + const dataset = useDataset(); + const { mutate, error } = useMutate(); + const [name, setName] = useState(""); + const [parent, setParent] = useState(null); + + 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}`; + } + const folders = [...dataset.folders].sort((a, b) => folderPath(a.id).localeCompare(folderPath(b.id))); + function allowedDestination(key: BudgetItemKey, id: string): boolean { + if (key.kind !== "folder") return true; + let cursor: string | null = id; + while (cursor !== null) { + if (cursor === key.id) return false; + cursor = dataset.folders.find(folder => folder.id === cursor)!.parentId; + } + return true; + } + function children(parentId: string | null): ReactNode { + const siblings = budgetSiblings(dataset, parentId); + return
    {siblings.map((key, index) => { + const item = key.kind === "folder" ? dataset.folders.find(item => item.id === key.id)! : key.kind === "post" ? dataset.posts.find(item => item.id === key.id)! : dataset.recurring.find(item => item.id === key.id)!; + return
  • +
    +
    + {key.kind === "folder" ? { + const name = event.currentTarget.value; + if (name.trim()) void mutate(draft => renameBudgetFolder(draft, key.id, name)); + }} /> : {item.name}} + {key.kind === "folder" ? "Folder" : key.kind === "post" ? "Post" : "Bill"} +
    + { + const destination = event.target.value || null; + void mutate(draft => placeBudgetItem(draft, key, destination, budgetSiblings(draft, destination).filter(sibling => !(sibling.kind === key.kind && sibling.id === key.id)).length)); + }}> + + {folders.filter(folder => allowedDestination(key, folder.id)).map(folder => )} + +
    + + +
    +
    + {key.kind === "folder" &&
    {children(key.id)}
    } +
  • ; + })}
; + } + + return
+ {error &&

Could not save: {error}

} +

Top level · Unfiled

+ {children(null)} +
+ + + +
+
; +} diff --git a/src/ui/routes/settings/PostsSection.tsx b/src/ui/routes/settings/PostsSection.tsx index 4b94af3..f6819b0 100644 --- a/src/ui/routes/settings/PostsSection.tsx +++ b/src/ui/routes/settings/PostsSection.tsx @@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input"; import { NativeSelect } from "@/components/ui/native-select"; import { useDataset } from "../../hooks/useDataset.ts"; import { useMutate } from "../../hooks/useMutate.ts"; -import { addPost, movePost, setPostArchived, updatePost } from "../../../store/actions.ts"; +import { addPost, setPostArchived, updatePost } from "../../../store/actions.ts"; import type { Currency, Post } from "../../../domain/types.ts"; import { ruleAt } from "../../../domain/allocation.ts"; import { currentMonth } from "../../../store/index.ts"; @@ -40,6 +40,7 @@ export function PostsSection() { title="Posts" hint="A post's allocation is a dated series: each rule applies from its own month onward until the next one takes over, so changing it never rewrites what earlier months got. Click a rule to see and edit that history. A single month can still override its own allocation from the month view, and percentages may total more than 100%." > +

Move and reorder posts in Budget folders

{error &&

Could not save: {error}

}
@@ -52,7 +53,7 @@ export function PostsSection() { - {ordered.map((post, index) => ( + {ordered.map((post) => ( - {ordered.map((cost, index) => ( + {ordered.map((cost) => ( ({ ...split })) }); }}>Edit - -
@@ -102,18 +103,7 @@ export function PostsSection() { - {/* Three inline-flex siblings with nothing between them: the - hover backgrounds touched, so the row read as one wide - slab rather than three actions. */}
- -