From dec90ff447bf4a92633ef5ef63f57bea1975e569 Mon Sep 17 00:00:00 2001 From: Pratik Gandhi Date: Thu, 20 Aug 2026 11:27:03 +0100 Subject: [PATCH 1/4] feat: add mocked tool preview tab Signed-off-by: Pratik Gandhi --- e2e/tools.spec.ts | 123 +++- src/api/tools.test.ts | 59 ++ src/api/tools.ts | 71 +++ .../tools/ToolArgumentsForm.test.tsx | 116 ++++ src/components/tools/ToolArgumentsForm.tsx | 553 ++++++++++++++++++ .../tools/ToolDetailsPanel.test.tsx | 34 +- src/components/tools/ToolDetailsPanel.tsx | 53 +- .../tools/ToolHeadersEditor.test.tsx | 61 ++ src/components/tools/ToolHeadersEditor.tsx | 184 ++++++ src/components/tools/ToolPreviewButton.tsx | 39 ++ .../tools/ToolPreviewResult.test.tsx | 65 ++ src/components/tools/ToolPreviewResult.tsx | 125 ++++ src/components/tools/ToolTryItTab.tsx | 115 ++++ src/components/tools/toolAnnotations.test.ts | 52 ++ src/components/tools/toolAnnotations.ts | 32 + src/hooks/useToolPreview.test.tsx | 93 +++ src/hooks/useToolPreview.ts | 98 ++++ src/i18n/locales/en-US/tools.json | 43 ++ src/i18n/locales/es-ES/tools.json | 43 ++ src/i18n/locales/pt-BR/tools.json | 43 ++ src/pages/Tools.test.tsx | 10 + 21 files changed, 1996 insertions(+), 16 deletions(-) create mode 100644 src/components/tools/ToolArgumentsForm.test.tsx create mode 100644 src/components/tools/ToolArgumentsForm.tsx create mode 100644 src/components/tools/ToolHeadersEditor.test.tsx create mode 100644 src/components/tools/ToolHeadersEditor.tsx create mode 100644 src/components/tools/ToolPreviewButton.tsx create mode 100644 src/components/tools/ToolPreviewResult.test.tsx create mode 100644 src/components/tools/ToolPreviewResult.tsx create mode 100644 src/components/tools/ToolTryItTab.tsx create mode 100644 src/components/tools/toolAnnotations.test.ts create mode 100644 src/components/tools/toolAnnotations.ts create mode 100644 src/hooks/useToolPreview.test.tsx create mode 100644 src/hooks/useToolPreview.ts diff --git a/e2e/tools.spec.ts b/e2e/tools.spec.ts index 010286b..727fe5a 100644 --- a/e2e/tools.spec.ts +++ b/e2e/tools.spec.ts @@ -1,4 +1,4 @@ -import type { Page } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import { test, expect } from "./fixtures/api-mock"; import { APP } from "./utils/paths"; import type { Tool } from "../src/types/tool"; @@ -28,6 +28,10 @@ async function routeToolsList(page: Page, tools: Tool[]) { }); } +async function showDefinitionTab(panel: Locator) { + await panel.getByRole("tab", { name: "Definition" }).click(); +} + async function openAddToolForm(page: Page) { await page.getByText("Add tools").click(); await expect(page.getByRole("heading", { name: "Add tool" })).toBeVisible(); @@ -285,6 +289,111 @@ test.describe("Tools page", () => { await expect(panel.getByText("create_issue").first()).toBeVisible(); }); + test("previews a tool with schema arguments and allowed passthrough headers", async ({ + page, + }) => { + const previewTool = makeTool("search_issues", "github-server", { + description: "Search repository issues", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string", description: "Search query" }, + limit: { type: "integer" }, + }, + }, + annotations: { readOnlyHint: true }, + }); + let previewBody: unknown = null; + let previewHeaders: Record = {}; + + await routeToolsList(page, [previewTool]); + await page.route("**/tools/preview/search_issues", async (route) => { + previewBody = route.request().postDataJSON(); + previewHeaders = route.request().headers(); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + target: { kind: "local" }, + resolved_arguments: { query: "cloudflare", limit: 5 }, + annotations: { readOnlyHint: true }, + pre_hooks_run: [], + warnings: [], + }), + }); + }); + + await page.goto(APP.TOOLS); + 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: /Tools for github-server/i }); + await expect(panel.getByText("Tool preview")).toBeVisible(); + await expect(panel.getByText("Read-only")).toBeVisible(); + + const previewButton = panel.getByRole("button", { name: "Preview" }); + await expect(previewButton).toBeDisabled(); + + await panel.getByLabel("query").fill("cloudflare"); + await panel.getByLabel("limit").fill("5"); + await panel.getByRole("button", { name: "Add header" }).click(); + await panel.getByLabel("Header 1 name").fill("X-Tenant-Id"); + await panel.getByLabel("Header 1 value").fill("team-a"); + await expect(previewButton).toBeEnabled(); + await previewButton.click(); + + await expect(panel.getByText("Preview 200")).toBeVisible(); + await expect(panel.getByText("Resolved arguments")).toBeVisible(); + expect(previewBody).toEqual({ arguments: { query: "cloudflare", limit: 5 } }); + expect(previewHeaders["x-tenant-id"]).toBe("team-a"); + }); + + test("warns for denied passthrough headers and excludes them from preview", async ({ page }) => { + const previewTool = makeTool("search_issues", "github-server", { + inputSchema: { + type: "object", + required: ["query"], + properties: { query: { type: "string" } }, + }, + }); + let previewHeaders: Record = {}; + + await routeToolsList(page, [previewTool]); + await page.route("**/tools/preview/search_issues", async (route) => { + previewHeaders = route.request().headers(); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ target: "local", resolved_arguments: { query: "cloudflare" } }), + }); + }); + + await page.goto(APP.TOOLS); + 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: /Tools for github-server/i }); + await panel.getByLabel("query").fill("cloudflare"); + await panel.getByRole("button", { name: "Add header" }).click(); + await panel.getByLabel("Header 1 name").fill("Authorization"); + await panel.getByLabel("Header 1 value").fill("Bearer typed-token"); + + await expect(panel.getByText("This header is not forwardable from the web UI.")).toBeVisible(); + await expect(panel.getByRole("button", { name: "Preview" })).toBeDisabled(); + + await panel.getByLabel("Header 1 name").fill("X-Api-Key"); + await panel.getByRole("button", { name: "Preview" }).click(); + + await expect(panel.getByText("Preview 200")).toBeVisible(); + expect(previewHeaders.authorization).toBeUndefined(); + expect(previewHeaders["x-api-key"]).toBe("Bearer typed-token"); + }); + test("closes details panel via close button", async ({ page }) => { await page.route("**/tools?*", async (route) => { await route.fulfill({ @@ -339,6 +448,7 @@ test.describe("Tools page", () => { await expect(panel).toBeVisible(); await expect(panel.getByText("get_issues").first()).toBeVisible(); + await showDefinitionTab(panel); await panel.getByRole("button", { name: "More options" }).first().click(); await page.getByRole("menuitem", { name: "Delete" }).click(); @@ -388,6 +498,7 @@ test.describe("Tools page", () => { const panel = page.getByRole("region", { name: /Tools for github-server/i }); await expect(panel).toBeVisible(); + await showDefinitionTab(panel); await panel.getByRole("button", { name: "More options" }).first().click(); await page.getByRole("menuitem", { name: "Delete" }).click(); @@ -420,6 +531,7 @@ test.describe("Tools page", () => { await page.getByRole("menuitem", { name: "View details" }).click(); const panel = page.getByRole("region", { name: /Tools for github-server/i }); + await showDefinitionTab(panel); await panel.getByRole("button", { name: "More options" }).first().click(); await page.getByRole("menuitem", { name: "Delete" }).click(); @@ -460,6 +572,7 @@ test.describe("Tools page", () => { const panel = page.getByRole("region", { name: /Tools for solo-gateway/i }); await expect(panel).toBeVisible(); + await showDefinitionTab(panel); await panel.getByRole("button", { name: "More options" }).first().click(); await page.getByRole("menuitem", { name: "Delete" }).click(); @@ -507,6 +620,7 @@ test.describe("Tools page", () => { const panel = page.getByRole("region", { name: /Tools for multi-gw/i }); await expect(panel).toBeVisible(); + await showDefinitionTab(panel); await expect(panel.getByText("alpha_tool").first()).toBeVisible(); await expect(panel.getByText("beta_tool").first()).toBeVisible(); @@ -557,6 +671,7 @@ test.describe("Tools page", () => { const panel = page.getByRole("region", { name: /Tools for rb-gateway/i }); await expect(panel).toBeVisible(); + await showDefinitionTab(panel); await panel.getByRole("button", { name: "More options" }).first().click(); await page.getByRole("menuitem", { name: "Delete" }).click(); @@ -605,6 +720,7 @@ test.describe("Tools page", () => { const panel = page.getByRole("region", { name: /Tools for lone-gateway/i }); await expect(panel).toBeVisible(); + await showDefinitionTab(panel); await panel.getByRole("button", { name: "More options" }).first().click(); await page.getByRole("menuitem", { name: "Delete" }).click(); @@ -867,6 +983,7 @@ test.describe("Tools page", () => { const panel = page.getByRole("region", { name: /Tools for edit-gw/i }); await expect(panel).toBeVisible(); + await showDefinitionTab(panel); await panel.getByRole("button", { name: "More options" }).first().click(); await page.getByRole("menuitem", { name: "Edit" }).click(); @@ -907,6 +1024,7 @@ test.describe("Tools page", () => { const panel = page.getByRole("region", { name: /Tools for schema-gw/i }); await expect(panel).toBeVisible(); + await showDefinitionTab(panel); await panel.getByRole("button", { name: "View schema" }).first().click(); @@ -958,6 +1076,7 @@ test.describe("Tools page", () => { const panel = page.getByRole("region", { name: /Tools for toggle-gw/i }); await expect(panel).toBeVisible(); + await showDefinitionTab(panel); await expect(panel.getByText("Active", { exact: true })).toBeVisible(); await panel.getByRole("button", { name: "More options" }).first().click(); @@ -1007,6 +1126,7 @@ test.describe("Tools page", () => { const panel = page.getByRole("region", { name: /Tools for activate-gw/i }); await expect(panel).toBeVisible(); + await showDefinitionTab(panel); await expect(panel.getByText("Inactive", { exact: true })).toBeVisible(); await panel.getByRole("button", { name: "More options" }).first().click(); @@ -1056,6 +1176,7 @@ test.describe("Tools page", () => { const panel = page.getByRole("region", { name: /Tools for fail-gw/i }); await expect(panel).toBeVisible(); + await showDefinitionTab(panel); await panel.getByRole("button", { name: "More options" }).first().click(); await page.getByRole("menuitem", { name: "Deactivate" }).click(); diff --git a/src/api/tools.test.ts b/src/api/tools.test.ts index 78047d4..46c4f42 100644 --- a/src/api/tools.test.ts +++ b/src/api/tools.test.ts @@ -43,6 +43,65 @@ describe("toolsApi", () => { }); }); + describe("preview", () => { + it("POSTs arguments to /tools/preview/:name with passthrough headers", async () => { + const body = { + resolved_arguments: { query: "cloudflare" }, + target: "local", + annotations: { readOnlyHint: true }, + pre_hooks_run: [], + warnings: [], + }; + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const result = await toolsApi.preview( + "search.issues", + { query: "cloudflare" }, + { "X-Api-Key": "session-key" }, + ); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/tools/preview/search.issues"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ arguments: { query: "cloudflare" } }), + headers: expect.objectContaining({ + "X-CSRF-Token": "test-csrf-token", + "X-Api-Key": "session-key", + }), + credentials: "same-origin", // pragma: allowlist secret + }), + ); + expect(result).toEqual({ preview: body, status: 200 }); + }); + + it("accepts prompt-style MCP names with spaces, dots, hyphens, and underscores", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ target: "local" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect(toolsApi.preview("my tool.v2_test-name")).resolves.toMatchObject({ + status: 200, + }); + }); + + it("throws synchronously for an empty name", () => { + expect(() => toolsApi.preview("")).toThrow("Invalid tool name"); + }); + + it("throws synchronously for name with path traversal characters", () => { + expect(() => toolsApi.preview("../etc/passwd")).toThrow("Invalid tool name format"); + }); + }); + describe("delete", () => { it("calls DELETE /tools/:id with CSRF token and same-origin credentials", async () => { mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 })); diff --git a/src/api/tools.ts b/src/api/tools.ts index 89e8d21..d4217ec 100644 --- a/src/api/tools.ts +++ b/src/api/tools.ts @@ -31,6 +31,40 @@ export interface GenerateSchemasFromOpenapiResult { requires_auth?: boolean; } +export interface ToolPreviewRequest { + arguments: Record; +} + +export interface ToolPreviewWarning { + code?: string; + message?: string; + hook?: string; + hooks?: string[]; + [key: string]: unknown; +} + +export interface ToolPreviewTarget { + kind?: "local" | "federated" | string; + gateway_name?: string | null; + gatewayName?: string | null; + name?: string | null; + [key: string]: unknown; +} + +export interface ToolPreviewResponse { + resolved_arguments?: Record; + target?: "local" | "federated" | ToolPreviewTarget | null; + annotations?: Record | null; + pre_hooks_run?: unknown[] | number | null; + warnings?: ToolPreviewWarning[]; + [key: string]: unknown; +} + +export interface ToolPreviewResult { + preview: ToolPreviewResponse; + status: number; +} + /** * Validates tool ID to prevent path traversal and injection attacks * @param id - The tool ID to validate @@ -50,6 +84,20 @@ function validateToolId(id: string): string { return id; } +// Mirrors the prompt preview name validator: tools are addressed by their MCP +// name on preview/call surfaces, not by the database ID used by CRUD routes. +const TOOL_NAME_PATTERN = /^[a-zA-Z0-9_.\- ]+$/; + +function validateToolName(name: string): string { + if (!name || typeof name !== "string") { + throw new Error("Invalid tool name"); + } + if (!TOOL_NAME_PATTERN.test(name)) { + throw new Error("Invalid tool name format"); + } + return name; +} + export const toolsApi = { /** * Fetch a single tool by ID. @@ -106,6 +154,29 @@ export const toolsApi = { return api.post(`/tools/${validId}/state?activate=false`); }, + /** + * Dry-run a tool invocation without contacting federated upstream servers. + * + * Uses the MCP tool name as the wire identifier, matching snippet output and + * the future `Mcp-Name` routing header. This intentionally does not use + * `validateToolId`, because valid tool names may contain spaces and dots. + */ + preview: ( + name: string, + args: Record = {}, + passthroughHeaders: Record = {}, + options: { signal?: AbortSignal } = {}, + ): Promise => { + const validName = validateToolName(name); + return api + .postWithMeta( + `/tools/preview/${encodeURIComponent(validName)}`, + { arguments: args } satisfies ToolPreviewRequest, + { headers: passthroughHeaders, signal: options.signal }, + ) + .then(({ data, status }) => ({ preview: data, status })); + }, + /** * Generate input/output JSON schemas for a REST tool from its OpenAPI spec. * diff --git a/src/components/tools/ToolArgumentsForm.test.tsx b/src/components/tools/ToolArgumentsForm.test.tsx new file mode 100644 index 0000000..276c43c --- /dev/null +++ b/src/components/tools/ToolArgumentsForm.test.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { renderWithProviders as render } from "@/test/test-utils"; +import { + buildFormSpec, + seedToolArguments, + ToolArgumentsForm, + validateToolArguments, +} from "./ToolArgumentsForm"; + +const SCHEMA = { + type: "object", + required: ["query"], + properties: { + query: { type: "string", description: "Search query" }, + limit: { type: "integer" }, + include_closed: { type: "boolean" }, + labels: { type: "array", items: { type: "string" } }, + mode: { type: "string", enum: ["fast", "full"] }, + owner: { + type: "object", + required: ["email"], + properties: { + email: { type: "string", format: "email" }, + }, + }, + }, +}; + +function FormHarness({ + schema = SCHEMA, + onValidityChange = vi.fn(), + onArgsChange = vi.fn(), +}: { + schema?: Record; + onValidityChange?: (valid: boolean) => void; + onArgsChange?: (value: Record) => void; +}) { + const [value, setValue] = useState(() => seedToolArguments(schema)); + return ( + { + setValue(next); + onArgsChange(next); + }} + onValidityChange={onValidityChange} + /> + ); +} + +describe("ToolArgumentsForm", () => { + it("builds a primitive schema form with one-level nested fields", () => { + const spec = buildFormSpec(SCHEMA); + + expect(spec.complex).toBe(false); + expect(spec.fields.map((field) => field.label)).toEqual([ + "query", + "limit", + "include_closed", + "labels", + "mode", + "owner.email", + ]); + }); + + it("validates required fields", () => { + const spec = buildFormSpec(SCHEMA); + expect(validateToolArguments(seedToolArguments(SCHEMA), spec.fields)).toEqual({ + query: "required", + "owner.email": "required", + }); + }); + + it("renders supported controls and emits typed values", async () => { + const user = userEvent.setup(); + const onArgsChange = vi.fn(); + render(); + + await user.type(screen.getByLabelText(/query/i), "cloudflare"); + await user.type(screen.getByLabelText(/limit/i), "5"); + await user.click(screen.getByLabelText(/include_closed/i)); + await user.type(screen.getByLabelText(/labels/i), "bug, ui"); + + expect(onArgsChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + query: "cloudflare", + limit: 5, + include_closed: true, + labels: ["bug", "ui"], + }), + ); + }); + + it("falls back to raw JSON for complex schemas", async () => { + const onArgsChange = vi.fn(); + render( + , + ); + + expect(screen.getByText("Complex schema: JSON editor")).toBeInTheDocument(); + const rawJson = screen.getByLabelText("Tool arguments as JSON"); + fireEvent.change(rawJson, { target: { value: "{{" } }); + expect(screen.getByText("Enter valid JSON.")).toBeInTheDocument(); + + fireEvent.change(rawJson, { target: { value: '{"query":"ok"}' } }); + expect(onArgsChange).toHaveBeenLastCalledWith({ query: "ok" }); + }); +}); diff --git a/src/components/tools/ToolArgumentsForm.tsx b/src/components/tools/ToolArgumentsForm.tsx new file mode 100644 index 0000000..a2de624 --- /dev/null +++ b/src/components/tools/ToolArgumentsForm.tsx @@ -0,0 +1,553 @@ +import { useEffect, useMemo, useState, type ChangeEvent } from "react"; +import { useIntl } from "react-intl"; + +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; + +export interface ToolArgumentsFormProps { + schema: Record | null | undefined; + value: Record; + onChange: (value: Record) => void; + onValidityChange?: (valid: boolean) => void; +} + +export interface FieldSpec { + path: string[]; + name: string; + label: string; + description?: string; + required: boolean; + type: "string" | "number" | "integer" | "boolean" | "enum" | "array"; + enumValues?: string[]; + format?: string; + itemType?: "string" | "number" | "integer" | "boolean"; +} + +export interface FormSpec { + complex: boolean; + fields: FieldSpec[]; +} + +const COMPLEX_KEYS = ["$ref", "oneOf", "anyOf", "allOf", "not"]; +const SUPPORTED_PRIMITIVES = new Set(["string", "number", "integer", "boolean"]); + +export function seedToolArguments(schema: Record | null | undefined) { + const spec = buildFormSpec(schema); + if (spec.complex) return {}; + const next: Record = {}; + for (const field of spec.fields) { + setPathValue(next, field.path, defaultValueForField(field)); + } + return stripEmptyOptionalValues(next, spec.fields); +} + +export function buildFormSpec(schema: Record | null | undefined): FormSpec { + if (!schema || Object.keys(schema).length === 0) return { complex: false, fields: [] }; + if (hasComplexKeys(schema)) return { complex: true, fields: [] }; + + const rootType = getSchemaType(schema); + if (rootType && rootType !== "object") return { complex: true, fields: [] }; + + const properties = asRecord(schema.properties); + const required = new Set(asStringArray(schema.required)); + if (!properties) return { complex: false, fields: [] }; + + const fields: FieldSpec[] = []; + for (const [name, childSchema] of Object.entries(properties)) { + const child = asRecord(childSchema); + if (!child || hasComplexKeys(child)) return { complex: true, fields: [] }; + const childType = getSchemaType(child); + if (childType === "object") { + const nested = asRecord(child.properties); + if (!nested || hasComplexKeys(child)) return { complex: true, fields: [] }; + const nestedRequired = new Set(asStringArray(child.required)); + for (const [nestedName, nestedSchema] of Object.entries(nested)) { + const nestedChild = asRecord(nestedSchema); + if (!nestedChild || hasComplexKeys(nestedChild)) return { complex: true, fields: [] }; + const field = buildFieldSpec(nestedName, nestedChild, nestedRequired.has(nestedName), [ + name, + nestedName, + ]); + if (!field) return { complex: true, fields: [] }; + fields.push({ ...field, label: `${name}.${nestedName}` }); + } + continue; + } + + const field = buildFieldSpec(name, child, required.has(name), [name]); + if (!field) return { complex: true, fields: [] }; + fields.push(field); + } + + return { complex: false, fields }; +} + +export function validateToolArguments( + value: Record, + fields: FieldSpec[], +): Record { + const errors: Record = {}; + + for (const field of fields) { + const current = getPathValue(value, field.path); + const empty = + current === undefined || + current === null || + current === "" || + (Array.isArray(current) && current.length === 0); + + if (field.required && empty) { + errors[field.path.join(".")] = "required"; + continue; + } + if (empty) continue; + if (field.type === "integer" && !Number.isInteger(current)) { + errors[field.path.join(".")] = "integer"; + } + if (field.type === "number" && typeof current !== "number") { + errors[field.path.join(".")] = "number"; + } + } + + return errors; +} + +export function ToolArgumentsForm({ + schema, + value, + onChange, + onValidityChange, +}: ToolArgumentsFormProps) { + const intl = useIntl(); + const spec = useMemo(() => buildFormSpec(schema), [schema]); + const [rawJson, setRawJson] = useState(() => JSON.stringify(value, null, 2)); + const [rawError, setRawError] = useState(null); + const [arrayDrafts, setArrayDrafts] = useState>({}); + const errors = useMemo( + () => (spec.complex ? {} : validateToolArguments(value, spec.fields)), + [spec, value], + ); + + useEffect(() => { + if (spec.complex) { + setRawJson(JSON.stringify(value, null, 2)); + setRawError(null); + } + }, [spec.complex, value]); + + useEffect(() => { + onValidityChange?.(spec.complex ? rawError === null : Object.keys(errors).length === 0); + }, [errors, onValidityChange, rawError, spec.complex]); + + const updateField = (field: FieldSpec, nextValue: unknown) => { + const next = cloneWithPathValue(value, field.path, nextValue); + onChange(stripEmptyOptionalValues(next, spec.fields)); + }; + + const updateArrayField = (field: FieldSpec, rawValue: string) => { + const key = field.path.join("."); + setArrayDrafts((current) => ({ ...current, [key]: rawValue })); + updateField(field, parseFieldValue(field, rawValue)); + }; + + if (spec.complex) { + return ( +
+
+

+ {intl.formatMessage({ id: "tools.details.preview.arguments.title" })} +

+ + {intl.formatMessage({ id: "tools.details.preview.arguments.complexBadge" })} + +
+