fix: preserve blank-name backups and open linked bill editors

This commit is contained in:
Lucas Winther
2026-09-30 01:04:08 +02:00
parent fc14341f1e
commit 581ede9b07
4 changed files with 92 additions and 14 deletions
+23 -10
View File
@@ -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/);
});
});
+4 -4
View File
@@ -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}"`);
}
+51
View File
@@ -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(<MemoryRouter initialEntries={["/month/2026-09"]}>
<Link id="second-bill" to={`/settings#bill-${second.id}`}>Other bill</Link>
<Link id="missing-bill" to="/settings#bill-missing">Missing bill</Link>
<Routes>
<Route path="/month/:month" element={<BudgetTree monthId="2026-09" rows={monthView(data, "2026-09").rows} />} />
<Route path="/settings" element={<SettingsRoute />} />
</Routes>
</MemoryRouter>));
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);
}
});
+14
View File
@@ -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 (
<div className="space-y-5">
<h1 className="text-2xl">Settings</h1>