Merge pull request #952 from cliffhall/sep-1330-enums
Add support for SEP-1330 Elicitation Enums
This commit is contained in:
commit
2056622607
6 changed files with 378 additions and 73 deletions
|
|
@ -11,7 +11,11 @@ import { Input } from "@/components/ui/input";
|
||||||
import JsonEditor from "./JsonEditor";
|
import JsonEditor from "./JsonEditor";
|
||||||
import { updateValueAtPath } from "@/utils/jsonUtils";
|
import { updateValueAtPath } from "@/utils/jsonUtils";
|
||||||
import { generateDefaultValue } from "@/utils/schemaUtils";
|
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||||
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
import type {
|
||||||
|
JsonValue,
|
||||||
|
JsonSchemaType,
|
||||||
|
JsonSchemaConst,
|
||||||
|
} from "@/utils/jsonUtils";
|
||||||
import { useToast } from "@/lib/hooks/useToast";
|
import { useToast } from "@/lib/hooks/useToast";
|
||||||
import { CheckCheck, Copy } from "lucide-react";
|
import { CheckCheck, Copy } from "lucide-react";
|
||||||
|
|
||||||
|
|
@ -76,7 +80,40 @@ const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => {
|
||||||
|
|
||||||
const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
||||||
({ schema, value, onChange, maxDepth = 3 }, ref) => {
|
({ schema, value, onChange, maxDepth = 3 }, ref) => {
|
||||||
const isOnlyJSON = !isSimpleObject(schema);
|
// Determine if we can render a form at the top level.
|
||||||
|
// This is more permissive than isSimpleObject():
|
||||||
|
// - Objects with any properties are form-capable (individual complex fields may still fallback to JSON)
|
||||||
|
// - Arrays with defined items are form-capable
|
||||||
|
// - Primitive types are form-capable
|
||||||
|
const canRenderTopLevelForm = (s: JsonSchemaType): boolean => {
|
||||||
|
const primitiveTypes = ["string", "number", "integer", "boolean", "null"];
|
||||||
|
|
||||||
|
const hasType = Array.isArray(s.type) ? s.type.length > 0 : !!s.type;
|
||||||
|
if (!hasType) return false;
|
||||||
|
|
||||||
|
const includesType = (t: string) =>
|
||||||
|
Array.isArray(s.type)
|
||||||
|
? (s.type as ReadonlyArray<string>).includes(t)
|
||||||
|
: s.type === t;
|
||||||
|
|
||||||
|
// Primitive at top-level
|
||||||
|
if (primitiveTypes.some(includesType)) return true;
|
||||||
|
|
||||||
|
// Object with properties
|
||||||
|
if (includesType("object")) {
|
||||||
|
const keys = Object.keys(s.properties ?? {});
|
||||||
|
return keys.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array with items
|
||||||
|
if (includesType("array")) {
|
||||||
|
return !!s.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOnlyJSON = !canRenderTopLevelForm(schema);
|
||||||
const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON);
|
const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON);
|
||||||
const [jsonError, setJsonError] = useState<string>();
|
const [jsonError, setJsonError] = useState<string>();
|
||||||
const [copiedJson, setCopiedJson] = useState<boolean>(false);
|
const [copiedJson, setCopiedJson] = useState<boolean>(false);
|
||||||
|
|
@ -267,63 +304,81 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
||||||
|
|
||||||
switch (fieldType) {
|
switch (fieldType) {
|
||||||
case "string": {
|
case "string": {
|
||||||
if (
|
// Titled single-select using oneOf/anyOf with const/title pairs
|
||||||
propSchema.oneOf &&
|
const titledOptions = (
|
||||||
propSchema.oneOf.every(
|
(propSchema.oneOf ?? propSchema.anyOf) as
|
||||||
(option) =>
|
| (JsonSchemaType | JsonSchemaConst)[]
|
||||||
typeof option.const === "string" &&
|
| undefined
|
||||||
typeof option.title === "string",
|
)?.filter((opt): opt is JsonSchemaConst => "const" in opt);
|
||||||
)
|
|
||||||
) {
|
if (titledOptions && titledOptions.length > 0) {
|
||||||
return (
|
return (
|
||||||
<select
|
<div className="space-y-2">
|
||||||
value={(currentValue as string) ?? ""}
|
{propSchema.description && (
|
||||||
onChange={(e) => {
|
<p className="text-sm text-gray-600">
|
||||||
const val = e.target.value;
|
{propSchema.description}
|
||||||
if (!val && !isRequired) {
|
</p>
|
||||||
handleFieldChange(path, undefined);
|
)}
|
||||||
} else {
|
<select
|
||||||
handleFieldChange(path, val);
|
value={(currentValue as string) ?? ""}
|
||||||
}
|
onChange={(e) => {
|
||||||
}}
|
const val = e.target.value;
|
||||||
required={isRequired}
|
if (!val && !isRequired) {
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
handleFieldChange(path, undefined);
|
||||||
>
|
} else {
|
||||||
<option value="">Select an option...</option>
|
handleFieldChange(path, val);
|
||||||
{propSchema.oneOf.map((option) => (
|
}
|
||||||
<option
|
}}
|
||||||
key={option.const as string}
|
required={isRequired}
|
||||||
value={option.const as string}
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
||||||
>
|
>
|
||||||
{option.title as string}
|
<option value="">Select an option...</option>
|
||||||
</option>
|
{titledOptions.map((option) => (
|
||||||
))}
|
<option
|
||||||
</select>
|
key={String(option.const)}
|
||||||
|
value={String(option.const)}
|
||||||
|
>
|
||||||
|
{option.title ?? String(option.const)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Untitled single-select using enum (with optional legacy enumNames for labels)
|
||||||
if (propSchema.enum) {
|
if (propSchema.enum) {
|
||||||
|
const names = Array.isArray(propSchema.enumNames)
|
||||||
|
? propSchema.enumNames
|
||||||
|
: undefined;
|
||||||
return (
|
return (
|
||||||
<select
|
<div className="space-y-2">
|
||||||
value={(currentValue as string) ?? ""}
|
{propSchema.description && (
|
||||||
onChange={(e) => {
|
<p className="text-sm text-gray-600">
|
||||||
const val = e.target.value;
|
{propSchema.description}
|
||||||
if (!val && !isRequired) {
|
</p>
|
||||||
handleFieldChange(path, undefined);
|
)}
|
||||||
} else {
|
<select
|
||||||
handleFieldChange(path, val);
|
value={(currentValue as string) ?? ""}
|
||||||
}
|
onChange={(e) => {
|
||||||
}}
|
const val = e.target.value;
|
||||||
required={isRequired}
|
if (!val && !isRequired) {
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
handleFieldChange(path, undefined);
|
||||||
>
|
} else {
|
||||||
<option value="">Select an option...</option>
|
handleFieldChange(path, val);
|
||||||
{propSchema.enum.map((option) => (
|
}
|
||||||
<option key={option} value={option}>
|
}}
|
||||||
{option}
|
required={isRequired}
|
||||||
</option>
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
||||||
))}
|
>
|
||||||
</select>
|
<option value="">Select an option...</option>
|
||||||
|
{propSchema.enum.map((option, idx) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{names?.[idx] ?? option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -413,13 +468,20 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
||||||
|
|
||||||
case "boolean":
|
case "boolean":
|
||||||
return (
|
return (
|
||||||
<Input
|
<div className="space-y-2">
|
||||||
type="checkbox"
|
{propSchema.description && (
|
||||||
checked={(currentValue as boolean) ?? false}
|
<p className="text-sm text-gray-600">
|
||||||
onChange={(e) => handleFieldChange(path, e.target.checked)}
|
{propSchema.description}
|
||||||
className="w-4 h-4"
|
</p>
|
||||||
required={isRequired}
|
)}
|
||||||
/>
|
<Input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(currentValue as boolean) ?? false}
|
||||||
|
onChange={(e) => handleFieldChange(path, e.target.checked)}
|
||||||
|
className="w-4 h-4"
|
||||||
|
required={isRequired}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
case "null":
|
case "null":
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -449,7 +511,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
||||||
{Object.entries(propSchema.properties).map(([key, subSchema]) => (
|
{Object.entries(propSchema.properties).map(([key, subSchema]) => (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
{key}
|
{(subSchema as JsonSchemaType).title ?? key}
|
||||||
{propSchema.required?.includes(key) && (
|
{propSchema.required?.includes(key) && (
|
||||||
<span className="text-red-500 ml-1">*</span>
|
<span className="text-red-500 ml-1">*</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -470,6 +532,72 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
||||||
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
|
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
|
||||||
if (!propSchema.items) return null;
|
if (!propSchema.items) return null;
|
||||||
|
|
||||||
|
// Special handling: array of enums -> render multi-select control
|
||||||
|
const itemSchema = propSchema.items as JsonSchemaType;
|
||||||
|
let multiOptions: { value: string; label: string }[] | null = null;
|
||||||
|
|
||||||
|
const titledMulti = (
|
||||||
|
(itemSchema.anyOf ?? itemSchema.oneOf) as
|
||||||
|
| (JsonSchemaType | JsonSchemaConst)[]
|
||||||
|
| undefined
|
||||||
|
)?.filter((opt): opt is JsonSchemaConst => "const" in opt);
|
||||||
|
|
||||||
|
if (titledMulti && titledMulti.length > 0) {
|
||||||
|
multiOptions = titledMulti.map((o) => ({
|
||||||
|
value: String(o.const),
|
||||||
|
label: o.title ?? String(o.const),
|
||||||
|
}));
|
||||||
|
} else if (itemSchema.enum) {
|
||||||
|
const names = Array.isArray(itemSchema.enumNames)
|
||||||
|
? itemSchema.enumNames
|
||||||
|
: undefined;
|
||||||
|
multiOptions = itemSchema.enum.map((v, i) => ({
|
||||||
|
value: v,
|
||||||
|
label: names?.[i] ?? v,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (multiOptions) {
|
||||||
|
const selectSize = Math.min(Math.max(multiOptions.length, 3), 8);
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{propSchema.description && (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{propSchema.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
multiple
|
||||||
|
size={selectSize}
|
||||||
|
value={arrayValue as string[]}
|
||||||
|
onChange={(e) => {
|
||||||
|
const selected = Array.from(
|
||||||
|
(e.target as HTMLSelectElement).selectedOptions,
|
||||||
|
).map((o) => o.value);
|
||||||
|
handleFieldChange(path, selected);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
{multiOptions.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(propSchema.minItems || propSchema.maxItems) && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{propSchema.minItems
|
||||||
|
? `Select at least ${propSchema.minItems}. `
|
||||||
|
: ""}
|
||||||
|
{propSchema.maxItems
|
||||||
|
? `Select at most ${propSchema.maxItems}.`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// If the array items are simple, render as form fields, otherwise use JSON editor
|
// If the array items are simple, render as form fields, otherwise use JSON editor
|
||||||
if (isSimpleObject(propSchema.items)) {
|
if (isSimpleObject(propSchema.items)) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
import { describe, it, expect, jest } from "@jest/globals";
|
import { describe, it, expect, jest } from "@jest/globals";
|
||||||
import DynamicJsonForm from "../DynamicJsonForm";
|
import DynamicJsonForm from "../DynamicJsonForm";
|
||||||
import type { JsonSchemaType } from "@/utils/jsonUtils";
|
import type { JsonSchemaType } from "@/utils/jsonUtils";
|
||||||
|
|
@ -104,7 +105,11 @@ describe("DynamicJsonForm Array Fields", () => {
|
||||||
it("should render JSON editor for complex arrays", () => {
|
it("should render JSON editor for complex arrays", () => {
|
||||||
renderComplexArrayForm();
|
renderComplexArrayForm();
|
||||||
|
|
||||||
// Should render as JSON editor (textarea)
|
// Initially renders form view with Switch to JSON button; switch to JSON to see textarea
|
||||||
|
const switchBtn = screen.getByRole("button", { name: /switch to json/i });
|
||||||
|
expect(switchBtn).toBeInTheDocument();
|
||||||
|
fireEvent.click(switchBtn);
|
||||||
|
|
||||||
const textarea = screen.getByRole("textbox");
|
const textarea = screen.getByRole("textbox");
|
||||||
expect(textarea).toHaveProperty("type", "textarea");
|
expect(textarea).toHaveProperty("type", "textarea");
|
||||||
|
|
||||||
|
|
@ -173,6 +178,81 @@ describe("DynamicJsonForm Array Fields", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Array with Different Item Types", () => {
|
describe("Array with Different Item Types", () => {
|
||||||
|
it("should render multi-select for untitled items.enum with helper text", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "array",
|
||||||
|
title: "Colors",
|
||||||
|
description: "Pick colors",
|
||||||
|
minItems: 1,
|
||||||
|
maxItems: 3,
|
||||||
|
items: { type: "string", enum: ["red", "green", "blue"] },
|
||||||
|
} as JsonSchemaType;
|
||||||
|
const onChange = jest.fn();
|
||||||
|
render(
|
||||||
|
<DynamicJsonForm schema={schema} value={["red"]} onChange={onChange} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Description visible
|
||||||
|
expect(screen.getByText("Pick colors")).toBeInTheDocument();
|
||||||
|
// Multi-select present
|
||||||
|
const listbox = screen.getByRole("listbox");
|
||||||
|
expect(listbox).toHaveAttribute("multiple");
|
||||||
|
|
||||||
|
// Helper text shows min/max
|
||||||
|
expect(screen.getByText(/Select at least 1/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Select at most 3/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Select another option by toggling option.selected
|
||||||
|
const colorOptions = screen.getAllByRole("option");
|
||||||
|
// options: red, green, blue
|
||||||
|
colorOptions[0].selected = true; // red
|
||||||
|
colorOptions[2].selected = true; // blue
|
||||||
|
fireEvent.change(listbox);
|
||||||
|
expect(onChange).toHaveBeenCalledWith(["red", "blue"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render titled multi-select for items.anyOf with const/title", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "array",
|
||||||
|
description: "Pick fish",
|
||||||
|
items: {
|
||||||
|
anyOf: [
|
||||||
|
{ const: "fish-1", title: "Tuna" },
|
||||||
|
{ const: "fish-2", title: "Salmon" },
|
||||||
|
{ const: "fish-3", title: "Trout" },
|
||||||
|
],
|
||||||
|
} as unknown as JsonSchemaType,
|
||||||
|
} as unknown as JsonSchemaType;
|
||||||
|
const onChange = jest.fn();
|
||||||
|
render(
|
||||||
|
<DynamicJsonForm
|
||||||
|
schema={schema}
|
||||||
|
value={["fish-1"]}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Description visible
|
||||||
|
expect(screen.getByText("Pick fish")).toBeInTheDocument();
|
||||||
|
|
||||||
|
const listbox = screen.getByRole("listbox");
|
||||||
|
expect(listbox).toHaveAttribute("multiple");
|
||||||
|
|
||||||
|
// Ensure options have titles as labels
|
||||||
|
const options = screen.getAllByRole("option");
|
||||||
|
expect(options[0]).toHaveProperty("textContent", "Tuna");
|
||||||
|
expect(options[1]).toHaveProperty("textContent", "Salmon");
|
||||||
|
expect(options[2]).toHaveProperty("textContent", "Trout");
|
||||||
|
|
||||||
|
// Select fish-2 and fish-3
|
||||||
|
const fishOptions = screen.getAllByRole("option");
|
||||||
|
// options: Tuna (fish-1), Salmon (fish-2), Trout (fish-3)
|
||||||
|
fishOptions[0].selected = false; // deselect fish-1
|
||||||
|
fishOptions[1].selected = true; // fish-2
|
||||||
|
fishOptions[2].selected = true; // fish-3
|
||||||
|
fireEvent.change(listbox);
|
||||||
|
expect(onChange).toHaveBeenCalledWith(["fish-2", "fish-3"]);
|
||||||
|
});
|
||||||
it("should handle integer array items", () => {
|
it("should handle integer array items", () => {
|
||||||
const schema = {
|
const schema = {
|
||||||
type: "array" as const,
|
type: "array" as const,
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,55 @@ describe("DynamicJsonForm String Fields", () => {
|
||||||
fireEvent.change(select, { target: { value: "amber" } });
|
fireEvent.change(select, { target: { value: "amber" } });
|
||||||
expect(onChange).toHaveBeenCalledWith("amber");
|
expect(onChange).toHaveBeenCalledWith("amber");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should render anyOf with const/title for labeled options and show description", () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "string",
|
||||||
|
title: "Heroes",
|
||||||
|
description: "Choose a hero",
|
||||||
|
anyOf: [
|
||||||
|
{ const: "hero-1", title: "Superman" },
|
||||||
|
{ const: "hero-2", title: "Batman" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
render(<DynamicJsonForm schema={schema} value="" onChange={onChange} />);
|
||||||
|
|
||||||
|
// Description should be visible above the select
|
||||||
|
expect(screen.getByText("Choose a hero")).toBeInTheDocument();
|
||||||
|
const select = screen.getByRole("combobox");
|
||||||
|
const options = screen.getAllByRole("option");
|
||||||
|
expect(options).toHaveLength(3);
|
||||||
|
expect(options[1]).toHaveProperty("textContent", "Superman");
|
||||||
|
expect(options[2]).toHaveProperty("textContent", "Batman");
|
||||||
|
|
||||||
|
fireEvent.change(select, { target: { value: "hero-2" } });
|
||||||
|
expect(onChange).toHaveBeenCalledWith("hero-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render legacy enum with enumNames as labels", () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "string",
|
||||||
|
title: "Pets",
|
||||||
|
description: "Choose a pet",
|
||||||
|
enum: ["pet-1", "pet-2", "pet-3"],
|
||||||
|
enumNames: ["Cat", "Dog", "Bird"],
|
||||||
|
} as unknown as JsonSchemaType; // enumNames is legacy extension
|
||||||
|
|
||||||
|
render(<DynamicJsonForm schema={schema} value="" onChange={onChange} />);
|
||||||
|
|
||||||
|
// Description should be visible above the select
|
||||||
|
expect(screen.getByText("Choose a pet")).toBeInTheDocument();
|
||||||
|
const options = screen.getAllByRole("option");
|
||||||
|
expect(options[1]).toHaveProperty("textContent", "Cat");
|
||||||
|
expect(options[2]).toHaveProperty("textContent", "Dog");
|
||||||
|
expect(options[3]).toHaveProperty("textContent", "Bird");
|
||||||
|
|
||||||
|
const select = screen.getByRole("combobox");
|
||||||
|
fireEvent.change(select, { target: { value: "pet-2" } });
|
||||||
|
expect(onChange).toHaveBeenCalledWith("pet-2");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Validation Attributes", () => {
|
describe("Validation Attributes", () => {
|
||||||
|
|
@ -535,8 +584,8 @@ describe("DynamicJsonForm Object Fields", () => {
|
||||||
<DynamicJsonForm schema={schema} value={{}} onChange={jest.fn()} />,
|
<DynamicJsonForm schema={schema} value={{}} onChange={jest.fn()} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
const nameLabel = screen.getByText("name");
|
const nameLabel = screen.getByText("Name");
|
||||||
const optionalLabel = screen.getByText("optional");
|
const optionalLabel = screen.getByText("Optional");
|
||||||
|
|
||||||
const nameInput = nameLabel.closest("div")?.querySelector("input");
|
const nameInput = nameLabel.closest("div")?.querySelector("input");
|
||||||
const optionalInput = optionalLabel
|
const optionalInput = optionalLabel
|
||||||
|
|
@ -567,30 +616,46 @@ describe("DynamicJsonForm Complex Fields", () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Basic Operations", () => {
|
describe("Basic Operations", () => {
|
||||||
it("should render textbox and autoformat button, but no switch-to-form button", () => {
|
it("should allow switching to JSON mode and show copy/format buttons", () => {
|
||||||
renderForm();
|
renderForm();
|
||||||
|
|
||||||
|
// Initially renders as a form with a Switch to JSON button
|
||||||
|
const switchToJson = screen.getByRole("button", {
|
||||||
|
name: /switch to json/i,
|
||||||
|
});
|
||||||
|
expect(switchToJson).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Switch to JSON mode
|
||||||
|
fireEvent.click(switchToJson);
|
||||||
|
|
||||||
|
// Now a textarea and JSON helpers should be visible
|
||||||
const input = screen.getByRole("textbox");
|
const input = screen.getByRole("textbox");
|
||||||
expect(input).toHaveProperty("type", "textarea");
|
expect(input).toHaveProperty("type", "textarea");
|
||||||
const buttons = screen.getAllByRole("button");
|
|
||||||
expect(buttons).toHaveLength(2); // Copy JSON + Format JSON
|
|
||||||
const copyButton = screen.getByRole("button", { name: /copy json/i });
|
const copyButton = screen.getByRole("button", { name: /copy json/i });
|
||||||
const formatButton = screen.getByRole("button", { name: /format json/i });
|
const formatButton = screen.getByRole("button", { name: /format json/i });
|
||||||
expect(copyButton).toBeTruthy();
|
expect(copyButton).toBeTruthy();
|
||||||
expect(formatButton).toBeTruthy();
|
expect(formatButton).toBeTruthy();
|
||||||
|
// And a Switch to Form button should appear
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /switch to form/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should pass changed values to onChange", () => {
|
it("should pass changed values to onChange in JSON mode", () => {
|
||||||
const onChange = jest.fn();
|
const onChange = jest.fn();
|
||||||
renderForm({ onChange });
|
renderForm({ onChange });
|
||||||
|
|
||||||
|
// Switch to JSON mode first
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /switch to json/i }));
|
||||||
|
|
||||||
const input = screen.getByRole("textbox");
|
const input = screen.getByRole("textbox");
|
||||||
fireEvent.change(input, {
|
fireEvent.change(input, {
|
||||||
target: { value: `{ "nested": "i am string" }` },
|
target: { value: `{ "nested": "i am string" }` },
|
||||||
});
|
});
|
||||||
|
|
||||||
// The onChange handler is debounced when using the JSON view, so we need to wait a little bit
|
// The onChange handler is debounced when using the JSON view, so we need to wait a little bit
|
||||||
waitFor(() => {
|
return waitFor(() => {
|
||||||
expect(onChange).toHaveBeenCalledWith(`{ "nested": "i am string" }`);
|
expect(onChange).toHaveBeenCalledWith({ nested: "i am string" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -626,6 +691,10 @@ describe("DynamicJsonForm Copy JSON Functionality", () => {
|
||||||
it("should render Copy JSON button when in JSON mode", () => {
|
it("should render Copy JSON button when in JSON mode", () => {
|
||||||
renderFormInJsonMode();
|
renderFormInJsonMode();
|
||||||
|
|
||||||
|
// Switch to JSON mode to reveal copy/format buttons
|
||||||
|
const switchBtn = screen.getByRole("button", { name: /switch to json/i });
|
||||||
|
fireEvent.click(switchBtn);
|
||||||
|
|
||||||
const copyButton = screen.getByRole("button", { name: "Copy JSON" });
|
const copyButton = screen.getByRole("button", { name: "Copy JSON" });
|
||||||
expect(copyButton).toBeTruthy();
|
expect(copyButton).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
@ -653,6 +722,10 @@ describe("DynamicJsonForm Copy JSON Functionality", () => {
|
||||||
|
|
||||||
renderFormInJsonMode({ value: testValue });
|
renderFormInJsonMode({ value: testValue });
|
||||||
|
|
||||||
|
// Switch to JSON mode first
|
||||||
|
const switchBtn = screen.getByRole("button", { name: /switch to json/i });
|
||||||
|
fireEvent.click(switchBtn);
|
||||||
|
|
||||||
const copyButton = screen.getByRole("button", { name: "Copy JSON" });
|
const copyButton = screen.getByRole("button", { name: "Copy JSON" });
|
||||||
fireEvent.click(copyButton);
|
fireEvent.click(copyButton);
|
||||||
|
|
||||||
|
|
@ -768,6 +841,10 @@ describe("DynamicJsonForm Validation Functionality", () => {
|
||||||
it("should return valid for valid JSON in JSON mode", () => {
|
it("should return valid for valid JSON in JSON mode", () => {
|
||||||
renderFormWithRef();
|
renderFormWithRef();
|
||||||
|
|
||||||
|
// Switch to JSON mode to enable textarea editing/validation
|
||||||
|
const switchBtn = screen.getByRole("button", { name: /switch to json/i });
|
||||||
|
fireEvent.click(switchBtn);
|
||||||
|
|
||||||
const validateButton = screen.getByTestId("validate-button");
|
const validateButton = screen.getByTestId("validate-button");
|
||||||
fireEvent.click(validateButton);
|
fireEvent.click(validateButton);
|
||||||
|
|
||||||
|
|
@ -778,6 +855,10 @@ describe("DynamicJsonForm Validation Functionality", () => {
|
||||||
it("should return invalid for malformed JSON in JSON mode", async () => {
|
it("should return invalid for malformed JSON in JSON mode", async () => {
|
||||||
renderFormWithRef();
|
renderFormWithRef();
|
||||||
|
|
||||||
|
// Switch to JSON mode first
|
||||||
|
const switchBtn = screen.getByRole("button", { name: /switch to json/i });
|
||||||
|
fireEvent.click(switchBtn);
|
||||||
|
|
||||||
// Enter invalid JSON
|
// Enter invalid JSON
|
||||||
const textarea = screen.getByRole("textbox");
|
const textarea = screen.getByRole("textbox");
|
||||||
fireEvent.change(textarea, { target: { value: '{ "invalid": json }' } });
|
fireEvent.change(textarea, { target: { value: '{ "invalid": json }' } });
|
||||||
|
|
@ -799,6 +880,10 @@ describe("DynamicJsonForm Validation Functionality", () => {
|
||||||
it("should return valid for empty JSON in JSON mode", () => {
|
it("should return valid for empty JSON in JSON mode", () => {
|
||||||
renderFormWithRef();
|
renderFormWithRef();
|
||||||
|
|
||||||
|
// Switch to JSON mode first
|
||||||
|
const switchBtn = screen.getByRole("button", { name: /switch to json/i });
|
||||||
|
fireEvent.click(switchBtn);
|
||||||
|
|
||||||
// Clear the textarea
|
// Clear the textarea
|
||||||
const textarea = screen.getByRole("textbox");
|
const textarea = screen.getByRole("textbox");
|
||||||
fireEvent.change(textarea, { target: { value: "" } });
|
fireEvent.change(textarea, { target: { value: "" } });
|
||||||
|
|
@ -813,6 +898,10 @@ describe("DynamicJsonForm Validation Functionality", () => {
|
||||||
it("should set error state when validation fails", async () => {
|
it("should set error state when validation fails", async () => {
|
||||||
renderFormWithRef();
|
renderFormWithRef();
|
||||||
|
|
||||||
|
// Switch to JSON mode first
|
||||||
|
const switchBtn = screen.getByRole("button", { name: /switch to json/i });
|
||||||
|
fireEvent.click(switchBtn);
|
||||||
|
|
||||||
// Enter invalid JSON
|
// Enter invalid JSON
|
||||||
const textarea = screen.getByRole("textbox");
|
const textarea = screen.getByRole("textbox");
|
||||||
fireEvent.change(textarea, {
|
fireEvent.change(textarea, {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@ export type JsonSchemaType = {
|
||||||
default?: JsonValue;
|
default?: JsonValue;
|
||||||
properties?: Record<string, JsonSchemaType>;
|
properties?: Record<string, JsonSchemaType>;
|
||||||
items?: JsonSchemaType;
|
items?: JsonSchemaType;
|
||||||
|
// Array validation constraints
|
||||||
|
minItems?: number;
|
||||||
|
maxItems?: number;
|
||||||
minimum?: number;
|
minimum?: number;
|
||||||
maximum?: number;
|
maximum?: number;
|
||||||
minLength?: number;
|
minLength?: number;
|
||||||
|
|
@ -45,6 +48,8 @@ export type JsonSchemaType = {
|
||||||
pattern?: string;
|
pattern?: string;
|
||||||
format?: string;
|
format?: string;
|
||||||
enum?: string[];
|
enum?: string[];
|
||||||
|
// Non-standard legacy support: titles for enum values
|
||||||
|
enumNames?: string[];
|
||||||
const?: JsonValue;
|
const?: JsonValue;
|
||||||
oneOf?: (JsonSchemaType | JsonSchemaConst)[];
|
oneOf?: (JsonSchemaType | JsonSchemaConst)[];
|
||||||
anyOf?: (JsonSchemaType | JsonSchemaConst)[];
|
anyOf?: (JsonSchemaType | JsonSchemaConst)[];
|
||||||
|
|
|
||||||
|
|
@ -115,9 +115,11 @@ export function generateDefaultValue(
|
||||||
if (!schema.properties) return isRequired ? {} : undefined;
|
if (!schema.properties) return isRequired ? {} : undefined;
|
||||||
|
|
||||||
const obj: JsonObject = {};
|
const obj: JsonObject = {};
|
||||||
// Only include properties that are required according to the schema's required array
|
// Include required properties OR optional properties that declare a default
|
||||||
Object.entries(schema.properties).forEach(([key, prop]) => {
|
Object.entries(schema.properties).forEach(([key, prop]) => {
|
||||||
if (isPropertyRequired(key, schema)) {
|
const hasExplicitDefault =
|
||||||
|
"default" in prop && (prop as JsonSchemaType).default !== undefined;
|
||||||
|
if (isPropertyRequired(key, schema) || hasExplicitDefault) {
|
||||||
const value = generateDefaultValue(prop, key, schema);
|
const value = generateDefaultValue(prop, key, schema);
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
obj[key] = value;
|
obj[key] = value;
|
||||||
|
|
|
||||||
|
|
@ -27,5 +27,6 @@
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"types": ["jest", "@testing-library/jest-dom", "node"]
|
"types": ["jest", "@testing-library/jest-dom", "node"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/__tests__/**"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue