Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ Use these patterns when extending list/detail or run-submission UX:

2. **Prefer in-place creation over navigation breaks.**
- For `/runs/new`, create profiles in a dialog and keep users on the page.
- Reuse shared forms (e.g. `ProfileCreateForm`) between full-page and modal flows to avoid behavior drift.
- Reuse shared forms (e.g. `ProfileCreateForm`, `McpServerForm`) between full-page and modal flows to avoid behavior drift.
- Keep entity sections visible when their list is empty: show an empty state plus the inline `New…` action instead of hiding the section.
- Inline-create dialogs mounted inside a page `<form>` (e.g. Submit Run) must call `e.stopPropagation()` in their own submit handler — React bubbles synthetic events through portals to the outer form — and mark their primary button with `data-command-enter` so page-level Cmd+Enter shortcuts don't fire underneath.

3. **Treat action counts as source-of-truth UX.**
- Any submit/CTA label must reflect the real backend effect (e.g. expanded run count, not just occurrence count).
Expand Down
103 changes: 103 additions & 0 deletions apps/portal/src/components/McpServerCreateDialog.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { useEffect, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { http, HttpResponse } from "msw";
import type { McpServerDocument } from "@/types";
import { getSelectedProjectId, setSelectedProjectIdHolder } from "@/lib/project-scope";
import { Button } from "@/components/ui/button";
import { McpServerCreateDialog } from "./McpServerCreateDialog";

const existingServers: McpServerDocument[] = [
{
_id: "learn-docs",
name: "Learn Docs",
type: "http",
url: "https://learn.example.com/mcp",
createdAt: "2026-01-01T00:00:00.000Z",
},
];

function DialogHarness() {
const [open, setOpen] = useState(true);
const [created, setCreated] = useState<string>("");
const [scopeReady, setScopeReady] = useState(false);

useEffect(() => {
const previousProjectId = getSelectedProjectId();
setSelectedProjectIdHolder("demo-project");
setScopeReady(true);
return () => setSelectedProjectIdHolder(previousProjectId);
}, []);

if (!scopeReady) return null;

return (
<div className="p-6 space-y-2">
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
New MCP server…
</Button>
<p data-testid="created" className="text-sm text-muted-foreground">{created}</p>
<McpServerCreateDialog
open={open}
onOpenChange={setOpen}
onCreated={(server) => {
setCreated(server._id);
setOpen(false);
}}
/>
</div>
);
}

const meta = {
component: McpServerCreateDialog,
render: () => <DialogHarness />,
args: {
open: true,
onOpenChange: () => {},
onCreated: () => {},
},
tags: ["ai-generated", "needs-work"],
parameters: {
msw: {
handlers: [
http.get("*/api/v1/mcp/servers", () => HttpResponse.json(existingServers)),
http.post("*/api/v1/mcp/servers", async ({ request }) => {
const body = (await request.json()) as Partial<McpServerDocument>;
return HttpResponse.json(
{ ...body, createdAt: new Date().toISOString() },
{ status: 201 },
);
}),
],
},
},
} satisfies Meta<typeof McpServerCreateDialog>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const DuplicateSlug: Story = {
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
const slug = await page.findByLabelText(/slug/i);
await userEvent.type(slug, "learn-docs");
await expect(await page.findByText(/already exists in this project/i)).toBeVisible();
await expect(page.getByRole("button", { name: /create server/i })).toBeDisabled();
},
};

export const CreateAndSelect: Story = {
play: async ({ canvas, canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await userEvent.type(await page.findByLabelText(/slug/i), "github-search");
await userEvent.type(page.getByLabelText(/^url/i), "https://example.com/mcp");
await userEvent.click(page.getByRole("button", { name: /create server/i }));
await expect(await canvas.findByText("github-search")).toBeVisible();
},
};
37 changes: 37 additions & 0 deletions apps/portal/src/components/McpServerCreateDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import {
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { McpServerForm } from "@/components/McpServerForm";
import type { McpServerDocument } from "@/types";

interface McpServerCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: (server: McpServerDocument) => void;
}

export function McpServerCreateDialog({ open, onOpenChange, onCreated }: McpServerCreateDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-2xl grid-rows-[auto_minmax(0,1fr)] overflow-hidden">
<DialogHeader>
<DialogTitle>New MCP Server</DialogTitle>
<DialogDescription>
Register an MCP server without leaving this flow.
</DialogDescription>
</DialogHeader>
<div className="-mx-6 min-h-0 overflow-y-auto px-6">
<McpServerForm
className="pb-1"
stickyFooter
showCancel={false}
onCreated={onCreated}
/>
</div>
</DialogContent>
</Dialog>
);
}
127 changes: 127 additions & 0 deletions apps/portal/src/components/McpServerForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { createPortal } from "react-dom";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { McpServerDocument } from "@/types";

