Merge branch 'main' into highlight-tool-errors-in-red-text

This commit is contained in:
kavinkumarbaskar
2025-04-09 22:47:12 +05:30
9 changed files with 269 additions and 79 deletions

View File

@@ -3,33 +3,9 @@ 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 { Label } from "@/components/ui/label";
import JsonEditor from "./JsonEditor"; import JsonEditor from "./JsonEditor";
import { updateValueAtPath, JsonObject } from "@/utils/jsonPathUtils"; import { updateValueAtPath } from "@/utils/jsonUtils";
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils"; import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils";
import type { JsonValue, JsonSchemaType, JsonObject } from "@/utils/jsonUtils";
export type JsonValue =
| string
| number
| boolean
| null
| undefined
| JsonValue[]
| { [key: string]: JsonValue };
export type JsonSchemaType = {
type:
| "string"
| "number"
| "integer"
| "boolean"
| "array"
| "object"
| "null";
description?: string;
required?: boolean;
default?: JsonValue;
properties?: Record<string, JsonSchemaType>;
items?: JsonSchemaType;
};
interface DynamicJsonFormProps { interface DynamicJsonFormProps {
schema: JsonSchemaType; schema: JsonSchemaType;

View File

@@ -1,9 +1,10 @@
import { useState, memo, useMemo, useCallback, useEffect } from "react"; import { useState, memo, useMemo, useCallback, useEffect } from "react";
import { JsonValue } from "./DynamicJsonForm"; import type { JsonValue } from "@/utils/jsonUtils";
import clsx from "clsx"; import clsx from "clsx";
import { Copy, CheckCheck } from "lucide-react"; import { Copy, CheckCheck } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { getDataType, tryParseJson } from "@/utils/jsonUtils";
interface JsonViewProps { interface JsonViewProps {
data: unknown; data: unknown;
@@ -14,21 +15,6 @@ interface JsonViewProps {
isError?: boolean; isError?: boolean;
} }
function tryParseJson(str: string): { success: boolean; data: JsonValue } {
const trimmed = str.trim();
if (
!(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
!(trimmed.startsWith("[") && trimmed.endsWith("]"))
) {
return { success: false, data: str };
}
try {
return { success: true, data: JSON.parse(str) };
} catch {
return { success: false, data: str };
}
}
const JsonView = memo( const JsonView = memo(
({ ({
data, data,
@@ -129,16 +115,7 @@ const JsonNode = memo(
isError = false, isError = false,
}: JsonNodeProps) => { }: JsonNodeProps) => {
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth); const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
const [typeStyleMap] = useState<Record<string, string>>({
const getDataType = (value: JsonValue): string => {
if (Array.isArray(value)) return "array";
if (value === null) return "null";
return typeof value;
};
const dataType = getDataType(data);
const typeStyleMap: Record<string, string> = {
number: "text-blue-600", number: "text-blue-600",
boolean: "text-amber-600", boolean: "text-amber-600",
null: "text-purple-600", null: "text-purple-600",
@@ -146,7 +123,8 @@ const JsonNode = memo(
string: "text-green-600 group-hover:text-green-500", string: "text-green-600 group-hover:text-green-500",
error: "text-red-600 group-hover:text-red-500", error: "text-red-600 group-hover:text-red-500",
default: "text-gray-700", default: "text-gray-700",
}; });
const dataType = getDataType(data);
const renderCollapsible = (isArray: boolean) => { const renderCollapsible = (isArray: boolean) => {
const items = isArray const items = isArray

View File

@@ -103,14 +103,19 @@ const Sidebar = ({
<div className="p-4 flex-1 overflow-auto"> <div className="p-4 flex-1 overflow-auto">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Transport Type</label> <label
className="text-sm font-medium"
htmlFor="transport-type-select"
>
Transport Type
</label>
<Select <Select
value={transportType} value={transportType}
onValueChange={(value: "stdio" | "sse") => onValueChange={(value: "stdio" | "sse") =>
setTransportType(value) setTransportType(value)
} }
> >
<SelectTrigger> <SelectTrigger id="transport-type-select">
<SelectValue placeholder="Select transport type" /> <SelectValue placeholder="Select transport type" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -123,8 +128,11 @@ const Sidebar = ({
{transportType === "stdio" ? ( {transportType === "stdio" ? (
<> <>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Command</label> <label className="text-sm font-medium" htmlFor="command-input">
Command
</label>
<Input <Input
id="command-input"
placeholder="Command" placeholder="Command"
value={command} value={command}
onChange={(e) => setCommand(e.target.value)} onChange={(e) => setCommand(e.target.value)}
@@ -132,8 +140,14 @@ const Sidebar = ({
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Arguments</label> <label
className="text-sm font-medium"
htmlFor="arguments-input"
>
Arguments
</label>
<Input <Input
id="arguments-input"
placeholder="Arguments (space-separated)" placeholder="Arguments (space-separated)"
value={args} value={args}
onChange={(e) => setArgs(e.target.value)} onChange={(e) => setArgs(e.target.value)}
@@ -144,8 +158,11 @@ const Sidebar = ({
) : ( ) : (
<> <>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">URL</label> <label className="text-sm font-medium" htmlFor="sse-url-input">
URL
</label>
<Input <Input
id="sse-url-input"
placeholder="URL" placeholder="URL"
value={sseUrl} value={sseUrl}
onChange={(e) => setSseUrl(e.target.value)} onChange={(e) => setSseUrl(e.target.value)}
@@ -157,6 +174,7 @@ const Sidebar = ({
variant="outline" variant="outline"
onClick={() => setShowBearerToken(!showBearerToken)} onClick={() => setShowBearerToken(!showBearerToken)}
className="flex items-center w-full" className="flex items-center w-full"
aria-expanded={showBearerToken}
> >
{showBearerToken ? ( {showBearerToken ? (
<ChevronDown className="w-4 h-4 mr-2" /> <ChevronDown className="w-4 h-4 mr-2" />
@@ -167,8 +185,14 @@ const Sidebar = ({
</Button> </Button>
{showBearerToken && ( {showBearerToken && (
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Bearer Token</label> <label
className="text-sm font-medium"
htmlFor="bearer-token-input"
>
Bearer Token
</label>
<Input <Input
id="bearer-token-input"
placeholder="Bearer Token" placeholder="Bearer Token"
value={bearerToken} value={bearerToken}
onChange={(e) => setBearerToken(e.target.value)} onChange={(e) => setBearerToken(e.target.value)}
@@ -187,6 +211,7 @@ const Sidebar = ({
onClick={() => setShowEnvVars(!showEnvVars)} onClick={() => setShowEnvVars(!showEnvVars)}
className="flex items-center w-full" className="flex items-center w-full"
data-testid="env-vars-button" data-testid="env-vars-button"
aria-expanded={showEnvVars}
> >
{showEnvVars ? ( {showEnvVars ? (
<ChevronDown className="w-4 h-4 mr-2" /> <ChevronDown className="w-4 h-4 mr-2" />
@@ -201,6 +226,7 @@ const Sidebar = ({
<div key={idx} className="space-y-2 pb-4"> <div key={idx} className="space-y-2 pb-4">
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
aria-label={`Environment variable key ${idx + 1}`}
placeholder="Key" placeholder="Key"
value={key} value={key}
onChange={(e) => { onChange={(e) => {
@@ -243,6 +269,7 @@ const Sidebar = ({
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
aria-label={`Environment variable value ${idx + 1}`}
type={shownEnvVars.has(key) ? "text" : "password"} type={shownEnvVars.has(key) ? "text" : "password"}
placeholder="Value" placeholder="Value"
value={value} value={value}
@@ -309,6 +336,7 @@ const Sidebar = ({
onClick={() => setShowConfig(!showConfig)} onClick={() => setShowConfig(!showConfig)}
className="flex items-center w-full" className="flex items-center w-full"
data-testid="config-button" data-testid="config-button"
aria-expanded={showConfig}
> >
{showConfig ? ( {showConfig ? (
<ChevronDown className="w-4 h-4 mr-2" /> <ChevronDown className="w-4 h-4 mr-2" />
@@ -325,7 +353,10 @@ const Sidebar = ({
return ( return (
<div key={key} className="space-y-2"> <div key={key} className="space-y-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<label className="text-sm font-medium text-green-600 break-all"> <label
className="text-sm font-medium text-green-600 break-all"
htmlFor={`${configKey}-input`}
>
{configItem.label} {configItem.label}
</label> </label>
<Tooltip> <Tooltip>
@@ -339,6 +370,7 @@ const Sidebar = ({
</div> </div>
{typeof configItem.value === "number" ? ( {typeof configItem.value === "number" ? (
<Input <Input
id={`${configKey}-input`}
type="number" type="number"
data-testid={`${configKey}-input`} data-testid={`${configKey}-input`}
value={configItem.value} value={configItem.value}
@@ -365,7 +397,7 @@ const Sidebar = ({
setConfig(newConfig); setConfig(newConfig);
}} }}
> >
<SelectTrigger> <SelectTrigger id={`${configKey}-input`}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -375,6 +407,7 @@ const Sidebar = ({
</Select> </Select>
) : ( ) : (
<Input <Input
id={`${configKey}-input`}
data-testid={`${configKey}-input`} data-testid={`${configKey}-input`}
value={configItem.value} value={configItem.value}
onChange={(e) => { onChange={(e) => {
@@ -448,14 +481,19 @@ const Sidebar = ({
{loggingSupported && connectionStatus === "connected" && ( {loggingSupported && connectionStatus === "connected" && (
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Logging Level</label> <label
className="text-sm font-medium"
htmlFor="logging-level-select"
>
Logging Level
</label>
<Select <Select
value={logLevel} value={logLevel}
onValueChange={(value: LoggingLevel) => onValueChange={(value: LoggingLevel) =>
sendLogLevelRequest(value) sendLogLevelRequest(value)
} }
> >
<SelectTrigger> <SelectTrigger id="logging-level-select">
<SelectValue placeholder="Select logging level" /> <SelectValue placeholder="Select logging level" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>

View File

@@ -5,7 +5,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { TabsContent } from "@/components/ui/tabs"; import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm"; import DynamicJsonForm from "./DynamicJsonForm";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
import { generateDefaultValue } from "@/utils/schemaUtils"; import { generateDefaultValue } from "@/utils/schemaUtils";
import { import {
CallToolResultSchema, CallToolResultSchema,

View File

@@ -1,7 +1,7 @@
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen, fireEvent } 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 "../DynamicJsonForm"; import type { JsonSchemaType } from "@/utils/jsonUtils";
describe("DynamicJsonForm String Fields", () => { describe("DynamicJsonForm String Fields", () => {
const renderForm = (props = {}) => { const renderForm = (props = {}) => {

View File

@@ -1,5 +1,146 @@
import { updateValueAtPath, getValueAtPath } from "../jsonPathUtils"; import {
import { JsonValue } from "../../components/DynamicJsonForm"; getDataType,
tryParseJson,
updateValueAtPath,
getValueAtPath,
} from "../jsonUtils";
import type { JsonValue } from "../jsonUtils";
describe("getDataType", () => {
test("should return 'string' for string values", () => {
expect(getDataType("hello")).toBe("string");
expect(getDataType("")).toBe("string");
});
test("should return 'number' for number values", () => {
expect(getDataType(123)).toBe("number");
expect(getDataType(0)).toBe("number");
expect(getDataType(-10)).toBe("number");
expect(getDataType(1.5)).toBe("number");
expect(getDataType(NaN)).toBe("number");
expect(getDataType(Infinity)).toBe("number");
});
test("should return 'boolean' for boolean values", () => {
expect(getDataType(true)).toBe("boolean");
expect(getDataType(false)).toBe("boolean");
});
test("should return 'undefined' for undefined value", () => {
expect(getDataType(undefined)).toBe("undefined");
});
test("should return 'object' for object values", () => {
expect(getDataType({})).toBe("object");
expect(getDataType({ key: "value" })).toBe("object");
});
test("should return 'array' for array values", () => {
expect(getDataType([])).toBe("array");
expect(getDataType([1, 2, 3])).toBe("array");
expect(getDataType(["a", "b", "c"])).toBe("array");
expect(getDataType([{}, { nested: true }])).toBe("array");
});
test("should return 'null' for null value", () => {
expect(getDataType(null)).toBe("null");
});
});
describe("tryParseJson", () => {
test("should correctly parse valid JSON object", () => {
const jsonString = '{"name":"test","value":123}';
const result = tryParseJson(jsonString);
expect(result.success).toBe(true);
expect(result.data).toEqual({ name: "test", value: 123 });
});
test("should correctly parse valid JSON array", () => {
const jsonString = '[1,2,3,"test"]';
const result = tryParseJson(jsonString);
expect(result.success).toBe(true);
expect(result.data).toEqual([1, 2, 3, "test"]);
});
test("should correctly parse JSON with whitespace", () => {
const jsonString = ' { "name" : "test" } ';
const result = tryParseJson(jsonString);
expect(result.success).toBe(true);
expect(result.data).toEqual({ name: "test" });
});
test("should correctly parse nested JSON structures", () => {
const jsonString =
'{"user":{"name":"test","details":{"age":30}},"items":[1,2,3]}';
const result = tryParseJson(jsonString);
expect(result.success).toBe(true);
expect(result.data).toEqual({
user: {
name: "test",
details: {
age: 30,
},
},
items: [1, 2, 3],
});
});
test("should correctly parse empty objects and arrays", () => {
expect(tryParseJson("{}").success).toBe(true);
expect(tryParseJson("{}").data).toEqual({});
expect(tryParseJson("[]").success).toBe(true);
expect(tryParseJson("[]").data).toEqual([]);
});
test("should return failure for non-JSON strings", () => {
const nonJsonString = "this is not json";
const result = tryParseJson(nonJsonString);
expect(result.success).toBe(false);
expect(result.data).toBe(nonJsonString);
});
test("should return failure for malformed JSON", () => {
const malformedJson = '{"name":"test",}';
const result = tryParseJson(malformedJson);
expect(result.success).toBe(false);
expect(result.data).toBe(malformedJson);
});
test("should return failure for strings with correct delimiters but invalid JSON", () => {
const invalidJson = "{name:test}";
const result = tryParseJson(invalidJson);
expect(result.success).toBe(false);
expect(result.data).toBe(invalidJson);
});
test("should handle edge cases", () => {
expect(tryParseJson("").success).toBe(false);
expect(tryParseJson("").data).toBe("");
expect(tryParseJson(" ").success).toBe(false);
expect(tryParseJson(" ").data).toBe(" ");
expect(tryParseJson("null").success).toBe(false);
expect(tryParseJson("null").data).toBe("null");
expect(tryParseJson('"string"').success).toBe(false);
expect(tryParseJson('"string"').data).toBe('"string"');
expect(tryParseJson("123").success).toBe(false);
expect(tryParseJson("123").data).toBe("123");
expect(tryParseJson("true").success).toBe(false);
expect(tryParseJson("true").data).toBe("true");
});
});
describe("updateValueAtPath", () => { describe("updateValueAtPath", () => {
// Basic functionality tests // Basic functionality tests
@@ -8,17 +149,17 @@ describe("updateValueAtPath", () => {
}); });
test("initializes an empty object when input is null/undefined and path starts with a string", () => { test("initializes an empty object when input is null/undefined and path starts with a string", () => {
expect(updateValueAtPath(null as any, ["foo"], "bar")).toEqual({ expect(updateValueAtPath(null, ["foo"], "bar")).toEqual({
foo: "bar", foo: "bar",
}); });
expect(updateValueAtPath(undefined as any, ["foo"], "bar")).toEqual({ expect(updateValueAtPath(undefined, ["foo"], "bar")).toEqual({
foo: "bar", foo: "bar",
}); });
}); });
test("initializes an empty array when input is null/undefined and path starts with a number", () => { test("initializes an empty array when input is null/undefined and path starts with a number", () => {
expect(updateValueAtPath(null as any, ["0"], "bar")).toEqual(["bar"]); expect(updateValueAtPath(null, ["0"], "bar")).toEqual(["bar"]);
expect(updateValueAtPath(undefined as any, ["0"], "bar")).toEqual(["bar"]); expect(updateValueAtPath(undefined, ["0"], "bar")).toEqual(["bar"]);
}); });
// Object update tests // Object update tests
@@ -152,10 +293,8 @@ describe("getValueAtPath", () => {
}); });
test("returns default value when input is null/undefined", () => { test("returns default value when input is null/undefined", () => {
expect(getValueAtPath(null as any, ["foo"], "default")).toBe("default"); expect(getValueAtPath(null, ["foo"], "default")).toBe("default");
expect(getValueAtPath(undefined as any, ["foo"], "default")).toBe( expect(getValueAtPath(undefined, ["foo"], "default")).toBe("default");
"default",
);
}); });
test("handles array indices correctly", () => { test("handles array indices correctly", () => {

View File

@@ -1,5 +1,5 @@
import { generateDefaultValue, formatFieldLabel } from "../schemaUtils"; import { generateDefaultValue, formatFieldLabel } from "../schemaUtils";
import { JsonSchemaType } from "../../components/DynamicJsonForm"; import type { JsonSchemaType } from "../jsonUtils";
describe("generateDefaultValue", () => { describe("generateDefaultValue", () => {
test("generates default string", () => { test("generates default string", () => {

View File

@@ -1,7 +1,66 @@
import { JsonValue } from "../components/DynamicJsonForm"; export type JsonValue =
| string
| number
| boolean
| null
| undefined
| JsonValue[]
| { [key: string]: JsonValue };
export type JsonSchemaType = {
type:
| "string"
| "number"
| "integer"
| "boolean"
| "array"
| "object"
| "null";
description?: string;
required?: boolean;
default?: JsonValue;
properties?: Record<string, JsonSchemaType>;
items?: JsonSchemaType;
};
export type JsonObject = { [key: string]: JsonValue }; export type JsonObject = { [key: string]: JsonValue };
export type DataType =
| "string"
| "number"
| "bigint"
| "boolean"
| "symbol"
| "undefined"
| "object"
| "function"
| "array"
| "null";
export function getDataType(value: JsonValue): DataType {
if (Array.isArray(value)) return "array";
if (value === null) return "null";
return typeof value;
}
export function tryParseJson(str: string): {
success: boolean;
data: JsonValue;
} {
const trimmed = str.trim();
if (
!(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
!(trimmed.startsWith("[") && trimmed.endsWith("]"))
) {
return { success: false, data: str };
}
try {
return { success: true, data: JSON.parse(str) };
} catch {
return { success: false, data: str };
}
}
/** /**
* Updates a value at a specific path in a nested JSON structure * Updates a value at a specific path in a nested JSON structure
* @param obj The original JSON value * @param obj The original JSON value

View File

@@ -1,5 +1,4 @@
import { JsonValue, JsonSchemaType } from "../components/DynamicJsonForm"; import type { JsonValue, JsonSchemaType, JsonObject } from "./jsonUtils";
import { JsonObject } from "./jsonPathUtils";
/** /**
* Generates a default value based on a JSON schema type * Generates a default value based on a JSON schema type