From ee2ed20a7a768461eb973614c7e1cbb7eff56feb Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Sun, 21 Jun 2026 21:07:45 +0200 Subject: [PATCH] feat: add event log filters --- README.md | 1 + src/app/events/page.test.tsx | 105 +++++++++++++++++++++++++++++++++++ src/app/events/page.tsx | 99 +++++++++++++++++++++++++++------ 3 files changed, 187 insertions(+), 18 deletions(-) create mode 100644 src/app/events/page.test.tsx diff --git a/README.md b/README.md index 3ab7b96..bb4d3e2 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Next.js app for [StableRoute](https://github.com/your-org/stableroute) — Stell - **Next.js 15** (App Router) with **React 19** - **TailwindCSS** for styling - Starter landing page; Stellar wallet integration can be added here +- Event log filtering by event type and payload substring ## Prerequisites diff --git a/src/app/events/page.test.tsx b/src/app/events/page.test.tsx new file mode 100644 index 0000000..70b27c0 --- /dev/null +++ b/src/app/events/page.test.tsx @@ -0,0 +1,105 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import EventsPage from "./page"; + +const events = [ + { + id: "1", + ts: 1_700_000_000_000, + type: "pair.registered", + payload: { source: "USDC", destination: "EURC" }, + }, + { + id: "2", + ts: 1_700_000_001_000, + type: "api_key.created", + payload: { prefix: "srk_abcd" }, + }, + { + id: "3", + ts: 1_700_000_002_000, + type: "pair.unregistered", + payload: { source: "XLM", destination: "USDC" }, + }, +]; + +const mockEvents = (items = events) => { + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ items }), + } as unknown as Response); +}; + +describe("EventsPage", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("renders loaded events in source order with a count", async () => { + mockEvents(); + render(); + + await waitFor(() => { + expect(screen.getByText(/showing 3 of 3 events/i)).toBeInTheDocument(); + }); + + const rows = screen.getAllByRole("listitem"); + expect(rows).toHaveLength(3); + expect(rows[0]).toHaveTextContent("pair.registered"); + expect(rows[1]).toHaveTextContent("api_key.created"); + expect(rows[2]).toHaveTextContent("pair.unregistered"); + }); + + it("filters events by type", async () => { + mockEvents(); + render(); + + await screen.findByText(/showing 3 of 3 events/i); + fireEvent.change(screen.getByLabelText(/event type/i), { + target: { value: "api_key.created" }, + }); + + expect(screen.getByText(/showing 1 of 3 events/i)).toBeInTheDocument(); + const rows = screen.getAllByRole("listitem"); + expect(rows).toHaveLength(1); + expect(rows[0]).toHaveTextContent("api_key.created"); + }); + + it("filters events by serialized payload substring", async () => { + mockEvents(); + render(); + + await screen.findByText(/showing 3 of 3 events/i); + fireEvent.change(screen.getByLabelText(/payload contains/i), { + target: { value: "EURC" }, + }); + + expect(screen.getByText(/showing 1 of 3 events/i)).toBeInTheDocument(); + const rows = screen.getAllByRole("listitem"); + expect(rows).toHaveLength(1); + expect(rows[0]).toHaveTextContent("pair.registered"); + expect(rows[0]).toHaveTextContent("EURC"); + }); + + it("shows EmptyState when filters match nothing", async () => { + mockEvents(); + render(); + + await screen.findByText(/showing 3 of 3 events/i); + fireEvent.change(screen.getByLabelText(/payload contains/i), { + target: { value: "does-not-exist" }, + }); + + expect(screen.getByText(/showing 0 of 3 events/i)).toBeInTheDocument(); + expect(screen.getByText(/no matching events/i)).toBeInTheDocument(); + expect(screen.queryByRole("listitem")).not.toBeInTheDocument(); + }); + + it("shows EmptyState when no events are loaded", async () => { + mockEvents([]); + render(); + + expect(await screen.findByText(/^No events$/)).toBeInTheDocument(); + }); +}); diff --git a/src/app/events/page.tsx b/src/app/events/page.tsx index fcf5ef0..3fabca8 100644 --- a/src/app/events/page.tsx +++ b/src/app/events/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { EmptyState } from "@/components/EmptyState"; import { apiGet } from "@/lib/apiClient"; type AppEvent = { @@ -10,9 +11,24 @@ type AppEvent = { payload: Record; }; +/** + * Filter loaded events without mutating their chronological source order. + */ +function filterEvents(events: AppEvent[], typeFilter: string, payloadFilter: string) { + const query = payloadFilter.trim().toLowerCase(); + return events.filter((event) => { + const matchesType = typeFilter === "all" || event.type === typeFilter; + const matchesPayload = + query.length === 0 || JSON.stringify(event.payload).toLowerCase().includes(query); + return matchesType && matchesPayload; + }); +} + export default function EventsPage() { const [items, setItems] = useState(null); const [error, setError] = useState(null); + const [typeFilter, setTypeFilter] = useState("all"); + const [payloadFilter, setPayloadFilter] = useState(""); useEffect(() => { apiGet<{ items: AppEvent[] }>("/api/v1/events?limit=100") @@ -20,6 +36,15 @@ export default function EventsPage() { .catch((e) => setError(e.message)); }, []); + const typeOptions = useMemo( + () => Array.from(new Set((items ?? []).map((event) => event.type))).sort(), + [items] + ); + const filteredItems = useMemo( + () => (items ? filterEvents(items, typeFilter, payloadFilter) : []), + [items, typeFilter, payloadFilter] + ); + return (
Event log {error &&

{error}

} {items && items.length === 0 && ( -

No events.

+ )} {items && items.length > 0 && ( -
    - {items.map((e) => ( -
  1. -
    - {e.type} - {new Date(e.ts).toISOString()} -
    -
    -                {JSON.stringify(e.payload, null, 2)}
    -              
    -
  2. - ))} -
+ <> +
+ + +
+

+ Showing {filteredItems.length} of {items.length} events +

+ {filteredItems.length === 0 ? ( + + ) : ( +
    + {filteredItems.map((e) => ( +
  1. +
    + {e.type} + {new Date(e.ts).toISOString()} +
    +
    +                    {JSON.stringify(e.payload, null, 2)}
    +                  
    +
  2. + ))} +
+ )} + )}
);