Skip to content

Commit f0dbeb2

Browse files
authored
feat: implement #540 #541 #568 #574 frontend components (#686)
- #541 TimeRangePicker: presets (1h/24h/7d/30d), custom datetime-local inputs, timezone selector, UTC display - #540 AdvancedFilterBuilder: AND/OR logic, multiple conditions with field/operator/value, filter preview, save/load/delete templates - #568 WebhookTable: responsive stacked mobile cards with collapsible details and touch-friendly buttons (min 44px), desktop table unchanged - #574 OrgLogoUpload: file upload UI, type/size validation, image preview, canvas-based square crop tool 62 tests passing across 4 suites
1 parent b2f406b commit f0dbeb2

8 files changed

Lines changed: 1502 additions & 139 deletions

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import React from "react"
2+
import { render, screen, fireEvent } from "@testing-library/react"
3+
import { AdvancedFilterBuilder } from "@/components/ui/AdvancedFilterBuilder"
4+
5+
const FIELDS = ["contractId", "eventType", "ledger", "value"]
6+
const mockOnChange = jest.fn()
7+
const mockOnApply = jest.fn()
8+
9+
beforeEach(() => {
10+
mockOnChange.mockClear()
11+
mockOnApply.mockClear()
12+
localStorage.clear()
13+
})
14+
15+
function renderBuilder(overrides = {}) {
16+
return render(
17+
<AdvancedFilterBuilder
18+
fields={FIELDS}
19+
onChange={mockOnChange}
20+
onApply={mockOnApply}
21+
storageKey="test_filter_templates"
22+
{...overrides}
23+
/>
24+
)
25+
}
26+
27+
describe("AdvancedFilterBuilder", () => {
28+
it("renders the filter builder heading", () => {
29+
renderBuilder()
30+
expect(screen.getByText("[FILTER_BUILDER]")).toBeInTheDocument()
31+
})
32+
33+
it("renders AND and OR logic buttons", () => {
34+
renderBuilder()
35+
expect(screen.getByRole("button", { name: "AND" })).toBeInTheDocument()
36+
expect(screen.getByRole("button", { name: "OR" })).toBeInTheDocument()
37+
})
38+
39+
it("defaults to AND logic", () => {
40+
renderBuilder()
41+
const andBtn = screen.getByRole("button", { name: "AND" })
42+
expect(andBtn).toHaveAttribute("aria-pressed", "true")
43+
})
44+
45+
it("switches to OR logic when OR is clicked", () => {
46+
renderBuilder()
47+
fireEvent.click(screen.getByRole("button", { name: "OR" }))
48+
expect(screen.getByRole("button", { name: "OR" })).toHaveAttribute("aria-pressed", "true")
49+
expect(mockOnChange).toHaveBeenCalledWith(expect.objectContaining({ logic: "OR" }))
50+
})
51+
52+
it("renders one condition row by default", () => {
53+
renderBuilder()
54+
expect(screen.getAllByTestId("filter-condition")).toHaveLength(1)
55+
})
56+
57+
it("adds a condition when ADD_CONDITION is clicked", () => {
58+
renderBuilder()
59+
fireEvent.click(screen.getByText("+ ADD_CONDITION"))
60+
expect(screen.getAllByTestId("filter-condition")).toHaveLength(2)
61+
expect(mockOnChange).toHaveBeenCalled()
62+
})
63+
64+
it("removes a condition when remove button is clicked", () => {
65+
renderBuilder()
66+
fireEvent.click(screen.getByText("+ ADD_CONDITION"))
67+
expect(screen.getAllByTestId("filter-condition")).toHaveLength(2)
68+
fireEvent.click(screen.getAllByLabelText("Remove condition")[0])
69+
expect(screen.getAllByTestId("filter-condition")).toHaveLength(1)
70+
})
71+
72+
it("updates field select and calls onChange", () => {
73+
renderBuilder()
74+
const fieldSelect = screen.getAllByLabelText("Filter field")[0]
75+
fireEvent.change(fieldSelect, { target: { value: "eventType" } })
76+
expect(mockOnChange).toHaveBeenCalledWith(
77+
expect.objectContaining({
78+
conditions: expect.arrayContaining([
79+
expect.objectContaining({ field: "eventType" }),
80+
]),
81+
})
82+
)
83+
})
84+
85+
it("updates operator select and calls onChange", () => {
86+
renderBuilder()
87+
const opSelect = screen.getAllByLabelText("Filter operator")[0]
88+
fireEvent.change(opSelect, { target: { value: "contains" } })
89+
expect(mockOnChange).toHaveBeenCalledWith(
90+
expect.objectContaining({
91+
conditions: expect.arrayContaining([
92+
expect.objectContaining({ operator: "contains" }),
93+
]),
94+
})
95+
)
96+
})
97+
98+
it("updates value input and calls onChange", () => {
99+
renderBuilder()
100+
const valueInput = screen.getAllByLabelText("Filter value")[0]
101+
fireEvent.change(valueInput, { target: { value: "swap" } })
102+
expect(mockOnChange).toHaveBeenCalledWith(
103+
expect.objectContaining({
104+
conditions: expect.arrayContaining([
105+
expect.objectContaining({ value: "swap" }),
106+
]),
107+
})
108+
)
109+
})
110+
111+
it("shows filter preview", () => {
112+
renderBuilder()
113+
expect(screen.getByTestId("filter-preview")).toBeInTheDocument()
114+
})
115+
116+
it("preview updates when value changes", () => {
117+
renderBuilder()
118+
const valueInput = screen.getAllByLabelText("Filter value")[0]
119+
fireEvent.change(valueInput, { target: { value: "myvalue" } })
120+
expect(screen.getByTestId("filter-preview").textContent).toContain("myvalue")
121+
})
122+
123+
it("calls onApply when APPLY is clicked", () => {
124+
renderBuilder()
125+
fireEvent.click(screen.getByRole("button", { name: "APPLY" }))
126+
expect(mockOnApply).toHaveBeenCalledTimes(1)
127+
})
128+
129+
it("shows save input when SAVE button is clicked", () => {
130+
renderBuilder()
131+
fireEvent.click(screen.getByRole("button", { name: "SAVE" }))
132+
expect(screen.getByLabelText("Template name")).toBeInTheDocument()
133+
})
134+
135+
it("saves a template and shows it in the list", () => {
136+
renderBuilder()
137+
fireEvent.click(screen.getByRole("button", { name: "SAVE" }))
138+
const nameInput = screen.getByLabelText("Template name")
139+
fireEvent.change(nameInput, { target: { value: "My Filter" } })
140+
// Click the second SAVE button (inside the save form)
141+
const saveBtns = screen.getAllByRole("button", { name: "SAVE" })
142+
fireEvent.click(saveBtns[saveBtns.length - 1])
143+
expect(screen.getByText("My Filter")).toBeInTheDocument()
144+
})
145+
146+
it("loads a saved template when clicked", () => {
147+
// Pre-populate localStorage
148+
const template = {
149+
name: "Preset",
150+
group: { logic: "OR", conditions: [{ id: "x", field: "ledger", operator: "gt", value: "100" }] },
151+
}
152+
localStorage.setItem("test_filter_templates", JSON.stringify([template]))
153+
renderBuilder()
154+
fireEvent.click(screen.getByText("Preset"))
155+
expect(mockOnChange).toHaveBeenCalledWith(
156+
expect.objectContaining({ logic: "OR" })
157+
)
158+
})
159+
160+
it("deletes a saved template", () => {
161+
const template = { name: "ToDelete", group: { logic: "AND", conditions: [] } }
162+
localStorage.setItem("test_filter_templates", JSON.stringify([template]))
163+
renderBuilder()
164+
expect(screen.getByText("ToDelete")).toBeInTheDocument()
165+
fireEvent.click(screen.getByLabelText("Delete template ToDelete"))
166+
expect(screen.queryByText("ToDelete")).not.toBeInTheDocument()
167+
})
168+
})
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import React from "react"
2+
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
3+
import { OrgLogoUpload } from "@/components/ui/OrgLogoUpload"
4+
5+
const mockOnSave = jest.fn()
6+
7+
class MockImage {
8+
naturalWidth = 200
9+
naturalHeight = 200
10+
onload: (() => void) | null = null
11+
onerror: (() => void) | null = null
12+
private _src = ""
13+
get src() { return this._src }
14+
set src(v: string) { this._src = v; setTimeout(() => this.onload?.(), 0) }
15+
}
16+
17+
const mockDrawImage = jest.fn()
18+
const mockToDataURL = jest.fn(() => "data:image/png;base64,CROPPED")
19+
20+
beforeAll(() => {
21+
// @ts-expect-error mock
22+
global.Image = MockImage
23+
HTMLCanvasElement.prototype.getContext = jest.fn(() => ({ drawImage: mockDrawImage })) as unknown as typeof HTMLCanvasElement.prototype.getContext
24+
HTMLCanvasElement.prototype.toDataURL = mockToDataURL
25+
})
26+
27+
beforeEach(() => { mockOnSave.mockClear(); mockDrawImage.mockClear(); mockToDataURL.mockClear() })
28+
29+
function makeFile(name = "logo.png", type = "image/png", size = 100) {
30+
return new File([new Uint8Array(size)], name, { type })
31+
}
32+
33+
function mockFileReader(result: string) {
34+
const fr = { readAsDataURL: jest.fn(), onload: null as unknown, result }
35+
jest.spyOn(global, "FileReader").mockImplementation(() => fr as unknown as FileReader)
36+
return fr
37+
}
38+
39+
describe("OrgLogoUpload", () => {
40+
it("renders the upload heading", () => {
41+
render(<OrgLogoUpload onSave={mockOnSave} />)
42+
expect(screen.getByText("[ORG_LOGO]")).toBeInTheDocument()
43+
})
44+
45+
it("renders UPLOAD_LOGO button by default", () => {
46+
render(<OrgLogoUpload onSave={mockOnSave} />)
47+
expect(screen.getByRole("button", { name: "UPLOAD_LOGO" })).toBeInTheDocument()
48+
})
49+
50+
it("renders CHANGE_LOGO when currentLogoUrl is provided", () => {
51+
render(<OrgLogoUpload onSave={mockOnSave} currentLogoUrl="https://example.com/logo.png" />)
52+
expect(screen.getByRole("button", { name: "CHANGE_LOGO" })).toBeInTheDocument()
53+
})
54+
55+
it("shows current logo preview when currentLogoUrl is provided", () => {
56+
render(<OrgLogoUpload onSave={mockOnSave} currentLogoUrl="https://example.com/logo.png" />)
57+
const img = screen.getByTestId("logo-preview") as HTMLImageElement
58+
expect(img.src).toContain("example.com/logo.png")
59+
})
60+
61+
it("shows error for unsupported file type", async () => {
62+
render(<OrgLogoUpload onSave={mockOnSave} />)
63+
fireEvent.change(screen.getByTestId("logo-file-input"), {
64+
target: { files: [makeFile("doc.pdf", "application/pdf")] },
65+
})
66+
await waitFor(() => expect(screen.getByTestId("logo-error").textContent).toMatch(/Unsupported file type/))
67+
})
68+
69+
it("shows error when file exceeds max size", async () => {
70+
render(<OrgLogoUpload onSave={mockOnSave} maxSizeBytes={50} />)
71+
fireEvent.change(screen.getByTestId("logo-file-input"), {
72+
target: { files: [makeFile("big.png", "image/png", 200)] },
73+
})
74+
await waitFor(() => expect(screen.getByTestId("logo-error").textContent).toMatch(/too large/))
75+
})
76+
77+
it("shows crop tool after valid file is selected", async () => {
78+
const fr = mockFileReader("data:image/png;base64,FAKE")
79+
render(<OrgLogoUpload onSave={mockOnSave} />)
80+
fireEvent.change(screen.getByTestId("logo-file-input"), { target: { files: [makeFile()] } })
81+
;(fr.onload as unknown as (e: unknown) => void)?.({ target: { result: fr.result } })
82+
await waitFor(() => expect(screen.getByTestId("crop-tool")).toBeInTheDocument())
83+
jest.restoreAllMocks()
84+
})
85+
86+
it("removes logo when REMOVE is clicked", () => {
87+
render(<OrgLogoUpload onSave={mockOnSave} currentLogoUrl="https://example.com/logo.png" />)
88+
fireEvent.click(screen.getByLabelText("Remove logo"))
89+
expect(screen.queryByTestId("logo-preview")).not.toBeInTheDocument()
90+
})
91+
92+
it("shows file type hint text", () => {
93+
render(<OrgLogoUpload onSave={mockOnSave} />)
94+
expect(screen.getByText(/PNG, JPEG, GIF, WEBP/)).toBeInTheDocument()
95+
})
96+
97+
it("crop tool has decrease and increase size buttons", async () => {
98+
const fr = mockFileReader("data:image/png;base64,FAKE")
99+
render(<OrgLogoUpload onSave={mockOnSave} />)
100+
fireEvent.change(screen.getByTestId("logo-file-input"), { target: { files: [makeFile()] } })
101+
;(fr.onload as unknown as (e: unknown) => void)?.({ target: { result: fr.result } })
102+
await waitFor(() => screen.getByTestId("crop-tool"))
103+
expect(screen.getByLabelText("Decrease crop size")).toBeInTheDocument()
104+
expect(screen.getByLabelText("Increase crop size")).toBeInTheDocument()
105+
jest.restoreAllMocks()
106+
})
107+
108+
it("crop tool CANCEL returns to upload view", async () => {
109+
const fr = mockFileReader("data:image/png;base64,FAKE")
110+
render(<OrgLogoUpload onSave={mockOnSave} />)
111+
fireEvent.change(screen.getByTestId("logo-file-input"), { target: { files: [makeFile()] } })
112+
;(fr.onload as unknown as (e: unknown) => void)?.({ target: { result: fr.result } })
113+
await waitFor(() => screen.getByTestId("crop-tool"))
114+
fireEvent.click(screen.getByRole("button", { name: "CANCEL" }))
115+
expect(screen.queryByTestId("crop-tool")).not.toBeInTheDocument()
116+
expect(screen.getByRole("button", { name: "UPLOAD_LOGO" })).toBeInTheDocument()
117+
jest.restoreAllMocks()
118+
})
119+
120+
it("crop tool CONFIRM_CROP calls onSave with data URL", async () => {
121+
const fr = mockFileReader("data:image/png;base64,FAKE")
122+
render(<OrgLogoUpload onSave={mockOnSave} />)
123+
fireEvent.change(screen.getByTestId("logo-file-input"), { target: { files: [makeFile()] } })
124+
;(fr.onload as unknown as (e: unknown) => void)?.({ target: { result: fr.result } })
125+
await waitFor(() => screen.getByTestId("crop-tool"))
126+
fireEvent.click(screen.getByRole("button", { name: "CONFIRM_CROP" }))
127+
await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith("data:image/png;base64,CROPPED"))
128+
jest.restoreAllMocks()
129+
})
130+
})
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import React from "react"
2+
import { render, screen, fireEvent } from "@testing-library/react"
3+
import { TimeRangePicker } from "@/components/ui/TimeRangePicker"
4+
5+
const mockOnChange = jest.fn()
6+
7+
beforeEach(() => mockOnChange.mockClear())
8+
9+
describe("TimeRangePicker", () => {
10+
it("renders preset buttons", () => {
11+
render(<TimeRangePicker onChange={mockOnChange} />)
12+
expect(screen.getByText("Last 1h")).toBeInTheDocument()
13+
expect(screen.getByText("Last 24h")).toBeInTheDocument()
14+
expect(screen.getByText("Last 7d")).toBeInTheDocument()
15+
expect(screen.getByText("Last 30d")).toBeInTheDocument()
16+
})
17+
18+
it("renders FROM and TO datetime inputs", () => {
19+
render(<TimeRangePicker onChange={mockOnChange} />)
20+
expect(screen.getByLabelText("From date and time")).toBeInTheDocument()
21+
expect(screen.getByLabelText("To date and time")).toBeInTheDocument()
22+
})
23+
24+
it("renders timezone selector with UTC default", () => {
25+
render(<TimeRangePicker onChange={mockOnChange} />)
26+
const tzSelect = screen.getByLabelText("Timezone")
27+
expect(tzSelect).toBeInTheDocument()
28+
expect((tzSelect as HTMLSelectElement).value).toBe("UTC")
29+
})
30+
31+
it("calls onChange when a preset is clicked", () => {
32+
render(<TimeRangePicker onChange={mockOnChange} />)
33+
fireEvent.click(screen.getByText("Last 1h"))
34+
expect(mockOnChange).toHaveBeenCalledTimes(1)
35+
const arg = mockOnChange.mock.calls[0][0]
36+
expect(arg).toHaveProperty("from")
37+
expect(arg).toHaveProperty("to")
38+
expect(arg.timezone).toBe("UTC")
39+
// from should be ~1h before to
40+
const diffMs = arg.to.getTime() - arg.from.getTime()
41+
expect(diffMs).toBeCloseTo(60 * 60 * 1000, -3)
42+
})
43+
44+
it("calls onChange when timezone changes", () => {
45+
render(<TimeRangePicker onChange={mockOnChange} />)
46+
const tzSelect = screen.getByLabelText("Timezone")
47+
fireEvent.change(tzSelect, { target: { value: "Europe/London" } })
48+
expect(mockOnChange).toHaveBeenCalledTimes(1)
49+
expect(mockOnChange.mock.calls[0][0].timezone).toBe("Europe/London")
50+
})
51+
52+
it("highlights active preset after clicking", () => {
53+
render(<TimeRangePicker onChange={mockOnChange} />)
54+
const btn = screen.getByText("Last 7d")
55+
fireEvent.click(btn)
56+
expect(btn.className).toMatch(/bg-terminal-green/)
57+
})
58+
59+
it("shows UTC display below FROM input after preset click", () => {
60+
render(<TimeRangePicker onChange={mockOnChange} />)
61+
fireEvent.click(screen.getByText("Last 1h"))
62+
// UTC display should contain "UTC"
63+
const utcLabels = screen.getAllByText(/UTC/)
64+
expect(utcLabels.length).toBeGreaterThanOrEqual(1)
65+
})
66+
67+
it("calls onChange when FROM input changes", () => {
68+
render(<TimeRangePicker onChange={mockOnChange} />)
69+
const fromInput = screen.getByLabelText("From date and time")
70+
fireEvent.change(fromInput, { target: { value: "2025-01-01T00:00" } })
71+
expect(mockOnChange).toHaveBeenCalled()
72+
})
73+
74+
it("calls onChange when TO input changes", () => {
75+
render(<TimeRangePicker onChange={mockOnChange} />)
76+
const toInput = screen.getByLabelText("To date and time")
77+
fireEvent.change(toInput, { target: { value: "2025-01-02T00:00" } })
78+
expect(mockOnChange).toHaveBeenCalled()
79+
})
80+
81+
it("accepts a value prop and reflects it", () => {
82+
const from = new Date("2025-06-01T10:00:00Z")
83+
const to = new Date("2025-06-01T12:00:00Z")
84+
render(
85+
<TimeRangePicker
86+
value={{ from, to, timezone: "UTC" }}
87+
onChange={mockOnChange}
88+
/>
89+
)
90+
const fromInput = screen.getByLabelText("From date and time") as HTMLInputElement
91+
expect(fromInput.value).toBe("2025-06-01T10:00")
92+
})
93+
})

0 commit comments

Comments
 (0)