feat(web): reorderable ServerCards — drag-and-drop + keyboard (#1369) (#1447)

* 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:
Cliff Hall 2026-06-09 14:15:58 -04:00 • committed by GitHub
commit d5f12b2a82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1039 additions and 27 deletions

View file

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

View file

@ -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>

View file

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