fix(rules): review and harden rule-history editing

This commit is contained in:
Lucas Winther
2026-09-29 19:01:57 +02:00
parent 8c3dbafc86
commit c93ff9908c
6 changed files with 154 additions and 41 deletions
+101
View File
@@ -0,0 +1,101 @@
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 { afterEach, expect, test } from "bun:test";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { createSeedDataset } from "../../domain/seed.ts";
import { store } from "../../store/index.ts";
import { RuleHistory } from "./RuleHistory.tsx";
import { RuleFromMonth } from "../routes/MonthRoute.tsx";
const originalMutate = store.mutate;
let root: Root | null = null;
let container: HTMLDivElement | null = null;
function mount(child: React.ReactNode): HTMLDivElement {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
act(() => root!.render(child));
return container;
}
afterEach(() => {
store.mutate = originalMutate;
if (root) act(() => root!.unmount());
root = null;
container?.remove();
container = null;
});
test("change from here preserves the currency of a prefilled fixed rule", async () => {
const data = createSeedDataset("2026-01");
const post = data.posts[0]!;
const initial = { kind: "fixed" as const, amount: { amount: 100, currency: "USD" } };
let saved = false;
store.mutate = async (change) => {
change(data);
saved = true;
};
const el = mount(
<RuleFromMonth post={post} monthId="2026-01" initial={initial} onDone={() => {}} />,
);
expect(el.querySelector('input[aria-label="Amount in USD"]')).not.toBeNull();
await act(async () => {
(Array.from(el.querySelectorAll("button")).find((button) => button.textContent === "Apply")!).click();
});
expect(saved).toBe(true);
expect(post.rules[0]!.rule).toEqual(initial);
});
test("change from here keeps the editor open and explains a failed write", async () => {
const data = createSeedDataset("2026-01");
let closed = false;
store.mutate = async () => {
throw new Error("Disk full");
};
const el = mount(
<RuleFromMonth
post={data.posts[0]!}
monthId="2026-01"
initial={{ kind: "percentOfIncome", percent: 10 }}
onDone={() => { closed = true; }}
/>,
);
await act(async () => {
(Array.from(el.querySelectorAll("button")).find((button) => button.textContent === "Apply")!).click();
});
expect(closed).toBe(false);
expect(el.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
});
test("rule history explains a failed write", async () => {
const data = createSeedDataset("2026-01");
const post = data.posts[0]!;
post.rules = [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 10 } }];
store.mutate = async () => {
throw new Error("Disk full");
};
const el = mount(<RuleHistory post={post} />);
await act(async () => {
(Array.from(el.querySelectorAll("button")).find((button) => button.textContent === "remove")!).click();
});
expect(el.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
});
test("rule history shows imported versions in chronological order", () => {
const data = createSeedDataset("2026-01");
const post = data.posts[0]!;
post.rules = [
{ from: "2026-07", rule: { kind: "percentOfIncome", percent: 15 } },
{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 10 } },
];
const el = mount(<RuleHistory post={post} />);
const months = Array.from(el.querySelectorAll("tbody tr td:first-child"), (cell) => cell.textContent);
expect(months).toEqual(["2026-01", "2026-07"]);
});
+10 -6
View File
@@ -27,12 +27,13 @@ const MONTH_ID = /^\d{4}-(0[1-9]|1[0-2])$/;
* so rather than letting it look like an append.
*/
export function RuleHistory({ post }: Props) {
const { mutate } = useMutate();
const { mutate, error, clearError } = useMutate();
const [from, setFrom] = useState<string>(currentMonth);
const [kind, setKind] = useState<Rule["kind"]>("percentOfIncome");
// Held as text, not a number, so the field can be cleared and can hold a
// half-typed "12." mid-entry. Parsed once, on submit.
const [value, setValue] = useState<string>("");
const versions = [...post.rules].sort((a, b) => a.from.localeCompare(b.from));
const replaces = post.rules.some((v) => v.from === from);
const monthOk = MONTH_ID.test(from);
@@ -44,7 +45,8 @@ export function RuleHistory({ post }: Props) {
kind === "fixed"
? { kind: "fixed", amount: { amount, currency: post.currency } }
: { kind: "percentOfIncome", percent: amount };
mutate((draft) => setRuleFrom(draft, post.id, from, rule));
clearError();
void mutate((draft) => setRuleFrom(draft, post.id, from, rule));
}
return (
@@ -64,7 +66,7 @@ export function RuleHistory({ post }: Props) {
</tr>
</thead>
<tbody>
{post.rules.map((version) => (
{versions.map((version) => (
<tr key={version.from} className="border-t">
<td className="py-1 font-money">{version.from}</td>
<td className="py-1">
@@ -76,9 +78,10 @@ export function RuleHistory({ post }: Props) {
<Button
size="sm"
variant="ghost"
onClick={() =>
mutate((draft) => removeRuleFrom(draft, post.id, version.from))
}
onClick={() => {
clearError();
void mutate((draft) => removeRuleFrom(draft, post.id, version.from));
}}
>
remove
</Button>
@@ -137,6 +140,7 @@ export function RuleHistory({ post }: Props) {
</span>
)}
</div>
{error && <p role="alert" className="text-sm text-destructive">Could not save: {error}</p>}
</div>
);
}