From 53fe3341db117413ff510b416de72c902c2b302d Mon Sep 17 00:00:00 2001 From: JSisques Date: Sat, 23 May 2026 17:58:25 +0200 Subject: [PATCH 1/8] feat(inspector): add i18n keys for view toggle, empty state, and retries --- features/inspector/i18n/en.ts | 6 ++++++ features/inspector/i18n/es.ts | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/features/inspector/i18n/en.ts b/features/inspector/i18n/en.ts index 5b8376f..7431ce9 100644 --- a/features/inspector/i18n/en.ts +++ b/features/inspector/i18n/en.ts @@ -17,6 +17,11 @@ const dict = { placeholder: "Search operation or payload…", }, }, + view: { + label: "View", + list: "List", + timeline: "Timeline", + }, clearBuffer: "Clear", statusPolling: "Live", statusError: "Error", @@ -30,6 +35,7 @@ const dict = { card: { duration: "{ms}ms", attempts: "{n} attempts", + retries: "{n} retries", }, detail: { title: "Request Detail", diff --git a/features/inspector/i18n/es.ts b/features/inspector/i18n/es.ts index 4debcfc..98f4ddc 100644 --- a/features/inspector/i18n/es.ts +++ b/features/inspector/i18n/es.ts @@ -22,6 +22,11 @@ const dict = { placeholder: "Buscar operación o payload…", }, }, + view: { + label: "Vista", + list: "Lista", + timeline: "Línea de tiempo", + }, clearBuffer: "Limpiar", statusPolling: "En vivo", statusError: "Error", @@ -35,6 +40,7 @@ const dict = { card: { duration: "{ms}ms", attempts: "{n} intentos", + retries: "{n} reintentos", }, detail: { title: "Detalle de solicitud", From fcb95bd98fcb9216a59d5e20f30df1f6af29be26 Mon Sep 17 00:00:00 2001 From: JSisques Date: Sat, 23 May 2026 17:58:28 +0200 Subject: [PATCH 2/8] feat(inspector): add InspectorEmpty and InspectorSkeleton components --- .../inspector-empty/inspector-empty.test.tsx | 38 +++++++++++++++ .../inspector-empty/inspector-empty.tsx | 25 ++++++++++ .../inspector-skeleton.test.tsx | 47 +++++++++++++++++++ .../inspector-skeleton/inspector-skeleton.tsx | 42 +++++++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 features/inspector/components/inspector-empty/inspector-empty.test.tsx create mode 100644 features/inspector/components/inspector-empty/inspector-empty.tsx create mode 100644 features/inspector/components/inspector-skeleton/inspector-skeleton.test.tsx create mode 100644 features/inspector/components/inspector-skeleton/inspector-skeleton.tsx diff --git a/features/inspector/components/inspector-empty/inspector-empty.test.tsx b/features/inspector/components/inspector-empty/inspector-empty.test.tsx new file mode 100644 index 0000000..4048405 --- /dev/null +++ b/features/inspector/components/inspector-empty/inspector-empty.test.tsx @@ -0,0 +1,38 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { InspectorEmpty } from "./inspector-empty"; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const dict = { + empty: { + title: "No requests yet", + body: "AWS SDK calls made by Server Actions will appear here.", + }, +}; + +afterEach(() => { + cleanup(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("InspectorEmpty", () => { + it("renders the empty title", () => { + render(); + expect(screen.getByText("No requests yet")).toBeInTheDocument(); + }); + + it("renders the empty body text", () => { + render(); + expect( + screen.getByText("AWS SDK calls made by Server Actions will appear here."), + ).toBeInTheDocument(); + }); + + it("renders a SearchX icon (svg element present)", () => { + const { container } = render(); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); +}); diff --git a/features/inspector/components/inspector-empty/inspector-empty.tsx b/features/inspector/components/inspector-empty/inspector-empty.tsx new file mode 100644 index 0000000..2c95fec --- /dev/null +++ b/features/inspector/components/inspector-empty/inspector-empty.tsx @@ -0,0 +1,25 @@ +import { SearchX } from "lucide-react"; +import type { InspectorDict } from "@/features/inspector/i18n/en"; +import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +type EmptyDict = Pick, "empty">; + +type InspectorEmptyProps = { + dict: EmptyDict; +}; + +// ── Component ───────────────────────────────────────────────────────────────── + +export function InspectorEmpty({ dict }: InspectorEmptyProps) { + return ( +
+ +
+

{dict.empty.title}

+

{dict.empty.body}

+
+
+ ); +} diff --git a/features/inspector/components/inspector-skeleton/inspector-skeleton.test.tsx b/features/inspector/components/inspector-skeleton/inspector-skeleton.test.tsx new file mode 100644 index 0000000..9109c73 --- /dev/null +++ b/features/inspector/components/inspector-skeleton/inspector-skeleton.test.tsx @@ -0,0 +1,47 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { InspectorSkeleton } from "./inspector-skeleton"; + +afterEach(() => { + cleanup(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("InspectorSkeleton", () => { + describe("list mode (withSpine=false)", () => { + it("renders skeleton card items", () => { + const { container } = render(); + // Expect at least one skeleton placeholder element + const items = container.querySelectorAll("[data-testid='skeleton-item']"); + expect(items.length).toBeGreaterThan(0); + }); + + it("does NOT render spine dots when withSpine is false", () => { + const { container } = render(); + expect(container.querySelector("[data-testid='spine-dot']")).not.toBeInTheDocument(); + }); + }); + + describe("timeline mode (withSpine=true)", () => { + it("renders skeleton card items with spine dots when withSpine is true", () => { + const { container } = render(); + const items = container.querySelectorAll("[data-testid='skeleton-item']"); + expect(items.length).toBeGreaterThan(0); + }); + + it("renders spine dots when withSpine is true", () => { + const { container } = render(); + const spineDots = container.querySelectorAll("[data-testid='spine-dot']"); + expect(spineDots.length).toBeGreaterThan(0); + }); + + it("spine dot count matches skeleton item count", () => { + const { container } = render(); + const items = container.querySelectorAll("[data-testid='skeleton-item']"); + const spineDots = container.querySelectorAll("[data-testid='spine-dot']"); + expect(spineDots.length).toBe(items.length); + }); + }); +}); diff --git a/features/inspector/components/inspector-skeleton/inspector-skeleton.tsx b/features/inspector/components/inspector-skeleton/inspector-skeleton.tsx new file mode 100644 index 0000000..b6c290f --- /dev/null +++ b/features/inspector/components/inspector-skeleton/inspector-skeleton.tsx @@ -0,0 +1,42 @@ +// ── Types ────────────────────────────────────────────────────────────────────── + +type InspectorSkeletonProps = { + withSpine?: boolean; + count?: number; +}; + +// ── Component ───────────────────────────────────────────────────────────────── + +const DEFAULT_COUNT = 5; + +export function InspectorSkeleton({ + withSpine = false, + count = DEFAULT_COUNT, +}: InspectorSkeletonProps) { + const items = Array.from({ length: count }); + + return ( +
+ {items.map((_, idx) => ( +
+ {withSpine && ( +
+
+ {idx < count - 1 &&
} +
+ )} +
+
+ ))} +
+ ); +} From b88c0c957c93da906ee17407cb0e82400c998c96 Mon Sep 17 00:00:00 2001 From: JSisques Date: Sat, 23 May 2026 17:58:31 +0200 Subject: [PATCH 3/8] feat(inspector): add InspectorTimeline with descending sort and spine connector --- .../inspector-timeline.test.tsx | 79 +++++++++++++++++++ .../inspector-timeline/inspector-timeline.tsx | 48 +++++++++++ 2 files changed, 127 insertions(+) create mode 100644 features/inspector/components/inspector-timeline/inspector-timeline.test.tsx create mode 100644 features/inspector/components/inspector-timeline/inspector-timeline.tsx diff --git a/features/inspector/components/inspector-timeline/inspector-timeline.test.tsx b/features/inspector/components/inspector-timeline/inspector-timeline.test.tsx new file mode 100644 index 0000000..eaa68ee --- /dev/null +++ b/features/inspector/components/inspector-timeline/inspector-timeline.test.tsx @@ -0,0 +1,79 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RequestEntry } from "@/features/inspector/lib/types/types"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("@/features/inspector/components/request-card/request-card", () => ({ + RequestCard: ({ entry }: { entry: RequestEntry }) => ( +
+ ), +})); + +import { InspectorTimeline } from "./inspector-timeline"; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const dict = { + card: { duration: "{ms}ms", attempts: "{n} attempts", retries: "{n} retries" }, +}; + +function makeEntry(id: string, timestamp: number): RequestEntry { + return { + id, + timestamp, + service: "SQS", + operation: "SendMessageCommand", + input: {}, + output: {}, + durationMs: 10, + status: "success", + attempts: 1, + }; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("InspectorTimeline", () => { + it("renders one RequestCard per entry", () => { + const entries = [ + makeEntry("e1", 1000), + makeEntry("e2", 2000), + makeEntry("e3", 3000), + ]; + render(); + expect(screen.getAllByTestId("request-card")).toHaveLength(3); + }); + + it("renders entries sorted descending by timestamp (newest first)", () => { + const entries = [ + makeEntry("e1", 1000), + makeEntry("e3", 3000), + makeEntry("e2", 2000), + ]; + render(); + const cards = screen.getAllByTestId("request-card"); + // e3 (timestamp=3000) must appear first + expect(cards[0].getAttribute("data-id")).toBe("e3"); + expect(cards[1].getAttribute("data-id")).toBe("e2"); + expect(cards[2].getAttribute("data-id")).toBe("e1"); + }); + + it("renders a spine container (flex gap structure) for each entry", () => { + const entries = [makeEntry("e1", 1000), makeEntry("e2", 2000)]; + const { container } = render(); + // Each entry is wrapped in a flex row with gap — verify spine dots are rendered + const spineDots = container.querySelectorAll("[data-testid='spine-dot']"); + expect(spineDots.length).toBe(2); + }); + + it("renders nothing when entries is empty", () => { + const { container } = render(); + expect(container.querySelectorAll("[data-testid='request-card']")).toHaveLength(0); + }); +}); diff --git a/features/inspector/components/inspector-timeline/inspector-timeline.tsx b/features/inspector/components/inspector-timeline/inspector-timeline.tsx new file mode 100644 index 0000000..3d76317 --- /dev/null +++ b/features/inspector/components/inspector-timeline/inspector-timeline.tsx @@ -0,0 +1,48 @@ +import { getServiceColorClasses } from "@/features/inspector/lib/service-color/service-color"; +import { RequestCard } from "@/features/inspector/components/request-card/request-card"; +import type { RequestEntry } from "@/features/inspector/lib/types/types"; +import { cn } from "@/lib/utils"; +import type { InspectorDict } from "@/features/inspector/i18n/en"; +import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +type CardDict = Pick, "card">; + +type InspectorTimelineProps = { + entries: RequestEntry[]; + dict: CardDict; +}; + +// ── Component ───────────────────────────────────────────────────────────────── + +export function InspectorTimeline({ entries, dict }: InspectorTimelineProps) { + const sorted = [...entries].sort((a, b) => b.timestamp - a.timestamp); + + return ( +
+ {sorted.map((entry, idx) => ( +
+ {/* Spine column */} +
+
+ {idx < sorted.length - 1 && ( +
+ )} +
+ + {/* Card column */} +
+ +
+
+ ))} +
+ ); +} From 47101a5351823757caa3bcc0d5d978628773661b Mon Sep 17 00:00:00 2001 From: JSisques Date: Sat, 23 May 2026 17:58:33 +0200 Subject: [PATCH 4/8] feat(inspector): add retry badge with i18n to RequestCard --- .../request-card/request-card.test.tsx | 55 ++++++++++++++++--- .../components/request-card/request-card.tsx | 17 +++++- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/features/inspector/components/request-card/request-card.test.tsx b/features/inspector/components/request-card/request-card.test.tsx index f7c3f35..6aa2415 100644 --- a/features/inspector/components/request-card/request-card.test.tsx +++ b/features/inspector/components/request-card/request-card.test.tsx @@ -1,6 +1,8 @@ import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { RequestEntry } from "@/features/inspector/lib/types/types"; +import type { InspectorDict } from "@/features/inspector/i18n/en"; +import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals"; // ── Mocks ───────────────────────────────────────────────────────────────────── @@ -18,6 +20,16 @@ import { RequestCard } from "./request-card"; // ── Fixtures ────────────────────────────────────────────────────────────────── +type CardDict = Pick, "card">; + +const defaultDict: CardDict = { + card: { + duration: "{ms}ms", + attempts: "{n} attempts", + retries: "{n} retries", + }, +}; + function makeEntry(overrides: Partial = {}): RequestEntry { return { id: "entry-1", @@ -42,40 +54,69 @@ afterEach(() => { describe("RequestCard", () => { it("renders the service name as a badge", () => { - render(); + render(); expect(screen.getByText("SQS")).toBeInTheDocument(); }); it("renders the operation name", () => { - render(); + render(); expect(screen.getByText("SendMessageCommand")).toBeInTheDocument(); }); it("renders the duration pill with ms value", () => { - render(); + render(); expect(screen.getByText("42ms")).toBeInTheDocument(); }); it("renders a green status indicator for success", () => { - render(); + render(); const indicator = screen.getByTestId("status-indicator"); expect(indicator).toBeInTheDocument(); expect(indicator.getAttribute("data-status")).toBe("success"); }); it("renders a red status indicator for error", () => { - render(); + render(); const indicator = screen.getByTestId("status-indicator"); expect(indicator.getAttribute("data-status")).toBe("error"); }); it("does NOT render retry badge when attempts === 1", () => { - render(); + render(); expect(screen.queryByTestId("retry-badge")).not.toBeInTheDocument(); }); + it("renders a retry badge when attempts > 1", () => { + render(); + expect(screen.getByTestId("retry-badge")).toBeInTheDocument(); + }); + + it("retry badge shows correct text for attempts > 1", () => { + render(); + // attempts=3 means 2 retries (attempts - 1) + expect(screen.getByTestId("retry-badge")).toHaveTextContent("2 retries"); + }); + + it("retry badge shows '1 retries' when attempts === 2", () => { + render(); + expect(screen.getByTestId("retry-badge")).toHaveTextContent("1 retries"); + }); + it("renders with a different service (DynamoDB)", () => { - render(); + render(); expect(screen.getByText("DynamoDB")).toBeInTheDocument(); }); + + it("retry badge text comes from dict.card.retries template", () => { + const customDict: CardDict = { + card: { + duration: "{ms}ms", + attempts: "{n} attempts", + retries: "{n} reintentos", + }, + }; + render(); + // 3 attempts → 2 retries: should use the custom dict template + expect(screen.getByTestId("retry-badge")).toHaveTextContent("2 reintentos"); + }); }); diff --git a/features/inspector/components/request-card/request-card.tsx b/features/inspector/components/request-card/request-card.tsx index c413f1b..1cfe6da 100644 --- a/features/inspector/components/request-card/request-card.tsx +++ b/features/inspector/components/request-card/request-card.tsx @@ -5,16 +5,21 @@ import { getServiceColorClasses } from "@/features/inspector/lib/service-color/s import type { RequestEntry } from "@/features/inspector/lib/types/types"; import { RequestDetailDialog } from "@/features/inspector/components/request-detail-dialog/request-detail-dialog"; import { cn } from "@/lib/utils"; +import type { InspectorDict } from "@/features/inspector/i18n/en"; +import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals"; // ── Types ────────────────────────────────────────────────────────────────────── +type CardDict = Pick, "card">; + type RequestCardProps = { entry: RequestEntry; + dict: CardDict; }; // ── Component ───────────────────────────────────────────────────────────────── -export function RequestCard({ entry }: RequestCardProps) { +export function RequestCard({ entry, dict }: RequestCardProps) { const [dialogOpen, setDialogOpen] = useState(false); const colorClasses = getServiceColorClasses(entry.service); @@ -46,6 +51,16 @@ export function RequestCard({ entry }: RequestCardProps) { {entry.durationMs}ms + {/* Retry badge — only when attempts > 1 */} + {entry.attempts > 1 && ( + + {dict.card.retries.replace("{n}", String(entry.attempts - 1))} + + )} + {/* Status indicator */} Date: Sat, 23 May 2026 17:58:37 +0200 Subject: [PATCH 5/8] feat(inspector): add view toggle and conditional rendering to InspectorClient --- .../inspector-client.test.tsx | 98 ++++++++++++++++--- .../inspector-client/inspector-client.tsx | 22 ++++- .../inspector-toolbar.test.tsx | 62 ++++++++++++ .../inspector-toolbar/inspector-toolbar.tsx | 24 ++++- .../components/request-list/request-list.tsx | 9 +- 5 files changed, 198 insertions(+), 17 deletions(-) diff --git a/features/inspector/components/inspector-client/inspector-client.test.tsx b/features/inspector/components/inspector-client/inspector-client.test.tsx index 21a11e0..20e338f 100644 --- a/features/inspector/components/inspector-client/inspector-client.test.tsx +++ b/features/inspector/components/inspector-client/inspector-client.test.tsx @@ -14,23 +14,33 @@ const { mockStartPolling, mockStopPolling, mockRehydrate, -} = vi.hoisted(() => ({ - mockSeedEntries: vi.fn(), - mockStartPolling: vi.fn(), - mockStopPolling: vi.fn(), - mockRehydrate: vi.fn().mockResolvedValue(undefined), -})); + mockStoreState, +} = vi.hoisted(() => { + const state = { + entries: [] as RequestEntry[], + isLoading: false, + view: "list" as "list" | "timeline", + }; + return { + mockSeedEntries: vi.fn(), + mockStartPolling: vi.fn(), + mockStopPolling: vi.fn(), + mockRehydrate: vi.fn().mockResolvedValue(undefined), + mockStoreState: state, + }; +}); vi.mock( "@/features/inspector/stores/use-inspector-store/use-inspector-store", () => { const mockStore = vi.fn(() => ({ - entries: [], + entries: mockStoreState.entries, filters: { service: "", status: "all", text: "" }, - status: "idle", + status: mockStoreState.isLoading ? "polling" : "idle", + isLoading: mockStoreState.isLoading, lastUpdatedAt: null, - isPolling: false, - view: "list", + isPolling: mockStoreState.isLoading, + view: mockStoreState.view, seedEntries: mockSeedEntries, startPolling: mockStartPolling, stopPolling: mockStopPolling, @@ -53,6 +63,22 @@ vi.mock("@/features/inspector/components/request-list/request-list", () => ({ ), })); +vi.mock("@/features/inspector/components/inspector-timeline/inspector-timeline", () => ({ + InspectorTimeline: ({ entries }: { entries: RequestEntry[] }) => ( +
+ ), +})); + +vi.mock("@/features/inspector/components/inspector-empty/inspector-empty", () => ({ + InspectorEmpty: () =>
, +})); + +vi.mock("@/features/inspector/components/inspector-skeleton/inspector-skeleton", () => ({ + InspectorSkeleton: ({ withSpine }: { withSpine: boolean }) => ( +
+ ), +})); + import { InspectorClient } from "./inspector-client"; // ── Fixtures ────────────────────────────────────────────────────────────────── @@ -69,6 +95,7 @@ const dict: ClientDict = { status: { label: "Status", all: "All", success: "Success", error: "Error" }, text: { placeholder: "Search…" }, }, + view: { label: "View", list: "List", timeline: "Timeline" }, clearBuffer: "Clear", statusPolling: "Live", statusError: "Error", @@ -76,7 +103,7 @@ const dict: ClientDict = { lastUpdated: "Updated {time} ago", }, empty: { title: "No requests", body: "Make some AWS calls" }, - card: { duration: "{ms}ms", attempts: "{n} attempts" }, + card: { duration: "{ms}ms", attempts: "{n} attempts", retries: "{n} retries" }, detail: { title: "Detail", input: "Input", @@ -107,6 +134,9 @@ afterEach(() => { cleanup(); vi.clearAllMocks(); mockRehydrate.mockResolvedValue(undefined); + mockStoreState.entries = []; + mockStoreState.isLoading = false; + mockStoreState.view = "list"; }); // ── Tests ───────────────────────────────────────────────────────────────────── @@ -119,7 +149,8 @@ describe("InspectorClient", () => { expect(screen.getByTestId("inspector-toolbar")).toBeInTheDocument(); }); - it("renders the request list", async () => { + it("renders the request list when entries exist", async () => { + mockStoreState.entries = [makeEntry("e1")]; await act(async () => { render(); }); @@ -147,4 +178,47 @@ describe("InspectorClient", () => { }); expect(mockStartPolling).toHaveBeenCalledOnce(); }); + + // ── Task 3.12 / 3.13: view toggle ──────────────────────────────────────────── + + it("renders RequestList when view is 'list' and entries exist", async () => { + mockStoreState.entries = [makeEntry("e1")]; + mockStoreState.view = "list"; + await act(async () => { + render(); + }); + expect(screen.getByTestId("request-list")).toBeInTheDocument(); + expect(screen.queryByTestId("inspector-timeline")).not.toBeInTheDocument(); + }); + + it("renders InspectorTimeline when view is 'timeline' and entries exist", async () => { + mockStoreState.entries = [makeEntry("e1")]; + mockStoreState.view = "timeline"; + await act(async () => { + render(); + }); + expect(screen.getByTestId("inspector-timeline")).toBeInTheDocument(); + expect(screen.queryByTestId("request-list")).not.toBeInTheDocument(); + }); + + it("renders InspectorSkeleton when isLoading is true", async () => { + mockStoreState.isLoading = true; + await act(async () => { + render(); + }); + expect(screen.getByTestId("inspector-skeleton")).toBeInTheDocument(); + expect(screen.queryByTestId("request-list")).not.toBeInTheDocument(); + expect(screen.queryByTestId("inspector-timeline")).not.toBeInTheDocument(); + }); + + it("renders InspectorEmpty when no entries and not loading", async () => { + mockStoreState.entries = []; + mockStoreState.isLoading = false; + await act(async () => { + render(); + }); + expect(screen.getByTestId("inspector-empty")).toBeInTheDocument(); + expect(screen.queryByTestId("request-list")).not.toBeInTheDocument(); + expect(screen.queryByTestId("inspector-timeline")).not.toBeInTheDocument(); + }); }); diff --git a/features/inspector/components/inspector-client/inspector-client.tsx b/features/inspector/components/inspector-client/inspector-client.tsx index 379aaec..5fb2b63 100644 --- a/features/inspector/components/inspector-client/inspector-client.tsx +++ b/features/inspector/components/inspector-client/inspector-client.tsx @@ -4,6 +4,9 @@ import { useEffect, useMemo } from "react"; import { useInspectorStore } from "@/features/inspector/stores/use-inspector-store/use-inspector-store"; import { InspectorToolbar } from "@/features/inspector/components/inspector-toolbar/inspector-toolbar"; import { RequestList } from "@/features/inspector/components/request-list/request-list"; +import { InspectorTimeline } from "@/features/inspector/components/inspector-timeline/inspector-timeline"; +import { InspectorEmpty } from "@/features/inspector/components/inspector-empty/inspector-empty"; +import { InspectorSkeleton } from "@/features/inspector/components/inspector-skeleton/inspector-skeleton"; import type { RequestEntry } from "@/features/inspector/lib/types/types"; import type { InspectorDict } from "@/features/inspector/i18n/en"; import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals"; @@ -27,7 +30,7 @@ const SERVICES = ["SQS", "SNS", "S3", "Lambda", "DynamoDB", "CloudWatchLogs"]; // ── Component ───────────────────────────────────────────────────────────────── export function InspectorClient({ initialEntries, dict }: InspectorClientProps) { - const { entries, filters, seedEntries, startPolling, stopPolling } = + const { entries, filters, view, isPolling, seedEntries, startPolling, stopPolling } = useInspectorStore(); // Mount: rehydrate → seed RSC entries → start polling @@ -57,11 +60,26 @@ export function InspectorClient({ initialEntries, dict }: InspectorClientProps) }); }, [entries, filters]); + // ── Render body ─────────────────────────────────────────────────────────────── + + function renderBody() { + if (isPolling && entries.length === 0) { + return ; + } + if (filtered.length === 0) { + return ; + } + if (view === "timeline") { + return ; + } + return ; + } + return (
- + {renderBody()}
); diff --git a/features/inspector/components/inspector-toolbar/inspector-toolbar.test.tsx b/features/inspector/components/inspector-toolbar/inspector-toolbar.test.tsx index d3f516d..b648ce8 100644 --- a/features/inspector/components/inspector-toolbar/inspector-toolbar.test.tsx +++ b/features/inspector/components/inspector-toolbar/inspector-toolbar.test.tsx @@ -7,6 +7,10 @@ import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals" const mockSetFilter = vi.fn(); const mockClearBuffer = vi.fn().mockResolvedValue(undefined); +const mockSetView = vi.fn(); + +// mockView holds the current view for the mock store +const mockState = { view: "list" as "list" | "timeline" }; vi.mock( "@/features/inspector/stores/use-inspector-store/use-inspector-store", @@ -15,8 +19,10 @@ vi.mock( filters: { service: "", status: "all", text: "" }, status: "idle", lastUpdatedAt: null, + view: mockState.view, setFilter: mockSetFilter, clearBuffer: mockClearBuffer, + setView: mockSetView, })), }), ); @@ -55,6 +61,11 @@ const dict: ToolbarDict = { placeholder: "Search…", }, }, + view: { + label: "View", + list: "List", + timeline: "Timeline", + }, clearBuffer: "Clear", statusPolling: "Live", statusError: "Error", @@ -66,6 +77,7 @@ const dict: ToolbarDict = { afterEach(() => { cleanup(); vi.clearAllMocks(); + mockState.view = "list"; }); // ── Tests ───────────────────────────────────────────────────────────────────── @@ -110,4 +122,54 @@ describe("InspectorToolbar", () => { // "All" appears in both trigger and dropdown item expect(screen.getAllByText("All").length).toBeGreaterThanOrEqual(1); }); + + describe("view toggle", () => { + it("renders List and Timeline toggle buttons", () => { + mockState.view = "list"; + render(); + expect(screen.getByRole("tab", { name: /^list$/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /^timeline$/i })).toBeInTheDocument(); + }); + + it("List button has aria-pressed=true when view is list", () => { + mockState.view = "list"; + render(); + expect(screen.getByRole("tab", { name: /^list$/i })).toHaveAttribute( + "aria-pressed", + "true", + ); + }); + + it("Timeline button has aria-pressed=false when view is list", () => { + mockState.view = "list"; + render(); + expect(screen.getByRole("tab", { name: /^timeline$/i })).toHaveAttribute( + "aria-pressed", + "false", + ); + }); + + it("Timeline button has aria-pressed=true when view is timeline", () => { + mockState.view = "timeline"; + render(); + expect(screen.getByRole("tab", { name: /^timeline$/i })).toHaveAttribute( + "aria-pressed", + "true", + ); + }); + + it("clicking List button calls setView('list')", () => { + mockState.view = "timeline"; + render(); + fireEvent.click(screen.getByRole("tab", { name: /^list$/i })); + expect(mockSetView).toHaveBeenCalledWith("list"); + }); + + it("clicking Timeline button calls setView('timeline')", () => { + mockState.view = "list"; + render(); + fireEvent.click(screen.getByRole("tab", { name: /^timeline$/i })); + expect(mockSetView).toHaveBeenCalledWith("timeline"); + }); + }); }); diff --git a/features/inspector/components/inspector-toolbar/inspector-toolbar.tsx b/features/inspector/components/inspector-toolbar/inspector-toolbar.tsx index a75221d..9a6ce76 100644 --- a/features/inspector/components/inspector-toolbar/inspector-toolbar.tsx +++ b/features/inspector/components/inspector-toolbar/inspector-toolbar.tsx @@ -25,7 +25,7 @@ const STATUS_OPTIONS = ["all", "success", "error"] as const; // ── Component ───────────────────────────────────────────────────────────────── export function InspectorToolbar({ dict, services }: InspectorToolbarProps) { - const { filters, setFilter, clearBuffer } = useInspectorStore(); + const { filters, setFilter, clearBuffer, view, setView } = useInspectorStore(); const t = dict.toolbar; return ( @@ -79,6 +79,28 @@ export function InspectorToolbar({ dict, services }: InspectorToolbarProps) {
+ {/* View toggle — segmented control */} +
+ + +
+ {/* Clear buffer */}