Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { SortToggle } from "./SortToggle";

const meta: Meta<typeof SortToggle> = {
title: "Elements/SortToggle",
component: SortToggle,
args: {
onChange: fn(),
},
};

export default meta;
type Story = StoryObj<typeof SortToggle>;

export const NewestFirst: Story = {
args: {
value: "newest-first",
},
};

export const OldestFirst: Story = {
args: {
value: "oldest-first",
},
};

export const FlipsDirection: Story = {
args: {
value: "newest-first",
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
const select = await body.findByRole("textbox", {
name: "Sort direction",
});
await userEvent.click(select);
await userEvent.click(await body.findByText("Oldest First"));
await expect(args.onChange).toHaveBeenCalledWith("oldest-first");
},
};
45 changes: 45 additions & 0 deletions clients/web/src/components/elements/SortToggle/SortToggle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { SortToggle } from "./SortToggle";

describe("SortToggle", () => {
it("renders the current value as the selected option", () => {
renderWithMantine(<SortToggle value="newest-first" onChange={() => {}} />);
expect(screen.getByDisplayValue("Newest First")).toBeInTheDocument();
});

it("renders 'Oldest First' when value is oldest-first", () => {
renderWithMantine(<SortToggle value="oldest-first" onChange={() => {}} />);
expect(screen.getByDisplayValue("Oldest First")).toBeInTheDocument();
});

it("uses the default aria-label", () => {
renderWithMantine(<SortToggle value="newest-first" onChange={() => {}} />);
expect(
screen.getByRole("textbox", { name: "Sort direction" }),
).toBeInTheDocument();
});

it("honors a custom aria-label", () => {
renderWithMantine(
<SortToggle
value="newest-first"
onChange={() => {}}
aria-label="Logs sort"
/>,
);
expect(
screen.getByRole("textbox", { name: "Logs sort" }),
).toBeInTheDocument();
});

it("invokes onChange with the new direction when the user picks another option", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithMantine(<SortToggle value="newest-first" onChange={onChange} />);
await user.click(screen.getByRole("textbox", { name: "Sort direction" }));
await user.click(await screen.findByText("Oldest First"));
expect(onChange).toHaveBeenCalledWith("oldest-first");
});
});
42 changes: 42 additions & 0 deletions clients/web/src/components/elements/SortToggle/SortToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Select } from "@mantine/core";
import { TbSortAscending2, TbSortDescending2 } from "react-icons/tb";

export type SortDirection = "oldest-first" | "newest-first";

export interface SortToggleProps {
value: SortDirection;
onChange: (next: SortDirection) => void;
"aria-label"?: string;
}

const OPTIONS: { value: SortDirection; label: string }[] = [
{ value: "newest-first", label: "Newest First" },
{ value: "oldest-first", label: "Oldest First" },
];

function isSortDirection(value: string | null): value is SortDirection {
return value === "oldest-first" || value === "newest-first";
}

