feat(sort): add SortToggle for Logs, History, and Network (#1371) (#1372)

* 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) <noreply@anthropic.com>

* fix(logs): move auto-scroll checkbox left of the sort selector

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* fix(sort-toggle): swap icons so they match label direction

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sort-toggle): use down arrow for newest, up arrow for oldest

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* fix(sort-toggle): apply prettier formatting

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-05-29 14:47:19 -04:00 • committed by GitHub
commit dd0f5de820
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 812 additions and 117 deletions

View file

@ -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");
},
};

View file

@ -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");
});
});

View file

@ -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}
/>
);
}