feat: choose recurring bill destinations
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
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 { expect, test } from "bun:test";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createSeedDataset } from "../../domain/seed.ts";
|
||||
import { RecurringSplitEditor, type RecurringSplitSelection } from "./RecurringSplitEditor.tsx";
|
||||
|
||||
test("bill can split across active posts", () => {
|
||||
const data = createSeedDataset("2026-09");
|
||||
data.posts[0]!.archived = true;
|
||||
const active = data.posts.filter(post => !post.archived);
|
||||
let selection: RecurringSplitSelection = { splitMode: "percent", splits: [{ postId: active[0]!.id, value: 100, absorbsRemainder: true }] };
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
const render = () => root.render(<RecurringSplitEditor {...selection} posts={active} onChange={next => { selection = next; render(); }} />);
|
||||
act(render);
|
||||
expect(container.querySelector(`option[value="${data.posts[0]!.id}"]`)).toBeNull();
|
||||
act(() => { (Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Add another post"))!).click(); });
|
||||
expect(selection.splits).toHaveLength(2);
|
||||
const selects = container.querySelectorAll("select");
|
||||
act(() => { selects[2]!.value = active[1]!.id; selects[2]!.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
const radios = container.querySelectorAll('input[type="radio"]');
|
||||
act(() => { (radios[1] as HTMLInputElement).click(); });
|
||||
expect(selection.splits.map(split => split.postId)).toEqual([active[0]!.id, active[1]!.id]);
|
||||
expect(selection.splits.map(split => split.absorbsRemainder)).toEqual([false, true]);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useId } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import type { Post, RecurringCost, Split } from "../../domain/types.ts";
|
||||
|
||||
export type RecurringSplitSelection = Pick<RecurringCost, "splitMode" | "splits">;
|
||||
|
||||
interface Props extends RecurringSplitSelection {
|
||||
posts: Post[];
|
||||
onChange: (next: RecurringSplitSelection) => void;
|
||||
}
|
||||
|
||||
export function RecurringSplitEditor({ splitMode, splits, posts, onChange }: Props) {
|
||||
const absorberGroup = useId();
|
||||
function changeSplit(index: number, changes: Partial<Split>) {
|
||||
onChange({ splitMode, splits: splits.map((split, i) => i === index ? { ...split, ...changes } : split) });
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">Destination posts</legend>
|
||||
{splits.length > 1 && (
|
||||
<NativeSelect aria-label="Split by" value={splitMode} onChange={event => {
|
||||
const mode = event.target.value as RecurringCost["splitMode"];
|
||||
onChange({ splitMode: mode, splits: splits.map((split, index) => ({ ...split, value: mode === "percent" && index === 0 ? 100 : 0 })) });
|
||||
}}>
|
||||
<option value="percent">Percentages</option>
|
||||
<option value="fixed">Amounts</option>
|
||||
</NativeSelect>
|
||||
)}
|
||||
{splits.map((split, index) => (
|
||||
<div key={index} className="flex flex-wrap items-center gap-2">
|
||||
<NativeSelect
|
||||
aria-label={index === 0 ? "Destination post" : `Destination post ${index + 1}`}
|
||||
className="min-w-36 flex-1"
|
||||
value={split.postId}
|
||||
onChange={event => changeSplit(index, { postId: event.target.value })}
|
||||
>
|
||||
<option value="">Choose a post…</option>
|
||||
{posts.map(post => <option key={post.id} value={post.id}>{post.name}</option>)}
|
||||
</NativeSelect>
|
||||
{splits.length > 1 && (
|
||||
<>
|
||||
<Input aria-label={`Split value ${index + 1}`} type="number" step="0.01" className="w-24" value={split.value} onChange={event => changeSplit(index, { value: Number(event.target.value) || 0 })} />
|
||||
<span className="text-xs text-budget-ink-muted">{splitMode === "percent" ? "%" : "amount"}</span>
|
||||
<label className="flex items-center gap-1 text-xs"><input type="radio" name={absorberGroup} checked={split.absorbsRemainder} onChange={() => onChange({ splitMode, splits: splits.map((item, i) => ({ ...item, absorbsRemainder: i === index })) })} />rounding</label>
|
||||
<Button type="button" size="xs" variant="ghost" onClick={() => {
|
||||
const remaining = splits.filter((_, i) => i !== index);
|
||||
if (!remaining.some(item => item.absorbsRemainder)) remaining[0] = { ...remaining[0]!, absorbsRemainder: true };
|
||||
onChange({ splitMode, splits: remaining });
|
||||
}}>remove</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => onChange({ splitMode, splits: [...splits, { postId: "", value: 0, absorbsRemainder: false }] })}>Add another post</Button>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user