From dd0f5de82009b2e2ad295ce3df1d9bfcd28f9554 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Fri, 29 May 2026 14:47:19 -0400 Subject: [PATCH] feat(sort): add SortToggle for Logs, History, and Network (#1371) (#1372) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sort): add SortToggle for Logs, History, and Network (#1371) Introduces a reusable SortToggle element and wires it into the Logs, History, and Network screens so users can flip chronological order. Selection is persisted per-screen to localStorage under the inspector.sortDirection.* namespace, with invalid stored values falling back to the newest-first default. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(logs): move auto-scroll checkbox left of the sort selector Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(logs): remove auto-scroll checkbox; ListToggle left of SortToggle - Drops the Logs auto-scroll checkbox and its plumbing through LoggingScreen / InspectorView. - Moves the ListToggle to the left of the SortToggle on the History and Network panel toolbars. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(sort): address review on #1372 - Hydrate sort direction synchronously (getInitialValueInEffect: false) to remove the one-frame newest-first flicker on first paint. - Drop the redundant `[...matched].sort()` copy across the three panels; `.filter()` already returns a fresh array. - Document why serializeSortDirection bypasses Mantine's default JSON stringify (raw enum value in storage). - MemoryStorage.getItem: collapse `has` + `!` to `?? null`. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(history): default the request list to collapsed for parity with Network The History list opened expanded while Network opened collapsed; align History with Network so both panels start in the same compact view. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(history,network): persist list compact state to localStorage Lifts the per-panel `compact` flag out of HistoryListPanel / NetworkStreamPanel and into InspectorView's localStorage hooks, mirroring the sort-direction shape: inspector.listCompact.history inspector.listCompact.network Defaults to collapsed (compact=true) on both, with a corrupted-value deserializer that clamps unknown strings back to the default. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(servers,resources): persist ListToggle state to localStorage Lifts the per-screen compact preference out of ServerListScreen and ResourceControls into InspectorView's localStorage hooks, matching the History/Network shape: inspector.listCompact.servers inspector.listCompact.resources Servers and Resources both default to expanded (compact=false). For ResourceControls the displayed compact is still derived from openSections (so per-section accordion clicks still work locally), and only explicit ListToggle clicks update the persisted preference — the handler computes the next compact value from the click outcome rather than blindly flipping, so a half-open accordion still records the right post-state. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(sort-toggle): switch to icon button matching ListToggle style Replaces the Mantine Select with a subtle Button (or ActionIcon for the subtle variant), using TbSortDescending2 for newest-first and TbSortAscending2 for oldest-first. Clicking the button flips between the two values. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(sort-toggle): restore Select with sort icon in rightSection Reverts SortToggle to a Mantine Select with simplified labels ("Newest First" / "Oldest First") and the corresponding TbSortDescending2 / TbSortAscending2 icon in the rightSection, replacing the default chevron. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(sort-toggle): swap icons so they match label direction Co-Authored-By: Claude Opus 4.7 (1M context) * fix(sort-toggle): use down arrow for newest, up arrow for oldest Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(inspector-view): extract useSortDirection / useListCompact Collapses seven near-identical useLocalStorage call sites in InspectorView into two scoped helpers. Each helper carries the shared namespace, serialize/deserialize adapters, and getInitialValueInEffect: false; the call sites now read as "this screen has sort persisted to this scope" rather than re-stating the storage shape every time. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(sort-toggle): apply prettier formatting Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../SortToggle/SortToggle.stories.tsx | 41 +++++ .../elements/SortToggle/SortToggle.test.tsx | 45 +++++ .../elements/SortToggle/SortToggle.tsx | 42 +++++ .../HistoryListPanel.stories.tsx | 4 + .../HistoryListPanel.test.tsx | 82 ++++++++- .../HistoryListPanel/HistoryListPanel.tsx | 38 +++-- .../LogStreamPanel/LogStreamPanel.stories.tsx | 4 +- .../LogStreamPanel/LogStreamPanel.test.tsx | 45 +++-- .../groups/LogStreamPanel/LogStreamPanel.tsx | 33 ++-- .../NetworkStreamPanel.stories.tsx | 4 + .../NetworkStreamPanel.test.tsx | 88 +++++++++- .../NetworkStreamPanel/NetworkStreamPanel.tsx | 39 +++-- .../ResourceControls.stories.tsx | 2 + .../ResourceControls.test.tsx | 49 ++++-- .../ResourceControls/ResourceControls.tsx | 38 +++-- .../HistoryScreen/HistoryScreen.stories.tsx | 4 + .../HistoryScreen/HistoryScreen.test.tsx | 4 + .../screens/HistoryScreen/HistoryScreen.tsx | 13 ++ .../LoggingScreen/LoggingScreen.stories.tsx | 4 +- .../LoggingScreen/LoggingScreen.test.tsx | 4 +- .../screens/LoggingScreen/LoggingScreen.tsx | 13 +- .../NetworkScreen/NetworkScreen.stories.tsx | 4 + .../NetworkScreen/NetworkScreen.test.tsx | 4 + .../screens/NetworkScreen/NetworkScreen.tsx | 13 ++ .../ResourcesScreen.stories.tsx | 2 + .../ResourcesScreen/ResourcesScreen.test.tsx | 2 + .../ResourcesScreen/ResourcesScreen.tsx | 6 + .../ServerListScreen.stories.tsx | 2 + .../ServerListScreen.test.tsx | 2 + .../ServerListScreen/ServerListScreen.tsx | 13 +- .../InspectorView/InspectorView.test.tsx | 155 +++++++++++++++++- .../views/InspectorView/InspectorView.tsx | 100 ++++++++++- clients/web/src/test/setup.ts | 32 ++++ 33 files changed, 813 insertions(+), 118 deletions(-) create mode 100644 clients/web/src/components/elements/SortToggle/SortToggle.stories.tsx create mode 100644 clients/web/src/components/elements/SortToggle/SortToggle.test.tsx create mode 100644 clients/web/src/components/elements/SortToggle/SortToggle.tsx diff --git a/clients/web/src/components/elements/SortToggle/SortToggle.stories.tsx b/clients/web/src/components/elements/SortToggle/SortToggle.stories.tsx new file mode 100644 index 00000000..5f159c93 --- /dev/null +++ b/clients/web/src/components/elements/SortToggle/SortToggle.stories.tsx @@ -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 = { + title: "Elements/SortToggle", + component: SortToggle, + args: { + onChange: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +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"); + }, +}; diff --git a/clients/web/src/components/elements/SortToggle/SortToggle.test.tsx b/clients/web/src/components/elements/SortToggle/SortToggle.test.tsx new file mode 100644 index 00000000..a921b451 --- /dev/null +++ b/clients/web/src/components/elements/SortToggle/SortToggle.test.tsx @@ -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( {}} />); + expect(screen.getByDisplayValue("Newest First")).toBeInTheDocument(); + }); + + it("renders 'Oldest First' when value is oldest-first", () => { + renderWithMantine( {}} />); + expect(screen.getByDisplayValue("Oldest First")).toBeInTheDocument(); + }); + + it("uses the default aria-label", () => { + renderWithMantine( {}} />); + expect( + screen.getByRole("textbox", { name: "Sort direction" }), + ).toBeInTheDocument(); + }); + + it("honors a custom aria-label", () => { + renderWithMantine( + {}} + 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(); + await user.click(screen.getByRole("textbox", { name: "Sort direction" })); + await user.click(await screen.findByText("Oldest First")); + expect(onChange).toHaveBeenCalledWith("oldest-first"); + }); +}); diff --git a/clients/web/src/components/elements/SortToggle/SortToggle.tsx b/clients/web/src/components/elements/SortToggle/SortToggle.tsx new file mode 100644 index 00000000..ea87b497 --- /dev/null +++ b/clients/web/src/components/elements/SortToggle/SortToggle.tsx @@ -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 ( +