diff --git a/client/pr_demo.mp4 b/client/pr_demo.mp4 new file mode 100644 index 000000000..b6ae523d5 Binary files /dev/null and b/client/pr_demo.mp4 differ diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index 2214add9b..96b03b3a4 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -1,4 +1,11 @@ -import { useState, useEffect, useCallback, useRef } from "react"; +import { + useState, + useEffect, + useCallback, + useRef, + forwardRef, + useImperativeHandle, +} from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import JsonEditor from "./JsonEditor"; @@ -15,6 +22,10 @@ interface DynamicJsonFormProps { maxDepth?: number; } +export interface DynamicJsonFormRef { + validateJson: () => { isValid: boolean; error: string | null }; +} + const isTypeSupported = ( type: JsonSchemaType["type"], supportedTypes: string[], @@ -63,303 +74,466 @@ const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => { } }; -const DynamicJsonForm = ({ - schema, - value, - onChange, - maxDepth = 3, -}: DynamicJsonFormProps) => { - const isOnlyJSON = !isSimpleObject(schema); - const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON); - const [jsonError, setJsonError] = useState(); - const [copiedJson, setCopiedJson] = useState(false); - const { toast } = useToast(); - - // Store the raw JSON string to allow immediate feedback during typing - // while deferring parsing until the user stops typing - const [rawJsonValue, setRawJsonValue] = useState( - JSON.stringify(value ?? generateDefaultValue(schema), null, 2), - ); - - // Use a ref to manage debouncing timeouts to avoid parsing JSON - // on every keystroke which would be inefficient and error-prone - const timeoutRef = useRef>(); - - // Debounce JSON parsing and parent updates to handle typing gracefully - const debouncedUpdateParent = useCallback( - (jsonString: string) => { - // Clear any existing timeout - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); +const DynamicJsonForm = forwardRef( + ({ schema, value, onChange, maxDepth = 3 }, ref) => { + const isOnlyJSON = !isSimpleObject(schema); + const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON); + const [jsonError, setJsonError] = useState(); + const [copiedJson, setCopiedJson] = useState(false); + const { toast } = useToast(); + + // Store the raw JSON string to allow immediate feedback during typing + // while deferring parsing until the user stops typing + const [rawJsonValue, setRawJsonValue] = useState( + JSON.stringify(value ?? generateDefaultValue(schema), null, 2), + ); + + // Use a ref to manage debouncing timeouts to avoid parsing JSON + // on every keystroke which would be inefficient and error-prone + const timeoutRef = useRef>(); + + // Debounce JSON parsing and parent updates to handle typing gracefully + const debouncedUpdateParent = useCallback( + (jsonString: string) => { + // Clear any existing timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + // Set a new timeout + timeoutRef.current = setTimeout(() => { + try { + const parsed = JSON.parse(jsonString); + onChange(parsed); + setJsonError(undefined); + } catch (err) { + // For invalid JSON, set error and reset to default if it's clearly malformed + const errorMessage = + err instanceof Error ? err.message : "Invalid JSON"; + setJsonError(errorMessage); + + // Reset to default for clearly invalid JSON (not just incomplete typing) + const trimmed = jsonString.trim(); + if (trimmed.length > 5 && !trimmed.match(/^[\s[{]/)) { + onChange(generateDefaultValue(schema)); + } + } + }, 300); + }, + [onChange, setJsonError, schema], + ); + + // Update rawJsonValue when value prop changes + useEffect(() => { + if (!isJsonMode) { + setRawJsonValue( + JSON.stringify(value ?? generateDefaultValue(schema), null, 2), + ); } + }, [value, schema, isJsonMode]); - // Set a new timeout - timeoutRef.current = setTimeout(() => { + const handleSwitchToFormMode = () => { + if (isJsonMode) { + // When switching to Form mode, ensure we have valid JSON try { - const parsed = JSON.parse(jsonString); + const parsed = JSON.parse(rawJsonValue); + // Update the parent component's state with the parsed value onChange(parsed); - setJsonError(undefined); - } catch { - // Don't set error during normal typing + // Switch to form mode + setIsJsonMode(false); + } catch (err) { + setJsonError(err instanceof Error ? err.message : "Invalid JSON"); } - }, 300); - }, - [onChange, setJsonError], - ); - - // Update rawJsonValue when value prop changes - useEffect(() => { - if (!isJsonMode) { - setRawJsonValue( - JSON.stringify(value ?? generateDefaultValue(schema), null, 2), - ); - } - }, [value, schema, isJsonMode]); - - const handleSwitchToFormMode = () => { - if (isJsonMode) { - // When switching to Form mode, ensure we have valid JSON + } else { + // Update raw JSON value when switching to JSON mode + setRawJsonValue( + JSON.stringify(value ?? generateDefaultValue(schema), null, 2), + ); + setIsJsonMode(true); + } + }; + + const formatJson = () => { try { - const parsed = JSON.parse(rawJsonValue); - // Update the parent component's state with the parsed value - onChange(parsed); - // Switch to form mode - setIsJsonMode(false); + const jsonStr = rawJsonValue.trim(); + if (!jsonStr) { + return; + } + const formatted = JSON.stringify(JSON.parse(jsonStr), null, 2); + setRawJsonValue(formatted); + debouncedUpdateParent(formatted); + setJsonError(undefined); } catch (err) { setJsonError(err instanceof Error ? err.message : "Invalid JSON"); } - } else { - // Update raw JSON value when switching to JSON mode - setRawJsonValue( - JSON.stringify(value ?? generateDefaultValue(schema), null, 2), - ); - setIsJsonMode(true); - } - }; - - const formatJson = () => { - try { - const jsonStr = rawJsonValue.trim(); - if (!jsonStr) { - return; + }; + + const validateJson = () => { + if (!isJsonMode) return { isValid: true, error: null }; + try { + const jsonStr = rawJsonValue.trim(); + if (!jsonStr) return { isValid: true, error: null }; + const parsed = JSON.parse(jsonStr); + // Clear any pending debounced update and immediately update parent + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + onChange(parsed); + setJsonError(undefined); + return { isValid: true, error: null }; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Invalid JSON"; + setJsonError(errorMessage); + return { isValid: false, error: errorMessage }; } - const formatted = JSON.stringify(JSON.parse(jsonStr), null, 2); - setRawJsonValue(formatted); - debouncedUpdateParent(formatted); - setJsonError(undefined); - } catch (err) { - setJsonError(err instanceof Error ? err.message : "Invalid JSON"); - } - }; - - const renderFormFields = ( - propSchema: JsonSchemaType, - currentValue: JsonValue, - path: string[] = [], - depth: number = 0, - parentSchema?: JsonSchemaType, - propertyName?: string, - ) => { - if ( - depth >= maxDepth && - (propSchema.type === "object" || propSchema.type === "array") - ) { - // Render as JSON editor when max depth is reached - return ( - { - try { - const parsed = JSON.parse(newValue); - handleFieldChange(path, parsed); - setJsonError(undefined); - } catch (err) { - setJsonError(err instanceof Error ? err.message : "Invalid JSON"); - } - }} - error={jsonError} - /> - ); - } - - // Check if this property is required in the parent schema - const isRequired = - parentSchema?.required?.includes(propertyName || "") ?? false; - - let fieldType = propSchema.type; - if (Array.isArray(fieldType)) { - // Of the possible types, find the first non-null type to determine the control to render - fieldType = fieldType.find((t) => t !== "null") ?? fieldType[0]; - } - - switch (fieldType) { - case "string": { - if ( - propSchema.oneOf && - propSchema.oneOf.every( - (option) => - typeof option.const === "string" && - typeof option.title === "string", - ) - ) { + }; + + const handleCopyJson = useCallback(async () => { + try { + await navigator.clipboard.writeText( + JSON.stringify(value, null, 2) ?? "[]", + ); + setCopiedJson(true); + + toast({ + title: "JSON copied", + description: + "The JSON data has been successfully copied to your clipboard.", + }); + + setTimeout(() => { + setCopiedJson(false); + }, 2000); + } catch (error) { + toast({ + title: "Error", + description: `Failed to copy JSON: ${error instanceof Error ? error.message : String(error)}`, + variant: "destructive", + }); + } + }, [toast, value]); + + useImperativeHandle(ref, () => ({ + validateJson, + })); + + const renderFormFields = ( + propSchema: JsonSchemaType, + currentValue: JsonValue, + path: string[] = [], + depth: number = 0, + parentSchema?: JsonSchemaType, + propertyName?: string, + ) => { + if ( + depth >= maxDepth && + (propSchema.type === "object" || propSchema.type === "array") + ) { + // Render as JSON editor when max depth is reached + return ( + { + try { + const parsed = JSON.parse(newValue); + handleFieldChange(path, parsed); + setJsonError(undefined); + } catch (err) { + setJsonError( + err instanceof Error ? err.message : "Invalid JSON", + ); + } + }} + error={jsonError} + /> + ); + } + + // Check if this property is required in the parent schema + const isRequired = + parentSchema?.required?.includes(propertyName || "") ?? false; + + let fieldType = propSchema.type; + if (Array.isArray(fieldType)) { + // Of the possible types, find the first non-null type to determine the control to render + fieldType = fieldType.find((t) => t !== "null") ?? fieldType[0]; + } + + switch (fieldType) { + case "string": { + if ( + propSchema.oneOf && + propSchema.oneOf.every( + (option) => + typeof option.const === "string" && + typeof option.title === "string", + ) + ) { + return ( + + ); + } + + if (propSchema.enum) { + return ( + + ); + } + + let inputType = "text"; + switch (propSchema.format) { + case "email": + inputType = "email"; + break; + case "uri": + inputType = "url"; + break; + case "date": + inputType = "date"; + break; + case "date-time": + inputType = "datetime-local"; + break; + default: + inputType = "text"; + break; + } + return ( - { const val = e.target.value; if (!val && !isRequired) { handleFieldChange(path, undefined); } else { - handleFieldChange(path, val); + const num = Number(val); + if (!isNaN(num)) { + handleFieldChange(path, num); + } } }} + placeholder={propSchema.description} required={isRequired} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800" - > - - {propSchema.oneOf.map((option) => ( - - ))} - + min={propSchema.minimum} + max={propSchema.maximum} + /> ); - } - if (propSchema.enum) { + case "integer": return ( - + min={propSchema.minimum} + max={propSchema.maximum} + /> ); - } - let inputType = "text"; - switch (propSchema.format) { - case "email": - inputType = "email"; - break; - case "uri": - inputType = "url"; - break; - case "date": - inputType = "date"; - break; - case "date-time": - inputType = "datetime-local"; - break; - default: - inputType = "text"; - break; - } + case "boolean": + return ( + handleFieldChange(path, e.target.checked)} + className="w-4 h-4" + required={isRequired} + /> + ); + case "null": + return null; + case "object": + if (!propSchema.properties) { + return ( + { + try { + const parsed = JSON.parse(newValue); + handleFieldChange(path, parsed); + setJsonError(undefined); + } catch (err) { + setJsonError( + err instanceof Error ? err.message : "Invalid JSON", + ); + } + }} + error={jsonError} + /> + ); + } - return ( - { - const val = e.target.value; - // Always allow setting string values, including empty strings - handleFieldChange(path, val); - }} - placeholder={propSchema.description} - required={isRequired} - minLength={propSchema.minLength} - maxLength={propSchema.maxLength} - pattern={propSchema.pattern} - /> - ); - } + return ( +
+ {Object.entries(propSchema.properties).map(([key, subSchema]) => ( +
+ + {renderFormFields( + subSchema as JsonSchemaType, + (currentValue as Record)?.[key], + [...path, key], + depth + 1, + propSchema, + key, + )} +
+ ))} +
+ ); + case "array": { + const arrayValue = Array.isArray(currentValue) ? currentValue : []; + if (!propSchema.items) return null; + + // If the array items are simple, render as form fields, otherwise use JSON editor + if (isSimpleObject(propSchema.items)) { + return ( +
+ {propSchema.description && ( +

+ {propSchema.description} +

+ )} - case "number": - return ( - { - const val = e.target.value; - if (!val && !isRequired) { - handleFieldChange(path, undefined); - } else { - const num = Number(val); - if (!isNaN(num)) { - handleFieldChange(path, num); - } - } - }} - placeholder={propSchema.description} - required={isRequired} - min={propSchema.minimum} - max={propSchema.maximum} - /> - ); + {propSchema.items?.description && ( +

+ Items: {propSchema.items.description} +

+ )} - case "integer": - return ( - { - const val = e.target.value; - if (!val && !isRequired) { - handleFieldChange(path, undefined); - } else { - const num = Number(val); - if (!isNaN(num) && Number.isInteger(num)) { - handleFieldChange(path, num); - } - } - }} - placeholder={propSchema.description} - required={isRequired} - min={propSchema.minimum} - max={propSchema.maximum} - /> - ); +
+ {arrayValue.map((item, index) => ( +
+ {renderFormFields( + propSchema.items as JsonSchemaType, + item, + [...path, index.toString()], + depth + 1, + )} + +
+ ))} + +
+
+ ); + } - case "boolean": - return ( - handleFieldChange(path, e.target.checked)} - className="w-4 h-4" - required={isRequired} - /> - ); - case "null": - return null; - case "object": - if (!propSchema.properties) { + // For complex arrays, fall back to JSON editor return ( { try { const parsed = JSON.parse(newValue); @@ -375,244 +549,113 @@ const DynamicJsonForm = ({ /> ); } - - return ( -
- {Object.entries(propSchema.properties).map(([key, subSchema]) => ( -
- - {renderFormFields( - subSchema as JsonSchemaType, - (currentValue as Record)?.[key], - [...path, key], - depth + 1, - propSchema, - key, - )} -
- ))} -
- ); - case "array": { - const arrayValue = Array.isArray(currentValue) ? currentValue : []; - if (!propSchema.items) return null; - - // If the array items are simple, render as form fields, otherwise use JSON editor - if (isSimpleObject(propSchema.items)) { - return ( -
- {propSchema.description && ( -

- {propSchema.description} -

- )} - - {propSchema.items?.description && ( -

- Items: {propSchema.items.description} -

- )} - -
- {arrayValue.map((item, index) => ( -
- {renderFormFields( - propSchema.items as JsonSchemaType, - item, - [...path, index.toString()], - depth + 1, - )} - -
- ))} - -
-
- ); - } - - // For complex arrays, fall back to JSON editor - return ( - { - try { - const parsed = JSON.parse(newValue); - handleFieldChange(path, parsed); - setJsonError(undefined); - } catch (err) { - setJsonError( - err instanceof Error ? err.message : "Invalid JSON", - ); - } - }} - error={jsonError} - /> - ); + default: + return null; } - default: - return null; - } - }; - - const handleFieldChange = (path: string[], fieldValue: JsonValue) => { - if (path.length === 0) { - onChange(fieldValue); - return; - } - - try { - const newValue = updateValueAtPath(value, path, fieldValue); - onChange(newValue); - } catch (error) { - console.error("Failed to update form value:", error); - onChange(value); - } - }; - - const shouldUseJsonMode = - schema.type === "object" && - (!schema.properties || Object.keys(schema.properties).length === 0); - - useEffect(() => { - if (shouldUseJsonMode && !isJsonMode) { - setIsJsonMode(true); - } - }, [shouldUseJsonMode, isJsonMode]); - - const handleCopyJson = useCallback(() => { - const copyToClipboard = async () => { - try { - await navigator.clipboard.writeText( - JSON.stringify(value, null, 2) ?? "[]", - ); - setCopiedJson(true); + }; - toast({ - title: "JSON copied", - description: - "The JSON data has been successfully copied to your clipboard.", - }); + const handleFieldChange = (path: string[], fieldValue: JsonValue) => { + if (path.length === 0) { + onChange(fieldValue); + return; + } - setTimeout(() => { - setCopiedJson(false); - }, 2000); + try { + const newValue = updateValueAtPath(value, path, fieldValue); + onChange(newValue); } catch (error) { - toast({ - title: "Error", - description: `Failed to copy JSON: ${error instanceof Error ? error.message : String(error)}`, - variant: "destructive", - }); + console.error("Failed to update form value:", error); + onChange(value); } }; - copyToClipboard(); - }, [toast, value]); + const shouldUseJsonMode = + schema.type === "object" && + (!schema.properties || Object.keys(schema.properties).length === 0); - return ( -
-
- {isJsonMode && ( - <> - + useEffect(() => { + if (shouldUseJsonMode && !isJsonMode) { + setIsJsonMode(true); + } + }, [shouldUseJsonMode, isJsonMode]); + + return ( +
+
+ {isJsonMode && ( + <> + + + + )} + {!isOnlyJSON && ( - - )} + )} +
+ + {isJsonMode ? ( + { + // Always update local state + setRawJsonValue(newValue); - {!isOnlyJSON && ( - + // Use the debounced function to attempt parsing and updating parent + debouncedUpdateParent(newValue); + }} + error={jsonError} + /> + ) : // If schema type is object but value is not an object or is empty, and we have actual JSON data, + // render a simple representation of the JSON data + schema.type === "object" && + (typeof value !== "object" || + value === null || + Object.keys(value).length === 0) && + rawJsonValue && + rawJsonValue !== "{}" ? ( +
+

+ Form view not available for this JSON structure. Using simplified + view: +

+
+              {rawJsonValue}
+            
+

+ Use JSON mode for full editing capabilities. +

+
+ ) : ( + renderFormFields(schema, value) )}
- - {isJsonMode ? ( - { - // Always update local state - setRawJsonValue(newValue); - - // Use the debounced function to attempt parsing and updating parent - debouncedUpdateParent(newValue); - }} - error={jsonError} - /> - ) : // If schema type is object but value is not an object or is empty, and we have actual JSON data, - // render a simple representation of the JSON data - schema.type === "object" && - (typeof value !== "object" || - value === null || - Object.keys(value).length === 0) && - rawJsonValue && - rawJsonValue !== "{}" ? ( -
-

- Form view not available for this JSON structure. Using simplified - view: -

-
-            {rawJsonValue}
-          
-

- Use JSON mode for full editing capabilities. -

-
- ) : ( - renderFormFields(schema, value) - )} -
- ); -}; + ); + }, +); export default DynamicJsonForm; diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 377521ae7..31979f0da 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -1,11 +1,11 @@ -import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { TabsContent } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; -import DynamicJsonForm from "./DynamicJsonForm"; +import DynamicJsonForm, { DynamicJsonFormRef } from "./DynamicJsonForm"; import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils"; import { generateDefaultValue, @@ -22,10 +22,11 @@ import { Send, ChevronDown, ChevronUp, + AlertCircle, Copy, CheckCheck, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import ListPane from "./ListPane"; import JsonView from "./JsonView"; import ToolResults from "./ToolResults"; @@ -45,6 +46,7 @@ const ToolsTab = ({ setSelectedTool, toolResult, nextCursor, + error, resourceContent, onReadResource, }: { @@ -64,9 +66,20 @@ const ToolsTab = ({ const [isToolRunning, setIsToolRunning] = useState(false); const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false); const [isMetaExpanded, setIsMetaExpanded] = useState(false); + const [hasValidationErrors, setHasValidationErrors] = useState(false); + const formRefs = useRef>({}); const { toast } = useToast(); const { copied, setCopied } = useCopy(); + // Function to check if any form has validation errors + const checkValidationErrors = () => { + const errors = Object.values(formRefs.current).some( + (ref) => ref && !ref.validateJson().isValid, + ); + setHasValidationErrors(errors); + return errors; + }; + useEffect(() => { const params = Object.entries( selectedTool?.inputSchema.properties ?? [], @@ -79,6 +92,12 @@ const ToolsTab = ({ ), ]); setParams(Object.fromEntries(params)); + + // Reset validation errors when switching tools + setHasValidationErrors(false); + + // Clear form refs for the previous tool + formRefs.current = {}; }, [selectedTool]); return ( @@ -112,7 +131,13 @@ const ToolsTab = ({
- {selectedTool ? ( + {error ? ( + + + Error + {error} + + ) : selectedTool ? (

{selectedTool.description} @@ -174,6 +199,7 @@ const ToolsTab = ({ ) : prop.type === "object" || prop.type === "array" ? (

(formRefs.current[key] = ref)} schema={{ type: prop.type, properties: prop.properties, @@ -189,6 +215,8 @@ const ToolsTab = ({ ...params, [key]: newValue, }); + // Check validation after a short delay to allow form to update + setTimeout(checkValidationErrors, 100); }} />
@@ -212,6 +240,7 @@ const ToolsTab = ({ ) : (
(formRefs.current[key] = ref)} schema={{ type: prop.type, properties: prop.properties, @@ -224,6 +253,8 @@ const ToolsTab = ({ ...params, [key]: newValue, }); + // Check validation after a short delay to allow form to update + setTimeout(checkValidationErrors, 100); }} />
@@ -305,6 +336,9 @@ const ToolsTab = ({
+
+ ); + }; + + return render(); + }; + + describe("validateJson method", () => { + it("should return valid for form mode", () => { + const simpleSchema = { + type: "string" as const, + description: "Test string field", + }; + + const TestComponent = () => { + const formRef = useRef(null); + + return ( +
+ + +
+ ); + }; + + render(); + + const validateButton = screen.getByTestId("validate-button"); + fireEvent.click(validateButton); + + expect(validateButton.getAttribute("data-validation-valid")).toBe("true"); + expect(validateButton.getAttribute("data-validation-error")).toBe(""); + }); + + it("should return valid for valid JSON in JSON mode", () => { + renderFormWithRef(); + + const validateButton = screen.getByTestId("validate-button"); + fireEvent.click(validateButton); + + expect(validateButton.getAttribute("data-validation-valid")).toBe("true"); + expect(validateButton.getAttribute("data-validation-error")).toBe(""); + }); + + it("should return invalid for malformed JSON in JSON mode", async () => { + renderFormWithRef(); + + // Enter invalid JSON + const textarea = screen.getByRole("textbox"); + fireEvent.change(textarea, { target: { value: '{ "invalid": json }' } }); + + // Wait a bit for any debounced updates + await waitFor(() => { + const validateButton = screen.getByTestId("validate-button"); + fireEvent.click(validateButton); + + expect(validateButton.getAttribute("data-validation-valid")).toBe( + "false", + ); + expect(validateButton.getAttribute("data-validation-error")).toContain( + "JSON", + ); + }); + }); + + it("should return valid for empty JSON in JSON mode", () => { + renderFormWithRef(); + + // Clear the textarea + const textarea = screen.getByRole("textbox"); + fireEvent.change(textarea, { target: { value: "" } }); + + const validateButton = screen.getByTestId("validate-button"); + fireEvent.click(validateButton); + + expect(validateButton.getAttribute("data-validation-valid")).toBe("true"); + expect(validateButton.getAttribute("data-validation-error")).toBe(""); + }); + + it("should set error state when validation fails", async () => { + renderFormWithRef(); + + // Enter invalid JSON + const textarea = screen.getByRole("textbox"); + fireEvent.change(textarea, { + target: { value: '{ "trailing": "comma", }' }, + }); + + // Trigger validation + const validateButton = screen.getByTestId("validate-button"); + fireEvent.click(validateButton); + + // Check that validation result shows error + expect(validateButton.getAttribute("data-validation-valid")).toBe( + "false", + ); + expect(validateButton.getAttribute("data-validation-error")).toContain( + "JSON", + ); + }); + }); + + describe("forwardRef functionality", () => { + it("should expose validateJson method through ref", () => { + const TestComponent = () => { + const formRef = useRef(null); + + return ( +
+ + +
+ ); + }; + + render(); + + const testButton = screen.getByTestId("ref-test-button"); + fireEvent.click(testButton); + + expect(testButton.getAttribute("data-has-validate-method")).toBe("true"); + }); + }); +}); diff --git a/client/src/components/__tests__/ToolsTab.test.tsx b/client/src/components/__tests__/ToolsTab.test.tsx index bc75401d4..290605dd8 100644 --- a/client/src/components/__tests__/ToolsTab.test.tsx +++ b/client/src/components/__tests__/ToolsTab.test.tsx @@ -637,4 +637,180 @@ describe("ToolsTab", () => { expect(screen.getByText(/version/i)).toBeInTheDocument(); }); }); + + describe("JSON Validation Integration", () => { + const toolWithJsonParams: Tool = { + name: "jsonTool", + description: "Tool with JSON parameters", + inputSchema: { + type: "object" as const, + properties: { + config: { + type: "object" as const, + // No properties defined - this will force JSON mode + }, + data: { + type: "array" as const, + // No items defined - this will force JSON mode + }, + }, + }, + }; + + it("should prevent tool execution when JSON validation fails", async () => { + const mockCallTool = jest.fn(); + renderToolsTab({ + tools: [toolWithJsonParams], + selectedTool: toolWithJsonParams, + callTool: mockCallTool, + }); + + // Find JSON editor textareas (there should be at least 1 for JSON parameters) + const textareas = screen.getAllByRole("textbox"); + expect(textareas.length).toBeGreaterThanOrEqual(1); + + // Enter invalid JSON in the first textarea + const configTextarea = textareas[0]; + fireEvent.change(configTextarea, { + target: { value: '{ "invalid": json }' }, + }); + + // Try to run the tool + const runButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(runButton); + }); + + // Tool should not have been called due to validation failure + expect(mockCallTool).not.toHaveBeenCalled(); + }); + + it("should allow tool execution when JSON validation passes", async () => { + const mockCallTool = jest.fn(); + renderToolsTab({ + tools: [toolWithJsonParams], + selectedTool: toolWithJsonParams, + callTool: mockCallTool, + }); + + // Find JSON editor textareas + const textareas = screen.getAllByRole("textbox"); + + // Enter valid JSON in the first textarea + fireEvent.change(textareas[0], { + target: { + value: + '{ "config": { "setting": "value" }, "data": ["item1", "item2"] }', + }, + }); + + // Wait for debounced updates + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); + + // Try to run the tool + const runButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(runButton); + }); + + // Tool should have been called successfully + expect(mockCallTool).toHaveBeenCalled(); + }); + + it("should handle mixed valid and invalid JSON parameters", async () => { + const mockCallTool = jest.fn(); + renderToolsTab({ + tools: [toolWithJsonParams], + selectedTool: toolWithJsonParams, + callTool: mockCallTool, + }); + + const textareas = screen.getAllByRole("textbox"); + + // Enter invalid JSON that contains both valid and invalid parts + fireEvent.change(textareas[0], { + target: { + value: + '{ "config": { "setting": "value" }, "data": ["unclosed array" }', + }, + }); + + // Try to run the tool + const runButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(runButton); + }); + + // Tool should not have been called due to validation failure + expect(mockCallTool).not.toHaveBeenCalled(); + }); + + it("should work with tools that have no JSON parameters", async () => { + const mockCallTool = jest.fn(); + const simpleToolWithStringParam: Tool = { + name: "simpleTool", + description: "Tool with simple parameters", + inputSchema: { + type: "object" as const, + properties: { + message: { type: "string" as const }, + count: { type: "number" as const }, + }, + }, + }; + + renderToolsTab({ + tools: [simpleToolWithStringParam], + selectedTool: simpleToolWithStringParam, + callTool: mockCallTool, + }); + + // Fill in the simple parameters + const messageInput = screen.getByRole("textbox"); + const countInput = screen.getByRole("spinbutton"); + + fireEvent.change(messageInput, { target: { value: "test message" } }); + fireEvent.change(countInput, { target: { value: "5" } }); + + // Run the tool + const runButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(runButton); + }); + + // Tool should have been called successfully (no JSON validation needed) + expect(mockCallTool).toHaveBeenCalledWith( + simpleToolWithStringParam.name, + { + message: "test message", + count: 5, + }, + ); + }); + + it("should handle empty JSON parameters correctly", async () => { + const mockCallTool = jest.fn(); + renderToolsTab({ + tools: [toolWithJsonParams], + selectedTool: toolWithJsonParams, + callTool: mockCallTool, + }); + + const textareas = screen.getAllByRole("textbox"); + + // Clear the textarea (empty JSON should be valid) + fireEvent.change(textareas[0], { target: { value: "" } }); + + // Try to run the tool + const runButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(runButton); + }); + + // Tool should have been called (empty JSON is considered valid) + expect(mockCallTool).toHaveBeenCalled(); + }); + }); });