From 7612fb41c2ca1fef0270946973c03ab664d2eacd Mon Sep 17 00:00:00 2001 From: She-ge Date: Mon, 1 Jun 2026 00:45:16 +0100 Subject: [PATCH 1/5] feat(webhooks): add visual filter expression builder (#578) - Add WebhookFilterExpressionBuilder with recursive AND/OR group nesting - Add FilterExpressionTester for real-time pass/fail evaluation against sample JSON - Add FilterBuilderModal combining builder + tester in a tabbed modal - Update CreateWebhookModal to integrate filter expression builder flow - Add standalone filter builder page at /webhooks/filter-builder - Add optional filterExpression field to Webhook type - Add 61 tests covering serializer, evaluator engine, and all UI interactions Closes #578 --- .../filter-expression-tester.test.tsx | 347 ++++++++++ ...webhook-filter-expression-builder.test.tsx | 321 +++++++++ .../components/CreateWebhookModal.tsx | 310 +++++---- .../components/FilterBuilderModal.tsx | 112 +++ .../app/webhooks/filter-builder/page.tsx | 187 +++++ soroscan-frontend/app/webhooks/types.ts | 2 + .../components/ui/FilterExpressionTester.tsx | 369 ++++++++++ .../ui/WebhookFilterExpressionBuilder.tsx | 651 ++++++++++++++++++ 8 files changed, 2184 insertions(+), 115 deletions(-) create mode 100644 soroscan-frontend/__tests__/filter-expression-tester.test.tsx create mode 100644 soroscan-frontend/__tests__/webhook-filter-expression-builder.test.tsx create mode 100644 soroscan-frontend/app/webhooks/components/FilterBuilderModal.tsx create mode 100644 soroscan-frontend/app/webhooks/filter-builder/page.tsx create mode 100644 soroscan-frontend/components/ui/FilterExpressionTester.tsx create mode 100644 soroscan-frontend/components/ui/WebhookFilterExpressionBuilder.tsx diff --git a/soroscan-frontend/__tests__/filter-expression-tester.test.tsx b/soroscan-frontend/__tests__/filter-expression-tester.test.tsx new file mode 100644 index 00000000..294faa2a --- /dev/null +++ b/soroscan-frontend/__tests__/filter-expression-tester.test.tsx @@ -0,0 +1,347 @@ +import React from "react" +import { render, screen, fireEvent, act, waitFor } from "@testing-library/react" +import { FilterExpressionTester, evaluateExpression } from "@/components/ui/FilterExpressionTester" +import { emptyExpression, type FilterExpression } from "@/components/ui/WebhookFilterExpressionBuilder" + +// ── Unit: evaluateExpression ────────────────────────────────────────────────── + +describe("evaluateExpression", () => { + const payload = { + event_type: "SWAP_COMPLETE", + contract_id: "CABC...9X4Z", + amount: 1500, + ledger: 52300, + success: true, + from_address: "GDEX...A1B2", + topic: "soroscan/swap", + } + + function makeExpr(field: string, operator: string, value: string): FilterExpression { + return { + root: { + id: "g1", + kind: "group", + logic: "AND", + children: [ + { id: "c1", kind: "condition", field, operator: operator as never, value }, + ], + }, + } + } + + it("passes eq condition when values match", () => { + const result = evaluateExpression(makeExpr("event_type", "eq", "SWAP_COMPLETE"), payload) + expect(result.passed).toBe(true) + expect(result.conditionResults[0].passed).toBe(true) + }) + + it("fails eq condition when values differ", () => { + const result = evaluateExpression(makeExpr("event_type", "eq", "LIQUIDITY_ADD"), payload) + expect(result.passed).toBe(false) + }) + + it("passes neq condition when values differ", () => { + const result = evaluateExpression(makeExpr("event_type", "neq", "LIQUIDITY_ADD"), payload) + expect(result.passed).toBe(true) + }) + + it("passes contains condition", () => { + const result = evaluateExpression(makeExpr("topic", "contains", "swap"), payload) + expect(result.passed).toBe(true) + }) + + it("fails not_contains when value is present", () => { + const result = evaluateExpression(makeExpr("topic", "not_contains", "swap"), payload) + expect(result.passed).toBe(false) + }) + + it("passes starts_with", () => { + const result = evaluateExpression(makeExpr("topic", "starts_with", "soroscan"), payload) + expect(result.passed).toBe(true) + }) + + it("passes ends_with", () => { + const result = evaluateExpression(makeExpr("topic", "ends_with", "swap"), payload) + expect(result.passed).toBe(true) + }) + + it("passes gt condition for numeric field", () => { + const result = evaluateExpression(makeExpr("amount", "gt", "1000"), payload) + expect(result.passed).toBe(true) + }) + + it("fails gt condition when value is not greater", () => { + const result = evaluateExpression(makeExpr("amount", "gt", "2000"), payload) + expect(result.passed).toBe(false) + }) + + it("passes gte condition at boundary", () => { + const result = evaluateExpression(makeExpr("amount", "gte", "1500"), payload) + expect(result.passed).toBe(true) + }) + + it("passes lt condition", () => { + const result = evaluateExpression(makeExpr("ledger", "lt", "60000"), payload) + expect(result.passed).toBe(true) + }) + + it("passes lte condition at boundary", () => { + const result = evaluateExpression(makeExpr("ledger", "lte", "52300"), payload) + expect(result.passed).toBe(true) + }) + + it("passes in condition when value is in list", () => { + const result = evaluateExpression( + makeExpr("event_type", "in", "SWAP_COMPLETE, LIQUIDITY_ADD"), + payload + ) + expect(result.passed).toBe(true) + }) + + it("fails in condition when value is not in list", () => { + const result = evaluateExpression( + makeExpr("event_type", "in", "GOV_PROPOSAL, ORACLE_UPDATE"), + payload + ) + expect(result.passed).toBe(false) + }) + + it("passes not_in when value is absent", () => { + const result = evaluateExpression( + makeExpr("event_type", "not_in", "GOV_PROPOSAL, ORACLE_UPDATE"), + payload + ) + expect(result.passed).toBe(true) + }) + + it("passes exists when field is present", () => { + const result = evaluateExpression(makeExpr("contract_id", "exists", ""), payload) + expect(result.passed).toBe(true) + }) + + it("fails exists when field is absent", () => { + const result = evaluateExpression(makeExpr("nonexistent_field", "exists", ""), payload) + expect(result.passed).toBe(false) + }) + + it("passes not_exists when field is absent", () => { + const result = evaluateExpression(makeExpr("nonexistent_field", "not_exists", ""), payload) + expect(result.passed).toBe(true) + }) + + it("evaluates AND group: all must pass", () => { + const expr: FilterExpression = { + root: { + id: "g1", + kind: "group", + logic: "AND", + children: [ + { id: "c1", kind: "condition", field: "event_type", operator: "eq", value: "SWAP_COMPLETE" }, + { id: "c2", kind: "condition", field: "amount", operator: "gt", value: "999" }, + ], + }, + } + const result = evaluateExpression(expr, payload) + expect(result.passed).toBe(true) + expect(result.conditionResults).toHaveLength(2) + expect(result.conditionResults.every((r) => r.passed)).toBe(true) + }) + + it("fails AND group when one condition fails", () => { + const expr: FilterExpression = { + root: { + id: "g1", + kind: "group", + logic: "AND", + children: [ + { id: "c1", kind: "condition", field: "event_type", operator: "eq", value: "SWAP_COMPLETE" }, + { id: "c2", kind: "condition", field: "amount", operator: "gt", value: "99999" }, + ], + }, + } + const result = evaluateExpression(expr, payload) + expect(result.passed).toBe(false) + expect(result.conditionResults.some((r) => r.passed)).toBe(true) + expect(result.conditionResults.some((r) => !r.passed)).toBe(true) + }) + + it("passes OR group when only one condition passes", () => { + const expr: FilterExpression = { + root: { + id: "g1", + kind: "group", + logic: "OR", + children: [ + { id: "c1", kind: "condition", field: "event_type", operator: "eq", value: "LIQUIDITY_ADD" }, + { id: "c2", kind: "condition", field: "amount", operator: "gt", value: "999" }, + ], + }, + } + const result = evaluateExpression(expr, payload) + expect(result.passed).toBe(true) + }) + + it("fails OR group when all conditions fail", () => { + const expr: FilterExpression = { + root: { + id: "g1", + kind: "group", + logic: "OR", + children: [ + { id: "c1", kind: "condition", field: "event_type", operator: "eq", value: "LIQUIDITY_ADD" }, + { id: "c2", kind: "condition", field: "amount", operator: "gt", value: "99999" }, + ], + }, + } + const result = evaluateExpression(expr, payload) + expect(result.passed).toBe(false) + }) + + it("evaluates nested groups: (A AND B) OR C", () => { + const expr: FilterExpression = { + root: { + id: "g1", + kind: "group", + logic: "OR", + children: [ + { + id: "g2", + kind: "group", + logic: "AND", + children: [ + // Both fail + { id: "c1", kind: "condition", field: "event_type", operator: "eq", value: "LIQUIDITY_ADD" }, + { id: "c2", kind: "condition", field: "amount", operator: "gt", value: "99999" }, + ], + }, + // This passes + { id: "c3", kind: "condition", field: "success", operator: "eq", value: "true" }, + ], + }, + } + const result = evaluateExpression(expr, payload) + expect(result.passed).toBe(true) + }) + + it("returns conditionResults with field/operator/value/actual", () => { + const result = evaluateExpression(makeExpr("amount", "gt", "1000"), payload) + expect(result.conditionResults[0]).toMatchObject({ + field: "amount", + operator: "gt", + value: "1000", + actual: 1500, + }) + }) + + it("includes reason string in each result", () => { + const result = evaluateExpression(makeExpr("event_type", "eq", "SWAP_COMPLETE"), payload) + expect(typeof result.conditionResults[0].reason).toBe("string") + expect(result.conditionResults[0].reason.length).toBeGreaterThan(0) + }) + + it("returns serialized expression string", () => { + const result = evaluateExpression(makeExpr("amount", "gt", "1000"), payload) + expect(typeof result.serialized).toBe("string") + expect(result.serialized).toContain("amount") + }) + + it("handles empty group (passes trivially)", () => { + const emptyExpr = { root: { id: "g1", kind: "group" as const, logic: "AND" as const, children: [] } } + const result = evaluateExpression(emptyExpr, payload) + expect(result.passed).toBe(true) + expect(result.conditionResults).toHaveLength(0) + }) +}) + +// ── Component: FilterExpressionTester ──────────────────────────────────────── + +describe("FilterExpressionTester component", () => { + function makeSimpleExpr(eventType: string): FilterExpression { + return { + root: { + id: "g1", + kind: "group", + logic: "AND", + children: [ + { id: "c1", kind: "condition", field: "event_type", operator: "eq", value: eventType }, + ], + }, + } + } + + it("renders the tester container", () => { + render() + expect(screen.getByTestId("filter-expression-tester")).toBeInTheDocument() + }) + + it("renders the RUN_TEST button", () => { + render() + expect(screen.getByTestId("run-test-btn")).toBeInTheDocument() + }) + + it("renders the sample payload textarea pre-filled with JSON", () => { + render() + const textarea = screen.getByLabelText("Sample JSON payload") + expect(textarea).toBeInTheDocument() + // Should contain the default sample + expect((textarea as HTMLTextAreaElement).value).toContain("event_type") + }) + + it("shows a passing verdict when expression matches payload", async () => { + const expr = makeSimpleExpr("SWAP_COMPLETE") // default sample has this + render() + fireEvent.click(screen.getByTestId("run-test-btn")) + await waitFor(() => { + expect(screen.getByTestId("test-verdict")).toBeInTheDocument() + }, { timeout: 1000 }) + expect(screen.getByTestId("test-verdict").textContent).toContain("FILTER_MATCH") + }) + + it("shows a failing verdict when expression does not match payload", async () => { + const expr = makeSimpleExpr("LIQUIDITY_ADD") // default sample has SWAP_COMPLETE + render() + fireEvent.click(screen.getByTestId("run-test-btn")) + await waitFor(() => { + expect(screen.getByTestId("test-verdict")).toBeInTheDocument() + }, { timeout: 1000 }) + expect(screen.getByTestId("test-verdict").textContent).toContain("NO_MATCH") + }) + + it("shows per-condition result rows after running", async () => { + const expr = makeSimpleExpr("SWAP_COMPLETE") + render() + fireEvent.click(screen.getByTestId("run-test-btn")) + await waitFor(() => { + expect(screen.getAllByTestId("condition-result-row")).toHaveLength(1) + }, { timeout: 1000 }) + }) + + it("shows a JSON parse error when payload is invalid JSON", () => { + render() + const textarea = screen.getByLabelText("Sample JSON payload") + fireEvent.change(textarea, { target: { value: "not valid json {{" } }) + fireEvent.click(screen.getByTestId("run-test-btn")) + expect(screen.getByText(/invalid json/i)).toBeInTheDocument() + expect(screen.queryByTestId("test-results")).not.toBeInTheDocument() + }) + + it("resets payload to default when RESET is clicked", () => { + render() + const textarea = screen.getByLabelText("Sample JSON payload") + fireEvent.change(textarea, { target: { value: '{"foo":"bar"}' } }) + expect((textarea as HTMLTextAreaElement).value).toBe('{"foo":"bar"}') + fireEvent.click(screen.getByLabelText("Reset sample payload")) + expect((textarea as HTMLTextAreaElement).value).toContain("event_type") + }) + + it("clears results when payload is edited after running", async () => { + const expr = makeSimpleExpr("SWAP_COMPLETE") + render() + fireEvent.click(screen.getByTestId("run-test-btn")) + await waitFor(() => expect(screen.getByTestId("test-verdict")).toBeInTheDocument(), { timeout: 1000 }) + // Edit the textarea — results should clear + const textarea = screen.getByLabelText("Sample JSON payload") + fireEvent.change(textarea, { target: { value: '{"event_type":"LIQUIDITY_ADD"}' } }) + expect(screen.queryByTestId("test-results")).not.toBeInTheDocument() + }) +}) diff --git a/soroscan-frontend/__tests__/webhook-filter-expression-builder.test.tsx b/soroscan-frontend/__tests__/webhook-filter-expression-builder.test.tsx new file mode 100644 index 00000000..ad76a5cd --- /dev/null +++ b/soroscan-frontend/__tests__/webhook-filter-expression-builder.test.tsx @@ -0,0 +1,321 @@ +import React from "react" +import { render, screen, fireEvent, within } from "@testing-library/react" +import { + WebhookFilterExpressionBuilder, + emptyExpression, + serializeExpression, + type FilterExpression, +} from "@/components/ui/WebhookFilterExpressionBuilder" + +const mockOnChange = jest.fn() +const mockOnApply = jest.fn() + +beforeEach(() => { + mockOnChange.mockClear() + mockOnApply.mockClear() +}) + +function renderBuilder(props: Partial> = {}) { + return render( + + ) +} + +// ── Unit: serializer ───────────────────────────────────────────────────────── + +describe("serializeExpression", () => { + it("serializes a simple eq condition", () => { + const expr: FilterExpression = { + root: { + id: "g1", + kind: "group", + logic: "AND", + children: [ + { id: "c1", kind: "condition", field: "event_type", operator: "eq", value: "SWAP_COMPLETE" }, + ], + }, + } + expect(serializeExpression(expr)).toBe('event_type = "SWAP_COMPLETE"') + }) + + it("serializes AND group with two conditions", () => { + const expr: FilterExpression = { + root: { + id: "g1", + kind: "group", + logic: "AND", + children: [ + { id: "c1", kind: "condition", field: "event_type", operator: "eq", value: "SWAP_COMPLETE" }, + { id: "c2", kind: "condition", field: "amount", operator: "gt", value: "1000" }, + ], + }, + } + const result = serializeExpression(expr) + expect(result).toContain("AND") + expect(result).toContain("event_type") + expect(result).toContain("amount") + }) + + it("serializes OR group", () => { + const expr: FilterExpression = { + root: { + id: "g1", + kind: "group", + logic: "OR", + children: [ + { id: "c1", kind: "condition", field: "event_type", operator: "eq", value: "SWAP_COMPLETE" }, + { id: "c2", kind: "condition", field: "event_type", operator: "eq", value: "LIQUIDITY_ADD" }, + ], + }, + } + expect(serializeExpression(expr)).toContain("OR") + }) + + it("serializes exists operator without value", () => { + const expr: FilterExpression = { + root: { + id: "g1", + kind: "group", + logic: "AND", + children: [ + { id: "c1", kind: "condition", field: "contract_id", operator: "exists", value: "" }, + ], + }, + } + expect(serializeExpression(expr)).toBe("EXISTS(contract_id)") + }) + + it("serializes IN operator with csv values", () => { + const expr: FilterExpression = { + root: { + id: "g1", + kind: "group", + logic: "AND", + children: [ + { id: "c1", kind: "condition", field: "event_type", operator: "in", value: "SWAP_COMPLETE, LIQUIDITY_ADD" }, + ], + }, + } + const result = serializeExpression(expr) + expect(result).toContain("IN") + expect(result).toContain("SWAP_COMPLETE") + expect(result).toContain("LIQUIDITY_ADD") + }) + + it("wraps nested group in parentheses", () => { + const expr: FilterExpression = { + root: { + id: "g1", + kind: "group", + logic: "AND", + children: [ + { id: "c1", kind: "condition", field: "event_type", operator: "eq", value: "SWAP_COMPLETE" }, + { + id: "g2", + kind: "group", + logic: "OR", + children: [ + { id: "c2", kind: "condition", field: "amount", operator: "gt", value: "1000" }, + { id: "c3", kind: "condition", field: "amount", operator: "lt", value: "50" }, + ], + }, + ], + }, + } + const result = serializeExpression(expr) + expect(result).toContain("(") + expect(result).toContain("AND") + expect(result).toContain("OR") + }) + + it("returns (empty) for empty root group", () => { + const expr: FilterExpression = { + root: { id: "g1", kind: "group", logic: "AND", children: [] }, + } + expect(serializeExpression(expr)).toBe("(empty)") + }) +}) + +// ── Unit: emptyExpression ───────────────────────────────────────────────────── + +describe("emptyExpression", () => { + it("creates a group with one default condition", () => { + const expr = emptyExpression() + expect(expr.root.kind).toBe("group") + expect(expr.root.children).toHaveLength(1) + expect(expr.root.children[0].kind).toBe("condition") + }) + + it("defaults to AND logic", () => { + expect(emptyExpression().root.logic).toBe("AND") + }) +}) + +// ── Component tests ─────────────────────────────────────────────────────────── + +describe("WebhookFilterExpressionBuilder component", () => { + it("renders accessible group and at least one condition row", () => { + renderBuilder() + expect(screen.getByRole("group", { name: /webhook filter expression builder/i })).toBeInTheDocument() + expect(screen.getAllByTestId("webhook-filter-condition")).toHaveLength(1) + }) + + it("renders root group with [ROOT] label", () => { + renderBuilder() + expect(screen.getByTestId("webhook-filter-root-group")).toBeInTheDocument() + expect(screen.getByText("[ROOT]")).toBeInTheDocument() + }) + + it("renders AND and OR logic toggle buttons", () => { + renderBuilder() + expect(screen.getByTestId("logic-and-btn")).toBeInTheDocument() + expect(screen.getByTestId("logic-or-btn")).toBeInTheDocument() + }) + + it("AND is pressed by default", () => { + renderBuilder() + expect(screen.getByTestId("logic-and-btn")).toHaveAttribute("aria-pressed", "true") + expect(screen.getByTestId("logic-or-btn")).toHaveAttribute("aria-pressed", "false") + }) + + it("switches to OR logic when OR is clicked", () => { + renderBuilder() + fireEvent.click(screen.getByTestId("logic-or-btn")) + expect(screen.getByTestId("logic-or-btn")).toHaveAttribute("aria-pressed", "true") + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ + root: expect.objectContaining({ logic: "OR" }), + }) + ) + }) + + it("adds a condition when ADD_CONDITION button is clicked", () => { + renderBuilder() + fireEvent.click(screen.getByTestId("add-condition-btn")) + expect(screen.getAllByTestId("webhook-filter-condition")).toHaveLength(2) + expect(mockOnChange).toHaveBeenCalled() + }) + + it("removes a condition when the remove button is clicked", () => { + renderBuilder() + // Add a second condition first + fireEvent.click(screen.getByTestId("add-condition-btn")) + expect(screen.getAllByTestId("webhook-filter-condition")).toHaveLength(2) + // Remove the first + fireEvent.click(screen.getAllByTestId("remove-condition-btn")[0]) + expect(screen.getAllByTestId("webhook-filter-condition")).toHaveLength(1) + }) + + it("adds a sub-group when GROUP button is clicked", () => { + renderBuilder() + fireEvent.click(screen.getByTestId("add-group-btn")) + expect(screen.getByTestId("webhook-filter-sub-group")).toBeInTheDocument() + }) + + it("updates field select and calls onChange", () => { + renderBuilder() + const fieldSelect = screen.getAllByLabelText("Filter field")[0] + fireEvent.change(fieldSelect, { target: { value: "amount" } }) + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ + root: expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ field: "amount" }), + ]), + }), + }) + ) + }) + + it("updates operator select and calls onChange", () => { + renderBuilder() + const opSelect = screen.getAllByLabelText("Filter operator")[0] + fireEvent.change(opSelect, { target: { value: "neq" } }) + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ + root: expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ operator: "neq" }), + ]), + }), + }) + ) + }) + + it("updates value input and calls onChange", () => { + renderBuilder() + const valueInput = screen.getAllByLabelText("Filter value")[0] + fireEvent.change(valueInput, { target: { value: "SWAP_COMPLETE" } }) + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ + root: expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ value: "SWAP_COMPLETE" }), + ]), + }), + }) + ) + }) + + it("renders live expression preview", () => { + renderBuilder() + expect(screen.getByTestId("filter-expression-preview")).toBeInTheDocument() + }) + + it("updates expression preview when value changes", () => { + renderBuilder() + // Switch the default enum field to a string field so we get a text input + const fieldSelect = screen.getAllByLabelText("Filter field")[0] + fireEvent.change(fieldSelect, { target: { value: "contract_id" } }) + const valueInput = screen.getAllByLabelText("Filter value")[0] + fireEvent.change(valueInput, { target: { value: "myvalue" } }) + const preview = screen.getByTestId("filter-expression-preview") + expect(preview.textContent).toContain("myvalue") + }) + + it("calls onApply with expression when APPLY_FILTER is clicked", () => { + renderBuilder() + fireEvent.click(screen.getByTestId("apply-expression-btn")) + expect(mockOnApply).toHaveBeenCalledTimes(1) + expect(mockOnApply).toHaveBeenCalledWith( + expect.objectContaining({ root: expect.any(Object) }), + expect.any(String) + ) + }) + + it("resets to empty expression on RESET click", () => { + renderBuilder() + // Add extra condition + fireEvent.click(screen.getByTestId("add-condition-btn")) + expect(screen.getAllByTestId("webhook-filter-condition")).toHaveLength(2) + fireEvent.click(screen.getByTestId("reset-expression-btn")) + expect(screen.getAllByTestId("webhook-filter-condition")).toHaveLength(1) + }) + + it("syncs external value prop when changed", () => { + const initial = emptyExpression() + const { rerender } = renderBuilder({ value: initial }) + const newExpr: FilterExpression = { + root: { + id: "g_new", + kind: "group", + logic: "OR", + children: [ + { id: "c_new", kind: "condition", field: "amount", operator: "gt", value: "500" }, + ], + }, + } + rerender( + + ) + expect(screen.getByTestId("logic-or-btn")).toHaveAttribute("aria-pressed", "true") + }) +}) diff --git a/soroscan-frontend/app/webhooks/components/CreateWebhookModal.tsx b/soroscan-frontend/app/webhooks/components/CreateWebhookModal.tsx index 988c8bf3..ee6b127e 100644 --- a/soroscan-frontend/app/webhooks/components/CreateWebhookModal.tsx +++ b/soroscan-frontend/app/webhooks/components/CreateWebhookModal.tsx @@ -1,10 +1,13 @@ "use client" import * as React from "react" +import { Wand2 } from "lucide-react" import { Modal } from "@/components/terminal/Modal" import { Input } from "@/components/terminal/Input" import { Button } from "@/components/terminal/Button" +import { FilterBuilderModal } from "./FilterBuilderModal" import type { Webhook, EventType, WebhookStatus } from "../types" +import type { FilterExpression } from "@/components/ui/WebhookFilterExpressionBuilder" const ALL_EVENT_TYPES: EventType[] = [ "ALL", @@ -42,6 +45,11 @@ export function CreateWebhookModal({ isOpen, onClose, onCreate }: CreateWebhookM const [timeoutTouched, setTimeoutTouched] = React.useState(false) const [submitting, setSubmitting] = React.useState(false) + // Filter expression state + const [filterExpression, setFilterExpression] = React.useState(undefined) + const [filterExpressionStr, setFilterExpressionStr] = React.useState("") + const [filterBuilderOpen, setFilterBuilderOpen] = React.useState(false) + const urlValid = isValidUrl(url) const timeoutValue = Number(timeoutInput) const timeoutValid = Number.isInteger(timeoutValue) && timeoutValue >= 5 && timeoutValue <= 60 @@ -59,6 +67,16 @@ export function CreateWebhookModal({ isOpen, onClose, onCreate }: CreateWebhookM }) } + const handleSaveFilter = (exprStr: string, expr: FilterExpression) => { + setFilterExpressionStr(exprStr) + setFilterExpression(expr) + } + + const handleClearFilter = () => { + setFilterExpression(undefined) + setFilterExpressionStr("") + } + const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (!urlValid) { setUrlTouched(true); return } @@ -76,10 +94,12 @@ export function CreateWebhookModal({ isOpen, onClose, onCreate }: CreateWebhookM contractFilter: contractFilter.trim() || undefined, status, timeoutSeconds: timeoutValue, + filterExpression: filterExpressionStr || undefined, }) // reset setUrl(""); setUrlTouched(false); setSelectedTypes(["ALL"]) setContractFilter(""); setStatus("ACTIVE"); setTimeoutInput("30"); setTimeoutTouched(false) + setFilterExpression(undefined); setFilterExpressionStr("") setSubmitting(false) onClose() }, 600) @@ -88,138 +108,198 @@ export function CreateWebhookModal({ isOpen, onClose, onCreate }: CreateWebhookM const handleClose = () => { setUrl(""); setUrlTouched(false); setSelectedTypes(["ALL"]) setContractFilter(""); setStatus("ACTIVE"); setTimeoutInput("30"); setTimeoutTouched(false) + setFilterExpression(undefined); setFilterExpressionStr("") onClose() } return ( - -
- {/* URL */} -
- setUrl(e.target.value)} - onBlur={() => setUrlTouched(true)} - aria-invalid={!!urlError} - /> - {urlError && ( -

{urlError}

- )} -
+ <> + + + {/* URL */} +
+ setUrl(e.target.value)} + onBlur={() => setUrlTouched(true)} + aria-invalid={!!urlError} + /> + {urlError && ( +

{urlError}

+ )} +
- {/* Event timeout */} -
+ {/* Event timeout */} +
+ setTimeoutInput(e.target.value)} + onBlur={() => setTimeoutTouched(true)} + aria-invalid={!!timeoutError} + /> +
+ {[10, 20, 30, 45, 60].map((value) => ( + + ))} +
+ {timeoutError && ( +

{timeoutError}

+ )} +
+ + {/* Event types */} +
+
EVENT_TYPES *
+
+ {ALL_EVENT_TYPES.map((t) => { + const checked = selectedTypes.includes(t) + return ( + + ) + })} +
+
+ + {/* Contract filter */} setTimeoutInput(e.target.value)} - onBlur={() => setTimeoutTouched(true)} - aria-invalid={!!timeoutError} + id="contract-filter-input" + label="CONTRACT_FILTER (optional)" + type="text" + placeholder="CABC...9X4Z — leave blank for all contracts" + value={contractFilter} + onChange={(e) => setContractFilter(e.target.value)} /> -
- {[10, 20, 30, 45, 60].map((value) => ( + + {/* Filter expression builder */} +
+
+ FILTER_EXPRESSION (optional) +
+ {filterExpressionStr ? ( +
+
ACTIVE_FILTER
+
+ {filterExpressionStr} +
+
+ + +
+
+ ) : ( - ))} + )} +

