Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
105 changes: 105 additions & 0 deletions src/app/events/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<EventsPage />);

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(<EventsPage />);

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(<EventsPage />);

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(<EventsPage />);

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(<EventsPage />);

expect(await screen.findByText(/^No events$/)).toBeInTheDocument();
});
});
99 changes: 81 additions & 18 deletions src/app/events/page.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -10,16 +11,40 @@ type AppEvent = {
payload: Record<string, unknown>;
};

/**
* 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<AppEvent[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [typeFilter, setTypeFilter] = useState("all");
const [payloadFilter, setPayloadFilter] = useState("");

useEffect(() => {
apiGet<{ items: AppEvent[] }>("/api/v1/events?limit=100")
.then((b) => setItems(b.items))
.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 (
<main
id="main-content"
Expand All @@ -29,25 +54,63 @@ export default function EventsPage() {
<h1 className="text-3xl font-semibold tracking-tight">Event log</h1>
{error && <p role="alert" className="text-sm text-rose-600">{error}</p>}
{items && items.length === 0 && (
<p className="text-sm text-neutral-600 dark:text-neutral-400">No events.</p>
<EmptyState title="No events" description="No backend events have been recorded yet." />
)}
{items && items.length > 0 && (
<ol className="flex flex-col gap-2">
{items.map((e) => (
<li
key={e.id}
className="rounded border border-neutral-200 p-3 font-mono text-xs dark:border-neutral-800"
>
<div className="flex justify-between text-neutral-500">
<span>{e.type}</span>
<span>{new Date(e.ts).toISOString()}</span>
</div>
<pre className="mt-2 whitespace-pre-wrap break-words">
{JSON.stringify(e.payload, null, 2)}
</pre>
</li>
))}
</ol>
<>
<div className="grid gap-3 rounded border border-neutral-200 p-4 dark:border-neutral-800 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]">
<label className="flex flex-col gap-1 text-sm font-medium">
Event type
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="rounded-md border border-neutral-300 px-3 py-2 font-normal focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-neutral-700 dark:bg-neutral-900"
>
<option value="all">All event types</option>
{typeOptions.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-sm font-medium">
Payload contains
<input
value={payloadFilter}
onChange={(e) => setPayloadFilter(e.target.value)}
placeholder="Search payload JSON"
className="rounded-md border border-neutral-300 px-3 py-2 font-normal focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-neutral-700 dark:bg-neutral-900"
/>
</label>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Showing {filteredItems.length} of {items.length} events
</p>
{filteredItems.length === 0 ? (
<EmptyState
title="No matching events"
description="Adjust the event type or payload filter to widen the results."
/>
) : (
<ol className="flex flex-col gap-2">
{filteredItems.map((e) => (
<li
key={e.id}
className="rounded border border-neutral-200 p-3 font-mono text-xs dark:border-neutral-800"
>
<div className="flex justify-between text-neutral-500">
<span>{e.type}</span>
<span>{new Date(e.ts).toISOString()}</span>
</div>
<pre className="mt-2 whitespace-pre-wrap break-words">
{JSON.stringify(e.payload, null, 2)}
</pre>
</li>
))}
</ol>
)}
</>
)}
</main>
);
Expand Down