Merge pull request #284 from max-stytch/max/default-tool-json

fix: When tool type cannot be determined, use DynamicJsonForm
This commit is contained in:
Cliff Hall
2025-04-16 17:12:32 -04:00
committed by GitHub
3 changed files with 92 additions and 125 deletions

View File

@@ -1,11 +1,10 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import JsonEditor from "./JsonEditor"; import JsonEditor from "./JsonEditor";
import { updateValueAtPath } from "@/utils/jsonUtils"; import { updateValueAtPath } from "@/utils/jsonUtils";
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils"; import { generateDefaultValue } from "@/utils/schemaUtils";
import type { JsonValue, JsonSchemaType, JsonObject } from "@/utils/jsonUtils"; import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
interface DynamicJsonFormProps { interface DynamicJsonFormProps {
schema: JsonSchemaType; schema: JsonSchemaType;
@@ -14,13 +13,23 @@ interface DynamicJsonFormProps {
maxDepth?: number; maxDepth?: number;
} }
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),
);
};
const DynamicJsonForm = ({ const DynamicJsonForm = ({
schema, schema,
value, value,
onChange, onChange,
maxDepth = 3, maxDepth = 3,
}: DynamicJsonFormProps) => { }: DynamicJsonFormProps) => {
const [isJsonMode, setIsJsonMode] = useState(false); const isOnlyJSON = !isSimpleObject(schema);
const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON);
const [jsonError, setJsonError] = useState<string>(); const [jsonError, setJsonError] = useState<string>();
// Store the raw JSON string to allow immediate feedback during typing // Store the raw JSON string to allow immediate feedback during typing
// while deferring parsing until the user stops typing // while deferring parsing until the user stops typing
@@ -207,111 +216,6 @@ const DynamicJsonForm = ({
required={propSchema.required} required={propSchema.required}
/> />
); );
case "object": {
// Handle case where we have a value but no schema properties
const objectValue = (currentValue as JsonObject) || {};
// If we have schema properties, use them to render fields
if (propSchema.properties) {
return (
<div className="space-y-4 border rounded-md p-4">
{Object.entries(propSchema.properties).map(([key, prop]) => (
<div key={key} className="space-y-2">
<Label>{formatFieldLabel(key)}</Label>
{renderFormFields(
prop,
objectValue[key],
[...path, key],
depth + 1,
)}
</div>
))}
</div>
);
}
// If we have a value but no schema properties, render fields based on the value
else if (Object.keys(objectValue).length > 0) {
return (
<div className="space-y-4 border rounded-md p-4">
{Object.entries(objectValue).map(([key, value]) => (
<div key={key} className="space-y-2">
<Label>{formatFieldLabel(key)}</Label>
<Input
type="text"
value={String(value)}
onChange={(e) =>
handleFieldChange([...path, key], e.target.value)
}
/>
</div>
))}
</div>
);
}
// If we have neither schema properties nor value, return null
return null;
}
case "array": {
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
if (!propSchema.items) return null;
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 = generateDefaultValue(
propSchema.items as JsonSchemaType,
);
handleFieldChange(path, [
...arrayValue,
defaultValue ?? null,
]);
}}
title={
propSchema.items?.description
? `Add new ${propSchema.items.description}`
: "Add new item"
}
>
Add Item
</Button>
</div>
</div>
);
}
default: default:
return null; return null;
} }
@@ -350,9 +254,11 @@ const DynamicJsonForm = ({
Format JSON Format JSON
</Button> </Button>
)} )}
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}> {!isOnlyJSON && (
{isJsonMode ? "Switch to Form" : "Switch to JSON"} <Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
</Button> {isJsonMode ? "Switch to Form" : "Switch to JSON"}
</Button>
)}
</div> </div>
{isJsonMode ? ( {isJsonMode ? (

View File

@@ -43,7 +43,13 @@ const ToolsTab = ({
const [isToolRunning, setIsToolRunning] = useState(false); const [isToolRunning, setIsToolRunning] = useState(false);
useEffect(() => { useEffect(() => {
setParams({}); const params = Object.entries(
selectedTool?.inputSchema.properties ?? [],
).map(([key, value]) => [
key,
generateDefaultValue(value as JsonSchemaType),
]);
setParams(Object.fromEntries(params));
}, [selectedTool]); }, [selectedTool]);
const renderToolResult = () => { const renderToolResult = () => {
@@ -217,13 +223,10 @@ const ToolsTab = ({
}} }}
/> />
</div> </div>
) : ( ) : prop.type === "number" ||
prop.type === "integer" ? (
<Input <Input
type={ type="number"
prop.type === "number" || prop.type === "integer"
? "number"
: "text"
}
id={key} id={key}
name={key} name={key}
placeholder={prop.description} placeholder={prop.description}
@@ -231,15 +234,29 @@ const ToolsTab = ({
onChange={(e) => onChange={(e) =>
setParams({ setParams({
...params, ...params,
[key]: [key]: Number(e.target.value),
prop.type === "number" ||
prop.type === "integer"
? Number(e.target.value)
: e.target.value,
}) })
} }
className="mt-1" className="mt-1"
/> />
) : (
<div className="mt-1">
<DynamicJsonForm
schema={{
type: prop.type,
properties: prop.properties,
description: prop.description,
items: prop.items,
}}
value={params[key] as JsonValue}
onChange={(newValue: JsonValue) => {
setParams({
...params,
[key]: newValue,
});
}}
/>
</div>
)} )}
</div> </div>
); );

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals"; import { describe, it, expect, jest } from "@jest/globals";
import DynamicJsonForm from "../DynamicJsonForm"; import DynamicJsonForm from "../DynamicJsonForm";
import type { JsonSchemaType } from "@/utils/jsonUtils"; import type { JsonSchemaType } from "@/utils/jsonUtils";
@@ -93,3 +93,47 @@ describe("DynamicJsonForm Integer Fields", () => {
}); });
}); });
}); });
describe("DynamicJsonForm Complex Fields", () => {
const renderForm = (props = {}) => {
const defaultProps = {
schema: {
type: "object",
properties: {
// The simplified JsonSchemaType does not accept oneOf fields
// But they exist in the more-complete JsonSchema7Type
nested: { oneOf: [{ type: "string" }, { type: "integer" }] },
},
} as unknown as JsonSchemaType,
value: undefined,
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
describe("Basic Operations", () => {
it("should render textbox and autoformat button, but no switch-to-form button", () => {
renderForm();
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("type", "textarea");
const buttons = screen.getAllByRole("button");
expect(buttons).toHaveLength(1);
expect(buttons[0]).toHaveProperty("textContent", "Format JSON");
});
it("should pass changed values to onChange", () => {
const onChange = jest.fn();
renderForm({ onChange });
const input = screen.getByRole("textbox");
fireEvent.change(input, {
target: { value: `{ "nested": "i am string" }` },
});
// The onChange handler is debounced when using the JSON view, so we need to wait a little bit
waitFor(() => {
expect(onChange).toHaveBeenCalledWith(`{ "nested": "i am string" }`);
});
});
});
});