+ Define custom field conditions to filter which events trigger this webhook. +

- {timeoutError && ( -

{timeoutError}

- )} -
- - {/* Event types */} -
-
EVENT_TYPES *
-
- {ALL_EVENT_TYPES.map((t) => { - const checked = selectedTypes.includes(t) - return ( -
- - {/* Contract filter */} - setContractFilter(e.target.value)} - /> - - {/* Status */} -
-
INITIAL_STATUS
-
- {(["ACTIVE", "SUSPENDED"] as const).map((s) => ( - - ))} + + {/* Submit */} +
+ +
-
- - {/* Submit */} -
- - -
- - + + + + {/* Filter expression builder modal */} + setFilterBuilderOpen(false)} + onSave={handleSaveFilter} + initialExpression={filterExpression} + /> + ) } diff --git a/soroscan-frontend/app/webhooks/components/FilterBuilderModal.tsx b/soroscan-frontend/app/webhooks/components/FilterBuilderModal.tsx new file mode 100644 index 00000000..c9fb3872 --- /dev/null +++ b/soroscan-frontend/app/webhooks/components/FilterBuilderModal.tsx @@ -0,0 +1,112 @@ +"use client" + +import * as React from "react" +import { FlaskConical, Wand2 } from "lucide-react" +import { Modal } from "@/components/terminal/Modal" +import { + WebhookFilterExpressionBuilder, + emptyExpression, + serializeExpression, + type FilterExpression, +} from "@/components/ui/WebhookFilterExpressionBuilder" +import { FilterExpressionTester } from "@/components/ui/FilterExpressionTester" + +export interface FilterBuilderModalProps { + isOpen: boolean + onClose: () => void + /** Called when the user confirms/saves the filter expression string */ + onSave: (expressionString: string, expression: FilterExpression) => void + /** Pre-populate with an existing expression */ + initialExpression?: FilterExpression +} + +type Tab = "build" | "test" + +export function FilterBuilderModal({ + isOpen, + onClose, + onSave, + initialExpression, +}: FilterBuilderModalProps) { + const [expr, setExpr] = React.useState(initialExpression ?? emptyExpression()) + const [activeTab, setActiveTab] = React.useState("build") + + // Reset when modal re-opens + React.useEffect(() => { + if (isOpen) { + setExpr(initialExpression ?? emptyExpression()) + setActiveTab("build") + } + }, [isOpen, initialExpression]) + + const serialized = serializeExpression(expr) + + const handleSave = () => { + onSave(serialized, expr) + onClose() + } + + const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [ + { id: "build", label: "BUILD", icon: }, + { id: "test", label: "TEST", icon: }, + ] + + return ( + +
+ {/* Tab bar */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Tab content */} + {activeTab === "build" && ( + + )} + {activeTab === "test" && ( + + )} + + {/* Footer actions */} +
+ + +
+
+
+ ) +} diff --git a/soroscan-frontend/app/webhooks/filter-builder/page.tsx b/soroscan-frontend/app/webhooks/filter-builder/page.tsx new file mode 100644 index 00000000..3bc53eed --- /dev/null +++ b/soroscan-frontend/app/webhooks/filter-builder/page.tsx @@ -0,0 +1,187 @@ +"use client" + +import * as React from "react" +import { ArrowLeft, Copy, Check, FlaskConical } from "lucide-react" +import Link from "next/link" +import { Navbar } from "@/components/terminal/landing/Navbar" +import { Footer } from "@/components/terminal/landing/Footer" +import { + WebhookFilterExpressionBuilder, + emptyExpression, + serializeExpression, + type FilterExpression, +} from "@/components/ui/WebhookFilterExpressionBuilder" +import { FilterExpressionTester } from "@/components/ui/FilterExpressionTester" + +export default function FilterBuilderPage() { + const [expr, setExpr] = React.useState(emptyExpression()) + const [activeTab, setActiveTab] = React.useState<"build" | "test">("build") + const [copied, setCopied] = React.useState(false) + + const serialized = serializeExpression(expr) + + const handleCopy = () => { + navigator.clipboard.writeText(serialized).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + } + + return ( +
+ + +
+ + {/* Breadcrumb */} + + BACK_TO_WEBHOOKS + + + {/* Header */} +
+
[FILTER_EXPRESSION_BUILDER]
+

+ FILTER BUILDER +

+

+ Construct complex filter expressions using nested AND/OR logic. Test your expression against + a sample event payload before attaching it to a webhook subscription. +

+
+ +
+ + {/* Builder panel — takes 3/5 width on large screens */} +
+ {/* Tab bar */} +
+ {([ + { id: "build", label: "BUILD_EXPRESSION" }, + { id: "test", label: "TEST_EXPRESSION", icon: }, + ] as const).map((tab) => ( + + ))} +
+ +
+ {activeTab === "build" ? ( + + ) : ( + + )} +
+
+ + {/* Sidebar — expression output + help */} +
+ + {/* Generated expression */} +
+
+ GENERATED_EXPRESSION + +
+
+                {serialized || "(empty)"}
+              
+
+ + {/* Quick reference */} +
+
+ QUICK_REFERENCE +
+
+ {[ + { sym: "=", desc: "Exact match" }, + { sym: "!=", desc: "Not equal" }, + { sym: "~=", desc: "Contains substring" }, + { sym: "^=", desc: "Starts with" }, + { sym: "$=", desc: "Ends with" }, + { sym: "> / >=", desc: "Greater than (numeric)" }, + { sym: "< / <=", desc: "Less than (numeric)" }, + { sym: "IN [...]", desc: "Matches any in list" }, + { sym: "EXISTS()", desc: "Field is present" }, + ].map((r) => ( +
+ {r.sym} + {r.desc} +
+ ))} + +
+
BOOLEAN_LOGIC
+
+ AND + All conditions must match +
+
+ OR + At least one must match +
+
+ Groups can be infinitely nested to build complex logic. +
+
+
+
+ + {/* Use expression CTA */} + + USE IN WEBHOOK → + + +
+
+
+ +
+
+
+ + {/* Background grid */} +
+
+
+
+ ) +} diff --git a/soroscan-frontend/app/webhooks/types.ts b/soroscan-frontend/app/webhooks/types.ts index 971cc385..d9771813 100644 --- a/soroscan-frontend/app/webhooks/types.ts +++ b/soroscan-frontend/app/webhooks/types.ts @@ -15,6 +15,8 @@ export interface Webhook { url: string eventTypes: EventType[] contractFilter?: string + /** Serialized filter expression built by the visual filter builder */ + filterExpression?: string status: WebhookStatus createdAt: string lastDelivery?: string diff --git a/soroscan-frontend/components/ui/FilterExpressionTester.tsx b/soroscan-frontend/components/ui/FilterExpressionTester.tsx new file mode 100644 index 00000000..b24d4eba --- /dev/null +++ b/soroscan-frontend/components/ui/FilterExpressionTester.tsx @@ -0,0 +1,369 @@ +"use client" + +import * as React from "react" +import { CheckCircle2, XCircle, AlertTriangle, Play, RotateCcw } from "lucide-react" +import type { FilterExpression, FilterNode, FilterGroup, FilterCondition, Operator } from "./WebhookFilterExpressionBuilder" +import { serializeExpression } from "./WebhookFilterExpressionBuilder" + +// ── Types ────────────────────────────────────────────────────────────────── + +export interface TestResult { + passed: boolean + conditionResults: ConditionResult[] + error?: string + serialized: string +} + +export interface ConditionResult { + conditionId: string + field: string + operator: Operator + value: string + actual: unknown + passed: boolean + reason: string +} + +// ── Evaluator ────────────────────────────────────────────────────────────── + +function getNestedValue(obj: Record, path: string): unknown { + return path.split(".").reduce((cur, key) => { + if (cur !== null && cur !== undefined && typeof cur === "object") { + return (cur as Record)[key] + } + return undefined + }, obj) +} + +function evaluateCondition( + cond: FilterCondition, + payload: Record +): ConditionResult { + const actual = getNestedValue(payload, cond.field) + const actualStr = String(actual ?? "") + const v = cond.value + + let passed = false + let reason = "" + + switch (cond.operator) { + case "eq": + passed = actualStr === v + reason = passed ? `${cond.field} = "${v}"` : `${cond.field} is "${actualStr}", expected "${v}"` + break + case "neq": + passed = actualStr !== v + reason = passed ? `${cond.field} ≠ "${v}"` : `${cond.field} equals "${v}" (should differ)` + break + case "contains": + passed = actualStr.includes(v) + reason = passed ? `"${actualStr}" contains "${v}"` : `"${actualStr}" does not contain "${v}"` + break + case "not_contains": + passed = !actualStr.includes(v) + reason = passed ? `"${actualStr}" does not contain "${v}"` : `"${actualStr}" contains "${v}"` + break + case "starts_with": + passed = actualStr.startsWith(v) + reason = passed ? `"${actualStr}" starts with "${v}"` : `"${actualStr}" does not start with "${v}"` + break + case "ends_with": + passed = actualStr.endsWith(v) + reason = passed ? `"${actualStr}" ends with "${v}"` : `"${actualStr}" does not end with "${v}"` + break + case "gt": + passed = Number(actual) > Number(v) + reason = passed ? `${actual} > ${v}` : `${actual} is not > ${v}` + break + case "gte": + passed = Number(actual) >= Number(v) + reason = passed ? `${actual} >= ${v}` : `${actual} is not >= ${v}` + break + case "lt": + passed = Number(actual) < Number(v) + reason = passed ? `${actual} < ${v}` : `${actual} is not < ${v}` + break + case "lte": + passed = Number(actual) <= Number(v) + reason = passed ? `${actual} <= ${v}` : `${actual} is not <= ${v}` + break + case "in": { + const vals = v.split(",").map((s) => s.trim()) + passed = vals.includes(actualStr) + reason = passed ? `"${actualStr}" is in [${vals.join(", ")}]` : `"${actualStr}" not in [${vals.join(", ")}]` + break + } + case "not_in": { + const vals = v.split(",").map((s) => s.trim()) + passed = !vals.includes(actualStr) + reason = passed ? `"${actualStr}" not in [${vals.join(", ")}]` : `"${actualStr}" is in [${vals.join(", ")}]` + break + } + case "exists": + passed = actual !== undefined && actual !== null && actual !== "" + reason = passed ? `${cond.field} exists` : `${cond.field} is absent or empty` + break + case "not_exists": + passed = actual === undefined || actual === null || actual === "" + reason = passed ? `${cond.field} is absent` : `${cond.field} exists with value "${actualStr}"` + break + default: + passed = false + reason = `Unknown operator "${cond.operator}"` + } + + return { conditionId: cond.id, field: cond.field, operator: cond.operator, value: cond.value, actual, passed, reason } +} + +function evaluateGroup( + group: FilterGroup, + payload: Record, + results: ConditionResult[] +): boolean { + if (group.children.length === 0) return true + + const childResults: boolean[] = group.children.map((child) => evaluateNode(child, payload, results)) + + if (group.logic === "AND") return childResults.every(Boolean) + return childResults.some(Boolean) +} + +function evaluateNode( + node: FilterNode, + payload: Record, + results: ConditionResult[] +): boolean { + if (node.kind === "condition") { + const r = evaluateCondition(node, payload) + results.push(r) + return r.passed + } + return evaluateGroup(node, payload, results) +} + +export function evaluateExpression(expr: FilterExpression, payload: Record): TestResult { + const conditionResults: ConditionResult[] = [] + try { + const passed = evaluateGroup(expr.root, payload, conditionResults) + return { passed, conditionResults, serialized: serializeExpression(expr) } + } catch (err) { + return { + passed: false, + conditionResults, + error: err instanceof Error ? err.message : String(err), + serialized: serializeExpression(expr), + } + } +} + +// ── Default sample payload ───────────────────────────────────────────────── + +const DEFAULT_SAMPLE = JSON.stringify( + { + event_type: "SWAP_COMPLETE", + contract_id: "CABC...9X4Z", + amount: 1500, + ledger: 52300, + timestamp: 1716000000, + from_address: "GDEX...A1B2", + to_address: "GCOL...K9L0", + asset_code: "USDC", + topic: "soroscan/swap", + success: true, + }, + null, + 2 +) + +// ── Component ────────────────────────────────────────────────────────────── + +export interface FilterExpressionTesterProps { + expression: FilterExpression + onClose?: () => void +} + +export function FilterExpressionTester({ expression, onClose }: FilterExpressionTesterProps) { + const [sampleJson, setSampleJson] = React.useState(DEFAULT_SAMPLE) + const [jsonError, setJsonError] = React.useState(null) + const [result, setResult] = React.useState(null) + const [running, setRunning] = React.useState(false) + + const handleRun = () => { + setJsonError(null) + setResult(null) + + let payload: Record + try { + payload = JSON.parse(sampleJson) + } catch { + setJsonError("Invalid JSON — fix the sample payload and try again.") + return + } + + setRunning(true) + // Slight artificial delay for UX + setTimeout(() => { + const r = evaluateExpression(expression, payload) + setResult(r) + setRunning(false) + }, 300) + } + + const handleReset = () => { + setSampleJson(DEFAULT_SAMPLE) + setResult(null) + setJsonError(null) + } + + const passed = result?.conditionResults.filter((r) => r.passed).length ?? 0 + const failed = result?.conditionResults.filter((r) => !r.passed).length ?? 0 + + return ( +
+ {/* Expression being tested */} +
+
TESTING_EXPRESSION
+
+ {serializeExpression(expression) || "(empty)"} +
+
+ + {/* Sample JSON editor */} +
+
+ + +
+