const seed: McpServerDocument[] = [
{
_id: "learn-docs",
name: "Learn Docs",
type: "http",
url: "https://learn.example.com/mcp",
createdAt: "2026-01-01T00:00:00.000Z",
},
];
let servers: McpServerDocument[] = [...seed];

vi.mock("@/lib/api", () => ({
api: {
listMcpServers: vi.fn(async () => servers),
createMcpServer: vi.fn(async (body: { _id: string; name: string; type: McpServerDocument["type"]; url?: string }) => {
const created: McpServerDocument = { ...body, createdAt: "2026-01-02T00:00:00.000Z" };
servers = [...servers, created];
return created;
}),
},
}));

vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

import { api } from "@/lib/api";
import { McpServerForm } from "./McpServerForm";
import { McpServerCreateDialog } from "./McpServerCreateDialog";

afterEach(() => {
cleanup();
vi.clearAllMocks();
servers = [...seed];
});

function renderWithClient(
ui: React.ReactElement,
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }),
) {
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}

async function fillValidHttpServer(slug: string) {
// Wait for the existing-server list to load so duplicate detection is live.
await waitFor(() => expect(api.listMcpServers).toHaveBeenCalled());
fireEvent.change(screen.getByLabelText(/slug/i), { target: { value: slug } });
fireEvent.change(screen.getByLabelText(/^url/i), { target: { value: "https://example.com/mcp" } });
}

describe("McpServerForm", () => {
it("blocks a slug that already exists in the project", async () => {
renderWithClient(<McpServerForm onCreated={vi.fn()} />);
await fillValidHttpServer("learn-docs");

expect(await screen.findByText(/already exists in this project/i)).toBeTruthy();
expect((screen.getByRole("button", { name: /create server/i }) as HTMLButtonElement).disabled).toBe(true);
});

it("creates the server, seeds the shared list, and reports it upward", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
// Capture the cache at callback time: callers rely on the new server already
// being in the list when they auto-select it.
let cachedAtCallback: string[] = [];
const onCreated = vi.fn(() => {
cachedAtCallback = (queryClient.getQueryData<McpServerDocument[]>(["mcp-servers"]) ?? []).map((s) => s._id);
});
renderWithClient(<McpServerForm onCreated={onCreated} />, queryClient);
await fillValidHttpServer("new-server");

const submit = screen.getByRole("button", { name: /create server/i }) as HTMLButtonElement;
await waitFor(() => expect(submit.disabled).toBe(false));
fireEvent.submit(submit.closest("form")!);

await waitFor(() => expect(onCreated).toHaveBeenCalledTimes(1));
expect(vi.mocked(api.createMcpServer).mock.calls[0][0]).toMatchObject({
_id: "new-server",
name: "New Server",
type: "http",
url: "https://example.com/mcp",
});
expect(onCreated.mock.calls[0]).toEqual([expect.objectContaining({ _id: "new-server" })]);
expect(cachedAtCallback).toEqual(["new-server", "learn-docs"]);
});

it("does not propagate submit to an enclosing page form across a portal", async () => {
const outerSubmit = vi.fn((e: React.FormEvent) => e.preventDefault());
renderWithClient(
<form onSubmit={outerSubmit}>
{createPortal(<McpServerForm onCreated={vi.fn()} />, document.body)}
</form>,
);
await fillValidHttpServer("portal-server");

const submit = screen.getByRole("button", { name: /create server/i });
fireEvent.submit(submit.closest("form")!);

await waitFor(() => expect(api.createMcpServer).toHaveBeenCalledTimes(1));
expect(outerSubmit).not.toHaveBeenCalled();
});
});

describe("McpServerCreateDialog", () => {
it("claims Cmd/Ctrl+Enter so page-level shortcuts do not fire underneath", async () => {
const pageShortcut = vi.fn();
const listener = (e: KeyboardEvent) => {
if (!e.defaultPrevented && e.key === "Enter") pageShortcut();
};
document.addEventListener("keydown", listener);
try {
renderWithClient(<McpServerCreateDialog open onOpenChange={vi.fn()} onCreated={vi.fn()} />);
const slug = await screen.findByLabelText(/slug/i);
fireEvent.keyDown(slug, { key: "Enter", metaKey: true, ctrlKey: true });
expect(pageShortcut).not.toHaveBeenCalled();
} finally {
document.removeEventListener("keydown", listener);
}
});
});
Loading
Loading