Default to nulls and update tests

This commit is contained in:
Ola Hungerford
2025-02-27 21:33:37 -07:00
parent 6ec82e21b1
commit 44982e6c97
5 changed files with 120 additions and 33 deletions

View File

@@ -24,8 +24,8 @@
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.4.1", "@modelcontextprotocol/sdk": "^1.4.1",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.3", "@radix-ui/react-popover": "^1.1.3",
@@ -37,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",
@@ -57,6 +57,7 @@
"@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",

View File

@@ -11,12 +11,15 @@ export type JsonValue =
| 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;
}; };
@@ -105,21 +108,61 @@ 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;
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;
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" if (!val && !propSchema.required) {
? e.target.value handleFieldChange(path, undefined);
: Number(e.target.value), } else {
) const num = Number(val);
} if (!isNaN(num) && Number.isInteger(num)) {
handleFieldChange(path, num);
}
}
}}
placeholder={propSchema.description} placeholder={propSchema.description}
required={propSchema.required}
/> />
); );
case "boolean": case "boolean":
@@ -129,6 +172,7 @@ 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": {
@@ -216,9 +260,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={

View File

@@ -7,40 +7,42 @@ import { JsonSchemaType } from "../../components/DynamicJsonForm";
describe("generateDefaultValue", () => { describe("generateDefaultValue", () => {
test("generates default string", () => { test("generates default string", () => {
expect(generateDefaultValue({ type: "string" })).toBe(""); expect(generateDefaultValue({ type: "string", required: true })).toBe("");
}); });
test("generates default number", () => { test("generates default number", () => {
expect(generateDefaultValue({ type: "number" })).toBe(0); expect(generateDefaultValue({ type: "number", required: true })).toBe(0);
}); });
test("generates default integer", () => { test("generates default integer", () => {
expect(generateDefaultValue({ type: "integer" })).toBe(0); expect(generateDefaultValue({ type: "integer", required: true })).toBe(0);
}); });
test("generates default boolean", () => { test("generates default boolean", () => {
expect(generateDefaultValue({ type: "boolean" })).toBe(false); expect(generateDefaultValue({ type: "boolean", required: true })).toBe(false);
}); });
test("generates default array", () => { test("generates default array", () => {
expect(generateDefaultValue({ type: "array" })).toEqual([]); expect(generateDefaultValue({ type: "array", required: true })).toEqual([]);
}); });
test("generates default empty object", () => { test("generates default empty object", () => {
expect(generateDefaultValue({ type: "object" })).toEqual({}); expect(generateDefaultValue({ type: "object", required: true })).toEqual({});
}); });
test("generates default null for unknown types", () => { test("generates default null for unknown types", () => {
expect(generateDefaultValue({ type: "unknown" as any })).toBe(null); // @ts-expect-error Testing with invalid type
expect(generateDefaultValue({ type: "unknown", required: true })).toBe(null);
}); });
test("generates object with properties", () => { test("generates object with properties", () => {
const schema: JsonSchemaType = { const schema: JsonSchemaType = {
type: "object", type: "object",
required: true,
properties: { properties: {
name: { type: "string" }, name: { type: "string", required: true },
age: { type: "number" }, age: { type: "number", required: true },
isActive: { type: "boolean" }, isActive: { type: "boolean", required: true },
}, },
}; };
expect(generateDefaultValue(schema)).toEqual({ expect(generateDefaultValue(schema)).toEqual({
@@ -53,15 +55,18 @@ describe("generateDefaultValue", () => {
test("handles nested objects", () => { test("handles nested objects", () => {
const schema: JsonSchemaType = { const schema: JsonSchemaType = {
type: "object", type: "object",
required: true,
properties: { properties: {
user: { user: {
type: "object", type: "object",
required: true,
properties: { properties: {
name: { type: "string" }, name: { type: "string", required: true },
address: { address: {
type: "object", type: "object",
required: true,
properties: { properties: {
city: { type: "string" }, city: { type: "string", required: true },
}, },
}, },
}, },
@@ -135,7 +140,8 @@ describe("validateValueAgainstSchema", () => {
test("returns true for unknown types", () => { test("returns true for unknown types", () => {
expect( expect(
validateValueAgainstSchema("anything", { type: "unknown" as any }), // @ts-expect-error Testing with invalid type
validateValueAgainstSchema("anything", { type: "unknown" }),
).toBe(true); ).toBe(true);
}); });
}); });

View File

@@ -4,9 +4,19 @@ import { JsonObject } from "./jsonPathUtils";
/** /**
* Generates a default value based on a JSON schema type * Generates a default value based on a JSON schema type
* @param schema The JSON schema definition * @param schema The JSON schema definition
* @returns A default value matching the schema type * @returns A default value matching the schema type, or null for non-required fields
*/ */
export function generateDefaultValue(schema: JsonSchemaType): JsonValue { export function generateDefaultValue(schema: JsonSchemaType): JsonValue {
if ("default" in schema) {
// Ensure we don't return undefined even if schema.default is undefined
return schema.default === undefined ? null : schema.default;
}
if (!schema.required) {
return null;
}
switch (schema.type) { switch (schema.type) {
case "string": case "string":
return ""; return "";
@@ -18,12 +28,15 @@ export function generateDefaultValue(schema: JsonSchemaType): JsonValue {
case "array": case "array":
return []; return [];
case "object": { case "object": {
if (!schema.properties) return {};
const obj: JsonObject = {}; const obj: JsonObject = {};
if (schema.properties) { Object.entries(schema.properties)
Object.entries(schema.properties).forEach(([key, prop]) => { .filter(([, prop]) => prop.required)
obj[key] = generateDefaultValue(prop); .forEach(([key, prop]) => {
const value = generateDefaultValue(prop);
obj[key] = value;
}); });
}
return obj; return obj;
} }
default: default:
@@ -53,13 +66,19 @@ export function validateValueAgainstSchema(
value: JsonValue, value: JsonValue,
schema: JsonSchemaType, schema: JsonSchemaType,
): boolean { ): boolean {
// Handle undefined values for non-required fields
if (value === undefined && !schema.required) {
return true;
}
// Basic type validation // Basic type validation
switch (schema.type) { switch (schema.type) {
case "string": case "string":
return typeof value === "string"; return typeof value === "string";
case "number": case "number":
case "integer":
return typeof value === "number"; return typeof value === "number";
case "integer":
return typeof value === "number" && Number.isInteger(value);
case "boolean": case "boolean":
return typeof value === "boolean"; return typeof value === "boolean";
case "array": case "array":
@@ -68,6 +87,8 @@ export function validateValueAgainstSchema(
return ( return (
typeof value === "object" && value !== null && !Array.isArray(value) typeof value === "object" && value !== null && !Array.isArray(value)
); );
case "null":
return value === null;
default: default:
return true; return true;
} }

12
package-lock.json generated
View File

@@ -71,6 +71,7 @@
"@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",
@@ -4800,6 +4801,17 @@
"react-dom": "^18 || ^19 || ^19.0.0-rc" "react-dom": "^18 || ^19 || ^19.0.0-rc"
} }
}, },
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
"integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">= 1.0.0",
"node": ">= 0.12.0"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",