diff --git a/soroscan-frontend/__tests__/EventCountBadge.test.tsx b/soroscan-frontend/__tests__/EventCountBadge.test.tsx new file mode 100644 index 000000000..c0f6403f8 --- /dev/null +++ b/soroscan-frontend/__tests__/EventCountBadge.test.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { EventStreamProvider } from "@/context/EventStreamContext"; +import { EventCountBadge } from "@/components/ui/EventCountBadge"; + +// ── Mock useContractEventSubscription ─────────────────────────────── +const mockHook = { + useContractEventSubscription: jest.fn(), +}; + +jest.mock("@/src/hooks/useContractEventSubscription", () => ({ + useContractEventSubscription: (opts: unknown) => mockHook.useContractEventSubscription(opts), +})); + +describe("EventCountBadge", () => { + beforeEach(() => { + mockHook.useContractEventSubscription.mockReset(); + }); + + it("renders the correct initial count", () => { + mockHook.useContractEventSubscription.mockReturnValue({ + events: [], + loading: false, + error: undefined, + connectionState: "connected", + }); + + render( + + + + ); + + expect(screen.getByTestId("event-count-badge-C123")).toHaveTextContent("42"); + }); + + it("renders correctly for zero events", () => { + mockHook.useContractEventSubscription.mockReturnValue({ + events: [], + loading: false, + error: undefined, + connectionState: "connected", + }); + + render( + + + + ); + + const badge = screen.getByTestId("event-count-badge-C123"); + expect(badge).toHaveTextContent("0"); + expect(badge.className).toContain("text-terminal-gray"); + }); + + it("formats extremely high event counts as k+", () => { + mockHook.useContractEventSubscription.mockReturnValue({ + events: [], + loading: false, + error: undefined, + connectionState: "connected", + }); + + render( + + + + ); + + expect(screen.getByTestId("event-count-badge-C123")).toHaveTextContent("12k+"); + }); + + it("updates automatically when a new event arrives in the stream", () => { + // Start with empty events list + let mockResult = { + events: [], + loading: false, + error: undefined, + connectionState: "connected", + }; + + mockHook.useContractEventSubscription.mockImplementation(() => mockResult); + + const { rerender } = render( + + + + ); + + expect(screen.getByTestId("event-count-badge-C123")).toHaveTextContent("5"); + + // Simulate an event arriving by updating mock implementation and rerendering + mockResult = { + events: [ + { + id: "evt-1", + eventType: "transfer", + ledgerSequence: 100, + timestamp: new Date().toISOString(), + payload: "{}", + }, + ], + loading: false, + error: undefined, + connectionState: "connected", + }; + + rerender( + + + + ); + + expect(screen.getByTestId("event-count-badge-C123")).toHaveTextContent("6"); + }); +}); diff --git a/soroscan-frontend/__tests__/batchService.test.ts b/soroscan-frontend/__tests__/batchService.test.ts new file mode 100644 index 000000000..00944e8fc --- /dev/null +++ b/soroscan-frontend/__tests__/batchService.test.ts @@ -0,0 +1,178 @@ +/** + * batchService.test.ts + * + * Unit tests for the batch service handlers. + * Uses mocked fetch for API calls. + */ + +import { exportEvents, resendWebhooks, tagEvents, deleteEvents } from "@/lib/batchService"; +import type { EventRecord } from "@/components/ingest/types"; + +// ── Mock global fetch ─────────────────────────────────────────────────────── +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +// ── Mock URL/Blob ──────────────────────────────────────────────────────────── +const mockCreateObjectURL = jest.fn(() => "blob:mock-url"); +const mockRevokeObjectURL = jest.fn(); +global.URL.createObjectURL = mockCreateObjectURL; +global.URL.revokeObjectURL = mockRevokeObjectURL; + +// Mock document.createElement for anchor click +const mockClick = jest.fn(); +const mockAnchor = { href: "", download: "", click: mockClick }; +jest.spyOn(document, "createElement").mockImplementation((tag) => { + if (tag === "a") return mockAnchor as unknown as HTMLElement; + return document.createElement(tag); +}); + +// ── Test data ────────────────────────────────────────────────────────────── +const sampleEvent: EventRecord = { + id: "1", + contractId: "CCAAA123", + contractName: "Test Contract", + eventType: "transfer", + ledger: 1000, + eventIndex: 0, + timestamp: "2024-01-01T00:00:00Z", + txHash: "abc123", + payload: { amount: 100 }, +}; + +describe("batchService", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }); + }); + + // ── exportEvents ──────────────────────────────────────────────────────────── + + describe("exportEvents", () => { + it("triggers a JSON download for json format", () => { + exportEvents([sampleEvent], "json"); + expect(mockCreateObjectURL).toHaveBeenCalledTimes(1); + expect(mockClick).toHaveBeenCalledTimes(1); + expect(mockAnchor.download).toMatch(/\.json$/); + }); + + it("triggers a CSV download for csv format", () => { + exportEvents([sampleEvent], "csv"); + expect(mockCreateObjectURL).toHaveBeenCalledTimes(1); + expect(mockClick).toHaveBeenCalledTimes(1); + expect(mockAnchor.download).toMatch(/\.csv$/); + }); + + it("does nothing when events array is empty", () => { + exportEvents([], "json"); + expect(mockCreateObjectURL).not.toHaveBeenCalled(); + }); + + it("revokes the object URL after download", () => { + exportEvents([sampleEvent], "json"); + expect(mockRevokeObjectURL).toHaveBeenCalledWith("blob:mock-url"); + }); + }); + + // ── resendWebhooks ───────────────────────────────────────────────────────── + + describe("resendWebhooks", () => { + it("POSTs to /api/events/:id/resend for each event", async () => { + const onProgress = jest.fn(); + await resendWebhooks(["1", "2"], onProgress); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledWith("/api/events/1/resend", expect.objectContaining({ method: "POST" })); + expect(mockFetch).toHaveBeenCalledWith("/api/events/2/resend", expect.objectContaining({ method: "POST" })); + }); + + it("reports progress after each event", async () => { + const onProgress = jest.fn(); + await resendWebhooks(["1", "2"], onProgress); + + expect(onProgress).toHaveBeenCalledTimes(2); + // Final call should have 100% + expect(onProgress).toHaveBeenLastCalledWith( + expect.objectContaining({ percent: 100, completed: 2 }), + ); + }); + + it("returns succeeded and failed IDs", async () => { + // First call succeeds, second fails + mockFetch + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) }) + .mockResolvedValueOnce({ ok: false, status: 500, json: () => Promise.resolve({}) }); + + const onProgress = jest.fn(); + const result = await resendWebhooks(["1", "2"], onProgress); + + expect(result.succeeded).toContain("1"); + expect(result.failed).toContain("2"); + }); + + it("handles fetch network errors gracefully", async () => { + mockFetch.mockRejectedValue(new Error("Network error")); + + const onProgress = jest.fn(); + const result = await resendWebhooks(["1"], onProgress); + + expect(result.failed).toContain("1"); + expect(result.succeeded).toHaveLength(0); + }); + }); + + // ── tagEvents ────────────────────────────────────────────────────────────── + + describe("tagEvents", () => { + it("POSTs to /api/events/:id/tags with the tag in the body", async () => { + const onProgress = jest.fn(); + await tagEvents(["1"], "urgent", onProgress); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/events/1/tags", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ tag: "urgent" }), + }), + ); + }); + + it("collects failed IDs on HTTP error", async () => { + mockFetch.mockResolvedValue({ ok: false, status: 403, json: () => Promise.resolve({}) }); + const onProgress = jest.fn(); + const result = await tagEvents(["1", "2"], "urgent", onProgress); + + expect(result.failed).toHaveLength(2); + expect(result.succeeded).toHaveLength(0); + }); + }); + + // ── deleteEvents ──────────────────────────────────────────────────────────── + + describe("deleteEvents", () => { + it("sends DELETE requests for each event ID", async () => { + const onProgress = jest.fn(); + await deleteEvents(["1", "2"], onProgress); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledWith("/api/events/1", expect.objectContaining({ method: "DELETE" })); + expect(mockFetch).toHaveBeenCalledWith("/api/events/2", expect.objectContaining({ method: "DELETE" })); + }); + + it("returns a result with all succeeded IDs on success", async () => { + const onProgress = jest.fn(); + const result = await deleteEvents(["1", "2"], onProgress); + + expect(result.succeeded).toEqual(expect.arrayContaining(["1", "2"])); + expect(result.failed).toHaveLength(0); + }); + + it("reports 100% progress when all done", async () => { + const onProgress = jest.fn(); + await deleteEvents(["1"], onProgress); + + expect(onProgress).toHaveBeenLastCalledWith( + expect.objectContaining({ percent: 100 }), + ); + }); + }); +}); 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 000000000..294faa2a8 --- /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__/nav-drawer.test.tsx b/soroscan-frontend/__tests__/nav-drawer.test.tsx new file mode 100644 index 000000000..ae3e22caf --- /dev/null +++ b/soroscan-frontend/__tests__/nav-drawer.test.tsx @@ -0,0 +1,167 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { Drawer } from "@/components/ui/drawer"; +import { HamburgerToggle } from "@/components/ui/hamburger-toggle"; +import { NavDrawer } from "@/components/terminal/landing/NavDrawer"; + +// ── Mock next/navigation (usePathname, useRouter) ────────────────── +jest.mock("next/navigation", () => ({ + usePathname: () => "/", + useRouter: () => ({ + push: jest.fn(), + back: jest.fn(), + forward: jest.fn(), + refresh: jest.fn(), + replace: jest.fn(), + prefetch: jest.fn(), + }), +})); + +// ── Mock @/lib/auth ──────────────────────────────────────────────── +jest.mock("@/lib/auth", () => ({ + isLoggedIn: jest.fn(() => false), + clearTokens: jest.fn(), + getAccessToken: jest.fn(() => null), + getRefreshToken: jest.fn(() => null), + setTokens: jest.fn(), + refreshAccessToken: jest.fn(), +})); + +// ── Mock next/link to simple ─────────────────────────────────── +jest.mock("next/link", () => { + const MockLink = ({ + href, + children, + ...props + }: { + href: string; + children: React.ReactNode; + [key: string]: unknown; + }) => ( + + {children} + + ); + MockLink.displayName = "MockLink"; + return MockLink; +}); + +describe("Drawer Base Component", () => { + it("renders when isOpen is true", () => { + render( + {}} title="Test Drawer"> +
Drawer Content
+
+ ); + expect(screen.getByText("Test Drawer")).toBeInTheDocument(); + expect(screen.getByText("Drawer Content")).toBeInTheDocument(); + }); + + it("does not render when isOpen is false", () => { + const { container } = render( + {}} title="Test Drawer"> +
Drawer Content
+
+ ); + expect(container.firstChild).toBeNull(); + }); + + it("calls onClose when the close button is clicked", () => { + const handleClose = jest.fn(); + render( + +
Drawer Content
+
+ ); + const closeBtn = screen.getByRole("button", { name: /close drawer/i }); + fireEvent.click(closeBtn); + expect(handleClose).toHaveBeenCalledTimes(1); + }); + + it("calls onClose when backdrop is clicked", () => { + const handleClose = jest.fn(); + render( + +
Drawer Content
+
+ ); + const backdrop = screen.getByRole("dialog").previousSibling; + expect(backdrop).toBeInTheDocument(); + if (backdrop) { + fireEvent.click(backdrop); + expect(handleClose).toHaveBeenCalledTimes(1); + } + }); +}); + +describe("HamburgerToggle Component", () => { + it("renders with correct ARIA attributes when closed", () => { + render( {}} ariaControls="nav-menu" />); + const toggle = screen.getByRole("button", { name: /toggle menu/i }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(toggle).toHaveAttribute("aria-controls", "nav-menu"); + }); + + it("renders with correct ARIA attributes when open", () => { + render( {}} ariaControls="nav-menu" />); + const toggle = screen.getByRole("button", { name: /close menu/i }); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + }); + + it("triggers onClick when clicked", () => { + const handleClick = jest.fn(); + render(); + const toggle = screen.getByRole("button", { name: /toggle menu/i }); + fireEvent.click(toggle); + expect(handleClick).toHaveBeenCalledTimes(1); + }); +}); + +describe("NavDrawer Component", () => { + it("renders navigation items", () => { + render( + {}} + authenticated={false} + handleLogout={() => {}} + pathname="/" + /> + ); + expect(screen.getByText("DOCS")).toBeInTheDocument(); + expect(screen.getByText("FEATURES")).toBeInTheDocument(); + expect(screen.getByText("API_DOCS")).toBeInTheDocument(); + expect(screen.getByText("GITHUB")).toBeInTheDocument(); + expect(screen.getByText("SIGN_IN")).toBeInTheDocument(); + }); + + it("renders logout button when authenticated", () => { + render( + {}} + authenticated={true} + handleLogout={() => {}} + pathname="/" + /> + ); + expect(screen.getByText("LOGOUT")).toBeInTheDocument(); + expect(screen.queryByText("SIGN_IN")).not.toBeInTheDocument(); + }); + + it("triggers onClose when a navigation item is clicked", () => { + const handleClose = jest.fn(); + render( + {}} + pathname="/" + /> + ); + const docsLink = screen.getByText("DOCS"); + fireEvent.click(docsLink); + expect(handleClose).toHaveBeenCalledTimes(1); + }); +}); 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 000000000..ad76a5cd3 --- /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/contracts/components/ContractTable.tsx b/soroscan-frontend/app/contracts/components/ContractTable.tsx index 544d104ae..9111159a2 100644 --- a/soroscan-frontend/app/contracts/components/ContractTable.tsx +++ b/soroscan-frontend/app/contracts/components/ContractTable.tsx @@ -13,6 +13,7 @@ import { import { Button } from "@/components/terminal/Button"; import type { Contract } from "@/components/ingest/contract-types"; import { ContractEmptyState } from "./ContractEmptyState"; +import { EventCountBadge } from "@/components/ui/EventCountBadge"; interface ContractTableProps { contracts: Contract[]; @@ -39,8 +40,13 @@ export function ContractTable({ contracts, onDelete, onRegister }: ContractTable
handleRowClick(contract.id)} - className="cursor-pointer border border-terminal-green/20 bg-terminal-green/5 p-4 flex flex-col gap-3" + className="relative cursor-pointer border border-terminal-green/20 bg-terminal-green/5 p-4 flex flex-col gap-3" > +
Contract ID
@@ -151,7 +157,12 @@ export function ContractTable({ contracts, onDelete, onRegister }: ContractTable {contract.status.toUpperCase()} - {contract.eventCount.toLocaleString()} + + +
{contract.tags?.slice(0, 3).map((tag) => ( diff --git a/soroscan-frontend/app/dashboard/components/BulkActionsToolbar.module.css b/soroscan-frontend/app/dashboard/components/BulkActionsToolbar.module.css new file mode 100644 index 000000000..85e35cf94 --- /dev/null +++ b/soroscan-frontend/app/dashboard/components/BulkActionsToolbar.module.css @@ -0,0 +1,213 @@ +/* BulkActionsToolbar.module.css + Styles for the floating bulk-actions toolbar and related inline elements. +*/ + +/* ── Toolbar container ─────────────────────────────────────────────────── */ +.toolbar { + position: fixed; + bottom: 2rem; + left: 50%; + transform: translateX(-50%) translateY(120%); + z-index: 900; + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + + padding: 0.65rem 1rem; + border-radius: 8px; + border: 1px solid rgba(0, 212, 255, 0.4); + background: linear-gradient( + 135deg, + rgba(6, 12, 22, 0.97) 0%, + rgba(10, 18, 30, 0.97) 100% + ); + box-shadow: + 0 0 0 1px rgba(0, 255, 156, 0.12), + 0 8px 32px rgba(0, 0, 0, 0.55), + 0 0 40px rgba(0, 212, 255, 0.08); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + + /* Hidden by default – slide in when items are selected */ + opacity: 0; + pointer-events: none; + transition: + transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), + opacity 0.25s ease; +} + +.toolbarVisible { + transform: translateX(-50%) translateY(0); + opacity: 1; + pointer-events: auto; +} + +/* ── Selection info cluster ─────────────────────────────────────────────── */ +.selectionInfo { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +.selectionCount { + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: #00ff9c; + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.03rem; + white-space: nowrap; +} + +.textBtn { + background: transparent; + border: none; + color: rgba(0, 212, 255, 0.7); + font: inherit; + font-size: 0.75rem; + cursor: pointer; + padding: 0.15rem 0.3rem; + border-radius: 3px; + text-decoration: underline; + text-underline-offset: 2px; + transition: color 0.15s ease; +} + +.textBtn:hover { + color: #00d4ff; +} + +/* ── Action buttons ─────────────────────────────────────────────────────── */ +.actions { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; +} + +.actionGroup { + display: flex; + gap: 0.3rem; +} + +.divider { + width: 1px; + height: 1.4rem; + background: rgba(0, 212, 255, 0.2); + flex-shrink: 0; +} + +.actionBtn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.75rem; + border-radius: 4px; + border: 1px solid transparent; + font: inherit; + font-size: 0.78rem; + cursor: pointer; + transition: + border-color 0.15s ease, + background 0.15s ease, + box-shadow 0.15s ease, + transform 0.1s ease; + white-space: nowrap; +} + +.actionBtn:hover:not(:disabled) { + transform: translateY(-1px); +} + +.actionBtn:disabled { + opacity: 0.5; + cursor: default; +} + +/* Export – cyan tint */ +.exportBtn { + border-color: rgba(0, 212, 255, 0.4); + background: rgba(0, 212, 255, 0.1); + color: #00d4ff; +} + +.exportBtn:hover:not(:disabled) { + border-color: #00d4ff; + background: rgba(0, 212, 255, 0.18); + box-shadow: 0 0 12px rgba(0, 212, 255, 0.2); +} + +/* Resend – green tint */ +.resendBtn { + border-color: rgba(0, 255, 156, 0.4); + background: rgba(0, 255, 156, 0.08); + color: #00ff9c; +} + +.resendBtn:hover:not(:disabled) { + border-color: #00ff9c; + background: rgba(0, 255, 156, 0.16); + box-shadow: 0 0 12px rgba(0, 255, 156, 0.2); +} + +/* Tag – amber tint */ +.tagBtn { + border-color: rgba(255, 170, 0, 0.4); + background: rgba(255, 170, 0, 0.08); + color: #ffaa00; +} + +.tagBtn:hover:not(:disabled) { + border-color: #ffaa00; + background: rgba(255, 170, 0, 0.16); + box-shadow: 0 0 12px rgba(255, 170, 0, 0.2); +} + +/* Delete – red tint */ +.deleteBtn { + border-color: rgba(255, 80, 80, 0.4); + background: rgba(255, 80, 80, 0.08); + color: #ff6a6a; +} + +.deleteBtn:hover:not(:disabled) { + border-color: #ff6a6a; + background: rgba(255, 80, 80, 0.16); + box-shadow: 0 0 12px rgba(255, 80, 80, 0.2); +} + +/* ── Tag input inside the modal ─────────────────────────────────────────── */ +.tagInputWrap { + margin-top: 0.75rem; +} + +/* ── Row checkbox cell ──────────────────────────────────────────────────── */ +.checkboxCell { + width: 2.2rem; + vertical-align: middle !important; + padding: 0.6rem 0.4rem !important; +} + +/* ── Selected row highlight ─────────────────────────────────────────────── */ +.selectedRow { + background: rgba(0, 255, 156, 0.04) !important; + outline: 1px solid rgba(0, 255, 156, 0.2); + outline-offset: -1px; +} + +/* ── Responsive collapse ────────────────────────────────────────────────── */ +@media (max-width: 640px) { + .toolbar { + bottom: 1rem; + width: calc(100vw - 2rem); + left: 1rem; + transform: translateX(0) translateY(120%); + } + + .toolbarVisible { + transform: translateX(0) translateY(0); + } +} diff --git a/soroscan-frontend/app/dashboard/components/BulkActionsToolbar.tsx b/soroscan-frontend/app/dashboard/components/BulkActionsToolbar.tsx new file mode 100644 index 000000000..5b59722ec --- /dev/null +++ b/soroscan-frontend/app/dashboard/components/BulkActionsToolbar.tsx @@ -0,0 +1,413 @@ +"use client"; + +/** + * BulkActionsToolbar.tsx + * + * Floating/sticky toolbar that slides into view when one or more events + * are selected in the EventTable. Provides Export, Resend, Tag and Delete + * bulk actions with progress tracking and confirmation modals. + */ + +import { useState, useCallback, useRef, useEffect } from "react"; +import type { EventRecord } from "@/components/ingest/types"; +import { + exportEvents, + resendWebhooks, + tagEvents, + deleteEvents, + type BatchProgress, + type BatchResult, +} from "@/lib/batchService"; +import { ConfirmationModal } from "./ConfirmationModal"; +import styles from "@/components/ingest/ingest-terminal.module.css"; +import toolbarStyles from "./BulkActionsToolbar.module.css"; + +interface BulkActionsToolbarProps { + selectedIds: Set; + allEvents: EventRecord[]; + onClearSelection: () => void; + onSelectAll: () => void; + /** Called with the IDs that were successfully deleted so the parent can remove them from state. */ + onDeleteSuccess: (deletedIds: string[]) => void; + /** Called when bulk-tag succeeds so the parent can update the local tag map. */ + onBulkTagSuccess: (eventIds: string[], tag: string) => void; + tagSuggestions: string[]; +} + +type ActiveAction = "resend" | "tag" | "delete" | null; + +export function BulkActionsToolbar({ + selectedIds, + allEvents, + onClearSelection, + onSelectAll, + onDeleteSuccess, + onBulkTagSuccess, + tagSuggestions, +}: BulkActionsToolbarProps) { + const count = selectedIds.size; + const visible = count > 0; + + // Modal / progress state + const [activeAction, setActiveAction] = useState(null); + const [progress, setProgress] = useState(null); + const [result, setResult] = useState(null); + const [tagInput, setTagInput] = useState(""); + const [isRunning, setIsRunning] = useState(false); + const tagInputRef = useRef(null); + + // Focus tag input when tag action is triggered + useEffect(() => { + if (activeAction === "tag" && tagInputRef.current) { + tagInputRef.current.focus(); + } + }, [activeAction]); + + const selectedEvents = allEvents.filter((e) => selectedIds.has(e.id)); + const selectedIdsArray = Array.from(selectedIds); + + // ---- Export --------------------------------------------------------------- + const handleExportCSV = useCallback(() => { + exportEvents(selectedEvents, "csv"); + }, [selectedEvents]); + + const handleExportJSON = useCallback(() => { + exportEvents(selectedEvents, "json"); + }, [selectedEvents]); + + // ---- Resend --------------------------------------------------------------- + const handleResendConfirm = useCallback(async () => { + setIsRunning(true); + setProgress({ total: selectedIdsArray.length, completed: 0, failed: 0, percent: 0 }); + + const batchResult = await resendWebhooks(selectedIdsArray, (p) => setProgress(p)); + + setResult(batchResult); + setIsRunning(false); + }, [selectedIdsArray]); + + // ---- Tag ------------------------------------------------------------------ + const handleTagConfirm = useCallback(async () => { + const tag = tagInput.trim().toLowerCase().replace(/\s+/g, "-"); + if (!tag) return; + + setIsRunning(true); + setProgress({ total: selectedIdsArray.length, completed: 0, failed: 0, percent: 0 }); + + const batchResult = await tagEvents(selectedIdsArray, tag, (p) => setProgress(p)); + + // Update parent's local tag map for succeeded IDs + if (batchResult.succeeded.length) { + onBulkTagSuccess(batchResult.succeeded, tag); + } + + setResult(batchResult); + setIsRunning(false); + }, [selectedIdsArray, tagInput, onBulkTagSuccess]); + + // ---- Delete --------------------------------------------------------------- + const handleDeleteConfirm = useCallback(async () => { + setIsRunning(true); + setProgress({ total: selectedIdsArray.length, completed: 0, failed: 0, percent: 0 }); + + const batchResult = await deleteEvents(selectedIdsArray, (p) => setProgress(p)); + + if (batchResult.succeeded.length) { + onDeleteSuccess(batchResult.succeeded); + } + + setResult(batchResult); + setIsRunning(false); + }, [selectedIdsArray, onDeleteSuccess]); + + // ---- Modal close ---------------------------------------------------------- + const handleModalClose = useCallback(() => { + if (isRunning) return; // prevent close during operation + setActiveAction(null); + setProgress(null); + setResult(null); + setTagInput(""); + }, [isRunning]); + + // When operation completes with full success, auto-close modal after a beat + useEffect(() => { + if (result && result.failed.length === 0 && !isRunning) { + const t = setTimeout(() => { + setActiveAction(null); + setProgress(null); + setResult(null); + setTagInput(""); + if (activeAction === "delete") { + onClearSelection(); + } + }, 1500); + return () => clearTimeout(t); + } + }, [result, isRunning, activeAction, onClearSelection]); + + return ( + <> + {/* ── Floating toolbar ── */} +
+
+ + + {count} selected + + + +
+ +
+ {/* Export group */} +
+ + +
+ + + + {/* ── Resend modal ── */} + {activeAction === "resend" && ( + + )} + + {/* ── Tag modal ── */} + {activeAction === "tag" && ( + + {!result && ( +
+ setTagInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && tagInput.trim() && !isRunning) { + handleTagConfirm(); + } + }} + list="bulk-tag-suggestions" + disabled={isRunning} + /> + + {tagSuggestions.map((t) => ( + +
+ )} +
+ )} + + {/* ── Delete modal ── */} + {activeAction === "delete" && ( + + )} + + ); +} + +// ── SVG icons (inline, no external dependency) ────────────────────────────── + +function ExportIcon() { + return ( + + ); +} + +function ResendIcon() { + return ( + + ); +} + +function TagIcon() { + return ( + + ); +} + +function DeleteIcon() { + return ( + + ); +} diff --git a/soroscan-frontend/app/dashboard/components/ConfirmationModal.module.css b/soroscan-frontend/app/dashboard/components/ConfirmationModal.module.css new file mode 100644 index 000000000..05c877cee --- /dev/null +++ b/soroscan-frontend/app/dashboard/components/ConfirmationModal.module.css @@ -0,0 +1,215 @@ +/* ConfirmationModal.module.css */ + +.overlay { + position: fixed; + inset: 0; + z-index: 1100; + background: rgba(4, 8, 14, 0.82); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + display: grid; + place-items: center; + padding: 1rem; +} + +.dialog { + width: min(480px, calc(100vw - 2rem)); + border: 1px solid rgba(0, 212, 255, 0.3); + background: linear-gradient( + 180deg, + rgba(10, 18, 30, 0.98) 0%, + rgba(6, 11, 18, 0.98) 100% + ); + box-shadow: + 0 0 0 1px rgba(0, 255, 156, 0.08), + 0 20px 50px rgba(0, 0, 0, 0.6); + border-radius: 6px; + overflow: hidden; + animation: dialogIn 0.22s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes dialogIn { + from { + opacity: 0; + transform: scale(0.94) translateY(8px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* ── Header ─────────────────────────────────────────────────────────────── */ +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.85rem 1rem; + border-bottom: 1px solid rgba(0, 212, 255, 0.18); +} + +.title { + margin: 0; + font-size: 0.95rem; + color: #00ff9c; + text-transform: uppercase; + letter-spacing: 0.05rem; +} + +.closeBtn { + background: transparent; + border: 1px solid rgba(0, 212, 255, 0.3); + color: #7ba8b5; + width: 1.8rem; + height: 1.8rem; + border-radius: 3px; + cursor: pointer; + font: inherit; + display: grid; + place-items: center; + transition: color 0.15s, border-color 0.15s; +} + +.closeBtn:hover { + color: #d6f7ff; + border-color: rgba(0, 212, 255, 0.6); +} + +/* ── Body ────────────────────────────────────────────────────────────────── */ +.body { + padding: 1rem; +} + +.message { + margin: 0 0 0.5rem; + color: #d6f7ff; + font-size: 0.88rem; + line-height: 1.55; +} + +/* ── Progress ────────────────────────────────────────────────────────────── */ +.progressSection { + margin-top: 1rem; + display: grid; + gap: 0.4rem; +} + +.progressMeta { + display: flex; + justify-content: space-between; + font-size: 0.78rem; + color: #7ba8b5; +} + +.progressLabel { + color: #7ba8b5; +} + +.progressPct { + color: #00d4ff; + font-weight: 600; +} + +.progressTrack { + width: 100%; + height: 6px; + border: 1px solid rgba(0, 212, 255, 0.35); + background: rgba(0, 0, 0, 0.35); + border-radius: 3px; + overflow: hidden; +} + +.progressFill { + height: 100%; + background: linear-gradient(90deg, #00ff9c, #00d4ff); + border-radius: 3px; + transition: width 0.2s ease; +} + +.resultSummary { + display: flex; + gap: 1rem; + font-size: 0.8rem; + margin-top: 0.2rem; +} + +.successCount { + color: #00ff9c; +} + +.failedCount { + color: #ff6a6a; +} + +/* ── Footer ──────────────────────────────────────────────────────────────── */ +.footer { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding: 0.8rem 1rem; + border-top: 1px solid rgba(0, 212, 255, 0.18); +} + +/* ── Confirm button variants ─────────────────────────────────────────────── */ +.confirmBtn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.45rem 0.9rem; + border-radius: 4px; + border: 1px solid transparent; + font: inherit; + font-size: 0.82rem; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, box-shadow 0.15s, transform 0.1s; +} + +.confirmBtn:hover:not(:disabled) { + transform: translateY(-1px); +} + +.confirmBtn:disabled { + opacity: 0.5; + cursor: default; +} + +.confirmBtnPrimary { + border-color: rgba(0, 255, 156, 0.5); + background: rgba(0, 255, 156, 0.12); + color: #00ff9c; +} + +.confirmBtnPrimary:hover:not(:disabled) { + border-color: #00ff9c; + background: rgba(0, 255, 156, 0.2); + box-shadow: 0 0 14px rgba(0, 255, 156, 0.25); +} + +.confirmBtnDanger { + border-color: rgba(255, 80, 80, 0.5); + background: rgba(255, 80, 80, 0.12); + color: #ff6a6a; +} + +.confirmBtnDanger:hover:not(:disabled) { + border-color: #ff6a6a; + background: rgba(255, 80, 80, 0.22); + box-shadow: 0 0 14px rgba(255, 80, 80, 0.25); +} + +/* ── Loading spinner ─────────────────────────────────────────────────────── */ +.spinner { + display: inline-block; + width: 0.85rem; + height: 0.85rem; + border: 2px solid rgba(0, 255, 156, 0.3); + border-top-color: #00ff9c; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/soroscan-frontend/app/dashboard/components/ConfirmationModal.tsx b/soroscan-frontend/app/dashboard/components/ConfirmationModal.tsx new file mode 100644 index 000000000..2f955beed --- /dev/null +++ b/soroscan-frontend/app/dashboard/components/ConfirmationModal.tsx @@ -0,0 +1,177 @@ +"use client"; + +/** + * ConfirmationModal.tsx + * + * Reusable modal for confirming bulk operations. + * Displays a message, an optional slot for extra content (e.g. tag input), + * a progress bar while the operation runs, and a result summary on completion. + */ + +import { useEffect, useRef, type ReactNode } from "react"; +import type { BatchProgress, BatchResult } from "@/lib/batchService"; +import styles from "@/components/ingest/ingest-terminal.module.css"; +import modalStyles from "./ConfirmationModal.module.css"; + +interface ConfirmationModalProps { + title: string; + message: string; + confirmLabel: string; + confirmVariant?: "primary" | "danger"; + confirmDisabled?: boolean; + onConfirm: () => void; + onCancel: () => void; + isRunning: boolean; + progress: BatchProgress | null; + result: BatchResult | null; + /** Optional extra content rendered between the message and the progress bar */ + children?: ReactNode; +} + +export function ConfirmationModal({ + title, + message, + confirmLabel, + confirmVariant = "primary", + confirmDisabled = false, + onConfirm, + onCancel, + isRunning, + progress, + result, + children, +}: ConfirmationModalProps) { + const overlayRef = useRef(null); + + // Close on Escape key + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && !isRunning) onCancel(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [isRunning, onCancel]); + + // Trap focus within the modal + const dialogRef = useRef(null); + useEffect(() => { + const focusable = dialogRef.current?.querySelectorAll( + 'button, input, [tabindex]:not([tabindex="-1"])', + ); + focusable?.[0]?.focus(); + }, []); + + const handleOverlayClick = (e: React.MouseEvent) => { + if (!isRunning && e.target === overlayRef.current) onCancel(); + }; + + const confirmBtnClass = + confirmVariant === "danger" + ? `${modalStyles.confirmBtn} ${modalStyles.confirmBtnDanger}` + : `${modalStyles.confirmBtn} ${modalStyles.confirmBtnPrimary}`; + + return ( +
+
+ {/* Header */} +
+

+ {title} +

+ {!isRunning && ( + + )} +
+ + {/* Body */} +
+

{message}

+ + {/* Slot for additional content (e.g. tag input) */} + {children} + + {/* Progress bar */} + {progress && ( +
+
+ + {result + ? "Complete" + : `Processing… ${progress.completed} / ${progress.total}`} + + {progress.percent}% +
+
+
+
+ + {/* Result summary */} + {result && ( +
+ {result.succeeded.length > 0 && ( + + ✓ {result.succeeded.length} succeeded + + )} + {result.failed.length > 0 && ( + + ✗ {result.failed.length} failed + + )} +
+ )} +
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/soroscan-frontend/app/dashboard/components/EventExplorerDashboard.tsx b/soroscan-frontend/app/dashboard/components/EventExplorerDashboard.tsx index 751a64919..da3890bc1 100644 --- a/soroscan-frontend/app/dashboard/components/EventExplorerDashboard.tsx +++ b/soroscan-frontend/app/dashboard/components/EventExplorerDashboard.tsx @@ -6,6 +6,7 @@ import { FilterBar } from "./FilterBar"; import { EventDetailModal } from "./EventDetailModal"; import { PaginationControls } from "./PaginationControls"; import { AdvancedSearch } from "./AdvancedSearch"; +import { BulkActionsToolbar } from "./BulkActionsToolbar"; import { fetchAllContracts, fetchExplorerEvents } from "@/components/ingest/graphql"; import type { EventRecord } from "@/components/ingest/types"; import styles from "@/components/ingest/ingest-terminal.module.css"; @@ -53,6 +54,88 @@ export function EventExplorerDashboard() { const [selectedEvent, setSelectedEvent] = useState(null); const [totalCount, setTotalCount] = useState(0); + // ── Multi-select state ───────────────────────────────────────────────────── + /** + * Memoized selection store keyed by event ID. + * We intentionally keep this as a Set so membership checks are O(1). + * The state is reset whenever the page / filters change to avoid stale IDs. + */ + const [selectedIds, setSelectedIds] = useState>(new Set()); + + // Reset selection when events change (new page load, filter change, etc.) + useEffect(() => { + setSelectedIds(new Set()); + }, [filteredEvents]); + + const handleToggleSelect = useCallback((eventId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(eventId)) { + next.delete(eventId); + } else { + next.add(eventId); + } + return next; + }); + }, []); + + const handleToggleSelectAll = useCallback(() => { + setSelectedIds((prev) => { + const allIds = filteredEvents.map((e) => e.id); + const allSelected = allIds.every((id) => prev.has(id)); + if (allSelected) { + // Deselect all + return new Set(); + } + // Select all visible events + return new Set(allIds); + }); + }, [filteredEvents]); + + const handleSelectAll = useCallback(() => { + setSelectedIds(new Set(filteredEvents.map((e) => e.id))); + }, [filteredEvents]); + + const handleClearSelection = useCallback(() => { + setSelectedIds(new Set()); + }, []); + + // Called after successful bulk delete — remove from local state (optimistic UI) + const handleDeleteSuccess = useCallback( + (deletedIds: string[]) => { + const deletedSet = new Set(deletedIds); + setEvents((prev) => prev.filter((e) => !deletedSet.has(e.id))); + setSelectedIds(new Set()); + showToast( + `${deletedIds.length} event${deletedIds.length !== 1 ? "s" : ""} deleted.`, + "success", + ); + }, + [showToast], + ); + + // Called after successful bulk tag + const handleBulkTagSuccess = useCallback( + (eventIds: string[], tag: string) => { + setEventTags((prev) => { + const next = { ...prev }; + for (const id of eventIds) { + const current = next[id] ?? []; + if (!current.includes(tag)) { + next[id] = [...current, tag]; + } + } + return next; + }); + showToast( + `Tag '${tag}' added to ${eventIds.length} event${eventIds.length !== 1 ? "s" : ""}.`, + "success", + ); + }, + [showToast], + ); + + // ── Persist tags ─────────────────────────────────────────────────────────── useEffect(() => { try { const raw = localStorage.getItem(EVENT_TAGS_STORAGE_KEY); @@ -332,6 +415,9 @@ export function EventExplorerDashboard() { showTags hasActiveFilters={hasActiveFilters} onClearFilters={handleClearFilters} + selectedIds={selectedIds} + onToggleSelect={handleToggleSelect} + onToggleSelectAll={handleToggleSelectAll} /> setSelectedEvent(null)} /> )} + + {/* Floating bulk actions toolbar – visible when ≥1 events are selected */} +
); } diff --git a/soroscan-frontend/app/dashboard/components/EventTable.tsx b/soroscan-frontend/app/dashboard/components/EventTable.tsx index 56e39cd1f..14aed8a21 100644 --- a/soroscan-frontend/app/dashboard/components/EventTable.tsx +++ b/soroscan-frontend/app/dashboard/components/EventTable.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState } from "react"; +import { useState, useRef, useEffect } from "react"; import { formatDateTime, shortHash } from "@/components/ingest/formatters"; import type { EventRecord } from "@/components/ingest/types"; import styles from "@/components/ingest/ingest-terminal.module.css"; +import toolbarStyles from "./BulkActionsToolbar.module.css"; interface EventTableProps { events: EventRecord[]; @@ -16,6 +17,10 @@ interface EventTableProps { hasActiveFilters?: boolean; onClearFilters?: () => void; showTags?: boolean; + // Multi-select + selectedIds?: Set; + onToggleSelect?: (eventId: string) => void; + onToggleSelectAll?: () => void; } export function EventTable({ @@ -29,6 +34,9 @@ export function EventTable({ hasActiveFilters, onClearFilters, showTags = false, + selectedIds = new Set(), + onToggleSelect = () => {}, + onToggleSelectAll = () => {}, }: EventTableProps) { const [copiedId, setCopiedId] = useState(null); const [tagInputs, setTagInputs] = useState>({}); @@ -54,12 +62,21 @@ export function EventTable({ return colors[hash % colors.length]; }; + const allSelected = events.length > 0 && events.every((e) => selectedIds.has(e.id)); + const someSelected = events.some((e) => selectedIds.has(e.id)); + // Total column count (checkbox + data cols) + const colCount = (showTags ? 7 : 6) + 1; // +1 for checkbox + + if (loading) { return (
+ @@ -72,6 +89,9 @@ export function EventTable({ {[...Array(5)].map((_, index) => ( + @@ -108,6 +128,15 @@ export function EventTable({
+ + Contract Type Ledger
+
+
+ @@ -120,7 +149,7 @@ export function EventTable({ {!events.length ? ( - ) : ( - events.map((event) => ( - { - e.currentTarget.style.boxShadow = `0 0 15px ${getEventTypeColor(event.eventType)}`; - }} - onMouseLeave={(e) => { - e.currentTarget.style.boxShadow = "none"; - }} - > - { + if (!isSelected) { + e.currentTarget.style.boxShadow = `0 0 15px ${getEventTypeColor(event.eventType)}`; + } + }} + onMouseLeave={(e) => { + e.currentTarget.style.boxShadow = "none"; + }} + > + {/* Checkbox cell */} + + + + - - - - + - {showTags && ( - + + + {showTags && ( + + )} + - )} - - - )) + + ); + }) )}
+ + Contract Type Ledger
+ {loading ? ( "Loading events..." ) : hasActiveFilters ? ( @@ -152,181 +181,220 @@ export function EventTable({
-
- {shortHash(event.contractId)} -
e.stopPropagation()} + > + onToggleSelect(event.id)} + aria-label={`Select event ${event.id}`} + id={`select-event-${event.id}`} + /> + +
+ {shortHash(event.contractId)} + +
+
+ { - e.stopPropagation(); - copyToClipboard(event.contractId, `contract-${event.id}`); - }} - title="Copy contract ID" > - {copiedId === `contract-${event.id}` ? "✓" : "📋"} - - - - - {event.eventType} - - - - {formatDateTime(event.timestamp)} -
- {shortHash(event.txHash)} + {event.eventType} + +
- - -
-
- {(eventTags[event.id] ?? []).map((tag) => ( - - {tag} -
{formatDateTime(event.timestamp)} +
+ {shortHash(event.txHash)} + +
+
+
+
+ {(eventTags[event.id] ?? []).map((tag) => ( + + {tag} + + + ))} +
+
+ e.stopPropagation()} + onChange={(e) => { + const value = e.target.value; + setTagInputs((prev) => ({ ...prev, [event.id]: value })); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); e.stopPropagation(); - onRemoveTag(event.id, tag); - }} - style={{ - background: "transparent", - border: 0, - color: "inherit", - cursor: "pointer", - marginLeft: "0.3rem", - padding: 0, - }} - title={`Remove ${tag}`} - > - x - - - ))} -
-
- e.stopPropagation()} - onChange={(e) => { - const value = e.target.value; - setTagInputs((prev) => ({ ...prev, [event.id]: value })); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); + const value = tagInputs[event.id] ?? ""; + onAddTag(event.id, value); + setTagInputs((prev) => ({ ...prev, [event.id]: "" })); + } + }} + style={{ padding: "0.35rem 0.45rem", fontSize: "0.75rem" }} + /> + + }} + title="Add tag" + > + + + +
+ + {tagSuggestions.map((tag) => ( +
- - {tagSuggestions.map((tag) => ( - - +
+ - -
); +} + +// ── IndeterminateCheckbox ──────────────────────────────────────────────────── +// A controlled checkbox that also supports the indeterminate state via a +// useEffect-based ref, avoiding the "uncontrolled-to-controlled" React warning. +interface IndeterminateCheckboxProps extends React.InputHTMLAttributes { + indeterminate?: boolean; +} + +function IndeterminateCheckbox({ indeterminate = false, ...props }: IndeterminateCheckboxProps) { + const ref = useRef(null); + + useEffect(() => { + if (ref.current) { + ref.current.indeterminate = indeterminate; + } + }, [indeterminate]); + + return ; } \ No newline at end of file diff --git a/soroscan-frontend/app/dashboard/components/__tests__/BulkActionsToolbar.test.tsx b/soroscan-frontend/app/dashboard/components/__tests__/BulkActionsToolbar.test.tsx new file mode 100644 index 000000000..870f51e1e --- /dev/null +++ b/soroscan-frontend/app/dashboard/components/__tests__/BulkActionsToolbar.test.tsx @@ -0,0 +1,390 @@ +/** + * BulkActionsToolbar.test.tsx + * + * Tests for: + * - Toolbar visibility based on selection + * - Export actions (CSV / JSON) + * - Resend / Tag / Delete confirmation modals + * - Progress indicator accuracy + * - Confirmation / cancellation workflow + */ + +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { BulkActionsToolbar } from "../BulkActionsToolbar"; +import type { EventRecord } from "@/components/ingest/types"; +import * as batchService from "@/lib/batchService"; + +// ── Mock batchService ────────────────────────────────────────────────────── +jest.mock("@/lib/batchService", () => ({ + exportEvents: jest.fn(), + resendWebhooks: jest.fn(), + tagEvents: jest.fn(), + deleteEvents: jest.fn(), +})); + +const mockExportEvents = batchService.exportEvents as jest.MockedFunction< + typeof batchService.exportEvents +>; +const mockResendWebhooks = batchService.resendWebhooks as jest.MockedFunction< + typeof batchService.resendWebhooks +>; +const mockTagEvents = batchService.tagEvents as jest.MockedFunction< + typeof batchService.tagEvents +>; +const mockDeleteEvents = batchService.deleteEvents as jest.MockedFunction< + typeof batchService.deleteEvents +>; + +// ── Test data ────────────────────────────────────────────────────────────── +const mockEvents: EventRecord[] = [ + { + id: "1", + contractId: "CCAAA123", + contractName: "Test Contract", + eventType: "transfer", + ledger: 1000, + eventIndex: 0, + timestamp: "2024-01-01T00:00:00Z", + txHash: "abc123", + payload: { amount: 100 }, + }, + { + id: "2", + contractId: "CCBBB456", + contractName: "Another Contract", + eventType: "swap", + ledger: 1001, + eventIndex: 1, + timestamp: "2024-01-01T01:00:00Z", + txHash: "def456", + payload: { from: "A", to: "B" }, + }, +]; + +const defaultProps = { + selectedIds: new Set(), + allEvents: mockEvents, + onClearSelection: jest.fn(), + onSelectAll: jest.fn(), + onDeleteSuccess: jest.fn(), + onBulkTagSuccess: jest.fn(), + tagSuggestions: ["urgent", "review"], +}; + +describe("BulkActionsToolbar", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + + // Default implementation: instant success + mockResendWebhooks.mockImplementation(async (_ids, onProgress) => { + onProgress({ total: 1, completed: 1, failed: 0, percent: 100 }); + return { succeeded: ["1"], failed: [] }; + }); + mockTagEvents.mockImplementation(async (_ids, _tag, onProgress) => { + onProgress({ total: 1, completed: 1, failed: 0, percent: 100 }); + return { succeeded: ["1"], failed: [] }; + }); + mockDeleteEvents.mockImplementation(async (_ids, onProgress) => { + onProgress({ total: 1, completed: 1, failed: 0, percent: 100 }); + return { succeeded: ["1"], failed: [] }; + }); + }); + + afterEach(() => { + act(() => { + jest.runOnlyPendingTimers(); + }); + jest.useRealTimers(); + }); + + // ── Visibility ───────────────────────────────────────────────────────────── + + describe("Toolbar visibility", () => { + it("is hidden (aria-hidden) when no events are selected", () => { + render(); + const toolbar = screen.getByRole("toolbar", { hidden: true }); + expect(toolbar).toHaveAttribute("aria-hidden", "true"); + }); + + it("is visible when at least one event is selected", () => { + render(); + const toolbar = screen.getByRole("toolbar"); + expect(toolbar).toHaveAttribute("aria-hidden", "false"); + }); + + it("shows the correct count of selected events", () => { + render(); + expect(screen.getByText(/2 selected/i)).toBeInTheDocument(); + }); + + it("shows singular label for 1 selected event", () => { + render(); + expect(screen.getByText(/1 selected/i)).toBeInTheDocument(); + }); + }); + + // ── Selection controls ───────────────────────────────────────────────────── + + describe("Selection controls", () => { + it("calls onSelectAll when 'Select all' is clicked", () => { + const onSelectAll = jest.fn(); + render( + , + ); + fireEvent.click(screen.getByText("Select all")); + expect(onSelectAll).toHaveBeenCalledTimes(1); + }); + + it("calls onClearSelection when 'Deselect all' is clicked", () => { + const onClearSelection = jest.fn(); + render( + , + ); + fireEvent.click(screen.getByText("Deselect all")); + expect(onClearSelection).toHaveBeenCalledTimes(1); + }); + }); + + // ── Export ───────────────────────────────────────────────────────────────── + + describe("Export actions", () => { + it("calls exportEvents with csv format when CSV button is clicked", () => { + render(); + fireEvent.click(screen.getByTitle("Export selected events as CSV")); + expect(mockExportEvents).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ id: "1" })]), + "csv", + ); + }); + + it("calls exportEvents with json format when JSON button is clicked", () => { + render(); + fireEvent.click(screen.getByTitle("Export selected events as JSON")); + expect(mockExportEvents).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ id: "1" })]), + "json", + ); + }); + + it("passes only selected events to exportEvents", () => { + render(); + fireEvent.click(screen.getByTitle("Export selected events as JSON")); + const [calledWith] = (mockExportEvents as jest.Mock).mock.calls[0]; + expect(calledWith).toHaveLength(1); + expect(calledWith[0].id).toBe("1"); + }); + }); + + // ── Resend ───────────────────────────────────────────────────────────────── + + describe("Resend Webhooks", () => { + it("opens resend confirmation modal when Resend is clicked", () => { + render(); + fireEvent.click(screen.getByTitle("Resend webhooks for selected events")); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Resend Webhooks")).toBeInTheDocument(); + }); + + it("closes the modal when Cancel is clicked", () => { + render(); + fireEvent.click(screen.getByTitle("Resend webhooks for selected events")); + fireEvent.click(screen.getByText("Cancel")); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("calls resendWebhooks and shows progress on confirm", async () => { + render(); + fireEvent.click(screen.getByTitle("Resend webhooks for selected events")); + fireEvent.click(screen.getById("confirm-modal-confirm")); + + await waitFor(() => { + expect(mockResendWebhooks).toHaveBeenCalledWith( + ["1"], + expect.any(Function), + ); + }); + + // Progress should reach 100% + await waitFor(() => { + expect(screen.getByText("100%")).toBeInTheDocument(); + }); + }); + + it("shows partial success result when some fail", async () => { + mockResendWebhooks.mockImplementation(async (_ids, onProgress) => { + onProgress({ total: 2, completed: 2, failed: 1, percent: 100 }); + return { succeeded: ["1"], failed: ["2"] }; + }); + + render(); + fireEvent.click(screen.getByTitle("Resend webhooks for selected events")); + fireEvent.click(screen.getById("confirm-modal-confirm")); + + await waitFor(() => { + expect(screen.getByText(/1 succeeded/i)).toBeInTheDocument(); + expect(screen.getByText(/1 failed/i)).toBeInTheDocument(); + }); + }); + }); + + // ── Tag ──────────────────────────────────────────────────────────────────── + + describe("Tag Events", () => { + it("opens tag modal when Tag button is clicked", () => { + render(); + fireEvent.click(screen.getByTitle("Add a tag to selected events")); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Add Tag to Events")).toBeInTheDocument(); + }); + + it("disables confirm button when tag input is empty", () => { + render(); + fireEvent.click(screen.getByTitle("Add a tag to selected events")); + const confirmBtn = screen.getById("confirm-modal-confirm"); + expect(confirmBtn).toBeDisabled(); + }); + + it("enables confirm button when tag input has text", () => { + render(); + fireEvent.click(screen.getByTitle("Add a tag to selected events")); + fireEvent.change(screen.getByPlaceholderText(/e.g. urgent/i), { + target: { value: "test-tag" }, + }); + expect(screen.getById("confirm-modal-confirm")).not.toBeDisabled(); + }); + + it("calls tagEvents and onBulkTagSuccess on confirm", async () => { + const onBulkTagSuccess = jest.fn(); + render( + , + ); + + fireEvent.click(screen.getByTitle("Add a tag to selected events")); + fireEvent.change(screen.getByPlaceholderText(/e.g. urgent/i), { + target: { value: "my-tag" }, + }); + fireEvent.click(screen.getById("confirm-modal-confirm")); + + await waitFor(() => { + expect(mockTagEvents).toHaveBeenCalledWith( + ["1"], + "my-tag", + expect.any(Function), + ); + expect(onBulkTagSuccess).toHaveBeenCalledWith(["1"], "my-tag"); + }); + }); + }); + + // ── Delete ───────────────────────────────────────────────────────────────── + + describe("Delete Events", () => { + it("opens delete confirmation modal when Delete is clicked", () => { + render(); + fireEvent.click(screen.getByTitle("Delete selected events")); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Delete Events")).toBeInTheDocument(); + }); + + it("shows the destructive warning message", () => { + render(); + fireEvent.click(screen.getByTitle("Delete selected events")); + expect(screen.getByText(/cannot be undone/i)).toBeInTheDocument(); + }); + + it("calls deleteEvents and onDeleteSuccess on confirm", async () => { + const onDeleteSuccess = jest.fn(); + render( + , + ); + + fireEvent.click(screen.getByTitle("Delete selected events")); + fireEvent.click(screen.getById("confirm-modal-confirm")); + + await waitFor(() => { + expect(mockDeleteEvents).toHaveBeenCalledWith( + ["1"], + expect.any(Function), + ); + expect(onDeleteSuccess).toHaveBeenCalledWith(["1"]); + }); + }); + + it("does not call deleteEvents when user cancels", () => { + render(); + fireEvent.click(screen.getByTitle("Delete selected events")); + fireEvent.click(screen.getByText("Cancel")); + expect(mockDeleteEvents).not.toHaveBeenCalled(); + }); + + it("closes on Escape key press", () => { + render(); + fireEvent.click(screen.getByTitle("Delete selected events")); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + // ── Progress indicator ───────────────────────────────────────────────────── + + describe("Progress indicator", () => { + it("shows progress bar while operation is running", async () => { + let resolveOperation!: (result: batchService.BatchResult) => void; + mockDeleteEvents.mockImplementation(async (_ids, onProgress) => { + onProgress({ total: 3, completed: 1, failed: 0, percent: 33 }); + return new Promise((resolve) => { resolveOperation = resolve; }); + }); + + render(); + fireEvent.click(screen.getByTitle("Delete selected events")); + fireEvent.click(screen.getById("confirm-modal-confirm")); + + await waitFor(() => { + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + expect(screen.getByText("33%")).toBeInTheDocument(); + }); + + // Resolve to clean up + await act(async () => { + resolveOperation({ succeeded: ["1", "2"], failed: [] }); + }); + }); + }); +}); + +// Helper to find elements by ID (not built into RTL by default) +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace jest { + interface Matchers { + toBeInTheDocument(): R; + } + } +} + +// Extend screen to support getByText with an aria-hidden element +// (RTL already supports this, we just add a helper for id-based lookup) +Object.defineProperty(screen, "getById", { + get() { + return (id: string) => document.getElementById(id) as HTMLElement; + }, +}); diff --git a/soroscan-frontend/app/dashboard/components/__tests__/EventTable.test.tsx b/soroscan-frontend/app/dashboard/components/__tests__/EventTable.test.tsx index 818e8f419..9d47beac4 100644 --- a/soroscan-frontend/app/dashboard/components/__tests__/EventTable.test.tsx +++ b/soroscan-frontend/app/dashboard/components/__tests__/EventTable.test.tsx @@ -30,9 +30,19 @@ describe("EventTable", () => { ]; const mockOnEventClick = jest.fn(); + const mockOnToggleSelect = jest.fn(); + const mockOnToggleSelectAll = jest.fn(); + + const defaultMultiSelectProps = { + selectedIds: new Set(), + onToggleSelect: mockOnToggleSelect, + onToggleSelectAll: mockOnToggleSelectAll, + }; beforeEach(() => { mockOnEventClick.mockClear(); + mockOnToggleSelect.mockClear(); + mockOnToggleSelectAll.mockClear(); // Mock clipboard API Object.assign(navigator, { clipboard: { @@ -48,7 +58,12 @@ describe("EventTable", () => { describe("Loading State (issue #595)", () => { it("shows skeleton loader while loading", () => { const { container } = render( - + ); // Check that skeleton rows are rendered @@ -60,39 +75,54 @@ describe("EventTable", () => { expect(skeletons.length).toBeGreaterThan(0); }); - it("skeleton matches table structure with 6 columns", () => { + it("skeleton matches table structure with 7 columns (including checkbox)", () => { const { container } = render( - + ); const firstRow = container.querySelector("tbody tr"); const cells = firstRow?.querySelectorAll("td"); - - // Should have 6 columns: Contract, Type, Ledger, Time, Transaction, Actions - expect(cells?.length).toBe(6); + + // Should have 7 columns: Checkbox, Contract, Type, Ledger, Time, Transaction, Actions + expect(cells?.length).toBe(7); }); it("skeleton has proper styling for each column type", () => { const { container } = render( - + ); const firstRow = container.querySelector("tbody tr"); const skeletons = firstRow?.querySelectorAll(".skeleton"); - - // Check that different columns have different skeleton widths - expect(skeletons?.length).toBe(6); - - // Contract column skeleton - expect(skeletons?.[0]).toHaveStyle({ width: "120px" }); - + + // 7 skeleton cells: checkbox + Contract + Type + Ledger + Time + Tx + Actions + expect(skeletons?.length).toBe(7); + + // Contract column skeleton (index 1, after checkbox) + expect(skeletons?.[1]).toHaveStyle({ width: "120px" }); + // Type column skeleton (pill-shaped) - expect(skeletons?.[1]).toHaveStyle({ borderRadius: "12px" }); + expect(skeletons?.[2]).toHaveStyle({ borderRadius: "12px" }); }); it("does not show skeleton when not loading", () => { const { container } = render( - + ); const skeletons = container.querySelectorAll(".skeleton"); @@ -101,7 +131,12 @@ describe("EventTable", () => { it("transitions smoothly from skeleton to content", () => { const { container, rerender } = render( - + ); // Initially shows skeleton @@ -110,7 +145,12 @@ describe("EventTable", () => { // Rerender with data rerender( - + ); // Skeleton should be gone @@ -125,7 +165,12 @@ describe("EventTable", () => { describe("Event Display", () => { it("renders events when not loading", () => { render( - + ); // Check for shortened contract IDs (not full names) @@ -137,7 +182,12 @@ describe("EventTable", () => { it("shows empty state when no events and not loading", () => { render( - + ); expect(screen.getByText(/No events found/i)).toBeInTheDocument(); @@ -145,7 +195,12 @@ describe("EventTable", () => { it("calls onEventClick when View button is clicked", () => { render( - + ); const viewButtons = screen.getAllByRole("button", { name: /view/i }); @@ -156,7 +211,12 @@ describe("EventTable", () => { it("copies contract ID to clipboard", async () => { render( - + ); const copyButtons = screen.getAllByTitle("Copy contract ID"); @@ -169,7 +229,12 @@ describe("EventTable", () => { it("copies transaction hash to clipboard", async () => { render( - + ); const copyButtons = screen.getAllByTitle("Copy transaction hash"); @@ -183,7 +248,12 @@ describe("EventTable", () => { it("shows checkmark after successful copy", async () => { jest.useFakeTimers(); render( - + ); const copyButtons = screen.getAllByTitle("Copy contract ID"); @@ -201,12 +271,16 @@ describe("EventTable", () => { await waitFor(() => { expect(copyButtons[0]).toHaveTextContent("📋"); }); - }); it("applies hover effects to event rows", () => { const { container } = render( - + ); const eventRow = container.querySelector("tbody tr"); @@ -214,29 +288,122 @@ describe("EventTable", () => { }); }); + describe("Multi-select (issue #569)", () => { + it("renders a checkbox in each event row", () => { + render( + + ); + + // One checkbox per row + one in header = 3 total + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes.length).toBe(mockEvents.length + 1); // rows + header + }); + + it("calls onToggleSelect when a row checkbox is clicked", () => { + render( + + ); + + const rowCheckboxes = screen.getAllByRole("checkbox").slice(1); // exclude header + fireEvent.click(rowCheckboxes[0]); + expect(mockOnToggleSelect).toHaveBeenCalledWith("1"); + }); + + it("calls onToggleSelectAll when header checkbox is clicked", () => { + render( + + ); + + const headerCheckbox = screen.getByLabelText(/select all events/i); + fireEvent.click(headerCheckbox); + expect(mockOnToggleSelectAll).toHaveBeenCalledTimes(1); + }); + + it("shows header checkbox as checked when all rows are selected", () => { + render( + + ); + + const headerCheckbox = screen.getByLabelText(/deselect all events/i); + expect(headerCheckbox).toBeChecked(); + }); + + it("shows selected row with visual highlight class", () => { + const { container } = render( + + ); + + const rows = container.querySelectorAll("tbody tr"); + // First row should have the selectedRow class + expect(rows[0].className).toMatch(/selectedRow/); + // Second row should NOT + expect(rows[1].className).not.toMatch(/selectedRow/); + }); + }); + describe("Accessibility", () => { it("has proper table structure", () => { render( - + ); const table = screen.getByRole("table"); expect(table).toBeInTheDocument(); const headers = screen.getAllByRole("columnheader"); - expect(headers).toHaveLength(6); + // Checkbox + Contract + Type + Ledger + Time + Transaction + Actions = 7 + expect(headers).toHaveLength(7); }); it("skeleton rows have unique keys", () => { const { container } = render( - + ); const rows = container.querySelectorAll("tbody tr"); - + // Check that we have 5 skeleton rows expect(rows.length).toBe(5); - + // React keys are used internally and don't appear in DOM // Just verify all rows are rendered rows.forEach((row) => { diff --git a/soroscan-frontend/app/providers.tsx b/soroscan-frontend/app/providers.tsx index 76b75df77..944096579 100644 --- a/soroscan-frontend/app/providers.tsx +++ b/soroscan-frontend/app/providers.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import { ToastProvider } from "@/context/ToastContext"; import { ApolloProvider } from "@/providers/ApolloProvider"; +import { EventStreamProvider } from "@/context/EventStreamContext"; interface ProvidersProps { children: ReactNode; @@ -11,7 +12,9 @@ interface ProvidersProps { export function Providers({ children }: ProvidersProps) { return ( - {children} + + {children} + ); } diff --git a/soroscan-frontend/app/webhooks/components/CreateWebhookModal.tsx b/soroscan-frontend/app/webhooks/components/CreateWebhookModal.tsx index 988c8bf30..ee6b127e1 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 000000000..c9fb38726 --- /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 000000000..3bc53eedb --- /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 971cc3850..d97718130 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/terminal/landing/NavDrawer.tsx b/soroscan-frontend/components/terminal/landing/NavDrawer.tsx new file mode 100644 index 000000000..298cf449a --- /dev/null +++ b/soroscan-frontend/components/terminal/landing/NavDrawer.tsx @@ -0,0 +1,107 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { LogOut } from "lucide-react"; +import { Drawer } from "@/components/ui/drawer"; +import { Button } from "../Button"; + +interface NavDrawerProps { + isOpen: boolean; + onClose: () => void; + authenticated: boolean; + handleLogout: () => void; + pathname: string; +} + +const navLinks = [ + { href: "/docs", label: "DOCS" }, + { href: "/features", label: "FEATURES" }, + { href: "/api/docs/", label: "API_DOCS", external: true }, + { href: "https://github.com/SoroScan/soroscan", label: "GITHUB", external: true }, +]; + +export function NavDrawer({ + isOpen, + onClose, + authenticated, + handleLogout, + pathname, +}: NavDrawerProps) { + return ( + +
+ {navLinks.map((link) => + link.external ? ( + + {link.label} + + ) : ( + + {link.label} + + ) + )} + + {authenticated ? ( + + ) : ( + + SIGN_IN + + )} + + + + +
+
+ ); +} diff --git a/soroscan-frontend/components/terminal/landing/Navbar.tsx b/soroscan-frontend/components/terminal/landing/Navbar.tsx index 32099c18e..de89c8f4f 100644 --- a/soroscan-frontend/components/terminal/landing/Navbar.tsx +++ b/soroscan-frontend/components/terminal/landing/Navbar.tsx @@ -4,9 +4,11 @@ import * as React from "react" import Link from "next/link" import { usePathname } from "next/navigation" import { Button } from "../Button" -import { Menu, X, LogOut } from "lucide-react" +import { LogOut } from "lucide-react" import { isLoggedIn, clearTokens } from "@/lib/auth" import { useRouter } from "next/navigation" +import { HamburgerToggle } from "@/components/ui/hamburger-toggle" +import { NavDrawer } from "./NavDrawer" const navLinks = [ { href: "/docs", label: "DOCS" }, @@ -97,75 +99,21 @@ export function Navbar() {
{/* Mobile hamburger */} - + ariaControls="mobile-menu" + />
- {/* Mobile menu */} - {open && ( - - )} + {/* Mobile navigation drawer */} + setOpen(false)} + authenticated={authenticated} + handleLogout={handleLogout} + pathname={pathname} + /> ) } diff --git a/soroscan-frontend/components/ui/EventCountBadge.tsx b/soroscan-frontend/components/ui/EventCountBadge.tsx new file mode 100644 index 000000000..ec9035f4d --- /dev/null +++ b/soroscan-frontend/components/ui/EventCountBadge.tsx @@ -0,0 +1,48 @@ +"use client"; + +import React, { useEffect } from "react"; +import { useEventStream } from "@/context/EventStreamContext"; + +interface EventCountBadgeProps { + contractId: string; + initialCount: number; + className?: string; +} + +export function EventCountBadge({ contractId, initialCount, className = "" }: EventCountBadgeProps) { + const { eventCounts, subscribe } = useEventStream(); + + useEffect(() => { + const unsubscribe = subscribe(contractId, initialCount); + return () => { + unsubscribe(); + }; + }, [contractId, initialCount, subscribe]); + + const currentCount = eventCounts[contractId] ?? initialCount; + + // Formatting: e.g. > 9999 -> "10k+" + const formatCount = (count: number): string => { + if (count > 9999) { + return `${Math.floor(count / 1000)}k+`; + } + return count.toLocaleString(); + }; + + return ( + 0 + ? "bg-terminal-green/10 text-terminal-green border-terminal-green/30" + : "bg-terminal-gray/10 text-terminal-gray border-terminal-gray/30", + className, + ].join(" ")} + title={`${currentCount.toLocaleString()} events`} + > + {formatCount(currentCount)} + + ); +} diff --git a/soroscan-frontend/components/ui/FilterExpressionTester.tsx b/soroscan-frontend/components/ui/FilterExpressionTester.tsx new file mode 100644 index 000000000..b24d4eba6 --- /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 */} +
+
+ + +
+