fix: preserve blank-name backups and open linked bill editors
This commit is contained in:
+23
-10
@@ -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
@@ -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}"`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user