Skip to content

Commit af133ed

Browse files
feat: split builder reordering, sliders, avatars, and invoice tags (#527)
Implements #471, #468, #469, #470 without adding dependencies — the libraries named in the issues (@dnd-kit, @headlessui/react, @radix-ui/react-slider, lucide-react) are not used in this repo, so native HTML5 drag and native range inputs stand in for them. #471 Drag handle per recipient row, HTML5 drag reordering with a drop-zone indicator, and Up/Down arrow reordering from the focused handle. Rows move as whole objects so percentages are never disturbed. #468 Per-row range slider (1% steps) plus a colour-coded sum indicator that turns green only at exactly 100%. Create Invoice is disabled with a tooltip while the sum is invalid. An exact-value number field is kept alongside each slider to preserve the sub-1% precision that "Equalize Shares" produces. #469 Gravatar (HTTPS, d=404) with a deterministic colour fallback seeded from the address, behind a circular skeleton. MD5 is implemented locally and verified against Node's crypto. #470 Off-chain tag store keyed by invoice id (Invoice is an on-chain SDK type), GET/PATCH routes, a cached useInvoiceTags hook with optimistic writes, an ARIA combobox tag input, pills on cards and detail, and a ?tag= dashboard filter. Co- Co-authored-by: Emmanuel Chukwunyere <emmanuelanalaba@gmail.com>
1 parent 969ee14 commit af133ed

23 files changed

Lines changed: 1903 additions & 18 deletions

next.config.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ const { withSentryConfig } = require("@sentry/nextjs");
55

66
/** @type {import('next').NextConfig} */
77
const nextConfig = {
8+
images: {
9+
// Recipient avatars fall back to Gravatar; served unoptimized so no image
10+
// requests are proxied through the Next.js optimizer.
11+
remotePatterns: [
12+
{
13+
protocol: "https",
14+
hostname: "www.gravatar.com",
15+
pathname: "/avatar/**",
16+
},
17+
],
18+
},
819
async headers() {
920
return [
1021
{
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* Issue #471 — drag-to-reorder recipients in the split builder.
3+
*/
4+
import { render, screen, fireEvent } from "@testing-library/react";
5+
import { vi } from "vitest";
6+
import SplitCalculator from "@/components/SplitCalculator";
7+
import { moveItem } from "@/lib/reorder";
8+
import type { RecipientLine, SplitMeta } from "@/hooks/useSplitCalculator";
9+
10+
vi.mock("@/lib/addressBook", () => ({
11+
getEmailForAddress: () => undefined,
12+
}));
13+
14+
function line(address: string, sharePercent: number): RecipientLine {
15+
return { address, sharePercent, taxRatePercent: 0, fixedFeeXLM: 0 };
16+
}
17+
18+
const recipients = [line("GAAA1", 50), line("GBBB2", 30), line("GCCC3", 20)];
19+
20+
function renderBuilder(onChange?: (m: SplitMeta) => void) {
21+
return render(
22+
<SplitCalculator
23+
splitMeta={{ totalAmount: 100, assetCode: "USDC", recipients }}
24+
onSplitMetaChange={onChange}
25+
/>
26+
);
27+
}
28+
29+
/** Addresses in the order they currently appear in the DOM. */
30+
function renderedAddresses(): string[] {
31+
return screen
32+
.getAllByPlaceholderText("G...")
33+
.map((el) => (el as HTMLInputElement).value);
34+
}
35+
36+
/** sharePercent values in DOM order, read off the exact-value number inputs. */
37+
function renderedShares(): number[] {
38+
return screen
39+
.getAllByLabelText(/share percentage, exact value/i)
40+
.map((el) => Number((el as HTMLInputElement).value));
41+
}
42+
43+
describe("moveItem", () => {
44+
it("moves an item forward and backward without mutating the source", () => {
45+
const src = ["a", "b", "c"];
46+
expect(moveItem(src, 0, 2)).toEqual(["b", "c", "a"]);
47+
expect(moveItem(src, 2, 0)).toEqual(["c", "a", "b"]);
48+
expect(src).toEqual(["a", "b", "c"]);
49+
});
50+
51+
it("is a no-op for equal or out-of-range indices", () => {
52+
const src = ["a", "b", "c"];
53+
expect(moveItem(src, 1, 1)).toEqual(src);
54+
expect(moveItem(src, -1, 1)).toEqual(src);
55+
expect(moveItem(src, 0, 9)).toEqual(src);
56+
});
57+
58+
it("preserves object identity so per-row values travel with the row", () => {
59+
const a = { sharePercent: 50 };
60+
const b = { sharePercent: 50 };
61+
const moved = moveItem([a, b], 0, 1);
62+
expect(moved[1]).toBe(a);
63+
});
64+
});
65+
66+
describe("SplitCalculator reordering", () => {
67+
it("renders a drag handle on the leading edge of every row", () => {
68+
renderBuilder();
69+
expect(screen.getByTestId("drag-handle-0")).toBeInTheDocument();
70+
expect(screen.getByTestId("drag-handle-1")).toBeInTheDocument();
71+
expect(screen.getByTestId("drag-handle-2")).toBeInTheDocument();
72+
73+
// Leading edge: the handle is the row's first interactive element.
74+
const row = screen.getByTestId("recipient-row-0");
75+
expect(row.querySelector("button")).toBe(screen.getByTestId("drag-handle-0"));
76+
});
77+
78+
it("reorders rows via drag and drop from the handle", () => {
79+
renderBuilder();
80+
expect(renderedAddresses()).toEqual(["GAAA1", "GBBB2", "GCCC3"]);
81+
82+
const handle = screen.getByTestId("drag-handle-0");
83+
const source = screen.getByTestId("recipient-row-0");
84+
const target = screen.getByTestId("recipient-row-2");
85+
86+
fireEvent.mouseDown(handle);
87+
fireEvent.dragStart(source);
88+
fireEvent.dragOver(target);
89+
fireEvent.drop(target);
90+
91+
expect(renderedAddresses()).toEqual(["GBBB2", "GCCC3", "GAAA1"]);
92+
});
93+
94+
it("shows a drop-zone indicator on the row being dragged over", () => {
95+
renderBuilder();
96+
97+
fireEvent.mouseDown(screen.getByTestId("drag-handle-0"));
98+
fireEvent.dragStart(screen.getByTestId("recipient-row-0"));
99+
fireEvent.dragOver(screen.getByTestId("recipient-row-1"));
100+
101+
expect(screen.getByTestId("recipient-row-1").className).toMatch(/ring-indigo-500/);
102+
// The dragged row is dimmed rather than marked as its own drop target.
103+
expect(screen.getByTestId("recipient-row-0").className).toMatch(/opacity-50/);
104+
expect(screen.getByTestId("recipient-row-0").className).not.toMatch(/ring-indigo-500/);
105+
});
106+
107+
it("reorders with ArrowDown/ArrowUp once the handle has focus", () => {
108+
renderBuilder();
109+
110+
const handle = screen.getByTestId("drag-handle-0");
111+
handle.focus();
112+
fireEvent.keyDown(handle, { key: "ArrowDown" });
113+
expect(renderedAddresses()).toEqual(["GBBB2", "GAAA1", "GCCC3"]);
114+
115+
// Focus follows the moved row so repeated presses keep moving it.
116+
expect(document.activeElement).toBe(screen.getByTestId("drag-handle-1"));
117+
118+
fireEvent.keyDown(screen.getByTestId("drag-handle-1"), { key: "ArrowUp" });
119+
expect(renderedAddresses()).toEqual(["GAAA1", "GBBB2", "GCCC3"]);
120+
});
121+
122+
it("does not move past the ends of the list", () => {
123+
renderBuilder();
124+
125+
fireEvent.keyDown(screen.getByTestId("drag-handle-0"), { key: "ArrowUp" });
126+
expect(renderedAddresses()).toEqual(["GAAA1", "GBBB2", "GCCC3"]);
127+
128+
fireEvent.keyDown(screen.getByTestId("drag-handle-2"), { key: "ArrowDown" });
129+
expect(renderedAddresses()).toEqual(["GAAA1", "GBBB2", "GCCC3"]);
130+
});
131+
132+
it("keeps each recipient's percentage attached to its row when reordering", () => {
133+
renderBuilder();
134+
expect(renderedShares()).toEqual([50, 30, 20]);
135+
136+
fireEvent.keyDown(screen.getByTestId("drag-handle-0"), { key: "ArrowDown" });
137+
138+
// The 50% row moved to position 2 — values travelled with it, and no
139+
// redistribution happened.
140+
expect(renderedAddresses()).toEqual(["GBBB2", "GAAA1", "GCCC3"]);
141+
expect(renderedShares()).toEqual([30, 50, 20]);
142+
});
143+
144+
it("includes the new order in the serialized split payload", () => {
145+
const onChange = vi.fn();
146+
renderBuilder(onChange);
147+
148+
fireEvent.keyDown(screen.getByTestId("drag-handle-0"), { key: "ArrowDown" });
149+
150+
const lastPayload = onChange.mock.calls[onChange.mock.calls.length - 1][0] as SplitMeta;
151+
expect(lastPayload.recipients.map((r) => r.address)).toEqual([
152+
"GBBB2",
153+
"GAAA1",
154+
"GCCC3",
155+
]);
156+
expect(lastPayload.recipients.map((r) => r.sharePercent)).toEqual([30, 50, 20]);
157+
});
158+
159+
it("announces keyboard moves to assistive technology", () => {
160+
renderBuilder();
161+
fireEvent.keyDown(screen.getByTestId("drag-handle-0"), { key: "ArrowDown" });
162+
expect(
163+
screen.getByText(/moved from position 1 to position 2 of 3/i)
164+
).toBeInTheDocument();
165+
});
166+
167+
it("omits drag handles in read-only mode", () => {
168+
render(
169+
<SplitCalculator
170+
splitMeta={{ totalAmount: 100, assetCode: "USDC", recipients }}
171+
readOnly
172+
/>
173+
);
174+
expect(screen.queryByTestId("drag-handle-0")).not.toBeInTheDocument();
175+
});
176+
});

src/__tests__/splitSlider.test.tsx

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* Issue #468 — split ratio sliders with real-time sum validation.
3+
*/
4+
import { render, screen, fireEvent, within } from "@testing-library/react";
5+
import { vi } from "vitest";
6+
import SplitCalculator from "@/components/SplitCalculator";
7+
import SplitSumIndicator from "@/components/invoice/SplitSumIndicator";
8+
import type { RecipientLine, SplitMeta } from "@/hooks/useSplitCalculator";
9+
10+
vi.mock("@/lib/addressBook", () => ({
11+
getEmailForAddress: () => undefined,
12+
}));
13+
14+
function line(address: string, sharePercent: number): RecipientLine {
15+
return { address, sharePercent, taxRatePercent: 0, fixedFeeXLM: 0 };
16+
}
17+
18+
function renderBuilder(recipients: RecipientLine[], onChange?: (m: SplitMeta) => void) {
19+
return render(
20+
<SplitCalculator
21+
splitMeta={{ totalAmount: 100, assetCode: "USDC", recipients }}
22+
onSplitMetaChange={onChange}
23+
/>
24+
);
25+
}
26+
27+
function sliders(): HTMLInputElement[] {
28+
return screen.getAllByLabelText(
29+
/^Recipient \d+ share percentage$/i
30+
) as HTMLInputElement[];
31+
}
32+
33+
describe("SplitSumIndicator", () => {
34+
it("turns green only at exactly 100%", () => {
35+
const { rerender } = render(<SplitSumIndicator sum={100} isValid />);
36+
expect(screen.getByTestId("split-sum-bar")).toHaveAttribute("data-state", "valid");
37+
expect(screen.getByTestId("split-sum-bar").className).toMatch(/bg-emerald-500/);
38+
39+
rerender(<SplitSumIndicator sum={99.9} isValid={false} />);
40+
expect(screen.getByTestId("split-sum-bar")).toHaveAttribute("data-state", "under");
41+
expect(screen.getByTestId("split-sum-bar").className).toMatch(/bg-red-500/);
42+
43+
rerender(<SplitSumIndicator sum={120} isValid={false} />);
44+
expect(screen.getByTestId("split-sum-bar")).toHaveAttribute("data-state", "over");
45+
expect(screen.getByTestId("split-sum-bar").className).toMatch(/bg-red-500/);
46+
});
47+
48+
it("reports how much is left to allocate, or the overage", () => {
49+
const { rerender } = render(<SplitSumIndicator sum={70} isValid={false} />);
50+
expect(screen.getByText(/30% left to allocate/i)).toBeInTheDocument();
51+
52+
rerender(<SplitSumIndicator sum={115} isValid={false} />);
53+
expect(screen.getByText(/over-allocated by 15%/i)).toBeInTheDocument();
54+
});
55+
56+
it("exposes progressbar semantics", () => {
57+
render(<SplitSumIndicator sum={40} isValid={false} />);
58+
const bar = screen.getByRole("progressbar");
59+
expect(bar).toHaveAttribute("aria-valuenow", "40");
60+
expect(bar).toHaveAttribute("aria-valuemin", "0");
61+
expect(bar).toHaveAttribute("aria-valuemax", "100");
62+
});
63+
});
64+
65+
describe("SplitCalculator sliders", () => {
66+
it("renders a slider per recipient row", () => {
67+
renderBuilder([line("GAAA1", 60), line("GBBB2", 40)]);
68+
const all = sliders();
69+
expect(all).toHaveLength(2);
70+
expect(all[0]).toHaveAttribute("type", "range");
71+
expect(all[0]).toHaveValue("60");
72+
expect(all[1]).toHaveValue("40");
73+
});
74+
75+
it("updates that recipient's percentage in real time", () => {
76+
renderBuilder([line("GAAA1", 60), line("GBBB2", 40)]);
77+
78+
fireEvent.change(sliders()[0], { target: { value: "75" } });
79+
80+
expect(sliders()[0]).toHaveValue("75");
81+
const indicator = screen.getByTestId("split-sum-indicator");
82+
expect(within(indicator).getByText("115% / 100%")).toBeInTheDocument();
83+
});
84+
85+
it("does not redistribute the remainder to other recipients", () => {
86+
renderBuilder([line("GAAA1", 50), line("GBBB2", 30), line("GCCC3", 20)]);
87+
88+
fireEvent.change(sliders()[0], { target: { value: "10" } });
89+
90+
// Only the adjusted row changes; the other two keep their values even
91+
// though the split is now under-allocated.
92+
expect(sliders().map((s) => s.value)).toEqual(["10", "30", "20"]);
93+
});
94+
95+
it("steps by 1% so arrow keys give whole-percent increments", () => {
96+
renderBuilder([line("GAAA1", 100)]);
97+
expect(sliders()[0]).toHaveAttribute("step", "1");
98+
expect(sliders()[0]).toHaveAttribute("min", "0");
99+
expect(sliders()[0]).toHaveAttribute("max", "100");
100+
});
101+
102+
it("keeps a paired exact-value field for sub-1% precision", () => {
103+
renderBuilder([line("GAAA1", 33.3333), line("GBBB2", 66.6667)]);
104+
105+
const exact = screen.getAllByLabelText(
106+
/share percentage, exact value/i
107+
) as HTMLInputElement[];
108+
expect(exact[0]).toHaveValue(33.3333);
109+
110+
fireEvent.change(exact[0], { target: { value: "33.5" } });
111+
expect(exact[0]).toHaveValue(33.5);
112+
});
113+
114+
it("marks the split valid only when the shares total exactly 100", () => {
115+
const onChange = vi.fn();
116+
renderBuilder([line("GAAA1", 50), line("GBBB2", 50)], onChange);
117+
118+
expect(screen.getByTestId("split-sum-bar")).toHaveAttribute("data-state", "valid");
119+
120+
fireEvent.change(sliders()[0], { target: { value: "51" } });
121+
expect(screen.getByTestId("split-sum-bar")).toHaveAttribute("data-state", "over");
122+
expect(
123+
screen.getByText(/share percentages must sum to 100%/i)
124+
).toBeInTheDocument();
125+
});
126+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import {
3+
MAX_TAGS_PER_INVOICE,
4+
TagsPayloadSchema,
5+
getTags,
6+
setTags,
7+
} from "@/lib/invoiceTags";
8+
9+
/** GET /api/invoices/:id/tags — tags currently applied to one invoice. */
10+
export async function GET(
11+
_request: NextRequest,
12+
{ params }: { params: { id: string } }
13+
) {
14+
try {
15+
return NextResponse.json(
16+
{ invoiceId: params.id, tags: getTags(params.id) },
17+
{ status: 200 }
18+
);
19+
} catch (error) {
20+
console.error("Tag fetch error:", error);
21+
return NextResponse.json(
22+
{
23+
error: "Failed to fetch tags",
24+
details: error instanceof Error ? error.message : String(error),
25+
},
26+
{ status: 500 }
27+
);
28+
}
29+
}
30+
31+
/**
32+
* PATCH /api/invoices/:id/tags — replace the invoice's tag set.
33+
*
34+
* The client sends the full desired list (both adds and removes go through
35+
* here), which keeps optimistic UI updates trivial to reconcile: the response
36+
* is the authoritative normalized set.
37+
*/
38+
export async function PATCH(
39+
request: NextRequest,
40+
{ params }: { params: { id: string } }
41+
) {
42+
try {
43+
const rawBody = await request.json();
44+
const parsed = TagsPayloadSchema.safeParse(rawBody);
45+
46+
if (!parsed.success) {
47+
return NextResponse.json(
48+
{
49+
error: `Invalid tags payload — expected { tags: string[] } with at most ${MAX_TAGS_PER_INVOICE} entries`,
50+
details: parsed.error.issues,
51+
},
52+
{ status: 422 }
53+
);
54+
}
55+
56+
const tags = setTags(params.id, parsed.data.tags);
57+
58+
return NextResponse.json({ success: true, invoiceId: params.id, tags }, { status: 200 });
59+
} catch (error) {
60+
console.error("Tag save error:", error);
61+
return NextResponse.json(
62+
{
63+
error: "Failed to persist tags",
64+
details: error instanceof Error ? error.message : String(error),
65+
},
66+
{ status: 500 }
67+
);
68+
}
69+
}

0 commit comments

Comments
 (0)