export function SortToggle({
value,
onChange,
"aria-label": ariaLabel = "Sort direction",
}: SortToggleProps) {
const Icon = value === "newest-first" ? TbSortDescending2 : TbSortAscending2;
return (
<Select
size="sm"
w={150}
data={OPTIONS}
value={value}
onChange={(next) => {
if (isSortDirection(next)) onChange(next);
}}
allowDeselect={false}
withCheckIcon={false}
rightSection={<Icon size={16} />}
aria-label={ariaLabel}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ const meta: Meta<typeof HistoryListPanel> = {
onExport: fn(),
onReplay: fn(),
onTogglePin: fn(),
sortDirection: "newest-first",
onSortChange: fn(),
compact: true,
onToggleCompact: fn(),
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ const baseProps = {
onExport: vi.fn(),
onReplay: vi.fn(),
onTogglePin: vi.fn(),
sortDirection: "newest-first" as const,
onSortChange: vi.fn(),
compact: true,
onToggleCompact: vi.fn(),
};

describe("HistoryListPanel", () => {
Expand Down Expand Up @@ -219,22 +223,82 @@ describe("HistoryListPanel", () => {
expect(onTogglePin).toHaveBeenCalledWith("req-1");
});

it("toggles compact list state when ListToggle is clicked", async () => {
const user = userEvent.setup();
it("renders entries newest-first by default", () => {
renderWithMantine(
<HistoryListPanel {...baseProps} entries={sampleEntries} />,
);
// Initially expanded — Collapse buttons exist on each entry, and the
// ListToggle exposes its aria-label as "Collapse all".
expect(
screen.getAllByRole("button", { name: "Collapse" }).length,
).toBeGreaterThan(0);
const methods = screen.getAllByText(
/tools\/call|resources\/read|tools\/list/,
);
expect(methods[0]).toHaveTextContent("tools/list");
expect(methods[methods.length - 1]).toHaveTextContent("tools/call");
});

await user.click(screen.getByRole("button", { name: "Collapse all" }));
it("reorders entries when sortDirection is oldest-first", () => {
renderWithMantine(
<HistoryListPanel
{...baseProps}
entries={sampleEntries}
sortDirection="oldest-first"
/>,
);
const methods = screen.getAllByText(
/tools\/call|resources\/read|tools\/list/,
);
expect(methods[0]).toHaveTextContent("tools/call");
expect(methods[methods.length - 1]).toHaveTextContent("tools/list");
});

it("invokes onSortChange when the user picks a new sort", async () => {
const user = userEvent.setup();
const onSortChange = vi.fn();
renderWithMantine(
<HistoryListPanel
{...baseProps}
entries={sampleEntries}
onSortChange={onSortChange}
/>,
);
await user.click(
screen.getByRole("textbox", { name: "History sort direction" }),
);
await user.click(await screen.findByText("Oldest First"));
expect(onSortChange).toHaveBeenCalledWith("oldest-first");
});

// After toggle, entries collapsed — they show Expand
it("renders entries collapsed when compact is true (default parity with Network)", () => {
renderWithMantine(
<HistoryListPanel {...baseProps} entries={sampleEntries} compact />,
);
expect(
screen.getAllByRole("button", { name: "Expand" }).length,
).toBeGreaterThan(0);
});

it("renders entries expanded when compact is false", () => {
renderWithMantine(
<HistoryListPanel
{...baseProps}
entries={sampleEntries}
compact={false}
/>,
);
expect(
screen.getAllByRole("button", { name: "Collapse" }).length,
).toBeGreaterThan(0);
});

it("invokes onToggleCompact when the ListToggle is clicked", async () => {
const user = userEvent.setup();
const onToggleCompact = vi.fn();
renderWithMantine(
<HistoryListPanel
{...baseProps}
entries={sampleEntries}
onToggleCompact={onToggleCompact}
/>,
);
await user.click(screen.getByRole("button", { name: "Expand all" }));
expect(onToggleCompact).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useMemo } from "react";
import {
Button,
Group,
Expand All @@ -11,6 +11,10 @@ import {
import type { MessageEntry, MessageMethod } from "@inspector/core/mcp/types.js";
import { HistoryEntry } from "../HistoryEntry/HistoryEntry";
import { ListToggle } from "../../elements/ListToggle/ListToggle";
import {
SortToggle,
type SortDirection,
} from "../../elements/SortToggle/SortToggle";
import { extractMethod } from "../historyUtils.js";

export interface HistoryListPanelProps {
Expand All @@ -22,6 +26,10 @@ export interface HistoryListPanelProps {
onExport: () => void;
onReplay: (id: string) => void;
onTogglePin: (id: string) => void;
sortDirection: SortDirection;
onSortChange: (next: SortDirection) => void;
compact: boolean;
onToggleCompact: () => void;
}

const PanelContainer = Paper.withProps({
Expand Down Expand Up @@ -71,13 +79,19 @@ export function HistoryListPanel({
onExport,
onReplay,
onTogglePin,
sortDirection,
onSortChange,
compact,
onToggleCompact,
}: HistoryListPanelProps) {
const [compact, setCompact] = useState(false);

const filteredEntries = useMemo(
() => entries.filter((e) => matchesFilters(e, searchText, methodFilter)),
[entries, searchText, methodFilter],
);
const filteredEntries = useMemo(() => {
// `.filter()` returns a fresh array, so sorting in-place is safe.
const sorted = entries
.filter((e) => matchesFilters(e, searchText, methodFilter))
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
if (sortDirection === "newest-first") sorted.reverse();
return sorted;
}, [entries, searchText, methodFilter, sortDirection]);

const pinnedEntries = useMemo(
() => filteredEntries.filter((e) => pinnedIds.has(e.id)),
Expand All @@ -97,11 +111,13 @@ export function HistoryListPanel({
<Title order={4}>Requests</Title>
<Group gap="xs">
{hasResults && (
<ListToggle
compact={compact}
onToggle={() => setCompact((c) => !c)}
/>
<ListToggle compact={compact} onToggle={onToggleCompact} />
)}
<SortToggle
value={sortDirection}
onChange={onSortChange}
aria-label="History sort direction"
/>
<Button
variant="default"
onClick={onClearAll}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ const meta: Meta<typeof LogStreamPanel> = {
alert: true,
emergency: true,
},
autoScroll: true,
onToggleAutoScroll: fn(),
onClear: fn(),
onExport: fn(),
sortDirection: "newest-first",
onSortChange: fn(),
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ const baseProps = {
entries,
filterText: "",
visibleLevels: allVisible,
autoScroll: true,
onToggleAutoScroll: vi.fn(),
onClear: vi.fn(),
onExport: vi.fn(),
sortDirection: "newest-first" as const,
onSortChange: vi.fn(),
};

describe("LogStreamPanel", () => {
Expand Down Expand Up @@ -149,18 +149,39 @@ describe("LogStreamPanel", () => {
expect(onExport).toHaveBeenCalledTimes(1);
});

it("invokes onToggleAutoScroll when the auto-scroll checkbox is clicked", async () => {
const user = userEvent.setup();
const onToggleAutoScroll = vi.fn();
it("renders entries newest-first by default", () => {
renderWithMantine(<LogStreamPanel {...baseProps} />);
const items = screen.getAllByText(
/Server started|Failed to read|Loading config|deprecated/,
);
// Newest receivedAt is the warning entry; oldest is "Server started".
expect(items[0]).toHaveTextContent('{"code":42,"msg":"deprecated"}');
expect(items[items.length - 1]).toHaveTextContent("Server started");
});

it("reorders entries when sortDirection is oldest-first", () => {
renderWithMantine(
<LogStreamPanel {...baseProps} onToggleAutoScroll={onToggleAutoScroll} />,
<LogStreamPanel {...baseProps} sortDirection="oldest-first" />,
);
const items = screen.getAllByText(
/Server started|Failed to read|Loading config|deprecated/,
);
expect(items[0]).toHaveTextContent("Server started");
expect(items[items.length - 1]).toHaveTextContent(
'{"code":42,"msg":"deprecated"}',
);
await user.click(screen.getByLabelText("Auto-scroll"));
expect(onToggleAutoScroll).toHaveBeenCalledTimes(1);
});

it("renders auto-scroll checkbox with correct checked state", () => {
renderWithMantine(<LogStreamPanel {...baseProps} autoScroll={false} />);
expect(screen.getByLabelText("Auto-scroll")).not.toBeChecked();
it("invokes onSortChange when the user picks a new sort", async () => {
const user = userEvent.setup();
const onSortChange = vi.fn();
renderWithMantine(
<LogStreamPanel {...baseProps} onSortChange={onSortChange} />,
);
await user.click(
screen.getByRole("textbox", { name: "Logs sort direction" }),
);
await user.click(await screen.findByText("Oldest First"));
expect(onSortChange).toHaveBeenCalledWith("oldest-first");
});
});
Loading
Loading