* feat(web): reorderable ServerCards — drag-and-drop + keyboard (#1369) Let users control the order of servers on the Servers screen instead of hand-editing mcp.json. Each ServerCard gets a grip handle (in the header, left of the name) that drives an @dnd-kit sortable; pointer and keyboard sensors both work, with live-region announcements for screen readers. - Backend: PUT /api/servers/order rewrites mcp.json in the supplied order inside the write lock, reusing the atomic-write + watcher-notify path. Registered before /:id so "order" isn't captured as a server id. Rejects with 409 when the id set no longer matches disk and 400 on duplicate / malformed bodies, so a reorder racing an external edit can't drop or duplicate an entry. - Hook: useServers.reorderServers(orderedIds) — optimistic local reorder, PUT, refresh-to-disk-truth on failure. - UI: SortableServerCard wrapper owns the dnd-kit concerns; ServerListScreen wraps the grid in DndContext + SortableContext (rect strategy) with pointer + keyboard sensors and announcements. Reorder math and a11y copy extracted to reorderIds.ts / serverReorder.ts for direct unit testing. Renders plain cards (no grip) when no onReorder is wired. - Wired onServerReorder through InspectorView -> App.tsx. Tests: pure-helper units, hook (persist / optimistic / 409 revert), 9 backend integration tests (incl. id-set-mismatch rejection and the route-registration guard), ServerCard/ServerListScreen units, and a real-browser Storybook keyboard-reorder play (the dnd-kit keyboard sensor needs layout rects that happy-dom doesn't provide). Adds @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): notify on reorder failure; skip reorder glue when not reorderable Address PR #1447 review: - App.tsx: catch reorderServers rejection (409 from a racing external edit, or a network error) and show a red toast, matching the error-handling pattern used by every other mutation — previously the rejection was discarded with `void` and the drag silently bounced back. - ServerListScreen: only build handleDragEnd / announcements when `reorderable`, so the four-closure announcements object isn't allocated every render when no onReorder is wired. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
721dae08c6
commit
d5f12b2a82
17 changed files with 1039 additions and 27 deletions
|
|
@ -194,3 +194,17 @@
|
|||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* Reorder grip on ServerCard. The grab/grabbing cursor is a draggability
|
||||
* affordance that can't be expressed as a Mantine prop (and the active-press
|
||||
* state is a pseudo-selector), so the whole grab-cursor unit lives here rather
|
||||
* than split across the theme.
|
||||
*/
|
||||
.server-drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.server-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -433,6 +433,7 @@ function App() {
|
|||
updateServer,
|
||||
updateServerSettings,
|
||||
removeServer,
|
||||
reorderServers,
|
||||
} = useServers({
|
||||
baseUrl:
|
||||
typeof window !== "undefined"
|
||||
|
|
@ -2149,6 +2150,20 @@ function App() {
|
|||
const target = servers.find((s) => s.id === id);
|
||||
if (target) setRemoveTarget(target);
|
||||
}}
|
||||
onServerReorder={(orderedIds) => {
|
||||
// reorderServers reverts the optimistic order via an internal
|
||||
// refresh() and re-throws on failure (409 from a racing external
|
||||
// edit, or a network error). Surface that to the user so the drag
|
||||
// doesn't silently bounce back — matching the toast pattern every
|
||||
// other mutation here uses.
|
||||
reorderServers(orderedIds).catch((err: unknown) => {
|
||||
notifications.show({
|
||||
title: "Failed to reorder servers",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
color: "red",
|
||||
});
|
||||
});
|
||||
}}
|
||||
serverSupportsTaskToolCalls={
|
||||
!!capabilities?.tasks?.requests?.tools?.call
|
||||
}
|
||||
|
|
|
|||
|
|
@ -215,4 +215,36 @@ describe("ServerCard", () => {
|
|||
renderWithMantine(<ServerCard {...baseProps} connection={errored} />);
|
||||
expect(screen.queryByText("Connection refused")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the dragHandle slot when provided", () => {
|
||||
renderWithMantine(
|
||||
<ServerCard
|
||||
{...baseProps}
|
||||
dragHandle={<button type="button">grip</button>}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "grip" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no drag handle by default", () => {
|
||||
renderWithMantine(<ServerCard {...baseProps} />);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "grip" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the dragHandle before the server name in the header", () => {
|
||||
renderWithMantine(
|
||||
<ServerCard
|
||||
{...baseProps}
|
||||
dragHandle={<button type="button">grip</button>}
|
||||
/>,
|
||||
);
|
||||
const grip = screen.getByRole("button", { name: "grip" });
|
||||
const name = screen.getByText("My MCP Server");
|
||||
// DOM order: the grip precedes the name (left of it in the header row).
|
||||
expect(grip.compareDocumentPosition(name)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Badge, Button, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
|
|
@ -18,6 +19,14 @@ export interface ServerCardProps extends ServerEntry {
|
|||
onClone: (id: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
compact?: boolean;
|
||||
/**
|
||||
* Optional drag-handle affordance rendered at the start of the card header,
|
||||
* before the server name. Supplied by the sortable wrapper
|
||||
* (`SortableServerCard`); omitted when the card is rendered outside a reorder
|
||||
* context, so the card stays a dumb display component with no knowledge of
|
||||
* drag-and-drop.
|
||||
*/
|
||||
dragHandle?: ReactNode;
|
||||
}
|
||||
|
||||
const HeaderLeft = Group.withProps({
|
||||
|
|
@ -103,6 +112,7 @@ export function ServerCard({
|
|||
onClone,
|
||||
onRemove,
|
||||
compact = false,
|
||||
dragHandle,
|
||||
}: ServerCardProps) {
|
||||
const isDimmed = activeServer !== undefined && activeServer !== id;
|
||||
const transport = getTransport(config);
|
||||
|
|
@ -121,6 +131,7 @@ export function ServerCard({
|
|||
<Stack gap="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<HeaderLeft>
|
||||
{dragHandle}
|
||||
<ServerName>{name}</ServerName>
|
||||
</HeaderLeft>
|
||||
<HeaderRight>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
import { ActionIcon, Box } from "@mantine/core";
|
||||
import { RiDraggable } from "react-icons/ri";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { ServerCard, type ServerCardProps } from "../ServerCard/ServerCard";
|
||||
|
||||
export type SortableServerCardProps = ServerCardProps;
|
||||
|
||||
/**
|
||||
* Sortable wrapper around the dumb `ServerCard`. Owns all drag-and-drop
|
||||
* concerns (the `@dnd-kit` sortable node, the per-frame transform, and the
|
||||
* grip activator) so `ServerCard` itself stays a pure display component that
|
||||
* only renders the `dragHandle` slot it's handed.
|
||||
*
|
||||
* The grip is the sole drag activator (pointer + keyboard) — bound via
|
||||
* `listeners`/`attributes` and `setActivatorNodeRef` — so the card's own
|
||||
* buttons (Clone / Edit / Remove / Settings) keep working without starting a
|
||||
* drag. It's passed into `ServerCard.dragHandle`, which renders it at the start
|
||||
* of the header row, before the server name.
|
||||
*/
|
||||
export function SortableServerCard(props: SortableServerCardProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: props.id });
|
||||
|
||||
const grip = (
|
||||
<ActionIcon
|
||||
ref={setActivatorNodeRef}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="md"
|
||||
className="server-drag-handle"
|
||||
aria-label={`Reorder ${props.name}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<RiDraggable size={16} />
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={setNodeRef}
|
||||
// dnd-kit positions the item with a transform that changes every
|
||||
// animation frame during a drag — the one place an inline style is
|
||||
// unavoidable, since the value can't be a static theme variant or prop.
|
||||
// While dragging we lift the item above its siblings and fade it
|
||||
// slightly so the drop target underneath stays legible.
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 2 : undefined,
|
||||
opacity: isDragging ? 0.85 : undefined,
|
||||
}}
|
||||
>
|
||||
<ServerCard {...props} dragHandle={grip} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
||||
import type { ServerEntry } from "@inspector/core/mcp/types.js";
|
||||
import { ServerListScreen } from "./ServerListScreen";
|
||||
|
||||
|
|
@ -18,6 +18,7 @@ const meta: Meta<typeof ServerListScreen> = {
|
|||
onEdit: fn(),
|
||||
onClone: fn(),
|
||||
onRemove: fn(),
|
||||
onReorder: fn(),
|
||||
compact: false,
|
||||
onToggleCompact: fn(),
|
||||
},
|
||||
|
|
@ -109,3 +110,42 @@ export const WithActiveServer: Story = {
|
|||
activeServer: connectedStdioServer.id,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Accessible keyboard reorder: focus a card's grip, press Space to pick it up,
|
||||
* an arrow key to move it, and Space again to drop. Runs in a real browser
|
||||
* (via the storybook test runner) where layout rects exist for the `@dnd-kit`
|
||||
* keyboard sensor — the path that's unreliable under happy-dom. At the default
|
||||
* 1280px viewport the grid is three columns wide, so ArrowRight moves the
|
||||
* first card one position to the right.
|
||||
*/
|
||||
export const KeyboardReorder: Story = {
|
||||
args: {
|
||||
servers: [connectedStdioServer, disconnectedStdioServer, failedHttpServer],
|
||||
},
|
||||
play: async ({ canvasElement, args, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const handle = await canvas.findByRole("button", {
|
||||
name: "Reorder Local Dev Server",
|
||||
});
|
||||
|
||||
await step("pick up the first card", async () => {
|
||||
handle.focus();
|
||||
await userEvent.keyboard("[Space]");
|
||||
});
|
||||
await step("move it one position to the right", async () => {
|
||||
await userEvent.keyboard("[ArrowRight]");
|
||||
});
|
||||
await step("drop it", async () => {
|
||||
await userEvent.keyboard("[Space]");
|
||||
});
|
||||
|
||||
await waitFor(() => expect(args.onReorder).toHaveBeenCalled());
|
||||
// The first card swapped places with the second; the third is unmoved.
|
||||
expect(args.onReorder).toHaveBeenCalledWith([
|
||||
disconnectedStdioServer.id,
|
||||
connectedStdioServer.id,
|
||||
failedHttpServer.id,
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ const servers: ServerEntry[] = [
|
|||
config: { type: "stdio", command: "echo" },
|
||||
connection: { status: "disconnected" },
|
||||
},
|
||||
{
|
||||
id: "beta",
|
||||
name: "Beta",
|
||||
config: { type: "stdio", command: "echo" },
|
||||
connection: { status: "disconnected" },
|
||||
},
|
||||
];
|
||||
|
||||
const baseProps = {
|
||||
|
|
@ -25,14 +31,16 @@ const baseProps = {
|
|||
onEdit: vi.fn(),
|
||||
onClone: vi.fn(),
|
||||
onRemove: vi.fn(),
|
||||
onReorder: vi.fn(),
|
||||
compact: false,
|
||||
onToggleCompact: vi.fn(),
|
||||
};
|
||||
|
||||
describe("ServerListScreen", () => {
|
||||
it("renders the server card", () => {
|
||||
it("renders the server cards", () => {
|
||||
renderWithMantine(<ServerListScreen {...baseProps} />);
|
||||
expect(screen.getByText("Alpha")).toBeInTheDocument();
|
||||
expect(screen.getByText("Beta")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the empty state with no servers", () => {
|
||||
|
|
@ -44,10 +52,43 @@ describe("ServerListScreen", () => {
|
|||
|
||||
it("toggles compact mode when the list toggle is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ServerListScreen {...baseProps} />);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
await user.click(buttons[0]);
|
||||
expect(screen.getByText("Alpha")).toBeInTheDocument();
|
||||
const onToggleCompact = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerListScreen {...baseProps} onToggleCompact={onToggleCompact} />,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /collapse all/i }));
|
||||
expect(onToggleCompact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("reorder affordances", () => {
|
||||
it("renders a labelled drag handle per card when onReorder is provided", () => {
|
||||
renderWithMantine(<ServerListScreen {...baseProps} />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Reorder Alpha" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Reorder Beta" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks each drag handle as a sortable activator (keyboard-operable)", () => {
|
||||
renderWithMantine(<ServerListScreen {...baseProps} />);
|
||||
const handle = screen.getByRole("button", { name: "Reorder Alpha" });
|
||||
// dnd-kit's useSortable spreads accessibility attributes onto the
|
||||
// activator: a roledescription plus a non-negative tabindex so keyboard
|
||||
// users can focus it and press Space to pick the card up.
|
||||
expect(handle).toHaveAttribute("aria-roledescription", "sortable");
|
||||
expect(handle).toHaveAttribute("tabindex", "0");
|
||||
});
|
||||
|
||||
it("omits drag handles when onReorder is not provided", () => {
|
||||
renderWithMantine(
|
||||
<ServerListScreen {...baseProps} onReorder={undefined} />,
|
||||
);
|
||||
expect(screen.getByText("Alpha")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /^Reorder / }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,25 @@
|
|||
import { ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
rectSortingStrategy,
|
||||
sortableKeyboardCoordinates,
|
||||
} from "@dnd-kit/sortable";
|
||||
import type { ServerEntry } from "@inspector/core/mcp/types.js";
|
||||
import { ServerCard } from "../../groups/ServerCard/ServerCard";
|
||||
import { ServerListControls } from "../../groups/ServerListControls/ServerListControls";
|
||||
import { SortableServerCard } from "../../groups/SortableServerCard/SortableServerCard";
|
||||
import {
|
||||
buildReorderAnnouncements,
|
||||
makeServerDragEndHandler,
|
||||
} from "./serverReorder";
|
||||
|
||||
export interface ServerListScreenProps {
|
||||
servers: ServerEntry[];
|
||||
|
|
@ -18,6 +36,11 @@ export interface ServerListScreenProps {
|
|||
onEdit: (id: string) => void;
|
||||
onClone: (id: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
/**
|
||||
* Persist a new server ordering. Receives the complete set of server ids in
|
||||
* the desired order. Omit to render the list without reorder affordances.
|
||||
*/
|
||||
onReorder?: (orderedIds: string[]) => void;
|
||||
compact: boolean;
|
||||
onToggleCompact: () => void;
|
||||
}
|
||||
|
|
@ -45,9 +68,63 @@ export function ServerListScreen({
|
|||
onEdit,
|
||||
onClone,
|
||||
onRemove,
|
||||
onReorder,
|
||||
compact,
|
||||
onToggleCompact,
|
||||
}: ServerListScreenProps) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const ids = servers.map((s) => s.id);
|
||||
|
||||
// `reorderable` only when a persistence callback is wired. Without it we
|
||||
// render plain `ServerCard`s (no grip, no DndContext) so the screen stays
|
||||
// usable as a pure display — the SortableServerCard's grip would otherwise
|
||||
// be a dead affordance.
|
||||
const reorderable = onReorder !== undefined;
|
||||
|
||||
// Built only when reorderable — the drag-end handler and the fresh
|
||||
// announcements object (four closures) are otherwise allocated every render
|
||||
// for nothing.
|
||||
const handleDragEnd = reorderable
|
||||
? makeServerDragEndHandler(servers, onReorder)
|
||||
: undefined;
|
||||
const announcements = reorderable
|
||||
? buildReorderAnnouncements(servers)
|
||||
: undefined;
|
||||
|
||||
const cardProps = (server: ServerEntry) => ({
|
||||
compact,
|
||||
activeServer,
|
||||
onToggleConnection,
|
||||
onConnectionInfo,
|
||||
onSettings,
|
||||
onEdit,
|
||||
onClone,
|
||||
onRemove,
|
||||
...server,
|
||||
});
|
||||
|
||||
const grid = (
|
||||
<SimpleGrid
|
||||
cols={{ base: 1, sm: 2, lg: 3 }}
|
||||
spacing="lg"
|
||||
className="grid-align-start"
|
||||
>
|
||||
{servers.map((server) =>
|
||||
reorderable ? (
|
||||
<SortableServerCard key={server.id} {...cardProps(server)} />
|
||||
) : (
|
||||
<ServerCard key={server.id} {...cardProps(server)} />
|
||||
),
|
||||
)}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<ServerListControls
|
||||
|
|
@ -65,27 +142,19 @@ export function ServerListScreen({
|
|||
<EmptyState>
|
||||
No servers configured. Add a server to get started.
|
||||
</EmptyState>
|
||||
) : (
|
||||
<SimpleGrid
|
||||
cols={{ base: 1, sm: 2, lg: 3 }}
|
||||
spacing="lg"
|
||||
className="grid-align-start"
|
||||
) : reorderable ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
accessibility={{ announcements }}
|
||||
>
|
||||
{servers.map((server) => (
|
||||
<ServerCard
|
||||
key={server.id}
|
||||
compact={compact}
|
||||
activeServer={activeServer}
|
||||
onToggleConnection={onToggleConnection}
|
||||
onConnectionInfo={onConnectionInfo}
|
||||
onSettings={onSettings}
|
||||
onEdit={onEdit}
|
||||
onClone={onClone}
|
||||
onRemove={onRemove}
|
||||
{...server}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<SortableContext items={ids} strategy={rectSortingStrategy}>
|
||||
{grid}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
grid
|
||||
)}
|
||||
</ScrollArea.Autosize>
|
||||
</PageContainer>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { reorderIds } from "./reorderIds";
|
||||
|
||||
describe("reorderIds", () => {
|
||||
const ids = ["a", "b", "c", "d"];
|
||||
|
||||
it("moves an item forward (drop onto a later id)", () => {
|
||||
expect(reorderIds(ids, "a", "c")).toEqual(["b", "c", "a", "d"]);
|
||||
});
|
||||
|
||||
it("moves an item backward (drop onto an earlier id)", () => {
|
||||
expect(reorderIds(ids, "d", "b")).toEqual(["a", "d", "b", "c"]);
|
||||
});
|
||||
|
||||
it("moves to the very front", () => {
|
||||
expect(reorderIds(ids, "c", "a")).toEqual(["c", "a", "b", "d"]);
|
||||
});
|
||||
|
||||
it("moves to the very end", () => {
|
||||
expect(reorderIds(ids, "a", "d")).toEqual(["b", "c", "d", "a"]);
|
||||
});
|
||||
|
||||
it("returns the same array reference when active === over", () => {
|
||||
expect(reorderIds(ids, "b", "b")).toBe(ids);
|
||||
});
|
||||
|
||||
it("returns the same array reference when active id is missing", () => {
|
||||
expect(reorderIds(ids, "x", "b")).toBe(ids);
|
||||
});
|
||||
|
||||
it("returns the same array reference when over id is missing", () => {
|
||||
expect(reorderIds(ids, "a", "x")).toBe(ids);
|
||||
});
|
||||
|
||||
it("preserves the full id set (no drops or duplicates)", () => {
|
||||
const out = reorderIds(ids, "a", "d");
|
||||
expect([...out].sort()).toEqual([...ids].sort());
|
||||
expect(out.length).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
|
||||
/**
|
||||
* Pure reorder: move `activeId` to where `overId` sits in `ids`. Returns the
|
||||
* input array unchanged (referential identity preserved) when either id is
|
||||
* missing or they're identical, so a no-movement drop is a no-op. Extracted
|
||||
* from `ServerListScreen` for direct unit testing — the `@dnd-kit`
|
||||
* keyboard/pointer sensors don't produce measurable layout rects under
|
||||
* happy-dom, so the full gesture is exercised in the Storybook play (real
|
||||
* browser) while this keeps the ordering math verifiable in the unit suite.
|
||||
*/
|
||||
export function reorderIds(
|
||||
ids: string[],
|
||||
activeId: string,
|
||||
overId: string,
|
||||
): string[] {
|
||||
if (activeId === overId) return ids;
|
||||
const from = ids.indexOf(activeId);
|
||||
const to = ids.indexOf(overId);
|
||||
if (from === -1 || to === -1) return ids;
|
||||
return arrayMove(ids, from, to);
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { DragEndEvent } from "@dnd-kit/core";
|
||||
import type { ServerEntry } from "@inspector/core/mcp/types.js";
|
||||
import {
|
||||
buildReorderAnnouncements,
|
||||
makeServerDragEndHandler,
|
||||
} from "./serverReorder";
|
||||
|
||||
const entry = (id: string, name: string): ServerEntry => ({
|
||||
id,
|
||||
name,
|
||||
config: { type: "stdio", command: "echo" },
|
||||
connection: { status: "disconnected" },
|
||||
});
|
||||
|
||||
const servers: ServerEntry[] = [
|
||||
entry("a", "Alpha"),
|
||||
entry("b", "Beta"),
|
||||
entry("c", "Gamma"),
|
||||
];
|
||||
|
||||
// Minimal stand-ins for dnd-kit's active/over/event descriptors — the handlers
|
||||
// only read `.id`, so we cast through `unknown` rather than constructing the
|
||||
// full (sensor-populated) event shape.
|
||||
const ref = (id: string) => ({ id });
|
||||
const endEvent = (activeId: string, overId: string | null): DragEndEvent =>
|
||||
({
|
||||
active: ref(activeId),
|
||||
over: overId === null ? null : ref(overId),
|
||||
}) as unknown as DragEndEvent;
|
||||
|
||||
describe("makeServerDragEndHandler", () => {
|
||||
it("calls onReorder with the reordered ids when the card moved", () => {
|
||||
const onReorder = vi.fn();
|
||||
makeServerDragEndHandler(servers, onReorder)(endEvent("a", "c"));
|
||||
expect(onReorder).toHaveBeenCalledWith(["b", "c", "a"]);
|
||||
});
|
||||
|
||||
it("does nothing when dropped on itself", () => {
|
||||
const onReorder = vi.fn();
|
||||
makeServerDragEndHandler(servers, onReorder)(endEvent("a", "a"));
|
||||
expect(onReorder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when there is no drop target", () => {
|
||||
const onReorder = vi.fn();
|
||||
makeServerDragEndHandler(servers, onReorder)(endEvent("a", null));
|
||||
expect(onReorder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tolerates an absent onReorder callback (no throw)", () => {
|
||||
expect(() =>
|
||||
makeServerDragEndHandler(servers, undefined)(endEvent("a", "b")),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildReorderAnnouncements", () => {
|
||||
const a = buildReorderAnnouncements(servers);
|
||||
|
||||
it("narrates pick-up with name and 1-based position", () => {
|
||||
expect(a.onDragStart({ active: ref("b") } as never)).toBe(
|
||||
"Picked up server Beta. It is in position 2 of 3.",
|
||||
);
|
||||
});
|
||||
|
||||
it("narrates a move over a target", () => {
|
||||
expect(a.onDragOver?.({ active: ref("a"), over: ref("c") } as never)).toBe(
|
||||
"Server Alpha moved to position 3 of 3.",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for onDragOver when there is no target", () => {
|
||||
expect(
|
||||
a.onDragOver?.({ active: ref("a"), over: null } as never),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("narrates a drop on a target", () => {
|
||||
expect(a.onDragEnd?.({ active: ref("a"), over: ref("b") } as never)).toBe(
|
||||
"Server Alpha dropped at position 2 of 3.",
|
||||
);
|
||||
});
|
||||
|
||||
it("narrates a drop with no target", () => {
|
||||
expect(a.onDragEnd?.({ active: ref("a"), over: null } as never)).toBe(
|
||||
"Server Alpha dropped.",
|
||||
);
|
||||
});
|
||||
|
||||
it("narrates a cancellation", () => {
|
||||
expect(a.onDragCancel?.({ active: ref("c") } as never)).toBe(
|
||||
"Reorder cancelled. Server Gamma returned to its original position.",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the id when the server is unknown", () => {
|
||||
expect(a.onDragStart({ active: ref("missing") } as never)).toBe(
|
||||
"Picked up server missing. It is in position 0 of 3.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import type { Announcements, DragEndEvent } from "@dnd-kit/core";
|
||||
import type { ServerEntry } from "@inspector/core/mcp/types.js";
|
||||
import { reorderIds } from "./reorderIds";
|
||||
|
||||
/**
|
||||
* Reorder glue for `ServerListScreen`, extracted from the component so the
|
||||
* announcement copy and the drag-end resolution are unit-testable directly.
|
||||
* The `@dnd-kit` keyboard/pointer sensors don't yield measurable layout rects
|
||||
* under happy-dom, so the live gesture can't be driven in the unit suite (it's
|
||||
* covered by the Storybook play instead) — keeping this logic out of the
|
||||
* component body is what lets it stay verified.
|
||||
*/
|
||||
|
||||
/** Display name for an id, falling back to the id when it isn't found. */
|
||||
function nameFor(servers: ServerEntry[], id: string): string {
|
||||
return servers.find((s) => s.id === id)?.name ?? id;
|
||||
}
|
||||
|
||||
/** 1-based position of an id in the list, for "position 3 of 7" narration. */
|
||||
function positionFor(servers: ServerEntry[], id: string): number {
|
||||
return servers.findIndex((s) => s.id === id) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live-region narration so keyboard / screen-reader users hear the pick-up,
|
||||
* each move, and the drop. Fed to `DndContext`'s `accessibility.announcements`,
|
||||
* which writes into dnd-kit's built-in `aria-live` region.
|
||||
*/
|
||||
export function buildReorderAnnouncements(
|
||||
servers: ServerEntry[],
|
||||
): Announcements {
|
||||
const count = servers.length;
|
||||
return {
|
||||
onDragStart: ({ active }) =>
|
||||
`Picked up server ${nameFor(servers, String(active.id))}. It is in position ${positionFor(
|
||||
servers,
|
||||
String(active.id),
|
||||
)} of ${count}.`,
|
||||
onDragOver: ({ active, over }) =>
|
||||
over
|
||||
? `Server ${nameFor(servers, String(active.id))} moved to position ${positionFor(
|
||||
servers,
|
||||
String(over.id),
|
||||
)} of ${count}.`
|
||||
: undefined,
|
||||
onDragEnd: ({ active, over }) =>
|
||||
over
|
||||
? `Server ${nameFor(servers, String(active.id))} dropped at position ${positionFor(
|
||||
servers,
|
||||
String(over.id),
|
||||
)} of ${count}.`
|
||||
: `Server ${nameFor(servers, String(active.id))} dropped.`,
|
||||
onDragCancel: ({ active }) =>
|
||||
`Reorder cancelled. Server ${nameFor(servers, String(active.id))} returned to its original position.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `DndContext.onDragEnd` handler bound to the current list and the
|
||||
* persistence callback. Returns a no-op-on-no-movement handler: when the drop
|
||||
* lands outside any target, on itself, or doesn't change the order, `onReorder`
|
||||
* is not called.
|
||||
*/
|
||||
export function makeServerDragEndHandler(
|
||||
servers: ServerEntry[],
|
||||
onReorder: ((orderedIds: string[]) => void) | undefined,
|
||||
): (event: DragEndEvent) => void {
|
||||
return ({ active, over }: DragEndEvent): void => {
|
||||
if (!over || active.id === over.id) return;
|
||||
const ids = servers.map((s) => s.id);
|
||||
const next = reorderIds(ids, String(active.id), String(over.id));
|
||||
if (next !== ids) onReorder?.(next);
|
||||
};
|
||||
}
|
||||
|
|
@ -356,6 +356,7 @@ const meta: Meta<typeof InspectorView> = {
|
|||
onServerEdit: fn(),
|
||||
onServerClone: fn(),
|
||||
onServerRemove: fn(),
|
||||
onServerReorder: fn(),
|
||||
serverSupportsTaskToolCalls: false,
|
||||
onToolsUiChange: fn(),
|
||||
onCallTool: fn(),
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ function makeProps(
|
|||
onServerEdit: vi.fn(),
|
||||
onServerClone: vi.fn(),
|
||||
onServerRemove: vi.fn(),
|
||||
onServerReorder: vi.fn(),
|
||||
serverSupportsTaskToolCalls: false,
|
||||
onToolsUiChange: vi.fn(),
|
||||
onCallTool: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -268,6 +268,8 @@ export interface InspectorViewProps {
|
|||
onServerEdit: (id: string) => void;
|
||||
onServerClone: (id: string) => void;
|
||||
onServerRemove: (id: string) => void;
|
||||
/** Persist a new server ordering (drag-and-drop / keyboard reorder). */
|
||||
onServerReorder: (orderedIds: string[]) => void;
|
||||
|
||||
// Per-primitive actions (route to `inspectorClient` methods / hook refresh).
|
||||
// Each `on{Screen}UiChange` persists that screen's lifted UI state (#1417).
|
||||
|
|
@ -380,6 +382,7 @@ export function InspectorView({
|
|||
onServerEdit,
|
||||
onServerClone,
|
||||
onServerRemove,
|
||||
onServerReorder,
|
||||
serverSupportsTaskToolCalls,
|
||||
onToolsUiChange,
|
||||
onCallTool,
|
||||
|
|
@ -549,6 +552,7 @@ export function InspectorView({
|
|||
onEdit={onServerEdit}
|
||||
onClone={onServerClone}
|
||||
onRemove={onServerRemove}
|
||||
onReorder={onServerReorder}
|
||||
compact={serversCompact}
|
||||
onToggleCompact={() => setServersCompact((c) => !c)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -492,6 +492,142 @@ describe("useServers", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("reorderServers persists the new order to disk and updates the list", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
alpha: { type: "stdio", command: "a" },
|
||||
beta: { type: "stdio", command: "b" },
|
||||
gamma: { type: "stdio", command: "g" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.servers.map((s) => s.id)).toEqual([
|
||||
"alpha",
|
||||
"beta",
|
||||
"gamma",
|
||||
]),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.reorderServers(["gamma", "alpha", "beta"]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.servers.map((s) => s.id)).toEqual([
|
||||
"gamma",
|
||||
"alpha",
|
||||
"beta",
|
||||
]);
|
||||
});
|
||||
// mcp.json map iteration order reflects the new order.
|
||||
expect(Object.keys(readConfig(h.configPath).mcpServers)).toEqual([
|
||||
"gamma",
|
||||
"alpha",
|
||||
"beta",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reorderServers updates the list optimistically before the round-trip resolves", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
alpha: { type: "stdio", command: "a" },
|
||||
beta: { type: "stdio", command: "b" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let resolvePut: (() => void) | undefined;
|
||||
const gatedFetch: typeof fetch = async (input, init) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
if (init?.method === "PUT" && url.endsWith("/api/servers/order")) {
|
||||
// Hold the PUT response open so we can observe the optimistic state
|
||||
// that must already be applied before the network settles.
|
||||
await new Promise<void>((r) => {
|
||||
resolvePut = r;
|
||||
});
|
||||
}
|
||||
return h.fetchFn(input, init);
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn: gatedFetch }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.servers.map((s) => s.id)).toEqual([
|
||||
"alpha",
|
||||
"beta",
|
||||
]),
|
||||
);
|
||||
|
||||
let pending: Promise<void>;
|
||||
act(() => {
|
||||
pending = result.current.reorderServers(["beta", "alpha"]);
|
||||
});
|
||||
|
||||
// Optimistic reorder is visible while the PUT is still in flight.
|
||||
await waitFor(() =>
|
||||
expect(result.current.servers.map((s) => s.id)).toEqual([
|
||||
"beta",
|
||||
"alpha",
|
||||
]),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
resolvePut?.();
|
||||
await pending;
|
||||
});
|
||||
});
|
||||
|
||||
it("reorderServers reverts to disk truth and throws when the set no longer matches (409)", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
alpha: { type: "stdio", command: "a" },
|
||||
beta: { type: "stdio", command: "b" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.servers.map((s) => s.id)).toEqual([
|
||||
"alpha",
|
||||
"beta",
|
||||
]),
|
||||
);
|
||||
|
||||
// Incomplete set (missing `beta`) — the backend rejects with 409 and the
|
||||
// hook re-fetches, snapping the list back to the on-disk order.
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.reorderServers(["alpha"]);
|
||||
}),
|
||||
).rejects.toThrow(/does not match/);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.servers.map((s) => s.id)).toEqual([
|
||||
"alpha",
|
||||
"beta",
|
||||
]);
|
||||
});
|
||||
expect(Object.keys(readConfig(h.configPath).mcpServers)).toEqual([
|
||||
"alpha",
|
||||
"beta",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses DEFAULT_SEED_CONFIG keys on the first load (seed-write contract)", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* Integration tests for PUT /api/servers/order — the reorder route added for
|
||||
* #1369. Spins up createRemoteApp against a per-test tmp mcpConfigPath and
|
||||
* exercises the route via real HTTP, asserting on the resulting on-disk
|
||||
* iteration order and the conflict/validation rejections.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, beforeEach } from "vitest";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { serve } from "@hono/node-server";
|
||||
import type { ServerType } from "@hono/node-server";
|
||||
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
|
||||
import { InMemorySecretStore } from "@inspector/core/auth/node/secret-store.js";
|
||||
import type { MCPConfig } from "@inspector/core/mcp/types.js";
|
||||
|
||||
interface Harness {
|
||||
baseUrl: string;
|
||||
server: ServerType;
|
||||
configPath: string;
|
||||
tempDir: string;
|
||||
}
|
||||
|
||||
async function startServer(
|
||||
configPath: string,
|
||||
): Promise<{ baseUrl: string; server: ServerType }> {
|
||||
const { app } = createRemoteApp({
|
||||
dangerouslyOmitAuth: true,
|
||||
mcpConfigPath: configPath,
|
||||
initialConfig: { defaultEnvironment: {} },
|
||||
secretStore: new InMemorySecretStore(),
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = serve(
|
||||
{ fetch: app.fetch, port: 0, hostname: "127.0.0.1" },
|
||||
(info) => {
|
||||
const port =
|
||||
info && typeof info === "object" && "port" in info
|
||||
? (info as { port: number }).port
|
||||
: 0;
|
||||
resolve({ baseUrl: `http://127.0.0.1:${port}`, server });
|
||||
},
|
||||
);
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function setup(seed: MCPConfig): Promise<Harness> {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "inspector-servers-order-"));
|
||||
const configPath = join(tempDir, "mcp.json");
|
||||
writeFileSync(configPath, JSON.stringify(seed, null, 2));
|
||||
const { baseUrl, server } = await startServer(configPath);
|
||||
return { baseUrl, server, configPath, tempDir };
|
||||
}
|
||||
|
||||
async function teardown(h: Harness): Promise<void> {
|
||||
await new Promise<void>((resolve) => h.server.close(() => resolve()));
|
||||
try {
|
||||
rmSync(h.tempDir, { recursive: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function readOrder(path: string): string[] {
|
||||
const cfg = JSON.parse(readFileSync(path, "utf-8")) as MCPConfig;
|
||||
return Object.keys(cfg.mcpServers);
|
||||
}
|
||||
|
||||
const SEED: MCPConfig = {
|
||||
mcpServers: {
|
||||
alpha: { type: "stdio", command: "a" },
|
||||
beta: { type: "stdio", command: "b" },
|
||||
gamma: { type: "stdio", command: "g" },
|
||||
},
|
||||
};
|
||||
|
||||
async function putOrder(baseUrl: string, body: unknown): Promise<Response> {
|
||||
return fetch(`${baseUrl}/api/servers/order`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("PUT /api/servers/order", () => {
|
||||
let h: Harness;
|
||||
|
||||
beforeEach(async () => {
|
||||
h = await setup(SEED);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardown(h);
|
||||
});
|
||||
|
||||
it("rewrites mcp.json in the supplied order", async () => {
|
||||
const res = await putOrder(h.baseUrl, {
|
||||
order: ["gamma", "alpha", "beta"],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ ok: true });
|
||||
expect(readOrder(h.configPath)).toEqual(["gamma", "alpha", "beta"]);
|
||||
});
|
||||
|
||||
it("preserves each entry's config when reordering (values untouched)", async () => {
|
||||
await putOrder(h.baseUrl, { order: ["beta", "gamma", "alpha"] });
|
||||
const cfg = JSON.parse(readFileSync(h.configPath, "utf-8")) as MCPConfig;
|
||||
expect(cfg.mcpServers.alpha).toEqual({ type: "stdio", command: "a" });
|
||||
expect(cfg.mcpServers.beta).toEqual({ type: "stdio", command: "b" });
|
||||
expect(cfg.mcpServers.gamma).toEqual({ type: "stdio", command: "g" });
|
||||
});
|
||||
|
||||
it("rejects an order missing an on-disk id (409) and leaves the file unchanged", async () => {
|
||||
const res = await putOrder(h.baseUrl, { order: ["alpha", "beta"] });
|
||||
expect(res.status).toBe(409);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(
|
||||
/does not match/,
|
||||
);
|
||||
// Original order preserved.
|
||||
expect(readOrder(h.configPath)).toEqual(["alpha", "beta", "gamma"]);
|
||||
});
|
||||
|
||||
it("rejects an order containing an unknown id (409)", async () => {
|
||||
const res = await putOrder(h.baseUrl, {
|
||||
order: ["alpha", "beta", "ghost"],
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
expect(readOrder(h.configPath)).toEqual(["alpha", "beta", "gamma"]);
|
||||
});
|
||||
|
||||
it("rejects duplicate ids in the order (400)", async () => {
|
||||
const res = await putOrder(h.baseUrl, {
|
||||
order: ["alpha", "alpha", "beta"],
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(
|
||||
/duplicate/,
|
||||
);
|
||||
expect(readOrder(h.configPath)).toEqual(["alpha", "beta", "gamma"]);
|
||||
});
|
||||
|
||||
it("rejects a non-array order (400)", async () => {
|
||||
const res = await putOrder(h.baseUrl, { order: "alpha,beta,gamma" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(
|
||||
/array of strings/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an order array with a non-string element (400)", async () => {
|
||||
const res = await putOrder(h.baseUrl, { order: ["alpha", 2, "gamma"] });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects an invalid JSON body (400)", async () => {
|
||||
const res = await fetch(`${h.baseUrl}/api/servers/order`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{ not json",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("is not captured by the :id route — 'order' is never treated as a server id", async () => {
|
||||
// Regression guard for route registration order: a PUT to /order must hit
|
||||
// the reorder handler, not PUT /api/servers/:id with id="order".
|
||||
const res = await putOrder(h.baseUrl, {
|
||||
order: ["gamma", "beta", "alpha"],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(readOrder(h.configPath)).toEqual(["gamma", "beta", "alpha"]);
|
||||
// No phantom "order" server was created.
|
||||
expect(readOrder(h.configPath)).not.toContain("order");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue