Skip to content

Commit 8940318

Browse files
fix: render nested object arrays and Optional arrays as structured forms
1 parent adfcccc commit 8940318

2 files changed

Lines changed: 140 additions & 46 deletions

File tree

client/src/components/DynamicJsonForm.tsx

Lines changed: 68 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button";
1010
import { Input } from "@/components/ui/input";
1111
import JsonEditor from "./JsonEditor";
1212
import { updateValueAtPath } from "@/utils/jsonUtils";
13-
import { generateDefaultValue } from "@/utils/schemaUtils";
13+
import { generateDefaultValue, normalizeUnionType } from "@/utils/schemaUtils";
1414
import type {
1515
JsonValue,
1616
JsonSchemaType,
@@ -87,6 +87,9 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
8787
// - Arrays with defined items are form-capable
8888
// - Primitive types are form-capable
8989
const canRenderTopLevelForm = (s: JsonSchemaType): boolean => {
90+
// Unwrap Optional[X] at the top level so anyOf:[X,null] is treated as X
91+
s = normalizeUnionType(s);
92+
9093
const primitiveTypes = ["string", "number", "integer", "boolean", "null"];
9194

9295
const hasType = Array.isArray(s.type) ? s.type.length > 0 : !!s.type;
@@ -303,6 +306,10 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
303306
parentSchema?: JsonSchemaType,
304307
propertyName?: string,
305308
) => {
309+
// Unwrap Optional[X] / nullable unions (anyOf: [X, null]) before ANY type checks
310+
// so that maxDepth enforcement and the type switch both see the real type.
311+
propSchema = normalizeUnionType(propSchema);
312+
306313
if (
307314
depth >= maxDepth &&
308315
(propSchema.type === "object" || propSchema.type === "array")
@@ -601,7 +608,10 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
601608
if (!propSchema.items) return null;
602609

603610
// Special handling: array of enums -> render multi-select control
604-
const itemSchema = propSchema.items as JsonSchemaType;
611+
// Normalize items so Optional[X] (anyOf:[X,null]) is unwrapped correctly.
612+
const itemSchema = normalizeUnionType(
613+
propSchema.items as JsonSchemaType,
614+
);
605615
let multiOptions: { value: string; label: string }[] | null = null;
606616

607617
const titledMulti = (
@@ -666,8 +676,11 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
666676
);
667677
}
668678

669-
// If the array items are simple, render as form fields, otherwise use JSON editor
670-
if (isSimpleObject(propSchema.items)) {
679+
// Typed object items → structured form with Add/Remove; untyped → JSON fallback
680+
const itemIsObject =
681+
itemSchema.type === "object" && !!itemSchema.properties;
682+
683+
if (isSimpleObject(itemSchema) || itemIsObject) {
671684
return (
672685
<div className="space-y-4">
673686
{propSchema.description && (
@@ -676,46 +689,68 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
676689
</p>
677690
)}
678691

679-
{propSchema.items?.description && (
692+
{itemSchema.description && (
680693
<p className="text-sm text-gray-500">
681-
Items: {propSchema.items.description}
694+
{itemSchema.description}
682695
</p>
683696
)}
684697

685698
<div className="space-y-2">
686-
{arrayValue.map((item, index) => (
687-
<div key={index} className="flex items-center gap-2">
688-
{renderFormFields(
689-
propSchema.items as JsonSchemaType,
690-
item,
691-
[...path, index.toString()],
692-
depth + 1,
693-
)}
694-
<Button
695-
variant="outline"
696-
size="sm"
697-
onClick={() => {
698-
const newArray = [...arrayValue];
699-
newArray.splice(index, 1);
700-
handleFieldChange(path, newArray);
701-
}}
702-
>
703-
Remove
704-
</Button>
705-
</div>
706-
))}
699+
{arrayValue.map((item, index) =>
700+
itemIsObject ? (
701+
<div key={index} className="space-y-2">
702+
{renderFormFields(
703+
itemSchema,
704+
item,
705+
[...path, index.toString()],
706+
depth + 1,
707+
)}
708+
<div className="flex justify-end">
709+
<Button
710+
variant="outline"
711+
size="sm"
712+
onClick={() => {
713+
const newArray = [...arrayValue];
714+
newArray.splice(index, 1);
715+
handleFieldChange(path, newArray);
716+
}}
717+
>
718+
Remove
719+
</Button>
720+
</div>
721+
</div>
722+
) : (
723+
<div key={index} className="flex items-center gap-2">
724+
{renderFormFields(
725+
itemSchema,
726+
item,
727+
[...path, index.toString()],
728+
depth + 1,
729+
)}
730+
<Button
731+
variant="outline"
732+
size="sm"
733+
onClick={() => {
734+
const newArray = [...arrayValue];
735+
newArray.splice(index, 1);
736+
handleFieldChange(path, newArray);
737+
}}
738+
>
739+
Remove
740+
</Button>
741+
</div>
742+
),
743+
)}
707744
<Button
708745
variant="outline"
709746
size="sm"
710747
onClick={() => {
711-
const defaultValue = getArrayItemDefault(
712-
propSchema.items as JsonSchemaType,
713-
);
748+
const defaultValue = getArrayItemDefault(itemSchema);
714749
handleFieldChange(path, [...arrayValue, defaultValue]);
715750
}}
716751
title={
717-
propSchema.items?.description
718-
? `Add new ${propSchema.items.description}`
752+
itemSchema.description
753+
? `Add new ${itemSchema.description}`
719754
: "Add new item"
720755
}
721756
>
@@ -726,7 +761,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
726761
);
727762
}
728763

729-
// For complex arrays, fall back to JSON editor
764+
// For truly unstructured arrays (no type or no properties), fall back to JSON editor
730765
return (
731766
<JsonEditor
732767
value={JSON.stringify(currentValue ?? [], null, 2)}

client/src/components/__tests__/DynamicJsonForm.array.test.tsx

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ describe("DynamicJsonForm Array Fields", () => {
4545

4646
// Should show array description
4747
expect(screen.getByText("Test array field")).toBeDefined();
48-
expect(screen.getByText("Items: Array item")).toBeDefined();
48+
expect(screen.getByText("Array item")).toBeDefined();
4949

5050
// Should show input fields for each item
5151
const inputs = screen.getAllByRole("textbox");
@@ -101,21 +101,80 @@ describe("DynamicJsonForm Array Fields", () => {
101101
});
102102
});
103103

104-
describe("Complex Array Fallback", () => {
105-
it("should render JSON editor for complex arrays", () => {
106-
renderComplexArrayForm();
104+
describe("Complex Array Rendering", () => {
105+
it("should render structured form for arrays of objects with properties", () => {
106+
renderComplexArrayForm({ value: [{ nested: {} }] });
107107

108-
// Initially renders form view with Switch to JSON button; switch to JSON to see textarea
109-
const switchBtn = screen.getByRole("button", { name: /switch to json/i });
110-
expect(switchBtn).toBeInTheDocument();
111-
fireEvent.click(switchBtn);
108+
// Should show Add Item and Remove — not fall back to raw JSON
109+
expect(screen.getByText("Add Item")).toBeInTheDocument();
110+
expect(screen.getByText("Remove")).toBeInTheDocument();
112111

113-
const textarea = screen.getByRole("textbox");
114-
expect(textarea).toHaveProperty("type", "textarea");
112+
// Switch to JSON should still be available
113+
expect(
114+
screen.getByRole("button", { name: /switch to json/i }),
115+
).toBeInTheDocument();
116+
});
115117

116-
// Should not show form-specific array controls
118+
it("should render JSON editor for untyped (no-type) array items", () => {
119+
const schema: JsonSchemaType = {
120+
type: "array",
121+
items: {} as JsonSchemaType, // no type at all
122+
};
123+
render(
124+
<DynamicJsonForm schema={schema} value={[]} onChange={jest.fn()} />,
125+
);
126+
127+
// Falls back to JSON editor; no structured controls
117128
expect(screen.queryByText("Add Item")).toBeNull();
118-
expect(screen.queryByText("Remove")).toBeNull();
129+
});
130+
131+
it("should render structured form for Optional array (anyOf:[array,null])", () => {
132+
const schema: JsonSchemaType = {
133+
anyOf: [
134+
{
135+
type: "array",
136+
items: { type: "string" as const },
137+
},
138+
{ type: "null" },
139+
],
140+
} as unknown as JsonSchemaType;
141+
render(
142+
<DynamicJsonForm
143+
schema={schema}
144+
value={["hello"]}
145+
onChange={jest.fn()}
146+
/>,
147+
);
148+
149+
// Should render as a structured string-array form, not a raw JSON box
150+
expect(screen.getByText("Add Item")).toBeInTheDocument();
151+
const inputs = screen.getAllByRole("textbox");
152+
expect(inputs[0]).toHaveProperty("value", "hello");
153+
});
154+
155+
it("should render structured form for array of Optional objects (items anyOf:[object,null])", () => {
156+
const schema: JsonSchemaType = {
157+
type: "array",
158+
items: {
159+
anyOf: [
160+
{
161+
type: "object" as const,
162+
properties: { name: { type: "string" as const } },
163+
},
164+
{ type: "null" },
165+
],
166+
} as unknown as JsonSchemaType,
167+
};
168+
render(
169+
<DynamicJsonForm
170+
schema={schema}
171+
value={[{ name: "Alice" }]}
172+
onChange={jest.fn()}
173+
/>,
174+
);
175+
176+
expect(screen.getByText("Add Item")).toBeInTheDocument();
177+
expect(screen.getByText("Remove")).toBeInTheDocument();
119178
});
120179
});
121180

@@ -305,7 +364,7 @@ describe("DynamicJsonForm Array Fields", () => {
305364
renderSimpleArrayForm({ schema });
306365

307366
expect(screen.getByText("List of names")).toBeDefined();
308-
expect(screen.getByText("Items: Person name")).toBeDefined();
367+
expect(screen.getByText("Person name")).toBeDefined();
309368
});
310369

311370
it("should use item description in add button title", () => {

0 commit comments

Comments
 (0)