* 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
1 changed files with 1039 additions and 27 deletions
|
|
@ -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 }),
|
||||
|
|
|
|||
Loading…
Reference in a new issue