Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 23 additions & 15 deletions packages/ui/src/extracted-data/schema-reconciliation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { JSONSchema } from "zod/v4/core";
import { isNullable } from "@/lib/json-schema";
import {
isNullable,
extractFirstNotNullableSchema,
getSchemaType,
} from "@/lib/json-schema";
import type { JsonShape, JsonValue } from "./types";

/**
Expand Down Expand Up @@ -180,14 +184,16 @@ function fillMissingFieldsFromJsonSchema<S extends JsonShape<S>>(
}

const baseSchema = fieldSchema as JSONSchema.BaseSchema;
const { schema: primarySchema } = extractFirstNotNullableSchema(baseSchema);
const effectiveSchema = primarySchema ?? baseSchema;

// Store metadata
// Store metadata using normalized schema (handles anyOf/nullable unions)
const metadata: FieldSchemaMetadata = {
isRequired,
isOptional: !isRequired,
schemaType: baseSchema.type || "unknown",
title: baseSchema.title,
description: baseSchema.description,
schemaType: getSchemaType(effectiveSchema),
title: (effectiveSchema as JSONSchema.BaseSchema).title,
description: (effectiveSchema as JSONSchema.BaseSchema).description,
wasMissing,
};

Expand All @@ -197,8 +203,8 @@ function fillMissingFieldsFromJsonSchema<S extends JsonShape<S>>(
// ===================================
// For arrays, generate normalized metadata entries for array items
// using "*" wildcard syntax. This unifies list and table renderer lookup.
if (baseSchema.type === "array") {
const arraySchema = baseSchema as JSONSchema.ArraySchema;
if (effectiveSchema.type === "array") {
const arraySchema = effectiveSchema as JSONSchema.ArraySchema;
if (
arraySchema.items &&
typeof arraySchema.items === "object" &&
Expand All @@ -222,7 +228,7 @@ function fillMissingFieldsFromJsonSchema<S extends JsonShape<S>>(
const itemMetadata: FieldSchemaMetadata = {
isRequired: false, // Array items themselves are not required
isOptional: true,
schemaType: itemSchema.type || "unknown",
schemaType: getSchemaType(itemSchema),
title: itemSchema.title,
description: itemSchema.description,
wasMissing: false,
Expand All @@ -238,22 +244,22 @@ function fillMissingFieldsFromJsonSchema<S extends JsonShape<S>>(

// Recursively process nested objects and arrays
if (
baseSchema.type === "object" &&
effectiveSchema.type === "object" &&
dataObj[fieldName] !== null &&
dataObj[fieldName] !== undefined
) {
const objectSchema = baseSchema as JSONSchema.ObjectSchema;
const objectSchema = effectiveSchema as JSONSchema.ObjectSchema;
fillMissingFieldsFromJsonSchema<S>(
dataObj[fieldName] as S,
objectSchema,
fieldPath,
context
);
} else if (
baseSchema.type === "array" &&
effectiveSchema.type === "array" &&
Array.isArray(dataObj[fieldName])
) {
const arraySchema = baseSchema as JSONSchema.ArraySchema;
const arraySchema = effectiveSchema as JSONSchema.ArraySchema;
if (
arraySchema.items &&
typeof arraySchema.items === "object" &&
Expand Down Expand Up @@ -349,15 +355,17 @@ function generateArrayItemMetadata<S extends JsonShape<S>>(
const fieldPath = [...currentPath, fieldName];
const pathString = fieldPath.join(".");
const baseSchema = fieldSchema as JSONSchema.BaseSchema;
const { schema: primarySchema } = extractFirstNotNullableSchema(baseSchema);
const effectiveSchema = primarySchema ?? baseSchema;
const isRequired = objectSchema.required?.includes(fieldName) ?? false;

// Generate metadata for this field
const metadata: FieldSchemaMetadata = {
isRequired,
isOptional: !isRequired,
schemaType: baseSchema.type || "unknown",
title: baseSchema.title,
description: baseSchema.description,
schemaType: getSchemaType(effectiveSchema),
title: (effectiveSchema as JSONSchema.BaseSchema).title,
description: (effectiveSchema as JSONSchema.BaseSchema).description,
wasMissing: false, // Array item fields are schema-defined, not missing
};

Expand Down
92 changes: 92 additions & 0 deletions packages/ui/tests/extracted-data/schema-anyof-boolean.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, it, expect, vi } from "vitest";
import { JSONSchema } from "zod/v4/core";
import { reconcileDataWithJsonSchema } from "@/src/extracted-data/schema-reconciliation";
import { TableRenderer } from "@/src/extracted-data/table-renderer";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

// Reduced schema from the bug report focusing on anyOf(boolean|null)
const schema: JSONSchema.ObjectSchema = {
type: "object",
title: "MySchema",
required: ["currency", "grand_total"],
properties: {
currency: { type: "string", title: "Currency" },
grand_total: {
anyOf: [{ type: "number" }, { type: "string" }],
title: "Grand Total",
},
line_items: {
anyOf: [
{
type: "array",
items: {
type: "object",
title: "LineItem",
required: ["line_total"],
properties: {
description: {
anyOf: [{ type: "string" }, { type: "null" }],
default: null,
title: "Description",
},
line_total: {
anyOf: [{ type: "number" }, { type: "string" }],
title: "Line Total",
},
is_tax: {
anyOf: [{ type: "boolean" }, { type: "null" }],
default: null,
title: "Is Tax",
},
},
},
},
{ type: "null" },
],
default: null,
title: "Line Items",
},
},
};

describe("anyOf(boolean|null) renders boolean editor", () => {
it("creates boolean metadata and renders boolean editor in table", async () => {
const data = {
currency: "USD",
grand_total: 100,
line_items: [
{ is_tax: false, line_total: 22.68, description: "RESORT FEE" },
{
is_tax: true,
line_total: 27.96,
description: "TAX2 for ROOM CHARGE",
},
],
};

const { schemaMetadata } = reconcileDataWithJsonSchema(data as any, schema);

// Ensure wildcard metadata exists and is boolean
expect(schemaMetadata["line_items.*.is_tax"]).toBeDefined();
expect(schemaMetadata["line_items.*.is_tax"].schemaType).toBe("boolean");

// Render table using reconciled schema
const onUpdate = vi.fn();
render(
<TableRenderer
data={data.line_items as any}
onUpdate={onUpdate as any}
keyPath={["line_items"]}
metadata={{ schema: schemaMetadata }}
/>
);

const trueCell = await screen.findByText("true");
await userEvent.click(trueCell);

const popover = await screen.findByTestId("editable-field-popover");
// Boolean editor should expose a combobox (select)
expect(within(popover).getByRole("combobox")).toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions packages/ui/tests/test-setup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import "@testing-library/jest-dom/vitest";
import { expect, afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
import * as matchers from "@testing-library/jest-dom/matchers";
Expand Down