From 581ede9b079e0f0fef45d64478e8e0945801877c Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 30 Sep 2026 01:04:08 +0200 Subject: [PATCH] fix: preserve blank-name backups and open linked bill editors --- src/export/json.test.ts | 33 ++++++++++++------ src/export/json.ts | 8 ++--- src/ui/routes/SettingsRoute.test.tsx | 51 ++++++++++++++++++++++++++++ src/ui/routes/SettingsRoute.tsx | 14 ++++++++ 4 files changed, 92 insertions(+), 14 deletions(-) create mode 100644 src/ui/routes/SettingsRoute.test.tsx diff --git a/src/export/json.test.ts b/src/export/json.test.ts index c496d91..5bc497b 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -6,6 +6,7 @@ import { ImportValidationError, exportFilename, } from "./json.ts"; +import { addRecurringCost, updatePost, updateRecurringCost } from "../store/actions.ts"; import { createSeedDataset } from "../domain/seed.ts"; import { monthView, yearView } from "../domain/views.ts"; import type { Dataset } from "../domain/types.ts"; @@ -164,21 +165,33 @@ describe("import rejects invalid folder graph", () => { expect(() => parse(data)).toThrow(ImportValidationError); }); - test("rejects a blank post name", () => { + test.each([ + ["", "Rent"], [" ", "Rent"], ["Games", ""], ["Games", " "], + ])("blank item names (%j, %j) survive saved dataset round trips and v7 import", (postName, billName) => { const data = populated(); - data.posts[0]!.name = " "; - expect(() => parse(data)).toThrow(ImportValidationError); - }); - - test("rejects a blank recurring-cost name", () => { - const data = populated(); - data.recurring.push({ - id: "rent", name: " ", order: 3, folderId: null, archived: false, + const bill = addRecurringCost(data, { + name: "Rent", folderId: null, archived: false, amount: { amount: 100, currency: "DKK" }, startDate: "2026-01", recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }], }); - expect(() => parse(data)).toThrow(ImportValidationError); + updatePost(data, data.posts[0]!.id, { name: postName }); + updateRecurringCost(data, bill.id, { name: billName }); + expect(parseDatasetJson(exportDatasetJson(data))).toEqual(data); + const legacy = JSON.parse(exportDatasetJson(data)); + legacy.settings.schemaVersion = 7; + delete legacy.folders; + for (const item of [...legacy.posts, ...legacy.recurring]) delete item.folderId; + const imported = parseDatasetJson(JSON.stringify(legacy)); + expect(imported.posts[0]!.name).toBe(postName); + expect(imported.recurring[0]!.name).toBe(billName); + expect(imported.folders).toEqual([]); + }); + + test.each(["", " "])("still rejects blank folder names %j", name => { + const data = populated(); + data.folders = [{ ...folder("home", null, data.posts.length), name }]; + expect(() => parse(data)).toThrow(/empty name/); }); }); diff --git a/src/export/json.ts b/src/export/json.ts index ee68998..6d92bc1 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -190,14 +190,14 @@ function requireFolderTree(data: Dataset): void { requireLocation(folder.parentId, folder.order, `Folder "${folder.id}"`); } for (const post of data.posts) { - if (typeof post?.name !== "string" || !post.name.trim()) { - throw new ImportValidationError("Post has an empty name"); + if (typeof post?.name !== "string") { + throw new ImportValidationError("Post name must be a string"); } requireLocation(post?.folderId, post?.order, `Post "${post?.name}"`); } for (const cost of data.recurring) { - if (typeof cost?.name !== "string" || !cost.name.trim()) { - throw new ImportValidationError("Recurring cost has an empty name"); + if (typeof cost?.name !== "string") { + throw new ImportValidationError("Recurring cost name must be a string"); } requireLocation(cost?.folderId, cost?.order, `Recurring cost "${cost?.name}"`); } diff --git a/src/ui/routes/SettingsRoute.test.tsx b/src/ui/routes/SettingsRoute.test.tsx new file mode 100644 index 0000000..82dc167 --- /dev/null +++ b/src/ui/routes/SettingsRoute.test.tsx @@ -0,0 +1,51 @@ +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 { Link, MemoryRouter, Route, Routes } from "react-router"; +import { createSeedDataset } from "../../domain/seed.ts"; +import { addRecurringCost } from "../../store/actions.ts"; +import { store } from "../../store/index.ts"; +import { monthView } from "../../domain/views.ts"; +import { BudgetTree } from "../components/BudgetTree.tsx"; +import { SettingsRoute } from "./SettingsRoute.tsx"; + +test("month bill Edit navigates to Settings, opens and focuses its editor; fragment changes retarget it", () => { + const data = createSeedDataset("2026-09"); + const bill = addRecurringCost(data, { + name: "Phone", folderId: null, archived: false, + amount: { amount: 30, currency: "DKK" }, startDate: "2026-09", + recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", + splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }], + }); + const second = addRecurringCost(data, { ...bill, name: "Internet" }); + const original = { get: store.get, subscribe: store.subscribe }; + store.get = () => data; + store.subscribe = () => () => {}; + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + try { + act(() => root.render( + Other bill + Missing bill + + } /> + } /> + + )); + act(() => (container.querySelector(`[data-budget-item="${bill.id}"] a`) as HTMLAnchorElement).click()); + expect(container.textContent).toContain("Save destinations"); + expect(document.activeElement?.id).toBe(`bill-${bill.id}`); + act(() => (container.querySelector("#second-bill") as HTMLAnchorElement).click()); + expect(document.activeElement?.id).toBe(`bill-${second.id}`); + const editorRow = container.querySelector(`[data-bill-id="${second.id}"]`)!.nextElementSibling!; + expect(editorRow.textContent).toContain("Save destinations"); + act(() => (container.querySelector("#missing-bill") as HTMLAnchorElement).click()); + expect(container.textContent).toContain("Recurring costs"); + } finally { + act(() => root.unmount()); container.remove(); Object.assign(store, original); + } +}); diff --git a/src/ui/routes/SettingsRoute.tsx b/src/ui/routes/SettingsRoute.tsx index 83928a6..f6c98b8 100644 --- a/src/ui/routes/SettingsRoute.tsx +++ b/src/ui/routes/SettingsRoute.tsx @@ -1,3 +1,5 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router"; import { CurrencySection } from "./settings/CurrencySection.tsx"; import { BudgetFoldersSection } from "./settings/BudgetFoldersSection.tsx"; import { PostsSection } from "./settings/PostsSection.tsx"; @@ -18,6 +20,18 @@ import { ColophonSection } from "./settings/ColophonSection.tsx"; * "remove" against the card edge. */ export function SettingsRoute() { + const location = useLocation(); + useEffect(() => { + // BrowserRouter updates the URL without native fragment navigation. Run + // after the settings rows mount, and again for navigation within Settings. + if (!location.hash.startsWith("#bill-")) return; + const target = document.getElementById(location.hash.slice(1)); + if (!(target instanceof HTMLButtonElement)) return; + target.click(); + target.focus({ preventScroll: true }); + target.scrollIntoView({ block: "center" }); + }, [location.key, location.hash]); + return (

Settings