Merge branch 'main' into feat/proxyServerUrl
This commit is contained in:
5
.github/workflows/main.yml
vendored
5
.github/workflows/main.yml
vendored
@@ -25,6 +25,11 @@ jobs:
|
|||||||
# Working around https://github.com/npm/cli/issues/4828
|
# Working around https://github.com/npm/cli/issues/4828
|
||||||
# - run: npm ci
|
# - run: npm ci
|
||||||
- run: npm install --no-package-lock
|
- run: npm install --no-package-lock
|
||||||
|
|
||||||
|
- name: Run client tests
|
||||||
|
working-directory: ./client
|
||||||
|
run: npm test
|
||||||
|
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ server/build
|
|||||||
client/dist
|
client/dist
|
||||||
client/tsconfig.app.tsbuildinfo
|
client/tsconfig.app.tsbuildinfo
|
||||||
client/tsconfig.node.tsbuildinfo
|
client/tsconfig.node.tsbuildinfo
|
||||||
|
.vscode
|
||||||
|
|||||||
10
bin/cli.js
10
bin/cli.js
@@ -27,9 +27,15 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsingFlags && arg === "-e" && i + 1 < args.length) {
|
if (parsingFlags && arg === "-e" && i + 1 < args.length) {
|
||||||
const [key, value] = args[++i].split("=");
|
const envVar = args[++i];
|
||||||
if (key && value) {
|
const equalsIndex = envVar.indexOf("=");
|
||||||
|
|
||||||
|
if (equalsIndex !== -1) {
|
||||||
|
const key = envVar.substring(0, equalsIndex);
|
||||||
|
const value = envVar.substring(equalsIndex + 1);
|
||||||
envVars[key] = value;
|
envVars[key] = value;
|
||||||
|
} else {
|
||||||
|
envVars[envVar] = "";
|
||||||
}
|
}
|
||||||
} else if (!command) {
|
} else if (!command) {
|
||||||
command = arg;
|
command = arg;
|
||||||
|
|||||||
37
client/jest.config.cjs
Normal file
37
client/jest.config.cjs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: "ts-jest",
|
||||||
|
testEnvironment: "jsdom",
|
||||||
|
moduleNameMapper: {
|
||||||
|
"^@/(.*)$": "<rootDir>/src/$1",
|
||||||
|
"^../components/DynamicJsonForm$":
|
||||||
|
"<rootDir>/src/utils/__mocks__/DynamicJsonForm.ts",
|
||||||
|
"^../../components/DynamicJsonForm$":
|
||||||
|
"<rootDir>/src/utils/__mocks__/DynamicJsonForm.ts",
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
"^.+\\.tsx?$": [
|
||||||
|
"ts-jest",
|
||||||
|
{
|
||||||
|
useESM: true,
|
||||||
|
jsx: "react-jsx",
|
||||||
|
tsconfig: "tsconfig.jest.json",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
extensionsToTreatAsEsm: [".ts", ".tsx"],
|
||||||
|
testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
|
||||||
|
// Exclude directories and files that don't need to be tested
|
||||||
|
testPathIgnorePatterns: [
|
||||||
|
"/node_modules/",
|
||||||
|
"/dist/",
|
||||||
|
"/bin/",
|
||||||
|
"\\.config\\.(js|ts|cjs|mjs)$",
|
||||||
|
],
|
||||||
|
// Exclude the same patterns from coverage reports
|
||||||
|
coveragePathIgnorePatterns: [
|
||||||
|
"/node_modules/",
|
||||||
|
"/dist/",
|
||||||
|
"/bin/",
|
||||||
|
"\\.config\\.(js|ts|cjs|mjs)$",
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -18,7 +18,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "jest --config jest.config.cjs",
|
||||||
|
"test:watch": "jest --config jest.config.cjs --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
"@modelcontextprotocol/sdk": "^1.6.1",
|
||||||
@@ -35,8 +37,8 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.4",
|
"cmdk": "^1.0.4",
|
||||||
"lucide-react": "^0.447.0",
|
"lucide-react": "^0.447.0",
|
||||||
"prismjs": "^1.29.0",
|
|
||||||
"pkce-challenge": "^4.1.0",
|
"pkce-challenge": "^4.1.0",
|
||||||
|
"prismjs": "^1.29.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
@@ -48,18 +50,23 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.11.1",
|
"@eslint/js": "^9.11.1",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^22.7.5",
|
"@types/node": "^22.7.5",
|
||||||
"@types/react": "^18.3.10",
|
"@types/react": "^18.3.10",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/serve-handler": "^6.1.4",
|
"@types/serve-handler": "^6.1.4",
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"co": "^4.6.0",
|
||||||
"eslint": "^9.11.1",
|
"eslint": "^9.11.1",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.12",
|
"eslint-plugin-react-refresh": "^0.4.12",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
|
"ts-jest": "^29.2.6",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"typescript-eslint": "^8.7.0",
|
"typescript-eslint": "^8.7.0",
|
||||||
"vite": "^5.4.8"
|
"vite": "^5.4.8"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Root,
|
Root,
|
||||||
ServerNotification,
|
ServerNotification,
|
||||||
Tool,
|
Tool,
|
||||||
|
LoggingLevel,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import React, { Suspense, useEffect, useRef, useState } from "react";
|
import React, { Suspense, useEffect, useRef, useState } from "react";
|
||||||
import { useConnection } from "./lib/hooks/useConnection";
|
import { useConnection } from "./lib/hooks/useConnection";
|
||||||
@@ -91,6 +92,7 @@ const App = () => {
|
|||||||
(localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio"
|
(localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
|
||||||
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
||||||
const [stdErrNotifications, setStdErrNotifications] = useState<
|
const [stdErrNotifications, setStdErrNotifications] = useState<
|
||||||
StdErrNotification[]
|
StdErrNotification[]
|
||||||
@@ -412,6 +414,17 @@ const App = () => {
|
|||||||
await sendNotification({ method: "notifications/roots/list_changed" });
|
await sendNotification({ method: "notifications/roots/list_changed" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sendLogLevelRequest = async (level: LoggingLevel) => {
|
||||||
|
await makeRequest(
|
||||||
|
{
|
||||||
|
method: "logging/setLevel" as const,
|
||||||
|
params: { level },
|
||||||
|
},
|
||||||
|
z.object({}),
|
||||||
|
);
|
||||||
|
setLogLevel(level);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<div className="flex h-screen bg-background">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
@@ -430,6 +443,9 @@ const App = () => {
|
|||||||
setBearerToken={setBearerToken}
|
setBearerToken={setBearerToken}
|
||||||
onConnect={connectMcpServer}
|
onConnect={connectMcpServer}
|
||||||
stdErrNotifications={stdErrNotifications}
|
stdErrNotifications={stdErrNotifications}
|
||||||
|
logLevel={logLevel}
|
||||||
|
sendLogLevelRequest={sendLogLevelRequest}
|
||||||
|
loggingSupported={!!serverCapabilities?.logging || false}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
|
|||||||
@@ -1,26 +1,36 @@
|
|||||||
import { useState } 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 { Label } from "@/components/ui/label";
|
||||||
import JsonEditor from "./JsonEditor";
|
import JsonEditor from "./JsonEditor";
|
||||||
|
import { updateValueAtPath, JsonObject } from "@/utils/jsonPathUtils";
|
||||||
|
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils";
|
||||||
|
|
||||||
export type JsonValue =
|
export type JsonValue =
|
||||||
| string
|
| string
|
||||||
| number
|
| number
|
||||||
| boolean
|
| boolean
|
||||||
| null
|
| null
|
||||||
|
| undefined
|
||||||
| JsonValue[]
|
| JsonValue[]
|
||||||
| { [key: string]: JsonValue };
|
| { [key: string]: JsonValue };
|
||||||
|
|
||||||
export type JsonSchemaType = {
|
export type JsonSchemaType = {
|
||||||
type: "string" | "number" | "integer" | "boolean" | "array" | "object";
|
type:
|
||||||
|
| "string"
|
||||||
|
| "number"
|
||||||
|
| "integer"
|
||||||
|
| "boolean"
|
||||||
|
| "array"
|
||||||
|
| "object"
|
||||||
|
| "null";
|
||||||
description?: string;
|
description?: string;
|
||||||
|
required?: boolean;
|
||||||
|
default?: JsonValue;
|
||||||
properties?: Record<string, JsonSchemaType>;
|
properties?: Record<string, JsonSchemaType>;
|
||||||
items?: JsonSchemaType;
|
items?: JsonSchemaType;
|
||||||
};
|
};
|
||||||
|
|
||||||
type JsonObject = { [key: string]: JsonValue };
|
|
||||||
|
|
||||||
interface DynamicJsonFormProps {
|
interface DynamicJsonFormProps {
|
||||||
schema: JsonSchemaType;
|
schema: JsonSchemaType;
|
||||||
value: JsonValue;
|
value: JsonValue;
|
||||||
@@ -28,13 +38,6 @@ interface DynamicJsonFormProps {
|
|||||||
maxDepth?: number;
|
maxDepth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatFieldLabel = (key: string): string => {
|
|
||||||
return key
|
|
||||||
.replace(/([A-Z])/g, " $1") // Insert space before capital letters
|
|
||||||
.replace(/_/g, " ") // Replace underscores with spaces
|
|
||||||
.replace(/^\w/, (c) => c.toUpperCase()); // Capitalize first letter
|
|
||||||
};
|
|
||||||
|
|
||||||
const DynamicJsonForm = ({
|
const DynamicJsonForm = ({
|
||||||
schema,
|
schema,
|
||||||
value,
|
value,
|
||||||
@@ -43,29 +46,65 @@ const DynamicJsonForm = ({
|
|||||||
}: DynamicJsonFormProps) => {
|
}: DynamicJsonFormProps) => {
|
||||||
const [isJsonMode, setIsJsonMode] = useState(false);
|
const [isJsonMode, setIsJsonMode] = useState(false);
|
||||||
const [jsonError, setJsonError] = useState<string>();
|
const [jsonError, setJsonError] = useState<string>();
|
||||||
|
// Store the raw JSON string to allow immediate feedback during typing
|
||||||
|
// while deferring parsing until the user stops typing
|
||||||
|
const [rawJsonValue, setRawJsonValue] = useState<string>(
|
||||||
|
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
const generateDefaultValue = (propSchema: JsonSchemaType): JsonValue => {
|
// Use a ref to manage debouncing timeouts to avoid parsing JSON
|
||||||
switch (propSchema.type) {
|
// on every keystroke which would be inefficient and error-prone
|
||||||
case "string":
|
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
return "";
|
|
||||||
case "number":
|
// Debounce JSON parsing and parent updates to handle typing gracefully
|
||||||
case "integer":
|
const debouncedUpdateParent = useCallback(
|
||||||
return 0;
|
(jsonString: string) => {
|
||||||
case "boolean":
|
// Clear any existing timeout
|
||||||
return false;
|
if (timeoutRef.current) {
|
||||||
case "array":
|
clearTimeout(timeoutRef.current);
|
||||||
return [];
|
|
||||||
case "object": {
|
|
||||||
const obj: JsonObject = {};
|
|
||||||
if (propSchema.properties) {
|
|
||||||
Object.entries(propSchema.properties).forEach(([key, prop]) => {
|
|
||||||
obj[key] = generateDefaultValue(prop);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
}
|
}
|
||||||
default:
|
|
||||||
return null;
|
// Set a new timeout
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonString);
|
||||||
|
onChange(parsed);
|
||||||
|
setJsonError(undefined);
|
||||||
|
} catch {
|
||||||
|
// Don't set error during normal typing
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
[onChange, setJsonError],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update rawJsonValue when value prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isJsonMode) {
|
||||||
|
setRawJsonValue(
|
||||||
|
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [value, schema, isJsonMode]);
|
||||||
|
|
||||||
|
const handleSwitchToFormMode = () => {
|
||||||
|
if (isJsonMode) {
|
||||||
|
// When switching to Form mode, ensure we have valid JSON
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawJsonValue);
|
||||||
|
// Update the parent component's state with the parsed value
|
||||||
|
onChange(parsed);
|
||||||
|
// Switch to form mode
|
||||||
|
setIsJsonMode(false);
|
||||||
|
} catch (err) {
|
||||||
|
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update raw JSON value when switching to JSON mode
|
||||||
|
setRawJsonValue(
|
||||||
|
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
|
||||||
|
);
|
||||||
|
setIsJsonMode(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,21 +142,68 @@ const DynamicJsonForm = ({
|
|||||||
|
|
||||||
switch (propSchema.type) {
|
switch (propSchema.type) {
|
||||||
case "string":
|
case "string":
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={propSchema.description}
|
||||||
|
required={propSchema.required}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "number":
|
case "number":
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
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) {
|
||||||
|
handleFieldChange(path, undefined);
|
||||||
|
} else {
|
||||||
|
const num = Number(val);
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
handleFieldChange(path, num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={propSchema.description}
|
||||||
|
required={propSchema.required}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "integer":
|
case "integer":
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
type={propSchema.type === "string" ? "text" : "number"}
|
type="number"
|
||||||
value={(currentValue as string | number) ?? ""}
|
step="1"
|
||||||
onChange={(e) =>
|
value={(currentValue as number)?.toString() ?? ""}
|
||||||
handleFieldChange(
|
onChange={(e) => {
|
||||||
path,
|
const val = e.target.value;
|
||||||
propSchema.type === "string"
|
// Allow clearing non-required integer fields
|
||||||
? e.target.value
|
// This preserves the distinction between 0 and unset
|
||||||
: Number(e.target.value),
|
if (!val && !propSchema.required) {
|
||||||
)
|
handleFieldChange(path, undefined);
|
||||||
}
|
} else {
|
||||||
|
const num = Number(val);
|
||||||
|
// Only update if it's a valid integer
|
||||||
|
if (!isNaN(num) && Number.isInteger(num)) {
|
||||||
|
handleFieldChange(path, num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder={propSchema.description}
|
placeholder={propSchema.description}
|
||||||
|
required={propSchema.required}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "boolean":
|
case "boolean":
|
||||||
@@ -127,25 +213,53 @@ const DynamicJsonForm = ({
|
|||||||
checked={(currentValue as boolean) ?? false}
|
checked={(currentValue as boolean) ?? false}
|
||||||
onChange={(e) => handleFieldChange(path, e.target.checked)}
|
onChange={(e) => handleFieldChange(path, e.target.checked)}
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
|
required={propSchema.required}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "object":
|
case "object": {
|
||||||
if (!propSchema.properties) return null;
|
// Handle case where we have a value but no schema properties
|
||||||
return (
|
const objectValue = (currentValue as JsonObject) || {};
|
||||||
<div className="space-y-4 border rounded-md p-4">
|
|
||||||
{Object.entries(propSchema.properties).map(([key, prop]) => (
|
// If we have schema properties, use them to render fields
|
||||||
<div key={key} className="space-y-2">
|
if (propSchema.properties) {
|
||||||
<Label>{formatFieldLabel(key)}</Label>
|
return (
|
||||||
{renderFormFields(
|
<div className="space-y-4 border rounded-md p-4">
|
||||||
prop,
|
{Object.entries(propSchema.properties).map(([key, prop]) => (
|
||||||
(currentValue as JsonObject)?.[key],
|
<div key={key} className="space-y-2">
|
||||||
[...path, key],
|
<Label>{formatFieldLabel(key)}</Label>
|
||||||
depth + 1,
|
{renderFormFields(
|
||||||
)}
|
prop,
|
||||||
</div>
|
objectValue[key],
|
||||||
))}
|
[...path, key],
|
||||||
</div>
|
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": {
|
case "array": {
|
||||||
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
|
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
|
||||||
if (!propSchema.items) return null;
|
if (!propSchema.items) return null;
|
||||||
@@ -187,9 +301,12 @@ const DynamicJsonForm = ({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
const defaultValue = generateDefaultValue(
|
||||||
|
propSchema.items as JsonSchemaType,
|
||||||
|
);
|
||||||
handleFieldChange(path, [
|
handleFieldChange(path, [
|
||||||
...arrayValue,
|
...arrayValue,
|
||||||
generateDefaultValue(propSchema.items as JsonSchemaType),
|
defaultValue ?? null,
|
||||||
]);
|
]);
|
||||||
}}
|
}}
|
||||||
title={
|
title={
|
||||||
@@ -215,139 +332,65 @@ const DynamicJsonForm = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateArray = (
|
|
||||||
array: JsonValue[],
|
|
||||||
path: string[],
|
|
||||||
value: JsonValue,
|
|
||||||
): JsonValue[] => {
|
|
||||||
const [index, ...restPath] = path;
|
|
||||||
const arrayIndex = Number(index);
|
|
||||||
|
|
||||||
// Validate array index
|
|
||||||
if (isNaN(arrayIndex)) {
|
|
||||||
console.error(`Invalid array index: ${index}`);
|
|
||||||
return array;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check array bounds
|
|
||||||
if (arrayIndex < 0) {
|
|
||||||
console.error(`Array index out of bounds: ${arrayIndex} < 0`);
|
|
||||||
return array;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newArray = [...array];
|
|
||||||
|
|
||||||
if (restPath.length === 0) {
|
|
||||||
newArray[arrayIndex] = value;
|
|
||||||
} else {
|
|
||||||
// Ensure index position exists
|
|
||||||
if (arrayIndex >= array.length) {
|
|
||||||
console.warn(`Extending array to index ${arrayIndex}`);
|
|
||||||
newArray.length = arrayIndex + 1;
|
|
||||||
newArray.fill(null, array.length, arrayIndex);
|
|
||||||
}
|
|
||||||
newArray[arrayIndex] = updateValue(
|
|
||||||
newArray[arrayIndex],
|
|
||||||
restPath,
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return newArray;
|
|
||||||
};
|
|
||||||
|
|
||||||
const 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)) {
|
|
||||||
console.warn(`Creating new key in object: ${key}`);
|
|
||||||
newObj[key] = {};
|
|
||||||
}
|
|
||||||
newObj[key] = updateValue(newObj[key], restPath, value);
|
|
||||||
}
|
|
||||||
return newObj;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateValue = (
|
|
||||||
current: JsonValue,
|
|
||||||
path: string[],
|
|
||||||
value: JsonValue,
|
|
||||||
): JsonValue => {
|
|
||||||
if (path.length === 0) return value;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!current) {
|
|
||||||
current = !isNaN(Number(path[0])) ? [] : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type checking
|
|
||||||
if (Array.isArray(current)) {
|
|
||||||
return updateArray(current, path, value);
|
|
||||||
} else if (typeof current === "object" && current !== null) {
|
|
||||||
return updateObject(current, path, value);
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
`Cannot update path ${path.join(".")} in non-object/array value:`,
|
|
||||||
current,
|
|
||||||
);
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error updating value at path ${path.join(".")}:`, error);
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newValue = updateValue(value, path, fieldValue);
|
const newValue = updateValueAtPath(value, path, fieldValue);
|
||||||
onChange(newValue);
|
onChange(newValue);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update form value:", error);
|
console.error("Failed to update form value:", error);
|
||||||
// Keep the original value unchanged
|
|
||||||
onChange(value);
|
onChange(value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shouldUseJsonMode =
|
||||||
|
schema.type === "object" &&
|
||||||
|
(!schema.properties || Object.keys(schema.properties).length === 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldUseJsonMode && !isJsonMode) {
|
||||||
|
setIsJsonMode(true);
|
||||||
|
}
|
||||||
|
}, [shouldUseJsonMode, isJsonMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsJsonMode(!isJsonMode)}
|
|
||||||
>
|
|
||||||
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
|
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isJsonMode ? (
|
{isJsonMode ? (
|
||||||
<JsonEditor
|
<JsonEditor
|
||||||
value={JSON.stringify(value ?? generateDefaultValue(schema), null, 2)}
|
value={rawJsonValue}
|
||||||
onChange={(newValue) => {
|
onChange={(newValue) => {
|
||||||
try {
|
// Always update local state
|
||||||
onChange(JSON.parse(newValue));
|
setRawJsonValue(newValue);
|
||||||
setJsonError(undefined);
|
|
||||||
} catch (err) {
|
// Use the debounced function to attempt parsing and updating parent
|
||||||
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
|
debouncedUpdateParent(newValue);
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
error={jsonError}
|
error={jsonError}
|
||||||
/>
|
/>
|
||||||
|
) : // If schema type is object but value is not an object or is empty, and we have actual JSON data,
|
||||||
|
// render a simple representation of the JSON data
|
||||||
|
schema.type === "object" &&
|
||||||
|
(typeof value !== "object" ||
|
||||||
|
value === null ||
|
||||||
|
Object.keys(value).length === 0) &&
|
||||||
|
rawJsonValue &&
|
||||||
|
rawJsonValue !== "{}" ? (
|
||||||
|
<div className="space-y-4 border rounded-md p-4">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Form view not available for this JSON structure. Using simplified
|
||||||
|
view:
|
||||||
|
</p>
|
||||||
|
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto">
|
||||||
|
{rawJsonValue}
|
||||||
|
</pre>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Use JSON mode for full editing capabilities.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
renderFormFields(schema, value)
|
renderFormFields(schema, value)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
import Editor from "react-simple-code-editor";
|
import Editor from "react-simple-code-editor";
|
||||||
import Prism from "prismjs";
|
import Prism from "prismjs";
|
||||||
import "prismjs/components/prism-json";
|
import "prismjs/components/prism-json";
|
||||||
@@ -10,7 +11,20 @@ interface JsonEditorProps {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => {
|
const JsonEditor = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
error: externalError,
|
||||||
|
}: JsonEditorProps) => {
|
||||||
|
const [editorContent, setEditorContent] = useState(value);
|
||||||
|
const [internalError, setInternalError] = useState<string | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditorContent(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
const formatJson = (json: string): string => {
|
const formatJson = (json: string): string => {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(JSON.parse(json), null, 2);
|
return JSON.stringify(JSON.parse(json), null, 2);
|
||||||
@@ -19,25 +33,42 @@ const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditorChange = (newContent: string) => {
|
||||||
|
setEditorContent(newContent);
|
||||||
|
setInternalError(undefined);
|
||||||
|
onChange(newContent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormatJson = () => {
|
||||||
|
try {
|
||||||
|
const formatted = formatJson(editorContent);
|
||||||
|
setEditorContent(formatted);
|
||||||
|
onChange(formatted);
|
||||||
|
setInternalError(undefined);
|
||||||
|
} catch (err) {
|
||||||
|
setInternalError(err instanceof Error ? err.message : "Invalid JSON");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayError = internalError || externalError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative space-y-2">
|
<div className="relative space-y-2">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={handleFormatJson}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onChange(formatJson(value))}
|
|
||||||
>
|
|
||||||
Format JSON
|
Format JSON
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`border rounded-md ${
|
className={`border rounded-md ${
|
||||||
error ? "border-red-500" : "border-gray-200 dark:border-gray-800"
|
displayError
|
||||||
|
? "border-red-500"
|
||||||
|
: "border-gray-200 dark:border-gray-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Editor
|
<Editor
|
||||||
value={value}
|
value={editorContent}
|
||||||
onValueChange={onChange}
|
onValueChange={handleEditorChange}
|
||||||
highlight={(code) =>
|
highlight={(code) =>
|
||||||
Prism.highlight(code, Prism.languages.json, "json")
|
Prism.highlight(code, Prism.languages.json, "json")
|
||||||
}
|
}
|
||||||
@@ -51,7 +82,9 @@ const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
|
{displayError && (
|
||||||
|
<p className="text-sm text-red-500 mt-1">{displayError}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { StdErrNotification } from "@/lib/notificationTypes";
|
import { StdErrNotification } from "@/lib/notificationTypes";
|
||||||
|
import {
|
||||||
|
LoggingLevel,
|
||||||
|
LoggingLevelSchema,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
|
||||||
import useTheme from "../lib/useTheme";
|
import useTheme from "../lib/useTheme";
|
||||||
import { version } from "../../../package.json";
|
import { version } from "../../../package.json";
|
||||||
@@ -39,6 +43,9 @@ interface SidebarProps {
|
|||||||
setBearerToken: (token: string) => void;
|
setBearerToken: (token: string) => void;
|
||||||
onConnect: () => void;
|
onConnect: () => void;
|
||||||
stdErrNotifications: StdErrNotification[];
|
stdErrNotifications: StdErrNotification[];
|
||||||
|
logLevel: LoggingLevel;
|
||||||
|
sendLogLevelRequest: (level: LoggingLevel) => void;
|
||||||
|
loggingSupported: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Sidebar = ({
|
const Sidebar = ({
|
||||||
@@ -57,6 +64,9 @@ const Sidebar = ({
|
|||||||
setBearerToken,
|
setBearerToken,
|
||||||
onConnect,
|
onConnect,
|
||||||
stdErrNotifications,
|
stdErrNotifications,
|
||||||
|
logLevel,
|
||||||
|
sendLogLevelRequest,
|
||||||
|
loggingSupported,
|
||||||
}: SidebarProps) => {
|
}: SidebarProps) => {
|
||||||
const [theme, setTheme] = useTheme();
|
const [theme, setTheme] = useTheme();
|
||||||
const [showEnvVars, setShowEnvVars] = useState(false);
|
const [showEnvVars, setShowEnvVars] = useState(false);
|
||||||
@@ -290,6 +300,28 @@ const Sidebar = ({
|
|||||||
: "Disconnected"}
|
: "Disconnected"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{loggingSupported && connectionStatus === "connected" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Logging Level</label>
|
||||||
|
<Select
|
||||||
|
value={logLevel}
|
||||||
|
onValueChange={(value: LoggingLevel) =>
|
||||||
|
sendLogLevelRequest(value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select logging level" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.values(LoggingLevelSchema.enum).map((level) => (
|
||||||
|
<SelectItem value={level}>{level}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{stdErrNotifications.length > 0 && (
|
{stdErrNotifications.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="mt-4 border-t border-gray-200 pt-4">
|
<div className="mt-4 border-t border-gray-200 pt-4">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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, { JsonSchemaType, JsonValue } from "./DynamicJsonForm";
|
||||||
|
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||||
import {
|
import {
|
||||||
ListToolsResult,
|
ListToolsResult,
|
||||||
Tool,
|
Tool,
|
||||||
@@ -214,7 +215,10 @@ const ToolsTab = ({
|
|||||||
description: prop.description,
|
description: prop.description,
|
||||||
items: prop.items,
|
items: prop.items,
|
||||||
}}
|
}}
|
||||||
value={(params[key] as JsonValue) ?? {}}
|
value={
|
||||||
|
(params[key] as JsonValue) ??
|
||||||
|
generateDefaultValue(prop)
|
||||||
|
}
|
||||||
onChange={(newValue: JsonValue) => {
|
onChange={(newValue: JsonValue) => {
|
||||||
setParams({
|
setParams({
|
||||||
...params,
|
...params,
|
||||||
|
|||||||
180
client/src/utils/__tests__/jsonPathUtils.test.ts
Normal file
180
client/src/utils/__tests__/jsonPathUtils.test.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { updateValueAtPath, getValueAtPath } from "../jsonPathUtils";
|
||||||
|
import { JsonValue } from "../../components/DynamicJsonForm";
|
||||||
|
|
||||||
|
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 as any, ["foo"], "bar")).toEqual({
|
||||||
|
foo: "bar",
|
||||||
|
});
|
||||||
|
expect(updateValueAtPath(undefined as any, ["foo"], "bar")).toEqual({
|
||||||
|
foo: "bar",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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(undefined as any, ["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 as any, ["foo"], "default")).toBe("default");
|
||||||
|
expect(getValueAtPath(undefined as any, ["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");
|
||||||
|
});
|
||||||
|
});
|
||||||
139
client/src/utils/__tests__/schemaUtils.test.ts
Normal file
139
client/src/utils/__tests__/schemaUtils.test.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { generateDefaultValue, formatFieldLabel } from "../schemaUtils";
|
||||||
|
import { JsonSchemaType } from "../../components/DynamicJsonForm";
|
||||||
|
|
||||||
|
describe("generateDefaultValue", () => {
|
||||||
|
test("generates default string", () => {
|
||||||
|
expect(generateDefaultValue({ type: "string", required: true })).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates default number", () => {
|
||||||
|
expect(generateDefaultValue({ type: "number", required: true })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates default integer", () => {
|
||||||
|
expect(generateDefaultValue({ type: "integer", required: true })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates default boolean", () => {
|
||||||
|
expect(generateDefaultValue({ type: "boolean", required: true })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates default array", () => {
|
||||||
|
expect(generateDefaultValue({ type: "array", required: true })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates default empty object", () => {
|
||||||
|
expect(generateDefaultValue({ type: "object", required: true })).toEqual(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates default null for unknown types", () => {
|
||||||
|
// @ts-expect-error Testing with invalid type
|
||||||
|
expect(generateDefaultValue({ type: "unknown", required: true })).toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates empty array for non-required array", () => {
|
||||||
|
expect(generateDefaultValue({ type: "array", required: false })).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates empty object for non-required object", () => {
|
||||||
|
expect(generateDefaultValue({ type: "object", required: false })).toEqual(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates null for non-required primitive types", () => {
|
||||||
|
expect(generateDefaultValue({ type: "string", required: false })).toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(generateDefaultValue({ type: "number", required: false })).toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(generateDefaultValue({ type: "boolean", required: false })).toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates object with properties", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "object",
|
||||||
|
required: true,
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", required: true },
|
||||||
|
age: { type: "number", required: true },
|
||||||
|
isActive: { type: "boolean", required: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(generateDefaultValue(schema)).toEqual({
|
||||||
|
name: "",
|
||||||
|
age: 0,
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles nested objects", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "object",
|
||||||
|
required: true,
|
||||||
|
properties: {
|
||||||
|
user: {
|
||||||
|
type: "object",
|
||||||
|
required: true,
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", required: true },
|
||||||
|
address: {
|
||||||
|
type: "object",
|
||||||
|
required: true,
|
||||||
|
properties: {
|
||||||
|
city: { type: "string", required: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(generateDefaultValue(schema)).toEqual({
|
||||||
|
user: {
|
||||||
|
name: "",
|
||||||
|
address: {
|
||||||
|
city: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses schema default value when provided", () => {
|
||||||
|
expect(generateDefaultValue({ type: "string", default: "test" })).toBe(
|
||||||
|
"test",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatFieldLabel", () => {
|
||||||
|
test("formats camelCase", () => {
|
||||||
|
expect(formatFieldLabel("firstName")).toBe("First Name");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats snake_case", () => {
|
||||||
|
expect(formatFieldLabel("first_name")).toBe("First name");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats single word", () => {
|
||||||
|
expect(formatFieldLabel("name")).toBe("Name");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats mixed case with underscores", () => {
|
||||||
|
expect(formatFieldLabel("user_firstName")).toBe("User first Name");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles empty string", () => {
|
||||||
|
expect(formatFieldLabel("")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
149
client/src/utils/jsonPathUtils.ts
Normal file
149
client/src/utils/jsonPathUtils.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { JsonValue } from "../components/DynamicJsonForm";
|
||||||
|
|
||||||
|
export type JsonObject = { [key: string]: JsonValue };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
57
client/src/utils/schemaUtils.ts
Normal file
57
client/src/utils/schemaUtils.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { JsonValue, JsonSchemaType } from "../components/DynamicJsonForm";
|
||||||
|
import { JsonObject } from "./jsonPathUtils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a default value based on a JSON schema type
|
||||||
|
* @param schema The JSON schema definition
|
||||||
|
* @returns A default value matching the schema type, or null for non-required fields
|
||||||
|
*/
|
||||||
|
export function generateDefaultValue(schema: JsonSchemaType): JsonValue {
|
||||||
|
if ("default" in schema) {
|
||||||
|
return schema.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema.required) {
|
||||||
|
if (schema.type === "array") return [];
|
||||||
|
if (schema.type === "object") return {};
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (schema.type) {
|
||||||
|
case "string":
|
||||||
|
return "";
|
||||||
|
case "number":
|
||||||
|
case "integer":
|
||||||
|
return 0;
|
||||||
|
case "boolean":
|
||||||
|
return false;
|
||||||
|
case "array":
|
||||||
|
return [];
|
||||||
|
case "object": {
|
||||||
|
if (!schema.properties) return {};
|
||||||
|
|
||||||
|
const obj: JsonObject = {};
|
||||||
|
Object.entries(schema.properties)
|
||||||
|
.filter(([, prop]) => prop.required)
|
||||||
|
.forEach(([key, prop]) => {
|
||||||
|
const value = generateDefaultValue(prop);
|
||||||
|
obj[key] = value;
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a field key into a human-readable label
|
||||||
|
* @param key The field key to format
|
||||||
|
* @returns A formatted label string
|
||||||
|
*/
|
||||||
|
export function formatFieldLabel(key: string): string {
|
||||||
|
return key
|
||||||
|
.replace(/([A-Z])/g, " $1") // Insert space before capital letters
|
||||||
|
.replace(/_/g, " ") // Replace underscores with spaces
|
||||||
|
.replace(/^\w/, (c) => c.toUpperCase()); // Capitalize first letter
|
||||||
|
}
|
||||||
10
client/tsconfig.jest.json
Normal file
10
client/tsconfig.jest.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.app.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
3269
package-lock.json
generated
3269
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user