From c1cccc8e847cf39f9cbebb68f3cc0a58d58692cf Mon Sep 17 00:00:00 2001 From: Marek Dano Date: Wed, 19 Aug 2026 19:56:45 +0100 Subject: [PATCH 1/2] feat: add a preview experience to the resource details panel Signed-off-by: Marek Dano --- e2e/resources.spec.ts | 116 ++++++- src/api/client.ts | 7 + src/api/resources.test.ts | 44 +++ src/api/resources.ts | 62 ++++ .../prompts/PromptDefinitionTable.tsx | 6 +- src/components/resources/ResourceArgsForm.tsx | 70 +++++ .../resources/ResourceDefinitionTab.tsx | 37 +++ .../resources/ResourceDetailsPanel.test.tsx | 44 +++ .../resources/ResourceDetailsPanel.tsx | 82 +++-- .../resources/ResourcePreviewButton.tsx | 50 +++ .../resources/ResourcePreviewResult.test.tsx | 95 ++++++ .../resources/ResourcePreviewResult.tsx | 232 ++++++++++++++ .../resources/ResourceTryItTab.test.tsx | 166 ++++++++++ src/components/resources/ResourceTryItTab.tsx | 167 ++++++++++ .../resources/ResourcesTable.test.tsx | 166 +++++++++- src/components/resources/ResourcesTable.tsx | 10 +- .../resources/buildResourceSnippets.test.ts | 75 +++++ .../resources/buildResourceSnippets.ts | 124 ++++++++ .../resources/parseUriTemplate.test.ts | 45 +++ src/components/resources/parseUriTemplate.ts | 44 +++ .../resources/useResourcePreview.test.tsx | 296 ++++++++++++++++++ .../resources/useResourcePreview.ts | 105 +++++++ src/components/tools/ToolsTable.test.tsx | 22 +- src/components/tools/ToolsTable.tsx | 13 +- src/components/ui/code-block.tsx | 7 +- src/i18n/locales/en-US/resources.json | 24 ++ src/i18n/locales/es-ES/resources.json | 24 ++ src/i18n/locales/pt-BR/resources.json | 24 ++ src/pages/Resources.test.tsx | 11 + 29 files changed, 2109 insertions(+), 59 deletions(-) create mode 100644 src/components/resources/ResourceArgsForm.tsx create mode 100644 src/components/resources/ResourceDefinitionTab.tsx create mode 100644 src/components/resources/ResourcePreviewButton.tsx create mode 100644 src/components/resources/ResourcePreviewResult.test.tsx create mode 100644 src/components/resources/ResourcePreviewResult.tsx create mode 100644 src/components/resources/ResourceTryItTab.test.tsx create mode 100644 src/components/resources/ResourceTryItTab.tsx create mode 100644 src/components/resources/buildResourceSnippets.test.ts create mode 100644 src/components/resources/buildResourceSnippets.ts create mode 100644 src/components/resources/parseUriTemplate.test.ts create mode 100644 src/components/resources/parseUriTemplate.ts create mode 100644 src/components/resources/useResourcePreview.test.tsx create mode 100644 src/components/resources/useResourcePreview.ts diff --git a/e2e/resources.spec.ts b/e2e/resources.spec.ts index 9e345f4..d0f101f 100644 --- a/e2e/resources.spec.ts +++ b/e2e/resources.spec.ts @@ -266,6 +266,98 @@ test.describe("Resources page", () => { await expect(panel).not.toBeVisible(); }); + test.describe("Try it preview", () => { + test("renders a preview in the details panel Try it tab", async ({ page }) => { + await page.route("**/resources?*", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([RESOURCE_A1]), + }); + }); + + let requestedUrl: string | null = null; + await page.route("**/v1/resources/test/**", async (route) => { + requestedUrl = route.request().url(); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + content: { mimeType: "text/plain", text: "hello from document-txt" }, + }), + }); + }); + + await page.goto(APP.RESOURCES); + await page.waitForLoadState("networkidle"); + + await page.getByRole("button", { name: "More options for github-server" }).click(); + await page.getByRole("menuitem", { name: "View details" }).click(); + + // Try it is the default tab, so Preview is available without switching tabs. + const panel = page.getByRole("region", { name: /Resources for github-server/i }); + await expect(panel).toBeVisible(); + await panel.getByRole("button", { name: "Preview" }).click(); + + await expect.poll(() => requestedUrl).not.toBeNull(); + expect(requestedUrl).toContain(`/v1/resources/test/${encodeURI(RESOURCE_A1.uri)}`); + + await expect(panel).toContainText("200 OK"); + await expect(panel).toContainText("hello from document-txt"); + }); + + test("disables Preview until every uriTemplate placeholder is filled, then sends the resolved uri", async ({ + page, + }) => { + const TEMPLATED = makeResource("repo-contents", "github-server", { + uri: "github://repos/{owner}/{repo}", + uriTemplate: "github://repos/{owner}/{repo}", + }); + + await page.route("**/resources?*", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([TEMPLATED]), + }); + }); + + let requestedUrl: string | null = null; + await page.route("**/v1/resources/test/**", async (route) => { + requestedUrl = route.request().url(); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ content: { mimeType: "text/plain", text: "readme contents" } }), + }); + }); + + await page.goto(APP.RESOURCES); + await page.waitForLoadState("networkidle"); + + await page.getByRole("button", { name: "More options for github-server" }).click(); + await page.getByRole("menuitem", { name: "View details" }).click(); + + const panel = page.getByRole("region", { name: /Resources for github-server/i }); + const previewButton = panel.getByRole("button", { name: "Preview" }); + await expect(previewButton).toBeDisabled(); + + await panel.getByLabel(/owner/).fill("ibm"); + await expect(previewButton).toBeDisabled(); + + await panel.getByLabel(/repo/).fill("mcp-context-forge"); + await expect(previewButton).toBeEnabled(); + + await previewButton.click(); + + await expect.poll(() => requestedUrl).not.toBeNull(); + expect(requestedUrl).toContain( + `/v1/resources/test/${encodeURI("github://repos/ibm/mcp-context-forge")}`, + ); + await expect(panel).toContainText("readme contents"); + }); + }); + test.describe("Delete resource", () => { test("cancel in confirm dialog keeps resource visible", async ({ page }) => { await page.route("**/resources?*", async (route) => { @@ -283,7 +375,8 @@ test.describe("Resources page", () => { await page.getByRole("menuitem", { name: "View details" }).click(); const panel = page.getByRole("region", { name: /Resources for github-server/i }); - await panel.getByRole("button", { name: "More options" }).first().click(); + await panel.getByRole("tab", { name: "Definition" }).click(); + await panel.getByRole("button", { name: "More options for document-txt" }).click(); await page.getByRole("menuitem", { name: "Delete" }).click(); const dialog = page.getByRole("dialog", { name: "Delete resource" }); @@ -329,7 +422,8 @@ test.describe("Resources page", () => { await expect(panel).toBeVisible(); await expect(panel.getByText("document-txt").first()).toBeVisible(); - await panel.getByRole("button", { name: "More options" }).first().click(); + await panel.getByRole("tab", { name: "Definition" }).click(); + await panel.getByRole("button", { name: "More options for document-txt" }).click(); await page.getByRole("menuitem", { name: "Delete" }).click(); const dialog = page.getByRole("dialog", { name: "Delete resource" }); @@ -376,7 +470,8 @@ test.describe("Resources page", () => { const panel = page.getByRole("region", { name: /Resources for github-server/i }); await expect(panel).toBeVisible(); - await panel.getByRole("button", { name: "More options" }).first().click(); + await panel.getByRole("tab", { name: "Definition" }).click(); + await panel.getByRole("button", { name: "More options for document-txt" }).click(); await page.getByRole("menuitem", { name: "Delete" }).click(); const dialog = page.getByRole("dialog", { name: "Delete resource" }); @@ -420,7 +515,8 @@ test.describe("Resources page", () => { const panel = page.getByRole("region", { name: /Resources for solo-gateway/i }); await expect(panel).toBeVisible(); - await panel.getByRole("button", { name: "More options" }).first().click(); + await panel.getByRole("tab", { name: "Definition" }).click(); + await panel.getByRole("button", { name: "More options for solo_resource" }).click(); await page.getByRole("menuitem", { name: "Delete" }).click(); await page @@ -469,7 +565,8 @@ test.describe("Resources page", () => { await expect(panel.getByText("alpha_resource").first()).toBeVisible(); await expect(panel.getByText("beta_resource").first()).toBeVisible(); - await panel.getByRole("button", { name: "More options" }).first().click(); + await panel.getByRole("tab", { name: "Definition" }).click(); + await panel.getByRole("button", { name: "More options for alpha_resource" }).click(); await page.getByRole("menuitem", { name: "Delete" }).click(); await page .getByRole("dialog", { name: "Delete resource" }) @@ -517,7 +614,8 @@ test.describe("Resources page", () => { const panel = page.getByRole("region", { name: /Resources for lone-gateway/i }); await expect(panel).toBeVisible(); - await panel.getByRole("button", { name: "More options" }).first().click(); + await panel.getByRole("tab", { name: "Definition" }).click(); + await panel.getByRole("button", { name: "More options for lone_resource" }).click(); await page.getByRole("menuitem", { name: "Delete" }).click(); await page .getByRole("dialog", { name: "Delete resource" }) @@ -572,7 +670,8 @@ test.describe("Resources page", () => { const panel = page.getByRole("region", { name: /Resources for github-server/i }); await expect(panel).toBeVisible(); - await panel.getByRole("button", { name: "More options" }).first().click(); + await panel.getByRole("tab", { name: "Definition" }).click(); + await panel.getByRole("button", { name: `More options for ${RESOURCE_A1.name}` }).click(); await page.getByRole("menuitem", { name: "Edit" }).click(); await expect(page.getByRole("heading", { name: "Edit resource" })).toBeVisible(); @@ -626,7 +725,8 @@ test.describe("Resources page", () => { await page.getByRole("menuitem", { name: "View details" }).click(); const panel = page.getByRole("region", { name: /Resources for github-server/i }); - await panel.getByRole("button", { name: "More options" }).first().click(); + await panel.getByRole("tab", { name: "Definition" }).click(); + await panel.getByRole("button", { name: `More options for ${RESOURCE_A1.name}` }).click(); await page.getByRole("menuitem", { name: "Edit" }).click(); await expect(page.getByLabel(/Content/)).toHaveValue("original content"); diff --git a/src/api/client.ts b/src/api/client.ts index 7e845b4..da69f4e 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -214,6 +214,13 @@ export const api = { return request(path, { method: "GET", headers, signal, ...opts }); }, + getWithMeta( + path: string, + opts?: Omit, + ): Promise> { + return requestWithMeta(path, { method: "GET", ...opts }); + }, + post( path: string, body?: unknown, diff --git a/src/api/resources.test.ts b/src/api/resources.test.ts index 4d8bcb8..5458e8d 100644 --- a/src/api/resources.test.ts +++ b/src/api/resources.test.ts @@ -168,6 +168,50 @@ describe("resourcesApi", () => { }); }); + describe("test", () => { + it("GETs /v1/resources/test/:uri (slashes preserved) and returns content + status", async () => { + mockFetch.mockResolvedValueOnce( + okJson({ content: { mimeType: "text/plain", text: "hello" } }), + ); + + const result = await resourcesApi.test("file:///tmp/a.txt"); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/v1/resources/test/file:///tmp/a.txt"), + expect.objectContaining({ method: "GET" }), + ); + expect(result).toEqual({ content: { mimeType: "text/plain", text: "hello" }, status: 200 }); + }); + + it("percent-encodes ? and # so the uri survives as a full path segment", async () => { + mockFetch.mockResolvedValueOnce( + okJson({ content: { mimeType: "text/plain", text: "hello" } }), + ); + + await resourcesApi.test("file:///a?b#c"); + + const requestedUrl = String(mockFetch.mock.calls[0][0]); + expect(requestedUrl).toContain("/v1/resources/test/file:///a%3Fb%23c"); + // Unescaped, `?`/`#` would truncate the path here instead of reaching the backend. + expect(requestedUrl).not.toContain("/v1/resources/test/file:///a?b#c"); + }); + + it("throws synchronously for an empty URI", () => { + expect(() => resourcesApi.test("")).toThrow("Invalid resource URI"); + }); + + it("throws ApiError on a non-2xx response", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect(resourcesApi.test("resource://missing")).rejects.toThrow("HTTP 404"); + }); + }); + describe("validateResourceId (via delete)", () => { it("rejects an empty id", () => { expect(() => resourcesApi.delete("")).toThrow(/^Invalid resource ID$/); diff --git a/src/api/resources.ts b/src/api/resources.ts index 6417e6f..cfdd9b0 100644 --- a/src/api/resources.ts +++ b/src/api/resources.ts @@ -29,6 +29,45 @@ function validateResourceId(id: string): string { return id; } +/** + * Loose shape of the `content` payload returned by {@link resourcesApi.test}. + * + * The backend returns `Dict[str, Any]` (see `test_resource_by_uri` in + * `mcpgateway/main.py`) — depending on the read path this is either a + * `ResourceContent`/`ResourceContents` model dump (`mimeType`, `text`, + * `blob`) or, for some template/direct-fetch paths, a raw dict with + * snake_case keys (`mime_type`). Callers should read through a helper that + * checks both key spellings (see `ResourcePreviewResult.normalizeMimeType`) + * rather than indexing a single key directly. + */ +export interface ResourceTestContent { + uri?: string; + mimeType?: string | null; + mime_type?: string | null; + text?: string | null; + blob?: string | null; + size?: number | null; + [key: string]: unknown; +} + +export interface ResourceTestResult { + content: ResourceTestContent; + status: number; +} + +/** + * `encodeURI` deliberately leaves `/` and `:` raw (see {@link resourcesApi.test}), + * but it also leaves `?` and `#` raw — and those two are structural to a URL: + * unescaped, `?` starts a query string and `#` starts a fragment (fragments + * never even leave the browser), silently truncating the path the backend + * receives. Percent-encode just those two so a uri containing them still + * reaches the `:path` route intact; the backend's path converter decodes + * `%3F`/`%23` back to the literal chars like any other percent-escape. + */ +function encodeResourceTestUri(uri: string): string { + return encodeURI(uri).replace(/[?#]/g, (ch) => (ch === "?" ? "%3F" : "%23")); +} + export const resourcesApi = { /** * Create a new resource @@ -37,6 +76,29 @@ export const resourcesApi = { return api.post("/resources", data); }, + /** + * Read a resource's resolved content by URI, without going through the + * cached `GET /resources/{id}` path. Backs the Resources "Try it" preview. + * + * Mirrors `GET /v1/resources/test/{resource_uri:path}` (`mcpgateway/main.py`). + * The URI is passed through `encodeURI` (not + * `encodeURIComponent`) so `/` and `:` in the URI survive as path + * separators rather than being percent-escaped — the backend's `:path` + * converter expects the raw URI, matching how `resources/read` addresses + * resources on the MCP wire. + */ + test: (uri: string, options: { signal?: AbortSignal } = {}): Promise => { + if (!uri || typeof uri !== "string") { + throw new Error("Invalid resource URI"); + } + return api + .getWithMeta<{ content: ResourceTestContent }>( + `/v1/resources/test/${encodeResourceTestUri(uri)}`, + { signal: options.signal }, + ) + .then(({ data, status }) => ({ content: data.content, status })); + }, + /** * Update a resource */ diff --git a/src/components/prompts/PromptDefinitionTable.tsx b/src/components/prompts/PromptDefinitionTable.tsx index bdabc68..b6e39d1 100644 --- a/src/components/prompts/PromptDefinitionTable.tsx +++ b/src/components/prompts/PromptDefinitionTable.tsx @@ -51,7 +51,7 @@ export function PromptDefinitionTable({ const intl = useIntl(); return ( - +
@@ -82,7 +82,9 @@ export function PromptDefinitionTable({ className="cursor-pointer border-0 bg-neutral-50 hover:bg-neutral-100 data-[state=selected]:bg-neutral-200 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset dark:bg-neutral-800/50 dark:hover:bg-neutral-700/60 dark:data-[state=selected]:bg-neutral-700 [&>td:first-child]:rounded-l-lg [&>td:last-child]:rounded-r-lg" > - {prompt.displayName || prompt.name} + + {prompt.displayName || prompt.name} + diff --git a/src/components/resources/ResourceArgsForm.tsx b/src/components/resources/ResourceArgsForm.tsx new file mode 100644 index 0000000..8661559 --- /dev/null +++ b/src/components/resources/ResourceArgsForm.tsx @@ -0,0 +1,70 @@ +import { useCallback, useId } from "react"; +import { useIntl } from "react-intl"; + +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export interface ResourceArgsFormProps { + args: Record; + /** Placeholder names parsed from the resource's `uriTemplate`. */ + placeholders: string[]; + onChange: (next: Record) => void; +} + +/** + * Renders one required input per URI-template placeholder + * (`parseUriTemplate.parseUriTemplatePlaceholders`). Adapted from + * `PromptArgsForm`: every placeholder is required (unlike prompt + * arguments, which declare their own `required` flag) since an unfilled + * placeholder can't produce a resolvable URI. Pure controlled component — + * holds no state of its own. + */ +export function ResourceArgsForm({ args, placeholders, onChange }: ResourceArgsFormProps) { + const intl = useIntl(); + const fieldIdPrefix = useId(); + + const handleChange = useCallback( + (name: string, value: string) => { + onChange({ ...args, [name]: value }); + }, + [args, onChange], + ); + + if (placeholders.length === 0) { + return null; + } + + return ( +
+

+ {intl.formatMessage({ id: "resources.details.code.args.heading" })} +

+
+ {placeholders.map((name) => { + const fieldId = `${fieldIdPrefix}-${name}`; + return ( +
+ + handleChange(name, event.target.value)} + required + aria-required + className="placeholder:text-neutral-400 dark:placeholder:text-neutral-500" + /> +
+ ); + })} +
+
+ ); +} diff --git a/src/components/resources/ResourceDefinitionTab.tsx b/src/components/resources/ResourceDefinitionTab.tsx new file mode 100644 index 0000000..b376f58 --- /dev/null +++ b/src/components/resources/ResourceDefinitionTab.tsx @@ -0,0 +1,37 @@ +import type { ResourceRead } from "@/generated/types"; +import { ResourcesTable } from "./ResourcesTable"; + +export interface ResourceDefinitionTabProps { + resources: NonNullable[]; + selectedResourceId?: string; + onSelectResource: (resource: NonNullable) => void; + onEditResource?: (resource: NonNullable) => void; + onDeleteResource?: (resourceId: string) => void; + onToggleResource?: (id: string, currentState: boolean) => void; +} + +/** + * "Definition" tab content for the resource details drawer — the grouped + * list-detail table that used to be the whole panel body. Selecting a + * row updates the same `selectedResourceId` the "Try it" tab's chip picker + * and the details sidebar share. + */ +export function ResourceDefinitionTab({ + resources, + selectedResourceId, + onSelectResource, + onEditResource, + onDeleteResource, + onToggleResource, +}: ResourceDefinitionTabProps) { + return ( + + ); +} diff --git a/src/components/resources/ResourceDetailsPanel.test.tsx b/src/components/resources/ResourceDetailsPanel.test.tsx index 8797f02..63096f3 100644 --- a/src/components/resources/ResourceDetailsPanel.test.tsx +++ b/src/components/resources/ResourceDetailsPanel.test.tsx @@ -5,6 +5,11 @@ import { renderWithProviders as render } from "@/test/test-utils"; import { ResourceDetailsPanel } from "./ResourceDetailsPanel"; import type { ResourceRead } from "@/generated/types"; +vi.mock("@/api/resources", async (importOriginal) => ({ + ...(await importOriginal()), + resourcesApi: { test: vi.fn() }, +})); + function mockResource(overrides?: Partial>): NonNullable { return { id: "42", @@ -57,3 +62,42 @@ describe("ResourceDetailsPanel inline tag add", () => { expect(screen.getByRole("button", { name: "Add tags" })).toBeDisabled(); }); }); + +describe("ResourceDetailsPanel tabs", () => { + it("opens on the Try it tab by default, with the preview snippet tabs visible", () => { + render( + , + ); + + expect(screen.getByRole("tab", { name: "Try it", selected: true })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "curl" })).toBeInTheDocument(); + }); + + it("shares resource selection between the Definition table and the Try it chip picker", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("tab", { name: "Definition" })); + expect(screen.getByRole("columnheader", { name: "Resource" })).toBeInTheDocument(); + + await user.click(screen.getByText("b.txt")); + + await user.click(screen.getByRole("tab", { name: "Try it" })); + expect(screen.getByRole("button", { name: "b.txt", pressed: true })).toBeInTheDocument(); + }); +}); diff --git a/src/components/resources/ResourceDetailsPanel.tsx b/src/components/resources/ResourceDetailsPanel.tsx index 39f100b..524eede 100644 --- a/src/components/resources/ResourceDetailsPanel.tsx +++ b/src/components/resources/ResourceDetailsPanel.tsx @@ -10,11 +10,17 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { CopyValue } from "@/components/ui/copy-value"; import { InlineTagAdd } from "@/components/ui/inline-tag-add"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import type { ResourceRead } from "@/generated/types"; import { formatBytes, formatDateTime } from "@/utils/format"; import { getTagLabels } from "@/utils/tags"; -import { ResourcesTable } from "@/components/resources/ResourcesTable"; +import { ResourceDefinitionTab } from "@/components/resources/ResourceDefinitionTab"; +import { ResourceTryItTab } from "@/components/resources/ResourceTryItTab"; + +// Segmented-control styling for the Try it / Definition tab triggers. +const SEGMENTED_TRIGGER_CLASS = + "flex-1 rounded-sm px-3 py-1.5 font-medium data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"; function DetailRow({ label, @@ -36,6 +42,8 @@ function DetailRow({ interface ResourceDetailsPanelProps { resources: NonNullable[]; gatewaySlug: string; + /** Tab to select each time the panel opens. Defaults to "tryIt". */ + initialTab?: "tryIt" | "definition"; open: boolean; onClose: () => void; onEditResource?: (resource: NonNullable) => void; @@ -52,6 +60,7 @@ interface ResourceDetailsPanelProps { export function ResourceDetailsPanel({ resources, gatewaySlug, + initialTab = "tryIt", open, onClose, onEditResource, @@ -60,7 +69,11 @@ export function ResourceDetailsPanel({ onAddTag, }: ResourceDetailsPanelProps) { const intl = useIntl(); - const [selectedResource, setSelectedResource] = useState | null>(null); + // Shared across the "Try it" chip picker, the "Definition" table, and the + // details sidebar — selecting a resource in either tab updates the same + // sidebar, matching PromptDetailsPanel's single `selectedId`. + const [selectedResourceId, setSelectedResourceId] = useState(undefined); + const [activeTab, setActiveTab] = useState(initialTab); const closeButtonRef = useRef(null); const previousFocusRef = useRef(null); const headingId = useMemo(() => `resource-details-heading-${gatewaySlug}`, [gatewaySlug]); @@ -69,24 +82,33 @@ export function ResourceDetailsPanel({ // re-sync when resources list refreshes to keep details column up-to-date. useEffect(() => { if (!open) { - setSelectedResource(null); + setSelectedResourceId(undefined); return; } // Select first resource when panel opens if none selected - if (resources.length > 0 && !selectedResource) { - setSelectedResource(resources[0]); + if (resources.length > 0 && !selectedResourceId) { + setSelectedResourceId(resources[0].id); return; } // Re-sync the selected resource when the resources list refreshes - if (selectedResource) { - const updated = resources.find((r) => r.id === selectedResource.id); - if (updated && updated !== selectedResource) { - setSelectedResource(updated); - } + if (selectedResourceId && !resources.some((r) => r.id === selectedResourceId)) { + setSelectedResourceId(resources[0]?.id); } - }, [open, resources, selectedResource]); + }, [open, resources, selectedResourceId]); + + // Land on `initialTab` (default "Try it") each time the panel opens, + // regardless of which tab was active when it was last closed — mirrors + // PromptDetailsPanel. + useEffect(() => { + if (open) setActiveTab(initialTab); + }, [open, initialTab]); + + const selectedResource = useMemo( + () => resources.find((r) => r.id === selectedResourceId) ?? null, + [resources, selectedResourceId], + ); // Focus close on open; restore focus on close/unmount. useEffect(() => { @@ -165,15 +187,35 @@ export function ResourceDetailsPanel({ - {/* Table */} - + + + + {intl.formatMessage({ id: "resources.details.tab.tryIt" })} + + + {intl.formatMessage({ id: "resources.details.tab.definition" })} + + + + + setSelectedResourceId(r.id)} + /> + + + + setSelectedResourceId(r.id)} + onEditResource={onEditResource} + onDeleteResource={onDeleteResource} + onToggleResource={onToggleResource} + /> + +
+
- + {intl.formatMessage({ id: "resources.table.resource" })} - + {intl.formatMessage({ id: "resources.table.uri" })} @@ -68,7 +68,9 @@ export function ResourcesTable({ className="cursor-pointer border-0 bg-neutral-50 hover:bg-neutral-100 data-[state=selected]:bg-neutral-200 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset dark:bg-neutral-800/50 dark:hover:bg-neutral-700/60 dark:data-[state=selected]:bg-neutral-700 [&>td:first-child]:rounded-l-lg [&>td:last-child]:rounded-r-lg" > - {resource.title || resource.name} + + {resource.title || resource.name} + diff --git a/src/components/resources/buildResourceSnippets.test.ts b/src/components/resources/buildResourceSnippets.test.ts new file mode 100644 index 0000000..0fba3c4 --- /dev/null +++ b/src/components/resources/buildResourceSnippets.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { + buildResourceCurl, + buildResourceJsonRpc, + buildResourcePython, + buildResourceTypescript, + RESOURCE_SNIPPETS, +} from "./buildResourceSnippets"; + +const uri = "github://repos/ibm/mcp-context-forge/contents/README.md"; + +describe("buildResourceCurl", () => { + it("targets GET /v1/resources/test/{uri}, single-quoted as its own literal", () => { + const snippet = buildResourceCurl({ uri }); + expect(snippet).toContain(`/v1/resources/test/"'${uri}'`); + expect(snippet).toContain("Authorization: Bearer"); + expect(snippet).not.toContain("resources/read"); + }); + + it("neutralizes shell metacharacters in the uri instead of splicing them into the double-quoted segment", () => { + const dangerous = `file:///a"; rm -rf ~; echo "$(whoami)\`id\``; + const snippet = buildResourceCurl({ uri: dangerous }); + + // The whole dangerous uri must land inside a single-quoted literal, where + // $, `, and " are inert — never inside the preceding double-quoted prefix. + expect(snippet).toContain(`/v1/resources/test/"'${dangerous}'`); + }); + + it("escapes a literal single quote in the uri using the '\\'' idiom", () => { + const snippet = buildResourceCurl({ uri: "a'b" }); + + expect(snippet).toContain(`/v1/resources/test/"'a'\\''b'`); + }); +}); + +describe("buildResourceJsonRpc", () => { + it("builds a resources/read envelope with the resolved uri", () => { + const envelope = JSON.parse(buildResourceJsonRpc({ uri })); + expect(envelope).toEqual({ + jsonrpc: "2.0", + id: 1, + method: "resources/read", + params: { uri }, + }); + }); +}); + +describe("buildResourcePython", () => { + it("POSTs a resources/read JSON-RPC body to /rpc", () => { + const snippet = buildResourcePython({ uri }); + expect(snippet).toContain("/rpc"); + expect(snippet).toContain('"method": "resources/read"'); + expect(snippet).toContain(JSON.stringify(uri)); + }); +}); + +describe("buildResourceTypescript", () => { + it("fetches /rpc with a resources/read JSON-RPC body", () => { + const snippet = buildResourceTypescript({ uri }); + expect(snippet).toContain("/rpc"); + expect(snippet).toContain('method: "resources/read"'); + expect(snippet).toContain(JSON.stringify(uri)); + }); +}); + +describe("RESOURCE_SNIPPETS", () => { + it("declares exactly the four documented tabs in order", () => { + expect(RESOURCE_SNIPPETS.map((s) => s.value)).toEqual([ + "curl", + "jsonRpc", + "python", + "typescript", + ]); + }); +}); diff --git a/src/components/resources/buildResourceSnippets.ts b/src/components/resources/buildResourceSnippets.ts new file mode 100644 index 0000000..995b43a --- /dev/null +++ b/src/components/resources/buildResourceSnippets.ts @@ -0,0 +1,124 @@ +import type { CodeBlockLanguage } from "@/components/ui/code-block"; + +export const URL_ENV = "MCPGATEWAY_URL"; +export const TOKEN_ENV = "MCPGATEWAY_BEARER_TOKEN"; + +export type ResourceSnippetLanguage = "curl" | "jsonRpc" | "python" | "typescript"; + +export interface ResourceSnippetInput { + /** Concrete resource URI — placeholders already substituted by the caller. */ + uri: string; +} + +export interface ResourceSnippetSpec { + value: ResourceSnippetLanguage; + labelId: string; + language: string; + prismLanguage: CodeBlockLanguage; + build: (input: ResourceSnippetInput) => string; +} + +// Renders a value as its own single-quoted bash literal, safe to place +// directly after a double-quoted segment (bash concatenates adjacent quoted +// segments with no separator). Single quotes neutralize $, `, and " — unlike +// double quotes, which leave them live for expansion/substitution. Bash +// treats ' as a hard terminator with no inner escape; the idiom to embed a +// literal ' is to close the quoted run, emit \', and reopen: '\''. +function bashSingleQuoteLiteral(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +/** + * The only tab that hits the actual preview wire call + * (`GET /v1/resources/test/{uri}`) — the REST analog of the Prompts + * `POST /prompts/{name}` snippet. Resources have no equivalent named REST + * endpoint for reads, so the other three tabs show the MCP `resources/read` + * shape instead (see {@link buildResourceJsonRpc}). + */ +export function buildResourceCurl({ uri }: ResourceSnippetInput): string { + return [ + `curl -H "Authorization: Bearer $${TOKEN_ENV}" \\`, + ` "$${URL_ENV}/v1/resources/test/"${bashSingleQuoteLiteral(uri)}`, + ].join("\n"); +} + +export function buildResourceJsonRpc({ uri }: ResourceSnippetInput): string { + const envelope = { + jsonrpc: "2.0", + id: 1, + method: "resources/read", + params: { uri }, + }; + return JSON.stringify(envelope, null, 2); +} + +export function buildResourcePython({ uri }: ResourceSnippetInput): string { + return [ + "import os", + "import requests", + "", + "response = requests.post(", + ` f"{os.environ['${URL_ENV}']}/rpc",`, + ` headers={"Authorization": f"Bearer {os.environ['${TOKEN_ENV}']}"},`, + " json={", + ' "jsonrpc": "2.0",', + ' "id": 1,', + ' "method": "resources/read",', + ` "params": {"uri": ${JSON.stringify(uri)}},`, + " },", + ")", + "response.raise_for_status()", + "print(response.json())", + ].join("\n"); +} + +export function buildResourceTypescript({ uri }: ResourceSnippetInput): string { + return [ + `const response = await fetch(\`\${process.env.${URL_ENV}}/rpc\`, {`, + ` method: "POST",`, + ` headers: {`, + ` Authorization: \`Bearer \${process.env.${TOKEN_ENV}}\`,`, + ` "Content-Type": "application/json",`, + ` },`, + ` body: JSON.stringify({`, + ` jsonrpc: "2.0",`, + ` id: 1,`, + ` method: "resources/read",`, + ` params: { uri: ${JSON.stringify(uri)} },`, + ` }),`, + `});`, + `if (!response.ok) throw new Error(\`Resource read failed: \${response.status}\`);`, + `const data = await response.json();`, + ].join("\n"); +} + +export const RESOURCE_SNIPPETS: ResourceSnippetSpec[] = [ + { + value: "curl", + labelId: "resources.details.code.tab.curl", + language: "curl", + prismLanguage: "bash", + build: buildResourceCurl, + }, + { + value: "jsonRpc", + labelId: "resources.details.code.tab.jsonRpc", + language: "JSON-RPC", + prismLanguage: "json", + build: buildResourceJsonRpc, + }, + { + value: "python", + labelId: "resources.details.code.tab.python", + language: "Python", + prismLanguage: "python", + build: buildResourcePython, + }, + { + value: "typescript", + labelId: "resources.details.code.tab.typescript", + language: "TypeScript", + prismLanguage: "tsx", + build: buildResourceTypescript, + }, +]; diff --git a/src/components/resources/parseUriTemplate.test.ts b/src/components/resources/parseUriTemplate.test.ts new file mode 100644 index 0000000..7b993ba --- /dev/null +++ b/src/components/resources/parseUriTemplate.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { parseUriTemplatePlaceholders, resolveUriTemplate } from "./parseUriTemplate"; + +describe("parseUriTemplatePlaceholders", () => { + it("extracts placeholder names in order, de-duplicated", () => { + expect(parseUriTemplatePlaceholders("github://repos/{owner}/{repo}/contents/{path}")).toEqual([ + "owner", + "repo", + "path", + ]); + }); + + it("de-duplicates a repeated placeholder", () => { + expect(parseUriTemplatePlaceholders("x://{a}/{a}/{b}")).toEqual(["a", "b"]); + }); + + it("strips RFC 6570 operator/modifier characters from the name", () => { + expect(parseUriTemplatePlaceholders("x://{+path}/{b*}")).toEqual(["path", "b"]); + }); + + it("returns an empty array for a template with no placeholders", () => { + expect(parseUriTemplatePlaceholders("file:///static/a.txt")).toEqual([]); + }); + + it("returns an empty array for null/undefined", () => { + expect(parseUriTemplatePlaceholders(null)).toEqual([]); + expect(parseUriTemplatePlaceholders(undefined)).toEqual([]); + }); +}); + +describe("resolveUriTemplate", () => { + it("substitutes every placeholder with its value", () => { + expect( + resolveUriTemplate("github://repos/{owner}/{repo}/contents/{path}", { + owner: "ibm", + repo: "mcp-context-forge", + path: "README.md", + }), + ).toBe("github://repos/ibm/mcp-context-forge/contents/README.md"); + }); + + it("substitutes a missing value as an empty string", () => { + expect(resolveUriTemplate("x://{a}/{b}", { a: "1" })).toBe("x://1/"); + }); +}); diff --git a/src/components/resources/parseUriTemplate.ts b/src/components/resources/parseUriTemplate.ts new file mode 100644 index 0000000..43660a0 --- /dev/null +++ b/src/components/resources/parseUriTemplate.ts @@ -0,0 +1,44 @@ +/** + * RFC 6570-lite placeholder handling for resource URI templates (e.g. + * `github://repos/{owner}/{repo}/contents/{path}`). Only plain + * `{name}` expressions are supported — level-1 simple string expansion. + * Reserved/fragment/query operators (`{+x}`, `{#x}`, `{?x}`, `{&x}`, `{;x}`, + * `{.x}`, `{/x}`) and the explode modifier (`{x*}`) are recognized just + * enough to extract the variable name; the operator/modifier themselves are + * not honored during expansion. Sufficient for the placeholder shapes the + * gateway's resource templates actually use. + */ +const PLACEHOLDER_PATTERN = /\{([+#./;?&]?)([a-zA-Z_][a-zA-Z0-9_]*)(\*)?\}/g; + +/** + * Extracts the ordered, de-duplicated list of placeholder names from a URI + * template. Returns an empty array for a template with no placeholders (or + * for `null`/`undefined`). + */ +export function parseUriTemplatePlaceholders(uriTemplate?: string | null): string[] { + if (!uriTemplate) return []; + const names: string[] = []; + const seen = new Set(); + for (const match of uriTemplate.matchAll(PLACEHOLDER_PATTERN)) { + const name = match[2]; + if (!seen.has(name)) { + seen.add(name); + names.push(name); + } + } + return names; +} + +/** + * Substitutes each `{name}` placeholder with the corresponding value, + * producing the concrete URI to send to `resources/read` / + * `GET /v1/resources/test/{uri}`. Missing values substitute as an empty + * string — the caller (`ResourceArgsForm`) marks every placeholder required + * so the Preview button stays disabled until all are filled. + */ +export function resolveUriTemplate(uriTemplate: string, values: Record): string { + return uriTemplate.replace( + PLACEHOLDER_PATTERN, + (_match, _operator, name: string) => values[name] ?? "", + ); +} diff --git a/src/components/resources/useResourcePreview.test.tsx b/src/components/resources/useResourcePreview.test.tsx new file mode 100644 index 0000000..194e48c --- /dev/null +++ b/src/components/resources/useResourcePreview.test.tsx @@ -0,0 +1,296 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { I18nProvider } from "@/i18n"; +import { useResourcePreview } from "./useResourcePreview"; +import { resourcesApi } from "@/api/resources"; +import { ApiError } from "@/api/client"; + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +vi.mock("@/api/resources", () => ({ + resourcesApi: { test: vi.fn() }, +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function setup(uri = "file:///a.txt") { + return renderHook(() => useResourcePreview(uri), { + wrapper: ({ children }) => {children}, + }); +} + +describe("useResourcePreview", () => { + it("starts in an idle state with no result and no error", () => { + const { result } = setup(); + expect(result.current).toMatchObject({ + isLoading: false, + result: null, + error: null, + hasRun: false, + }); + }); + + it("captures a successful run with a measured renderTimeMs and the HTTP status", async () => { + vi.mocked(resourcesApi.test).mockResolvedValue({ + content: { mimeType: "text/plain", text: "hello" }, + status: 200, + }); + const { result } = setup("file:///a.txt"); + + await act(async () => { + await result.current.run(); + }); + + expect(resourcesApi.test).toHaveBeenCalledWith( + "file:///a.txt", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(result.current.result).not.toBeNull(); + expect(result.current.result?.content).toEqual({ mimeType: "text/plain", text: "hello" }); + expect(result.current.result?.renderTimeMs).toBeGreaterThanOrEqual(0); + expect(result.current.result?.status).toBe(200); + expect(result.current.hasRun).toBe(true); + expect(result.current.error).toBeNull(); + }); + + it("unwraps ApiError.detail into a readable failure message, captures the status, and toasts", async () => { + const { toast } = await import("sonner"); + vi.mocked(resourcesApi.test).mockRejectedValue( + new ApiError(404, { detail: "Resource not found" }, "Not Found"), + ); + const { result } = setup(); + + await act(async () => { + await result.current.run(); + }); + + expect(result.current.error?.message).toBe("Resource not found"); + expect(result.current.error?.status).toBe(404); + expect(result.current.result).toBeNull(); + expect(result.current.hasRun).toBe(true); + expect(toast.error).toHaveBeenCalledTimes(1); + }); + + it("leaves error.status as null for non-Api errors (e.g. network failures)", async () => { + vi.mocked(resourcesApi.test).mockRejectedValue(new Error("network offline")); + const { result } = setup(); + + await act(async () => { + await result.current.run(); + }); + + expect(result.current.error?.message).toBe("network offline"); + expect(result.current.error?.status).toBeNull(); + }); + + it("falls back to a generic message when a non-Error value is thrown", async () => { + vi.mocked(resourcesApi.test).mockRejectedValue("just a string"); + const { result } = setup(); + + await act(async () => { + await result.current.run(); + }); + + expect(result.current.error?.message).toBe("Unknown error"); + expect(result.current.error?.status).toBeNull(); + }); + + it("swallows a rejection from an aborted request instead of setting error state", async () => { + let reject: ((err: unknown) => void) | undefined; + let capturedSignal: AbortSignal | undefined; + vi.mocked(resourcesApi.test).mockImplementation((_uri, opts) => { + capturedSignal = opts?.signal; + return new Promise((_res, rej) => { + reject = rej; + }); + }); + const { toast } = await import("sonner"); + const { result } = setup(); + + act(() => { + void result.current.run(); + }); + await waitFor(() => expect(result.current.isLoading).toBe(true)); + + act(() => { + result.current.reset(); // aborts the in-flight request + }); + expect(capturedSignal?.aborted).toBe(true); + + // The underlying fetch settles with an AbortError after the abort — the + // stale rejection must not repopulate error state or toast. + reject?.(new DOMException("The operation was aborted", "AbortError")); + await new Promise((r) => setTimeout(r, 0)); + + expect(result.current.error).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("clears result and error when uri changes", async () => { + vi.mocked(resourcesApi.test).mockResolvedValue({ + content: { mimeType: "text/plain", text: "hello" }, + status: 200, + }); + const { result, rerender } = renderHook(({ uri }: { uri: string }) => useResourcePreview(uri), { + initialProps: { uri: "file:///a.txt" }, + wrapper: ({ children }) => {children}, + }); + + await act(async () => { + await result.current.run(); + }); + expect(result.current.hasRun).toBe(true); + + rerender({ uri: "file:///b.txt" }); + expect(result.current.result).toBeNull(); + expect(result.current.error).toBeNull(); + expect(result.current.hasRun).toBe(false); + }); + + it("aborts an in-flight preview when the hook unmounts and drops the late resolution", async () => { + let resolve: ((v: { content: { text: string }; status: number }) => void) | undefined; + let capturedSignal: AbortSignal | undefined; + vi.mocked(resourcesApi.test).mockImplementation((_uri, opts) => { + capturedSignal = opts?.signal; + return new Promise((r) => { + resolve = r; + }); + }); + const { toast } = await import("sonner"); + const { result, unmount } = setup(); + + act(() => { + void result.current.run(); + }); + await waitFor(() => expect(result.current.isLoading).toBe(true)); + expect(capturedSignal?.aborted).toBe(false); + + unmount(); + expect(capturedSignal?.aborted).toBe(true); + + // Late resolution after unmount must not toast or otherwise surface. + resolve?.({ content: { text: "hello" }, status: 200 }); + await new Promise((r) => setTimeout(r, 0)); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("aborts the previous request when run() is called again before it settles", async () => { + const signals: AbortSignal[] = []; + vi.mocked(resourcesApi.test).mockImplementation( + (_uri, opts) => + new Promise((r) => { + if (opts?.signal) signals.push(opts.signal); + setTimeout(() => r({ content: { text: "hello" }, status: 200 }), 0); + }), + ); + const { result } = setup(); + + act(() => { + void result.current.run(); + }); + await waitFor(() => expect(signals).toHaveLength(1)); + + act(() => { + void result.current.run(); + }); + await waitFor(() => expect(signals).toHaveLength(2)); + + expect(signals[0].aborted).toBe(true); + expect(signals[1].aborted).toBe(false); + }); + + it("flips isLoading while the request is in flight", async () => { + let resolve: ((value: { content: { text: string }; status: number }) => void) | undefined; + vi.mocked(resourcesApi.test).mockReturnValue( + new Promise((r) => { + resolve = r; + }), + ); + const { result } = setup(); + + let pending: Promise; + act(() => { + pending = result.current.run(); + }); + await waitFor(() => expect(result.current.isLoading).toBe(true)); + resolve?.({ content: { text: "hello" }, status: 200 }); + await act(async () => { + await pending!; + }); + expect(result.current.isLoading).toBe(false); + }); + + it("reset() clears a completed run so hasRun returns to false", async () => { + vi.mocked(resourcesApi.test).mockResolvedValue({ + content: { mimeType: "text/plain", text: "hello" }, + status: 200, + }); + const { result } = setup(); + + await act(async () => { + await result.current.run(); + }); + expect(result.current.hasRun).toBe(true); + + act(() => { + result.current.reset(); + }); + expect(result.current.result).toBeNull(); + expect(result.current.error).toBeNull(); + expect(result.current.hasRun).toBe(false); + expect(result.current.isLoading).toBe(false); + }); + + it("reset() clears a captured error", async () => { + vi.mocked(resourcesApi.test).mockRejectedValue(new Error("boom")); + const { result } = setup(); + + await act(async () => { + await result.current.run(); + }); + expect(result.current.error).not.toBeNull(); + + act(() => { + result.current.reset(); + }); + expect(result.current.error).toBeNull(); + expect(result.current.hasRun).toBe(false); + }); + + it("reset() aborts an in-flight request and clears isLoading", async () => { + let capturedSignal: AbortSignal | undefined; + let resolve: ((v: { content: { text: string }; status: number }) => void) | undefined; + vi.mocked(resourcesApi.test).mockImplementation((_uri, opts) => { + capturedSignal = opts?.signal; + return new Promise((r) => { + resolve = r; + }); + }); + const { result } = setup(); + + act(() => { + void result.current.run(); + }); + await waitFor(() => expect(result.current.isLoading).toBe(true)); + expect(capturedSignal?.aborted).toBe(false); + + act(() => { + result.current.reset(); + }); + expect(capturedSignal?.aborted).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(result.current.result).toBeNull(); + expect(result.current.error).toBeNull(); + + // Late resolution after reset must not repopulate state. + resolve?.({ content: { text: "hello" }, status: 200 }); + await new Promise((r) => setTimeout(r, 0)); + expect(result.current.result).toBeNull(); + expect(result.current.hasRun).toBe(false); + }); +}); diff --git a/src/components/resources/useResourcePreview.ts b/src/components/resources/useResourcePreview.ts new file mode 100644 index 0000000..07ca2e4 --- /dev/null +++ b/src/components/resources/useResourcePreview.ts @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useIntl } from "react-intl"; +import { toast } from "sonner"; + +import { ApiError } from "@/api/client"; +import { resourcesApi, type ResourceTestContent } from "@/api/resources"; +import { parseApiError } from "@/lib/errorUtils"; + +export interface ResourcePreviewSuccess { + content: ResourceTestContent; + renderTimeMs: number; + status: number; +} + +export interface ResourcePreviewFailure { + message: string; + renderTimeMs: number; + status: number | null; +} + +export interface ResourcePreviewState { + run: () => Promise; + reset: () => void; + isLoading: boolean; + result: ResourcePreviewSuccess | null; + error: ResourcePreviewFailure | null; + hasRun: boolean; +} + +/** + * Owns the render-only Preview lifecycle for a resource — same abort / + * timing / error shape as {@link usePromptPreview}, adapted to the resource + * test endpoint (`GET /v1/resources/test/{uri}`) instead of the + * prompt render endpoint. + * + * `uri` is the *concrete* URI — the caller resolves any `{placeholder}` + * template variables (see `parseUriTemplate.resolveUriTemplate`) before + * passing it in. + */ +export function useResourcePreview(uri: string): ResourcePreviewState { + const intl = useIntl(); + const [isLoading, setLoading] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + // Clear stale result/error when the caller switches to a different resource. + useEffect(() => { + setResult(null); + setError(null); + }, [uri]); + + // Abort any in-flight preview when the hook unmounts (the host component + // is keyed by resource id, so this also fires on resource switch). + useEffect(() => { + return () => { + abortRef.current?.abort(); + }; + }, []); + + const reset = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + setResult(null); + setError(null); + setLoading(false); + }, []); + + const run = useCallback(async () => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setLoading(true); + setError(null); + const startedAt = performance.now(); + try { + const { content, status } = await resourcesApi.test(uri, { signal: controller.signal }); + if (controller.signal.aborted) return; + const renderTimeMs = Math.round(performance.now() - startedAt); + setResult({ content, renderTimeMs, status }); + } catch (err) { + if (controller.signal.aborted) return; + const renderTimeMs = Math.round(performance.now() - startedAt); + const status = err instanceof ApiError ? err.status : null; + const message = parseApiError(err, err instanceof Error ? err.message : "Unknown error"); + setError({ message, renderTimeMs, status }); + setResult(null); + toast.error(intl.formatMessage({ id: "resources.details.preview.error" })); + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + }, [uri, intl]); + + return { + run, + reset, + isLoading, + result, + error, + hasRun: result !== null || error !== null, + }; +} diff --git a/src/components/tools/ToolsTable.test.tsx b/src/components/tools/ToolsTable.test.tsx index c0f107a..8d951ed 100644 --- a/src/components/tools/ToolsTable.test.tsx +++ b/src/components/tools/ToolsTable.test.tsx @@ -310,19 +310,21 @@ describe("ToolsTable", () => { expect(rows.length).toBeGreaterThan(1); // Header row + data rows }); - it("handles very long tool names with line-clamp", () => { - const tools = [ - createMockTool(1, { - displayName: "This is a very long tool name that should be clamped to one line", - }), - ]; + it("truncates a very long tool name to a single line instead of overflowing the table", () => { + const longName = + "This is a very long tool name that should be truncated to one line, not wrapped or overflowed"; + const tools = [createMockTool(1, { displayName: longName })]; render(); - const displayName = screen.getByText( - "This is a very long tool name that should be clamped to one line", - ); + const displayName = screen.getByText(longName); const span = displayName.closest("span"); - expect(span).toHaveClass("line-clamp-1"); + expect(span).toHaveClass("truncate"); + expect(span).toHaveAttribute("title", longName); + + // table-fixed + a percentage column width is what actually stops an + // unbreakable long name from forcing the whole table to scroll. + const table = screen.getByRole("table"); + expect(table).toHaveClass("table-fixed"); }); describe("delete dropdown (onDeleteTool provided)", () => { diff --git a/src/components/tools/ToolsTable.tsx b/src/components/tools/ToolsTable.tsx index adb6115..996c6a8 100644 --- a/src/components/tools/ToolsTable.tsx +++ b/src/components/tools/ToolsTable.tsx @@ -47,13 +47,13 @@ export function ToolsTable({ return ( <> -
+
- + {intl.formatMessage({ id: "tools.table.tool" })} - + {intl.formatMessage({ id: "tools.table.name" })} @@ -81,7 +81,12 @@ export function ToolsTable({ className="cursor-pointer border-0 bg-neutral-50 hover:bg-neutral-100 data-[state=selected]:bg-neutral-200 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset dark:bg-neutral-800/50 dark:hover:bg-neutral-700/60 dark:data-[state=selected]:bg-neutral-700 [&>td:first-child]:rounded-l-lg [&>td:last-child]:rounded-r-lg" > - {tool.displayName || tool.title || tool.name} + + {tool.displayName || tool.title || tool.name} + diff --git a/src/components/ui/code-block.tsx b/src/components/ui/code-block.tsx index ad51c4e..190687f 100644 --- a/src/components/ui/code-block.tsx +++ b/src/components/ui/code-block.tsx @@ -9,7 +9,7 @@ import { cn } from "@/lib/utils"; const COPY_FEEDBACK_DURATION_MS = 1500; -export type CodeBlockLanguage = "bash" | "json" | "python" | "tsx"; +export type CodeBlockLanguage = "bash" | "json" | "python" | "tsx" | "markdown" | "xml" | "text"; export interface CodeBlockProps { code: string; @@ -34,6 +34,11 @@ const TOKEN_LANGUAGE: Record = { json: "json", python: "python", tsx: "tsx", + markdown: "markdown", + xml: "xml", + // No "text" Prism grammar is registered — falls back to unhighlighted + // tokens, same as "bash" does today, which is what raw text content wants. + text: "text", }; /** diff --git a/src/i18n/locales/en-US/resources.json b/src/i18n/locales/en-US/resources.json index b5d69b9..1a7876f 100644 --- a/src/i18n/locales/en-US/resources.json +++ b/src/i18n/locales/en-US/resources.json @@ -73,6 +73,30 @@ "resources.details.componentDetails": "Component details", "resources.details.activity": "Activity", "resources.details.notAvailable": "Not available", + "resources.details.tab.tryIt": "Try it", + "resources.details.tab.definition": "Definition", + "resources.details.resourcePreview": "Resource preview", + "resources.details.selectResource": "Select resource", + "resources.details.code.args.heading": "Arguments", + "resources.details.code.args.required": "Required", + "resources.details.code.tab.curl": "curl", + "resources.details.code.tab.jsonRpc": "JSON-RPC", + "resources.details.code.tab.python": "Python", + "resources.details.code.tab.typescript": "TypeScript", + "resources.details.code.copyAriaLabel": "Copy {language} snippet", + "resources.details.code.copySuccess": "Copied!", + "resources.details.preview.run": "Preview", + "resources.details.preview.rerun": "Re-run", + "resources.details.preview.running": "Fetching...", + "resources.details.preview.renderMs": "Render {ms} ms", + "resources.details.preview.statusOk": "{status} OK", + "resources.details.preview.statusError": "Fetch failed", + "resources.details.preview.statusErrorWithCode": "{status} — fetch failed", + "resources.details.preview.error": "Failed to fetch resource content", + "resources.details.preview.collapsed": "{mimeType} · {size} — content is large and hidden by default", + "resources.details.preview.viewAll": "View all", + "resources.details.preview.openInNewTab": "Open in new tab", + "resources.details.preview.downloadRaw": "Download raw", "resources.details.label.status": "Status", "resources.details.label.visibility": "Visibility", "resources.details.label.type": "Type", diff --git a/src/i18n/locales/es-ES/resources.json b/src/i18n/locales/es-ES/resources.json index 49aa673..36b1d57 100644 --- a/src/i18n/locales/es-ES/resources.json +++ b/src/i18n/locales/es-ES/resources.json @@ -73,6 +73,30 @@ "resources.details.componentDetails": "Detalles del componente", "resources.details.activity": "Actividad", "resources.details.notAvailable": "No disponible", + "resources.details.tab.tryIt": "Probar", + "resources.details.tab.definition": "Definición", + "resources.details.resourcePreview": "Vista previa del recurso", + "resources.details.selectResource": "Seleccionar recurso", + "resources.details.code.args.heading": "Argumentos", + "resources.details.code.args.required": "Requerido", + "resources.details.code.tab.curl": "curl", + "resources.details.code.tab.jsonRpc": "JSON-RPC", + "resources.details.code.tab.python": "Python", + "resources.details.code.tab.typescript": "TypeScript", + "resources.details.code.copyAriaLabel": "Copiar fragmento de {language}", + "resources.details.code.copySuccess": "¡Copiado!", + "resources.details.preview.run": "Vista previa", + "resources.details.preview.rerun": "Volver a ejecutar", + "resources.details.preview.running": "Obteniendo...", + "resources.details.preview.renderMs": "Render {ms} ms", + "resources.details.preview.statusOk": "{status} OK", + "resources.details.preview.statusError": "Error al obtener", + "resources.details.preview.statusErrorWithCode": "{status} — error al obtener", + "resources.details.preview.error": "Error al obtener el contenido del recurso", + "resources.details.preview.collapsed": "{mimeType} · {size} — el contenido es grande y está oculto de forma predeterminada", + "resources.details.preview.viewAll": "Ver todo", + "resources.details.preview.openInNewTab": "Abrir en una pestaña nueva", + "resources.details.preview.downloadRaw": "Descargar sin procesar", "resources.details.label.status": "Estado", "resources.details.label.visibility": "Visibilidad", "resources.details.label.type": "Tipo", diff --git a/src/i18n/locales/pt-BR/resources.json b/src/i18n/locales/pt-BR/resources.json index c869367..0a654bc 100644 --- a/src/i18n/locales/pt-BR/resources.json +++ b/src/i18n/locales/pt-BR/resources.json @@ -73,6 +73,30 @@ "resources.details.componentDetails": "Detalhes do componente", "resources.details.activity": "Atividade", "resources.details.notAvailable": "Não disponível", + "resources.details.tab.tryIt": "Testar", + "resources.details.tab.definition": "Definição", + "resources.details.resourcePreview": "Pré-visualização do recurso", + "resources.details.selectResource": "Selecionar recurso", + "resources.details.code.args.heading": "Argumentos", + "resources.details.code.args.required": "Obrigatório", + "resources.details.code.tab.curl": "curl", + "resources.details.code.tab.jsonRpc": "JSON-RPC", + "resources.details.code.tab.python": "Python", + "resources.details.code.tab.typescript": "TypeScript", + "resources.details.code.copyAriaLabel": "Copiar trecho de {language}", + "resources.details.code.copySuccess": "Copiado!", + "resources.details.preview.run": "Pré-visualizar", + "resources.details.preview.rerun": "Executar novamente", + "resources.details.preview.running": "Buscando...", + "resources.details.preview.renderMs": "Render {ms} ms", + "resources.details.preview.statusOk": "{status} OK", + "resources.details.preview.statusError": "Falha ao buscar", + "resources.details.preview.statusErrorWithCode": "{status} — falha ao buscar", + "resources.details.preview.error": "Falha ao buscar o conteúdo do recurso", + "resources.details.preview.collapsed": "{mimeType} · {size} — o conteúdo é grande e está oculto por padrão", + "resources.details.preview.viewAll": "Ver tudo", + "resources.details.preview.openInNewTab": "Abrir em nova aba", + "resources.details.preview.downloadRaw": "Baixar bruto", "resources.details.label.status": "Status", "resources.details.label.visibility": "Visibilidade", "resources.details.label.type": "Tipo", diff --git a/src/pages/Resources.test.tsx b/src/pages/Resources.test.tsx index b69ac42..95c1477 100644 --- a/src/pages/Resources.test.tsx +++ b/src/pages/Resources.test.tsx @@ -658,6 +658,10 @@ describe("Resources", () => { ).toBeInTheDocument(); }); + // Row actions (Edit/Delete) live in the Definition tab; the panel + // opens on Try it by default. + await user.click(screen.getByRole("tab", { name: "Definition" })); + await user.click(screen.getByLabelText("More options for Resource 1")); await user.click(await screen.findByText("Edit")); @@ -736,6 +740,7 @@ describe("Resources", () => { await waitFor(() => expect(screen.getByText("test-gateway")).toBeInTheDocument()); await user.click(screen.getByLabelText("More options for test-gateway")); await user.click(await screen.findByText("View details")); + await user.click(screen.getByRole("tab", { name: "Definition" })); await user.click(await screen.findByLabelText("More options for Resource 1")); await user.click(await screen.findByText("Edit")); @@ -756,6 +761,7 @@ describe("Resources", () => { await waitFor(() => expect(screen.getByText("test-gateway")).toBeInTheDocument()); await user.click(screen.getByLabelText("More options for test-gateway")); await user.click(await screen.findByText("View details")); + await user.click(screen.getByRole("tab", { name: "Definition" })); await user.click(await screen.findByLabelText("More options for Resource 1")); await user.click(await screen.findByText("Edit")); @@ -894,6 +900,9 @@ describe("Resources", () => { screen.getByRole("region", { name: new RegExp(`Resources for ${gatewaySlug}`, "i") }), ).toBeInTheDocument(), ); + // Row actions (Edit/Delete) live in the Definition tab; the panel + // opens on Try it by default. + await user.click(screen.getByRole("tab", { name: "Definition" })); return { user }; } @@ -931,6 +940,7 @@ describe("Resources", () => { screen.getByRole("region", { name: /Resources for opt-gateway/i }), ).toBeInTheDocument(), ); + await user.click(screen.getByRole("tab", { name: "Definition" })); await user.click(screen.getByLabelText("More options for Resource 1")); await user.click(await screen.findByText("Delete")); @@ -967,6 +977,7 @@ describe("Resources", () => { screen.getByRole("region", { name: /Resources for rollback-gateway/i }), ).toBeInTheDocument(), ); + await user.click(screen.getByRole("tab", { name: "Definition" })); await user.click(screen.getByLabelText("More options for Resource 1")); await user.click(await screen.findByText("Delete")); From 6dba7b07b2c2d149a7fefdb7f3db57bf3d97450e Mon Sep 17 00:00:00 2001 From: Marek Dano Date: Fri, 21 Aug 2026 08:34:57 +0100 Subject: [PATCH 2/2] fix: address pr review feedback on resource preview Signed-off-by: Marek Dano --- .../resources/ResourceDetailsPanel.test.tsx | 35 +++++++++++++++- .../resources/ResourceDetailsPanel.tsx | 26 ++++++++++-- .../resources/ResourcePreviewResult.test.tsx | 7 ++-- .../resources/ResourcePreviewResult.tsx | 5 ++- .../resources/useResourcePreview.test.tsx | 17 ++++++++ .../resources/useResourcePreview.ts | 42 +++++++++++++++---- src/i18n/locales/en-US/resources.json | 1 + src/i18n/locales/es-ES/resources.json | 1 + src/i18n/locales/pt-BR/resources.json | 1 + 9 files changed, 119 insertions(+), 16 deletions(-) diff --git a/src/components/resources/ResourceDetailsPanel.test.tsx b/src/components/resources/ResourceDetailsPanel.test.tsx index 63096f3..c246598 100644 --- a/src/components/resources/ResourceDetailsPanel.test.tsx +++ b/src/components/resources/ResourceDetailsPanel.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders as render } from "@/test/test-utils"; import { ResourceDetailsPanel } from "./ResourceDetailsPanel"; @@ -100,4 +100,37 @@ describe("ResourceDetailsPanel tabs", () => { await user.click(screen.getByRole("tab", { name: "Try it" })); expect(screen.getByRole("button", { name: "b.txt", pressed: true })).toBeInTheDocument(); }); + + it("moves focus into the newly active panel when a tab is clicked", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("tab", { name: "Definition" })); + + await waitFor(() => { + expect(document.activeElement).toBe(screen.getByRole("tabpanel", { name: "Definition" })); + }); + }); + + it("leaves focus on the close button when the panel first opens (no tab-change yet)", () => { + render( + , + ); + + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Close resource details" }), + ); + }); }); diff --git a/src/components/resources/ResourceDetailsPanel.tsx b/src/components/resources/ResourceDetailsPanel.tsx index 524eede..73a55cb 100644 --- a/src/components/resources/ResourceDetailsPanel.tsx +++ b/src/components/resources/ResourceDetailsPanel.tsx @@ -76,6 +76,8 @@ export function ResourceDetailsPanel({ const [activeTab, setActiveTab] = useState(initialTab); const closeButtonRef = useRef(null); const previousFocusRef = useRef(null); + const tryItContentRef = useRef(null); + const definitionContentRef = useRef(null); const headingId = useMemo(() => `resource-details-heading-${gatewaySlug}`, [gatewaySlug]); // Manage selected resource state: select first on open, reset on close, and @@ -105,6 +107,19 @@ export function ResourceDetailsPanel({ if (open) setActiveTab(initialTab); }, [open, initialTab]); + // Fires only on a genuine trigger click/keypress (never on the + // open/initialTab reset above, which sets `activeTab` directly) — moves + // focus into the newly active panel so keyboard/screen-reader users + // aren't left on a trigger that now points at different content. + const handleTabChange = useCallback((value: string) => { + setActiveTab(value); + requestAnimationFrame(() => { + const target = + value === "definition" ? definitionContentRef.current : tryItContentRef.current; + target?.focus(); + }); + }, []); + const selectedResource = useMemo( () => resources.find((r) => r.id === selectedResourceId) ?? null, [resources, selectedResourceId], @@ -187,7 +202,7 @@ export function ResourceDetailsPanel({ - + {intl.formatMessage({ id: "resources.details.tab.tryIt" })} @@ -197,7 +212,7 @@ export function ResourceDetailsPanel({ - + - + { expect(document.querySelector("pre")?.textContent).toBe('{"a":1}'); }); - it("renders an inline image for image/* content", () => { + it("renders an inline image for image/* content with descriptive alt text", () => { render( { })} />, ); - // Decorative (empty-alt) images resolve to the "presentation" role, not - // "img" — query the element directly. - const img = document.querySelector("img"); + const img = screen.getByRole("img"); expect(img).toHaveAttribute("src", "data:image/png;base64,Zm9v"); + expect(img).not.toHaveAttribute("alt", ""); }); it("renders MIME + size + a download link for an unknown binary type, never as text", () => { diff --git a/src/components/resources/ResourcePreviewResult.tsx b/src/components/resources/ResourcePreviewResult.tsx index 8e39533..03fa805 100644 --- a/src/components/resources/ResourcePreviewResult.tsx +++ b/src/components/resources/ResourcePreviewResult.tsx @@ -176,7 +176,10 @@ function ResourceContentPreview({
diff --git a/src/components/resources/useResourcePreview.test.tsx b/src/components/resources/useResourcePreview.test.tsx index 194e48c..f8c4ba5 100644 --- a/src/components/resources/useResourcePreview.test.tsx +++ b/src/components/resources/useResourcePreview.test.tsx @@ -262,6 +262,23 @@ describe("useResourcePreview", () => { expect(result.current.hasRun).toBe(false); }); + it("collapses rapid repeated run() calls into a single request", async () => { + vi.mocked(resourcesApi.test).mockResolvedValue({ + content: { mimeType: "text/plain", text: "hello" }, + status: 200, + }); + const { result } = setup(); + + act(() => { + void result.current.run(); + void result.current.run(); + void result.current.run(); + }); + await waitFor(() => expect(result.current.hasRun).toBe(true)); + + expect(resourcesApi.test).toHaveBeenCalledTimes(1); + }); + it("reset() aborts an in-flight request and clears isLoading", async () => { let capturedSignal: AbortSignal | undefined; let resolve: ((v: { content: { text: string }; status: number }) => void) | undefined; diff --git a/src/components/resources/useResourcePreview.ts b/src/components/resources/useResourcePreview.ts index 07ca2e4..a167aa2 100644 --- a/src/components/resources/useResourcePreview.ts +++ b/src/components/resources/useResourcePreview.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useIntl } from "react-intl"; import { toast } from "sonner"; @@ -6,6 +6,11 @@ import { ApiError } from "@/api/client"; import { resourcesApi, type ResourceTestContent } from "@/api/resources"; import { parseApiError } from "@/lib/errorUtils"; +// Rapid repeated triggers (double-clicking Preview, mashing Enter on the +// focused button) collapse into a single fetch instead of one request per +// click — trailing-edge debounce, so only the last call in a burst runs. +const RUN_DEBOUNCE_MS = 300; + export interface ResourcePreviewSuccess { content: ResourceTestContent; renderTimeMs: number; @@ -43,30 +48,42 @@ export function useResourcePreview(uri: string): ResourcePreviewState { const [result, setResult] = useState(null); const [error, setError] = useState(null); const abortRef = useRef(null); + const debounceTimerRef = useRef | null>(null); + + const clearDebounce = useCallback(() => { + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + }, []); // Clear stale result/error when the caller switches to a different resource. useEffect(() => { setResult(null); setError(null); - }, [uri]); + clearDebounce(); + }, [uri, clearDebounce]); - // Abort any in-flight preview when the hook unmounts (the host component - // is keyed by resource id, so this also fires on resource switch). + // Abort any in-flight preview / pending debounce when the hook unmounts + // (the host component is keyed by resource id, so this also fires on + // resource switch). useEffect(() => { return () => { + clearDebounce(); abortRef.current?.abort(); }; - }, []); + }, [clearDebounce]); const reset = useCallback(() => { + clearDebounce(); abortRef.current?.abort(); abortRef.current = null; setResult(null); setError(null); setLoading(false); - }, []); + }, [clearDebounce]); - const run = useCallback(async () => { + const executeRun = useCallback(async () => { abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; @@ -94,6 +111,17 @@ export function useResourcePreview(uri: string): ResourcePreviewState { } }, [uri, intl]); + const run = useMemo(() => { + return () => + new Promise((resolve) => { + clearDebounce(); + debounceTimerRef.current = setTimeout(() => { + debounceTimerRef.current = null; + resolve(executeRun()); + }, RUN_DEBOUNCE_MS); + }); + }, [executeRun, clearDebounce]); + return { run, reset, diff --git a/src/i18n/locales/en-US/resources.json b/src/i18n/locales/en-US/resources.json index 1a7876f..e8b431a 100644 --- a/src/i18n/locales/en-US/resources.json +++ b/src/i18n/locales/en-US/resources.json @@ -97,6 +97,7 @@ "resources.details.preview.viewAll": "View all", "resources.details.preview.openInNewTab": "Open in new tab", "resources.details.preview.downloadRaw": "Download raw", + "resources.details.preview.imageAlt": "Previewed {mimeType} image, {size}", "resources.details.label.status": "Status", "resources.details.label.visibility": "Visibility", "resources.details.label.type": "Type", diff --git a/src/i18n/locales/es-ES/resources.json b/src/i18n/locales/es-ES/resources.json index 36b1d57..e404905 100644 --- a/src/i18n/locales/es-ES/resources.json +++ b/src/i18n/locales/es-ES/resources.json @@ -97,6 +97,7 @@ "resources.details.preview.viewAll": "Ver todo", "resources.details.preview.openInNewTab": "Abrir en una pestaña nueva", "resources.details.preview.downloadRaw": "Descargar sin procesar", + "resources.details.preview.imageAlt": "Imagen {mimeType} obtenida en la vista previa, {size}", "resources.details.label.status": "Estado", "resources.details.label.visibility": "Visibilidad", "resources.details.label.type": "Tipo", diff --git a/src/i18n/locales/pt-BR/resources.json b/src/i18n/locales/pt-BR/resources.json index 0a654bc..875e9e8 100644 --- a/src/i18n/locales/pt-BR/resources.json +++ b/src/i18n/locales/pt-BR/resources.json @@ -97,6 +97,7 @@ "resources.details.preview.viewAll": "Ver tudo", "resources.details.preview.openInNewTab": "Abrir em nova aba", "resources.details.preview.downloadRaw": "Baixar bruto", + "resources.details.preview.imageAlt": "Imagem {mimeType} da pré-visualização, {size}", "resources.details.label.status": "Status", "resources.details.label.visibility": "Visibilidade", "resources.details.label.type": "Tipo",