Merge branch 'main' into highlight-tool-errors-in-red-text
This commit is contained in:
commit
1596973b05
9 changed files with 269 additions and 79 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 = {}) => {
|
||||||
|
|
|
||||||
319
client/src/utils/__tests__/jsonUtils.test.ts
Normal file
319
client/src/utils/__tests__/jsonUtils.test.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
import {
|
||||||
|
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", () => {
|
||||||
|
// Basic functionality tests
|
||||||
|
test("returns the new value when path is empty", () => {
|
||||||
|
expect(updateValueAtPath({ foo: "bar" }, [], "newValue")).toBe("newValue");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("initializes an empty object when input is null/undefined and path starts with a string", () => {
|
||||||
|
expect(updateValueAtPath(null, ["foo"], "bar")).toEqual({
|
||||||
|
foo: "bar",
|
||||||
|
});
|
||||||
|
expect(updateValueAtPath(undefined, ["foo"], "bar")).toEqual({
|
||||||
|
foo: "bar",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("initializes an empty array when input is null/undefined and path starts with a number", () => {
|
||||||
|
expect(updateValueAtPath(null, ["0"], "bar")).toEqual(["bar"]);
|
||||||
|
expect(updateValueAtPath(undefined, ["0"], "bar")).toEqual(["bar"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Object update tests
|
||||||
|
test("updates a simple object property", () => {
|
||||||
|
const obj = { name: "John", age: 30 };
|
||||||
|
expect(updateValueAtPath(obj, ["age"], 31)).toEqual({
|
||||||
|
name: "John",
|
||||||
|
age: 31,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates a nested object property", () => {
|
||||||
|
const obj = { user: { name: "John", address: { city: "New York" } } };
|
||||||
|
expect(
|
||||||
|
updateValueAtPath(obj, ["user", "address", "city"], "Boston"),
|
||||||
|
).toEqual({ user: { name: "John", address: { city: "Boston" } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates missing object properties", () => {
|
||||||
|
const obj = { user: { name: "John" } };
|
||||||
|
expect(
|
||||||
|
updateValueAtPath(obj, ["user", "address", "city"], "Boston"),
|
||||||
|
).toEqual({ user: { name: "John", address: { city: "Boston" } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Array update tests
|
||||||
|
test("updates an array item", () => {
|
||||||
|
const arr = [1, 2, 3, 4];
|
||||||
|
expect(updateValueAtPath(arr, ["2"], 5)).toEqual([1, 2, 5, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extends an array when index is out of bounds", () => {
|
||||||
|
const arr = [1, 2, 3];
|
||||||
|
const result = updateValueAtPath(arr, ["5"], "new") as JsonValue[];
|
||||||
|
|
||||||
|
// Check overall array structure
|
||||||
|
expect(result).toEqual([1, 2, 3, null, null, "new"]);
|
||||||
|
|
||||||
|
// Explicitly verify that indices 3 and 4 contain null, not undefined
|
||||||
|
expect(result[3]).toBe(null);
|
||||||
|
expect(result[4]).toBe(null);
|
||||||
|
|
||||||
|
// Verify these aren't "holes" in the array (important distinction)
|
||||||
|
expect(3 in result).toBe(true);
|
||||||
|
expect(4 in result).toBe(true);
|
||||||
|
|
||||||
|
// Verify the array has the correct length
|
||||||
|
expect(result.length).toBe(6);
|
||||||
|
|
||||||
|
// Verify the array doesn't have holes by checking every index exists
|
||||||
|
expect(result.every((_, index: number) => index in result)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates a nested array item", () => {
|
||||||
|
const obj = { users: [{ name: "John" }, { name: "Jane" }] };
|
||||||
|
expect(updateValueAtPath(obj, ["users", "1", "name"], "Janet")).toEqual({
|
||||||
|
users: [{ name: "John" }, { name: "Janet" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handling tests
|
||||||
|
test("returns original value when trying to update a primitive with a path", () => {
|
||||||
|
const spy = jest.spyOn(console, "error").mockImplementation();
|
||||||
|
const result = updateValueAtPath("string", ["foo"], "bar");
|
||||||
|
expect(result).toBe("string");
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns original array when index is invalid", () => {
|
||||||
|
const spy = jest.spyOn(console, "error").mockImplementation();
|
||||||
|
const arr = [1, 2, 3];
|
||||||
|
expect(updateValueAtPath(arr, ["invalid"], 4)).toEqual(arr);
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns original array when index is negative", () => {
|
||||||
|
const spy = jest.spyOn(console, "error").mockImplementation();
|
||||||
|
const arr = [1, 2, 3];
|
||||||
|
expect(updateValueAtPath(arr, ["-1"], 4)).toEqual(arr);
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles sparse arrays correctly by filling holes with null", () => {
|
||||||
|
// Create a sparse array by deleting an element
|
||||||
|
const sparseArr = [1, 2, 3];
|
||||||
|
delete sparseArr[1]; // Now sparseArr is [1, <1 empty item>, 3]
|
||||||
|
|
||||||
|
// Update a value beyond the array length
|
||||||
|
const result = updateValueAtPath(sparseArr, ["5"], "new") as JsonValue[];
|
||||||
|
|
||||||
|
// Check overall array structure
|
||||||
|
expect(result).toEqual([1, null, 3, null, null, "new"]);
|
||||||
|
|
||||||
|
// Explicitly verify that index 1 (the hole) contains null, not undefined
|
||||||
|
expect(result[1]).toBe(null);
|
||||||
|
|
||||||
|
// Verify this isn't a hole in the array
|
||||||
|
expect(1 in result).toBe(true);
|
||||||
|
|
||||||
|
// Verify all indices contain null (not undefined)
|
||||||
|
expect(result[1]).not.toBe(undefined);
|
||||||
|
expect(result[3]).toBe(null);
|
||||||
|
expect(result[4]).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getValueAtPath", () => {
|
||||||
|
test("returns the original value when path is empty", () => {
|
||||||
|
const obj = { foo: "bar" };
|
||||||
|
expect(getValueAtPath(obj, [])).toBe(obj);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns the value at a simple path", () => {
|
||||||
|
const obj = { name: "John", age: 30 };
|
||||||
|
expect(getValueAtPath(obj, ["name"])).toBe("John");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns the value at a nested path", () => {
|
||||||
|
const obj = { user: { name: "John", address: { city: "New York" } } };
|
||||||
|
expect(getValueAtPath(obj, ["user", "address", "city"])).toBe("New York");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns default value when path does not exist", () => {
|
||||||
|
const obj = { user: { name: "John" } };
|
||||||
|
expect(getValueAtPath(obj, ["user", "address", "city"], "Unknown")).toBe(
|
||||||
|
"Unknown",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns default value when input is null/undefined", () => {
|
||||||
|
expect(getValueAtPath(null, ["foo"], "default")).toBe("default");
|
||||||
|
expect(getValueAtPath(undefined, ["foo"], "default")).toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles array indices correctly", () => {
|
||||||
|
const arr = ["a", "b", "c"];
|
||||||
|
expect(getValueAtPath(arr, ["1"])).toBe("b");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns default value for out of bounds array indices", () => {
|
||||||
|
const arr = ["a", "b", "c"];
|
||||||
|
expect(getValueAtPath(arr, ["5"], "default")).toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns default value for invalid array indices", () => {
|
||||||
|
const arr = ["a", "b", "c"];
|
||||||
|
expect(getValueAtPath(arr, ["invalid"], "default")).toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("navigates through mixed object and array paths", () => {
|
||||||
|
const obj = { users: [{ name: "John" }, { name: "Jane" }] };
|
||||||
|
expect(getValueAtPath(obj, ["users", "1", "name"])).toBe("Jane");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
208
client/src/utils/jsonUtils.ts
Normal file
208
client/src/utils/jsonUtils.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
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 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
|
||||||
|
* @param obj The original JSON value
|
||||||
|
* @param path Array of keys/indices representing the path to the value
|
||||||
|
* @param value The new value to set
|
||||||
|
* @returns A new JSON value with the updated path
|
||||||
|
*/
|
||||||
|
export function updateValueAtPath(
|
||||||
|
obj: JsonValue,
|
||||||
|
path: string[],
|
||||||
|
value: JsonValue,
|
||||||
|
): JsonValue {
|
||||||
|
if (path.length === 0) return value;
|
||||||
|
|
||||||
|
if (obj === null || obj === undefined) {
|
||||||
|
obj = !isNaN(Number(path[0])) ? [] : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return updateArray(obj, path, value);
|
||||||
|
} else if (typeof obj === "object" && obj !== null) {
|
||||||
|
return updateObject(obj as JsonObject, path, value);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`Cannot update path ${path.join(".")} in non-object/array value:`,
|
||||||
|
obj,
|
||||||
|
);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an array at a specific path
|
||||||
|
*/
|
||||||
|
function updateArray(
|
||||||
|
array: JsonValue[],
|
||||||
|
path: string[],
|
||||||
|
value: JsonValue,
|
||||||
|
): JsonValue[] {
|
||||||
|
const [index, ...restPath] = path;
|
||||||
|
const arrayIndex = Number(index);
|
||||||
|
|
||||||
|
if (isNaN(arrayIndex)) {
|
||||||
|
console.error(`Invalid array index: ${index}`);
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arrayIndex < 0) {
|
||||||
|
console.error(`Array index out of bounds: ${arrayIndex} < 0`);
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newArray: JsonValue[] = [];
|
||||||
|
for (let i = 0; i < array.length; i++) {
|
||||||
|
newArray[i] = i in array ? array[i] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arrayIndex >= newArray.length) {
|
||||||
|
const extendedArray: JsonValue[] = new Array(arrayIndex).fill(null);
|
||||||
|
// Copy over the existing elements (now guaranteed to be dense)
|
||||||
|
for (let i = 0; i < newArray.length; i++) {
|
||||||
|
extendedArray[i] = newArray[i];
|
||||||
|
}
|
||||||
|
newArray = extendedArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (restPath.length === 0) {
|
||||||
|
newArray[arrayIndex] = value;
|
||||||
|
} else {
|
||||||
|
newArray[arrayIndex] = updateValueAtPath(
|
||||||
|
newArray[arrayIndex],
|
||||||
|
restPath,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return newArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an object at a specific path
|
||||||
|
*/
|
||||||
|
function updateObject(
|
||||||
|
obj: JsonObject,
|
||||||
|
path: string[],
|
||||||
|
value: JsonValue,
|
||||||
|
): JsonObject {
|
||||||
|
const [key, ...restPath] = path;
|
||||||
|
|
||||||
|
// Validate object key
|
||||||
|
if (typeof key !== "string") {
|
||||||
|
console.error(`Invalid object key: ${key}`);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newObj = { ...obj };
|
||||||
|
|
||||||
|
if (restPath.length === 0) {
|
||||||
|
newObj[key] = value;
|
||||||
|
} else {
|
||||||
|
// Ensure key exists
|
||||||
|
if (!(key in newObj)) {
|
||||||
|
newObj[key] = {};
|
||||||
|
}
|
||||||
|
newObj[key] = updateValueAtPath(newObj[key], restPath, value);
|
||||||
|
}
|
||||||
|
return newObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a value at a specific path in a nested JSON structure
|
||||||
|
* @param obj The JSON value to traverse
|
||||||
|
* @param path Array of keys/indices representing the path to the value
|
||||||
|
* @param defaultValue Value to return if path doesn't exist
|
||||||
|
* @returns The value at the path, or defaultValue if not found
|
||||||
|
*/
|
||||||
|
export function getValueAtPath(
|
||||||
|
obj: JsonValue,
|
||||||
|
path: string[],
|
||||||
|
defaultValue: JsonValue = null,
|
||||||
|
): JsonValue {
|
||||||
|
if (path.length === 0) return obj;
|
||||||
|
|
||||||
|
const [first, ...rest] = path;
|
||||||
|
|
||||||
|
if (obj === null || obj === undefined) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
const index = Number(first);
|
||||||
|
if (isNaN(index) || index < 0 || index >= obj.length) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return getValueAtPath(obj[index], rest, defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj === "object" && obj !== null) {
|
||||||
|
if (!(first in obj)) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return getValueAtPath((obj as JsonObject)[first], rest, defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue