Skip to content
Open
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,42 @@
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);
const oldestOption = await body.findByText("Sort: Oldest First");
await userEvent.click(oldestOption);
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("Sort: Newest First")).toBeInTheDocument();
});

it("renders 'Oldest First' when value is oldest-first", () => {
renderWithMantine(<SortToggle value="oldest-first" onChange={() => {}} />);
expect(screen.getByDisplayValue("Sort: 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("Sort: Oldest First"));
expect(onChange).toHaveBeenCalledWith("oldest-first");
});
});
39 changes: 39 additions & 0 deletions clients/web/src/components/elements/SortToggle/SortToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Select } from "@mantine/core";

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: "Sort: Newest First" },
{ value: "oldest-first", label: "Sort: 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) {
return (
<Select
size="sm"
w={190}
data={OPTIONS}
value={value}
onChange={(next) => {
if (isSortDirection(next)) onChange(next);
}}
allowDeselect={false}
withCheckIcon={false}
aria-label={ariaLabel}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const meta: Meta<typeof HistoryListPanel> = {
onExport: fn(),
onReplay: fn(),
onTogglePin: fn(),
sortDirection: "newest-first",
onSortChange: fn(),
},
};

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

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

it("renders entries newest-first by default", () => {
renderWithMantine(
<HistoryListPanel {...baseProps} entries={sampleEntries} />,
);
const methods = screen.getAllByText(
/tools\/call|resources\/read|tools\/list/,
);
expect(methods[0]).toHaveTextContent("tools/list");
expect(methods[methods.length - 1]).toHaveTextContent("tools/call");
});

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("Sort: Oldest First"));
expect(onSortChange).toHaveBeenCalledWith("oldest-first");
});

it("toggles compact list state when ListToggle is clicked", async () => {
const user = userEvent.setup();
renderWithMantine(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,8 @@ export interface HistoryListPanelProps {
onExport: () => void;
onReplay: (id: string) => void;
onTogglePin: (id: string) => void;
sortDirection: SortDirection;
onSortChange: (next: SortDirection) => void;
}

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

const filteredEntries = useMemo(
() => entries.filter((e) => matchesFilters(e, searchText, methodFilter)),
[entries, searchText, methodFilter],
);
const filteredEntries = useMemo(() => {
const matched = entries.filter((e) =>
matchesFilters(e, searchText, methodFilter),
);
const sorted = [...matched].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 @@ -102,6 +116,11 @@ export function HistoryListPanel({
onToggle={() => setCompact((c) => !c)}
/>
)}
<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("Sort: Oldest First"));
expect(onSortChange).toHaveBeenCalledWith("oldest-first");
});
});
Loading
Loading