Merge branch 'main' into fveiras/copy_json
This commit is contained in:
commit
7ff70e15b0
7 changed files with 897 additions and 159 deletions
|
|
@ -18,10 +18,39 @@ interface DynamicJsonFormProps {
|
|||
const isSimpleObject = (schema: JsonSchemaType): boolean => {
|
||||
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
|
||||
if (supportedTypes.includes(schema.type)) return true;
|
||||
if (schema.type !== "object") return false;
|
||||
return Object.values(schema.properties ?? {}).every((prop) =>
|
||||
supportedTypes.includes(prop.type),
|
||||
);
|
||||
if (schema.type === "object") {
|
||||
return Object.values(schema.properties ?? {}).every((prop) =>
|
||||
supportedTypes.includes(prop.type),
|
||||
);
|
||||
}
|
||||
if (schema.type === "array") {
|
||||
return !!schema.items && isSimpleObject(schema.items);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => {
|
||||
if ("default" in schema && schema.default !== undefined) {
|
||||
return schema.default;
|
||||
}
|
||||
|
||||
switch (schema.type) {
|
||||
case "string":
|
||||
return "";
|
||||
case "number":
|
||||
case "integer":
|
||||
return 0;
|
||||
case "boolean":
|
||||
return false;
|
||||
case "array":
|
||||
return [];
|
||||
case "object":
|
||||
return {};
|
||||
case "null":
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const DynamicJsonForm = ({
|
||||
|
|
@ -118,6 +147,8 @@ const DynamicJsonForm = ({
|
|||
currentValue: JsonValue,
|
||||
path: string[] = [],
|
||||
depth: number = 0,
|
||||
parentSchema?: JsonSchemaType,
|
||||
propertyName?: string,
|
||||
) => {
|
||||
if (
|
||||
depth >= maxDepth &&
|
||||
|
|
@ -127,7 +158,8 @@ const DynamicJsonForm = ({
|
|||
return (
|
||||
<JsonEditor
|
||||
value={JSON.stringify(
|
||||
currentValue ?? generateDefaultValue(propSchema),
|
||||
currentValue ??
|
||||
generateDefaultValue(propSchema, propertyName, parentSchema),
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
|
|
@ -145,6 +177,10 @@ const DynamicJsonForm = ({
|
|||
);
|
||||
}
|
||||
|
||||
// Check if this property is required in the parent schema
|
||||
const isRequired =
|
||||
parentSchema?.required?.includes(propertyName || "") ?? false;
|
||||
|
||||
switch (propSchema.type) {
|
||||
case "string":
|
||||
return (
|
||||
|
|
@ -153,16 +189,11 @@ const DynamicJsonForm = ({
|
|||
value={(currentValue as string) ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
// Allow clearing non-required fields by setting undefined
|
||||
// This preserves the distinction between empty string and unset
|
||||
if (!val && !propSchema.required) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
handleFieldChange(path, val);
|
||||
}
|
||||
// Always allow setting string values, including empty strings
|
||||
handleFieldChange(path, val);
|
||||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={propSchema.required}
|
||||
required={isRequired}
|
||||
/>
|
||||
);
|
||||
case "number":
|
||||
|
|
@ -172,9 +203,7 @@ const DynamicJsonForm = ({
|
|||
value={(currentValue as number)?.toString() ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
// Allow clearing non-required number fields
|
||||
// This preserves the distinction between 0 and unset
|
||||
if (!val && !propSchema.required) {
|
||||
if (!val && !isRequired) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
const num = Number(val);
|
||||
|
|
@ -184,7 +213,7 @@ const DynamicJsonForm = ({
|
|||
}
|
||||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={propSchema.required}
|
||||
required={isRequired}
|
||||
/>
|
||||
);
|
||||
case "integer":
|
||||
|
|
@ -195,9 +224,7 @@ const DynamicJsonForm = ({
|
|||
value={(currentValue as number)?.toString() ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
// Allow clearing non-required integer fields
|
||||
// This preserves the distinction between 0 and unset
|
||||
if (!val && !propSchema.required) {
|
||||
if (!val && !isRequired) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
const num = Number(val);
|
||||
|
|
@ -208,7 +235,7 @@ const DynamicJsonForm = ({
|
|||
}
|
||||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={propSchema.required}
|
||||
required={isRequired}
|
||||
/>
|
||||
);
|
||||
case "boolean":
|
||||
|
|
@ -218,9 +245,135 @@ const DynamicJsonForm = ({
|
|||
checked={(currentValue as boolean) ?? false}
|
||||
onChange={(e) => handleFieldChange(path, e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
required={propSchema.required}
|
||||
required={isRequired}
|
||||
/>
|
||||
);
|
||||
case "object":
|
||||
if (!propSchema.properties) {
|
||||
return (
|
||||
<JsonEditor
|
||||
value={JSON.stringify(currentValue ?? {}, null, 2)}
|
||||
onChange={(newValue) => {
|
||||
try {
|
||||
const parsed = JSON.parse(newValue);
|
||||
handleFieldChange(path, parsed);
|
||||
setJsonError(undefined);
|
||||
} catch (err) {
|
||||
setJsonError(
|
||||
err instanceof Error ? err.message : "Invalid JSON",
|
||||
);
|
||||
}
|
||||
}}
|
||||
error={jsonError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border rounded p-3">
|
||||
{Object.entries(propSchema.properties).map(([key, subSchema]) => (
|
||||
<div key={key}>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{key}
|
||||
{propSchema.required?.includes(key) && (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
{renderFormFields(
|
||||
subSchema as JsonSchemaType,
|
||||
(currentValue as Record<string, JsonValue>)?.[key],
|
||||
[...path, key],
|
||||
depth + 1,
|
||||
propSchema,
|
||||
key,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{propSchema.description && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{propSchema.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{propSchema.items?.description && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Items: {propSchema.items.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{arrayValue.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{renderFormFields(
|
||||
propSchema.items as JsonSchemaType,
|
||||
item,
|
||||
[...path, index.toString()],
|
||||
depth + 1,
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newArray = [...arrayValue];
|
||||
newArray.splice(index, 1);
|
||||
handleFieldChange(path, newArray);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const defaultValue = getArrayItemDefault(
|
||||
propSchema.items as JsonSchemaType,
|
||||
);
|
||||
handleFieldChange(path, [...arrayValue, defaultValue]);
|
||||
}}
|
||||
title={
|
||||
propSchema.items?.description
|
||||
? `Add new ${propSchema.items.description}`
|
||||
: "Add new item"
|
||||
}
|
||||
>
|
||||
Add Item
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For complex arrays, fall back to JSON editor
|
||||
return (
|
||||
<JsonEditor
|
||||
value={JSON.stringify(currentValue ?? [], null, 2)}
|
||||
onChange={(newValue) => {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
155
client/src/components/HistoryAndNotifications.tsx
Normal file
155
client/src/components/HistoryAndNotifications.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { useState } from "react";
|
||||
import JsonView from "./JsonView";
|
||||
|
||||
const HistoryAndNotifications = ({
|
||||
requestHistory,
|
||||
serverNotifications,
|
||||
}: {
|
||||
requestHistory: Array<{ request: string; response?: string }>;
|
||||
serverNotifications: ServerNotification[];
|
||||
}) => {
|
||||
const [expandedRequests, setExpandedRequests] = useState<{
|
||||
[key: number]: boolean;
|
||||
}>({});
|
||||
const [expandedNotifications, setExpandedNotifications] = useState<{
|
||||
[key: number]: boolean;
|
||||
}>({});
|
||||
|
||||
const toggleRequestExpansion = (index: number) => {
|
||||
setExpandedRequests((prev) => ({ ...prev, [index]: !prev[index] }));
|
||||
};
|
||||
|
||||
const toggleNotificationExpansion = (index: number) => {
|
||||
setExpandedNotifications((prev) => ({ ...prev, [index]: !prev[index] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-card overflow-hidden flex h-full">
|
||||
<div className="flex-1 overflow-y-auto p-4 border-r">
|
||||
<h2 className="text-lg font-semibold mb-4">History</h2>
|
||||
{requestHistory.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
No history yet
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{requestHistory
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((request, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="text-sm text-foreground bg-secondary py-2 px-3 rounded"
|
||||
>
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() =>
|
||||
toggleRequestExpansion(requestHistory.length - 1 - index)
|
||||
}
|
||||
>
|
||||
<span className="font-mono">
|
||||
{requestHistory.length - index}.{" "}
|
||||
{JSON.parse(request.request).method}
|
||||
</span>
|
||||
<span>
|
||||
{expandedRequests[requestHistory.length - 1 - index]
|
||||
? "▼"
|
||||
: "▶"}
|
||||
</span>
|
||||
</div>
|
||||
{expandedRequests[requestHistory.length - 1 - index] && (
|
||||
<>
|
||||
<div className="mt-2">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="font-semibold text-blue-600">
|
||||
Request:
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<JsonView
|
||||
data={request.request}
|
||||
className="bg-background"
|
||||
/>
|
||||
</div>
|
||||
{request.response && (
|
||||
<div className="mt-2">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="font-semibold text-green-600">
|
||||
Response:
|
||||
</span>
|
||||
</div>
|
||||
<JsonView
|
||||
data={request.response}
|
||||
className="bg-background"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<h2 className="text-lg font-semibold mb-4">Server Notifications</h2>
|
||||
{serverNotifications.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
No notifications yet
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{serverNotifications
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((notification, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="text-sm text-foreground bg-secondary py-2 px-3 rounded"
|
||||
>
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() =>
|
||||
toggleNotificationExpansion(
|
||||
serverNotifications.length - 1 - index,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="font-mono">
|
||||
{serverNotifications.length - index}.{" "}
|
||||
{notification.method}
|
||||
</span>
|
||||
<span>
|
||||
{expandedNotifications[
|
||||
serverNotifications.length - 1 - index
|
||||
]
|
||||
? "▼"
|
||||
: "▶"}
|
||||
</span>
|
||||
</div>
|
||||
{expandedNotifications[
|
||||
serverNotifications.length - 1 - index
|
||||
] && (
|
||||
<div className="mt-2">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="font-semibold text-purple-600">
|
||||
Details:
|
||||
</span>
|
||||
</div>
|
||||
<JsonView
|
||||
data={JSON.stringify(notification, null, 2)}
|
||||
className="bg-background"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryAndNotifications;
|
||||
|
|
@ -98,12 +98,12 @@ const PromptsTab = ({
|
|||
setPromptArgs({});
|
||||
}}
|
||||
renderItem={(prompt) => (
|
||||
<>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="flex-1">{prompt.name}</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
<span className="text-sm text-gray-500 text-left">
|
||||
{prompt.description}
|
||||
</span>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
title="Prompts"
|
||||
buttonText={nextCursor ? "List More Prompts" : "List Prompts"}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { TabsContent } from "@/components/ui/tabs";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import DynamicJsonForm from "./DynamicJsonForm";
|
||||
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||
import { generateDefaultValue, isPropertyRequired } from "@/utils/schemaUtils";
|
||||
import {
|
||||
CompatibilityCallToolResult,
|
||||
ListToolsResult,
|
||||
|
|
@ -48,7 +48,11 @@ const ToolsTab = ({
|
|||
selectedTool?.inputSchema.properties ?? [],
|
||||
).map(([key, value]) => [
|
||||
key,
|
||||
generateDefaultValue(value as JsonSchemaType),
|
||||
generateDefaultValue(
|
||||
value as JsonSchemaType,
|
||||
key,
|
||||
selectedTool?.inputSchema as JsonSchemaType,
|
||||
),
|
||||
]);
|
||||
setParams(Object.fromEntries(params));
|
||||
}, [selectedTool]);
|
||||
|
|
@ -92,6 +96,9 @@ const ToolsTab = ({
|
|||
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
||||
([key, value]) => {
|
||||
const prop = value as JsonSchemaType;
|
||||
const inputSchema =
|
||||
selectedTool.inputSchema as JsonSchemaType;
|
||||
const required = isPropertyRequired(key, inputSchema);
|
||||
return (
|
||||
<div key={key}>
|
||||
<Label
|
||||
|
|
@ -99,6 +106,9 @@ const ToolsTab = ({
|
|||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{key}
|
||||
{required && (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
)}
|
||||
</Label>
|
||||
{prop.type === "boolean" ? (
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
|
|
@ -163,12 +173,13 @@ const ToolsTab = ({
|
|||
name={key}
|
||||
placeholder={prop.description}
|
||||
value={(params[key] as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setParams({
|
||||
...params,
|
||||
[key]: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
[key]: value === "" ? "" : Number(value),
|
||||
});
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
256
client/src/components/__tests__/DynamicJsonForm.array.test.tsx
Normal file
256
client/src/components/__tests__/DynamicJsonForm.array.test.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, jest } from "@jest/globals";
|
||||
import DynamicJsonForm from "../DynamicJsonForm";
|
||||
import type { JsonSchemaType } from "@/utils/jsonUtils";
|
||||
|
||||
describe("DynamicJsonForm Array Fields", () => {
|
||||
const renderSimpleArrayForm = (props = {}) => {
|
||||
const defaultProps = {
|
||||
schema: {
|
||||
type: "array" as const,
|
||||
description: "Test array field",
|
||||
items: {
|
||||
type: "string" as const,
|
||||
description: "Array item",
|
||||
},
|
||||
} satisfies JsonSchemaType,
|
||||
value: [],
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
return render(<DynamicJsonForm {...defaultProps} {...props} />);
|
||||
};
|
||||
|
||||
const renderComplexArrayForm = (props = {}) => {
|
||||
const defaultProps = {
|
||||
schema: {
|
||||
type: "array" as const,
|
||||
description: "Test complex array field",
|
||||
items: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
nested: { type: "object" as const },
|
||||
},
|
||||
},
|
||||
} satisfies JsonSchemaType,
|
||||
value: [],
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
return render(<DynamicJsonForm {...defaultProps} {...props} />);
|
||||
};
|
||||
|
||||
describe("Simple Array Rendering", () => {
|
||||
it("should render form fields for simple array items", () => {
|
||||
renderSimpleArrayForm({ value: ["item1", "item2"] });
|
||||
|
||||
// Should show array description
|
||||
expect(screen.getByText("Test array field")).toBeDefined();
|
||||
expect(screen.getByText("Items: Array item")).toBeDefined();
|
||||
|
||||
// Should show input fields for each item
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs[0]).toHaveProperty("value", "item1");
|
||||
expect(inputs[1]).toHaveProperty("value", "item2");
|
||||
|
||||
// Should show remove buttons
|
||||
const removeButtons = screen.getAllByText("Remove");
|
||||
expect(removeButtons).toHaveLength(2);
|
||||
|
||||
// Should show add button
|
||||
expect(screen.getByText("Add Item")).toBeDefined();
|
||||
});
|
||||
|
||||
it("should add new items when Add Item button is clicked", () => {
|
||||
const onChange = jest.fn();
|
||||
renderSimpleArrayForm({ value: ["item1"], onChange });
|
||||
|
||||
const addButton = screen.getByText("Add Item");
|
||||
fireEvent.click(addButton);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["item1", ""]);
|
||||
});
|
||||
|
||||
it("should remove items when Remove button is clicked", () => {
|
||||
const onChange = jest.fn();
|
||||
renderSimpleArrayForm({ value: ["item1", "item2"], onChange });
|
||||
|
||||
const removeButtons = screen.getAllByText("Remove");
|
||||
fireEvent.click(removeButtons[0]);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["item2"]);
|
||||
});
|
||||
|
||||
it("should update item values when input changes", () => {
|
||||
const onChange = jest.fn();
|
||||
renderSimpleArrayForm({ value: ["item1"], onChange });
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "updated item" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["updated item"]);
|
||||
});
|
||||
|
||||
it("should handle empty arrays", () => {
|
||||
renderSimpleArrayForm({ value: [] });
|
||||
|
||||
// Should show description and add button but no items
|
||||
expect(screen.getByText("Test array field")).toBeDefined();
|
||||
expect(screen.getByText("Add Item")).toBeDefined();
|
||||
expect(screen.queryByText("Remove")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Complex Array Fallback", () => {
|
||||
it("should render JSON editor for complex arrays", () => {
|
||||
renderComplexArrayForm();
|
||||
|
||||
// Should render as JSON editor (textarea)
|
||||
const textarea = screen.getByRole("textbox");
|
||||
expect(textarea).toHaveProperty("type", "textarea");
|
||||
|
||||
// Should not show form-specific array controls
|
||||
expect(screen.queryByText("Add Item")).toBeNull();
|
||||
expect(screen.queryByText("Remove")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Array Type Detection", () => {
|
||||
it("should detect string arrays as simple", () => {
|
||||
const schema = {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const },
|
||||
};
|
||||
renderSimpleArrayForm({ schema, value: ["test"] });
|
||||
|
||||
// Should render form fields, not JSON editor
|
||||
expect(screen.getByRole("textbox")).not.toHaveProperty(
|
||||
"type",
|
||||
"textarea",
|
||||
);
|
||||
});
|
||||
|
||||
it("should detect number arrays as simple", () => {
|
||||
const schema = {
|
||||
type: "array" as const,
|
||||
items: { type: "number" as const },
|
||||
};
|
||||
renderSimpleArrayForm({ schema, value: [1, 2] });
|
||||
|
||||
// Should render form fields (number inputs)
|
||||
const inputs = screen.getAllByRole("spinbutton");
|
||||
expect(inputs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should detect boolean arrays as simple", () => {
|
||||
const schema = {
|
||||
type: "array" as const,
|
||||
items: { type: "boolean" as const },
|
||||
};
|
||||
renderSimpleArrayForm({ schema, value: [true, false] });
|
||||
|
||||
// Should render form fields (checkboxes)
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
expect(checkboxes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should detect simple object arrays as simple", () => {
|
||||
const schema = {
|
||||
type: "array" as const,
|
||||
items: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
name: { type: "string" as const },
|
||||
age: { type: "number" as const },
|
||||
},
|
||||
},
|
||||
};
|
||||
renderSimpleArrayForm({ schema, value: [{ name: "John", age: 30 }] });
|
||||
|
||||
// Should render form fields for simple objects
|
||||
expect(screen.getByText("Add Item")).toBeDefined();
|
||||
expect(screen.getByText("Remove")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Array with Different Item Types", () => {
|
||||
it("should handle integer array items", () => {
|
||||
const schema = {
|
||||
type: "array" as const,
|
||||
items: { type: "integer" as const },
|
||||
};
|
||||
const onChange = jest.fn();
|
||||
renderSimpleArrayForm({ schema, value: [1, 2], onChange });
|
||||
|
||||
const inputs = screen.getAllByRole("spinbutton");
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs[0]).toHaveProperty("value", "1");
|
||||
expect(inputs[1]).toHaveProperty("value", "2");
|
||||
|
||||
// Test adding new integer item
|
||||
const addButton = screen.getByText("Add Item");
|
||||
fireEvent.click(addButton);
|
||||
expect(onChange).toHaveBeenCalledWith([1, 2, 0]);
|
||||
});
|
||||
|
||||
it("should handle boolean array items", () => {
|
||||
const schema = {
|
||||
type: "array" as const,
|
||||
items: { type: "boolean" as const },
|
||||
};
|
||||
const onChange = jest.fn();
|
||||
renderSimpleArrayForm({ schema, value: [true, false], onChange });
|
||||
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
expect(checkboxes).toHaveLength(2);
|
||||
expect(checkboxes[0]).toHaveProperty("checked", true);
|
||||
expect(checkboxes[1]).toHaveProperty("checked", false);
|
||||
|
||||
// Test adding new boolean item
|
||||
const addButton = screen.getByText("Add Item");
|
||||
fireEvent.click(addButton);
|
||||
expect(onChange).toHaveBeenCalledWith([true, false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Array Item Descriptions", () => {
|
||||
it("should show item description when available", () => {
|
||||
const schema = {
|
||||
type: "array" as const,
|
||||
description: "List of names",
|
||||
items: {
|
||||
type: "string" as const,
|
||||
description: "Person name",
|
||||
},
|
||||
};
|
||||
renderSimpleArrayForm({ schema });
|
||||
|
||||
expect(screen.getByText("List of names")).toBeDefined();
|
||||
expect(screen.getByText("Items: Person name")).toBeDefined();
|
||||
});
|
||||
|
||||
it("should use item description in add button title", () => {
|
||||
const schema = {
|
||||
type: "array" as const,
|
||||
items: {
|
||||
type: "string" as const,
|
||||
description: "Email address",
|
||||
},
|
||||
};
|
||||
renderSimpleArrayForm({ schema });
|
||||
|
||||
const addButton = screen.getByText("Add Item");
|
||||
expect(addButton).toHaveProperty("title", "Add new Email address");
|
||||
});
|
||||
|
||||
it("should use default title when no item description", () => {
|
||||
const schema = {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const },
|
||||
};
|
||||
renderSimpleArrayForm({ schema });
|
||||
|
||||
const addButton = screen.getByText("Add Item");
|
||||
expect(addButton).toHaveProperty("title", "Add new item");
|
||||
});
|
||||
});
|
||||
});
|
||||
226
client/src/components/__tests__/HistoryAndNotifications.test.tsx
Normal file
226
client/src/components/__tests__/HistoryAndNotifications.test.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, jest } from "@jest/globals";
|
||||
import HistoryAndNotifications from "../HistoryAndNotifications";
|
||||
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
// Mock JsonView component
|
||||
jest.mock("../JsonView", () => {
|
||||
return function JsonView({ data }: { data: string }) {
|
||||
return <div data-testid="json-view">{data}</div>;
|
||||
};
|
||||
});
|
||||
|
||||
describe("HistoryAndNotifications", () => {
|
||||
const mockRequestHistory = [
|
||||
{
|
||||
request: JSON.stringify({ method: "test/method1", params: {} }),
|
||||
response: JSON.stringify({ result: "success" }),
|
||||
},
|
||||
{
|
||||
request: JSON.stringify({ method: "test/method2", params: {} }),
|
||||
response: JSON.stringify({ result: "success" }),
|
||||
},
|
||||
];
|
||||
|
||||
const mockNotifications: ServerNotification[] = [
|
||||
{
|
||||
method: "notifications/message",
|
||||
params: {
|
||||
level: "info" as const,
|
||||
message: "First notification",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "notifications/progress",
|
||||
params: {
|
||||
progressToken: "test-token",
|
||||
progress: 50,
|
||||
message: "Second notification",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
it("renders history and notifications sections", () => {
|
||||
render(
|
||||
<HistoryAndNotifications
|
||||
requestHistory={mockRequestHistory}
|
||||
serverNotifications={mockNotifications}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("History")).toBeTruthy();
|
||||
expect(screen.getByText("Server Notifications")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays request history items with correct numbering", () => {
|
||||
render(
|
||||
<HistoryAndNotifications
|
||||
requestHistory={mockRequestHistory}
|
||||
serverNotifications={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Items should be numbered in reverse order (newest first)
|
||||
expect(screen.getByText("2. test/method2")).toBeTruthy();
|
||||
expect(screen.getByText("1. test/method1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays server notifications with correct numbering", () => {
|
||||
render(
|
||||
<HistoryAndNotifications
|
||||
requestHistory={[]}
|
||||
serverNotifications={mockNotifications}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Items should be numbered in reverse order (newest first)
|
||||
expect(screen.getByText("2. notifications/progress")).toBeTruthy();
|
||||
expect(screen.getByText("1. notifications/message")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("expands and collapses request items when clicked", () => {
|
||||
render(
|
||||
<HistoryAndNotifications
|
||||
requestHistory={mockRequestHistory}
|
||||
serverNotifications={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const firstRequestHeader = screen.getByText("2. test/method2");
|
||||
|
||||
// Initially collapsed - should show ▶ arrows (there are multiple)
|
||||
expect(screen.getAllByText("▶")).toHaveLength(2);
|
||||
expect(screen.queryByText("Request:")).toBeNull();
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(firstRequestHeader);
|
||||
|
||||
// Should now be expanded - one ▼ and one ▶
|
||||
expect(screen.getByText("▼")).toBeTruthy();
|
||||
expect(screen.getAllByText("▶")).toHaveLength(1);
|
||||
expect(screen.getByText("Request:")).toBeTruthy();
|
||||
expect(screen.getByText("Response:")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("expands and collapses notification items when clicked", () => {
|
||||
render(
|
||||
<HistoryAndNotifications
|
||||
requestHistory={[]}
|
||||
serverNotifications={mockNotifications}
|
||||
/>,
|
||||
);
|
||||
|
||||
const firstNotificationHeader = screen.getByText(
|
||||
"2. notifications/progress",
|
||||
);
|
||||
|
||||
// Initially collapsed
|
||||
expect(screen.getAllByText("▶")).toHaveLength(2);
|
||||
expect(screen.queryByText("Details:")).toBeNull();
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(firstNotificationHeader);
|
||||
|
||||
// Should now be expanded
|
||||
expect(screen.getByText("▼")).toBeTruthy();
|
||||
expect(screen.getAllByText("▶")).toHaveLength(1);
|
||||
expect(screen.getByText("Details:")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("maintains expanded state when new notifications are added", () => {
|
||||
const { rerender } = render(
|
||||
<HistoryAndNotifications
|
||||
requestHistory={[]}
|
||||
serverNotifications={mockNotifications}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find and expand the older notification (should be "1. notifications/message")
|
||||
const olderNotificationHeader = screen.getByText(
|
||||
"1. notifications/message",
|
||||
);
|
||||
fireEvent.click(olderNotificationHeader);
|
||||
|
||||
// Verify it's expanded
|
||||
expect(screen.getByText("Details:")).toBeTruthy();
|
||||
|
||||
// Add a new notification at the beginning (simulating real behavior)
|
||||
const newNotifications: ServerNotification[] = [
|
||||
{
|
||||
method: "notifications/resources/updated",
|
||||
params: { uri: "file://test.txt" },
|
||||
},
|
||||
...mockNotifications,
|
||||
];
|
||||
|
||||
// Re-render with new notifications
|
||||
rerender(
|
||||
<HistoryAndNotifications
|
||||
requestHistory={[]}
|
||||
serverNotifications={newNotifications}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The original notification should still be expanded
|
||||
// It's now numbered as "2. notifications/message" due to the new item
|
||||
expect(screen.getByText("3. notifications/progress")).toBeTruthy();
|
||||
expect(screen.getByText("2. notifications/message")).toBeTruthy();
|
||||
expect(screen.getByText("1. notifications/resources/updated")).toBeTruthy();
|
||||
|
||||
// The originally expanded notification should still show its details
|
||||
expect(screen.getByText("Details:")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("maintains expanded state when new requests are added", () => {
|
||||
const { rerender } = render(
|
||||
<HistoryAndNotifications
|
||||
requestHistory={mockRequestHistory}
|
||||
serverNotifications={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find and expand the older request (should be "1. test/method1")
|
||||
const olderRequestHeader = screen.getByText("1. test/method1");
|
||||
fireEvent.click(olderRequestHeader);
|
||||
|
||||
// Verify it's expanded
|
||||
expect(screen.getByText("Request:")).toBeTruthy();
|
||||
expect(screen.getByText("Response:")).toBeTruthy();
|
||||
|
||||
// Add a new request at the beginning
|
||||
const newRequestHistory = [
|
||||
{
|
||||
request: JSON.stringify({ method: "test/new_method", params: {} }),
|
||||
response: JSON.stringify({ result: "new success" }),
|
||||
},
|
||||
...mockRequestHistory,
|
||||
];
|
||||
|
||||
// Re-render with new request history
|
||||
rerender(
|
||||
<HistoryAndNotifications
|
||||
requestHistory={newRequestHistory}
|
||||
serverNotifications={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The original request should still be expanded
|
||||
// It's now numbered as "2. test/method1" due to the new item
|
||||
expect(screen.getByText("3. test/method2")).toBeTruthy();
|
||||
expect(screen.getByText("2. test/method1")).toBeTruthy();
|
||||
expect(screen.getByText("1. test/new_method")).toBeTruthy();
|
||||
|
||||
// The originally expanded request should still show its details
|
||||
expect(screen.getByText("Request:")).toBeTruthy();
|
||||
expect(screen.getByText("Response:")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays empty state messages when no data is available", () => {
|
||||
render(
|
||||
<HistoryAndNotifications requestHistory={[]} serverNotifications={[]} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("No history yet")).toBeTruthy();
|
||||
expect(screen.getByText("No notifications yet")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -111,6 +111,27 @@ describe("ToolsTab", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should allow typing negative numbers", async () => {
|
||||
renderToolsTab({
|
||||
selectedTool: mockTools[0],
|
||||
});
|
||||
|
||||
const input = screen.getByRole("spinbutton") as HTMLInputElement;
|
||||
|
||||
// Complete the negative number
|
||||
fireEvent.change(input, { target: { value: "-42" } });
|
||||
expect(input.value).toBe("-42");
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: /run tool/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[0].name, {
|
||||
num: -42,
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable button and change text while tool is running", async () => {
|
||||
// Create a promise that we can resolve later
|
||||
let resolvePromise: ((value: unknown) => void) | undefined;
|
||||
|
|
|
|||
Loading…
Reference in a new issue