diff --git a/apps/web/src/components/sections-editor/fields/resolved-schema-cache.test.ts b/apps/web/src/components/sections-editor/fields/resolved-schema-cache.test.ts index 93a8f46084..d9db827b1f 100644 --- a/apps/web/src/components/sections-editor/fields/resolved-schema-cache.test.ts +++ b/apps/web/src/components/sections-editor/fields/resolved-schema-cache.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, test } from "bun:test"; import { cachedResolveSchema, expandRecursiveSchema, + fillDepthCappedBranches, } from "./resolved-schema-cache"; import type { LiveMeta, SchemaProperty } from "../resolve-schema"; @@ -175,3 +176,88 @@ describe("a self-referencing menu, six levels deep (A9: main's behaviour)", () = expect(item?.titleBy).toBe("{{{label}}}"); }); }); + +describe("fillDepthCappedBranches", () => { + const branch = (resolveType: string, schema?: SchemaProperty) => ({ + resolveType, + title: resolveType, + discriminatorValue: resolveType, + schema, + }); + const imageSchema: SchemaProperty = { + type: "object", + properties: { image: { type: "string", format: "image-uri" } }, + }; + const videoSchema: SchemaProperty = { + type: "object", + properties: { video: { type: "string", format: "video-uri" } }, + }; + const media = (withSchemas: boolean): SchemaProperty => ({ + type: "block-ref", + discriminatorKey: "type", + anyOfRefs: [ + branch("image", withSchemas ? imageSchema : undefined), + branch("video", withSchemas ? videoSchema : undefined), + ], + }); + + test("fills discriminated branches the depth cap emptied", () => { + const capped: SchemaProperty = { + type: "object", + properties: { media: media(false) }, + }; + const shallow: SchemaProperty = { + type: "object", + properties: { media: media(true) }, + }; + const filled = fillDepthCappedBranches(capped, shallow); + const refs = filled.properties?.media?.anyOfRefs ?? []; + expect(refs.map((r) => r.schema)).toEqual([imageSchema, videoSchema]); + // Same inputs, same object: the form re-renders without churn. + expect(fillDepthCappedBranches(capped, shallow)).toBe(filled); + }); + + test("keeps what the schema already resolved", () => { + // `Product[]` built as a loader picker in one definition and as an inline + // list in the other: the picker stays. + const picker: SchemaProperty = { + type: "block-ref", + anyOfRefs: [{ resolveType: "site/loaders/products.ts", title: "List" }], + }; + const resolved: SchemaProperty = { + type: "object", + properties: { media: media(true), shelf: picker }, + }; + const other: SchemaProperty = { + type: "object", + properties: { + media: media(true), + shelf: { type: "array", items: { type: "object" } }, + }, + }; + expect(fillDepthCappedBranches(resolved, other)).toBe(resolved); + }); + + test("leaves empty branches of a module union to lazy resolution", () => { + const moduleUnion: SchemaProperty = { + type: "block-ref", + anyOfRefs: [{ resolveType: "site/sections/Hero.tsx", title: "Hero" }], + }; + const shallow: SchemaProperty = { + type: "block-ref", + anyOfRefs: [ + { + resolveType: "site/sections/Hero.tsx", + title: "Hero", + schema: imageSchema, + }, + ], + }; + expect(fillDepthCappedBranches(moduleUnion, shallow)).toBe(moduleUnion); + }); + + test("without a fallback returns the schema", () => { + const schema = media(false); + expect(fillDepthCappedBranches(schema, undefined)).toBe(schema); + }); +}); diff --git a/apps/web/src/components/sections-editor/fields/resolved-schema-cache.ts b/apps/web/src/components/sections-editor/fields/resolved-schema-cache.ts index 2313a33110..7b35508d66 100644 --- a/apps/web/src/components/sections-editor/fields/resolved-schema-cache.ts +++ b/apps/web/src/components/sections-editor/fields/resolved-schema-cache.ts @@ -70,3 +70,93 @@ export function expandRecursiveSchema( } return expanded; } + +const filledCache = new WeakMap< + SchemaProperty, + WeakMap +>(); + +/** + * `schema` with the `type`-discriminated union branches that the depth cap left + * without a schema taken from `fallback`, the same type built shallower. Such + * branches have no lazy resolution, so without this the branch select draws no + * fields. Only empty branches are filled: what `schema` already has is kept. + */ +export function fillDepthCappedBranches( + schema: SchemaProperty, + fallback: SchemaProperty | undefined, +): SchemaProperty { + if (!fallback || schema === fallback) return schema; + let byFallback = filledCache.get(schema); + let filled = byFallback?.get(fallback); + if (!filled) { + filled = fillBranches(schema, fallback, 0); + if (!byFallback) { + byFallback = new WeakMap(); + filledCache.set(schema, byFallback); + } + byFallback.set(fallback, filled); + } + return filled; +} + +// resolveSchema() output is acyclic; the cap only bounds a malformed tree. +const MAX_FILL_DEPTH = 64; + +function fillBranches( + schema: SchemaProperty, + fallback: SchemaProperty, + depth: number, +): SchemaProperty { + if (depth > MAX_FILL_DEPTH) return schema; + let changed = false; + const fillChild = ( + child: SchemaProperty | undefined, + other: SchemaProperty | undefined, + ) => { + if (!child || !other || child === other) return child; + const next = fillBranches(child, other, depth + 1); + if (next !== child) changed = true; + return next; + }; + + const anyOfRefs = schema.anyOfRefs?.map((ref) => { + const other = fallback.anyOfRefs?.find( + (r) => r.resolveType === ref.resolveType, + ); + if (!other?.schema) return ref; + if (!ref.schema) { + if (!schema.discriminatorKey) return ref; + changed = true; + return { ...ref, schema: other.schema }; + } + const next = fillChild(ref.schema, other.schema); + return next === ref.schema ? ref : { ...ref, schema: next }; + }); + const properties = schema.properties + ? Object.fromEntries( + Object.entries(schema.properties).map(([key, prop]) => [ + key, + fillChild(prop, fallback.properties?.[key]) ?? prop, + ]), + ) + : undefined; + const items = fillChild(schema.items, fallback.items); + const inlineUnionBranches = schema.inlineUnionBranches?.map( + (branch, index) => { + const other = fallback.inlineUnionBranches?.[index]; + if (other?.title !== branch.title) return branch; + const next = fillChild(branch.schema, other.schema); + return next === branch.schema ? branch : { ...branch, schema: next }; + }, + ); + + if (!changed) return schema; + return { + ...schema, + ...(anyOfRefs && { anyOfRefs }), + ...(properties && { properties }), + ...(items && { items }), + ...(inlineUnionBranches && { inlineUnionBranches }), + }; +} diff --git a/apps/web/src/components/sections-editor/schema-form.test.ts b/apps/web/src/components/sections-editor/schema-form.test.ts index 53d7a5adf9..9d07b35922 100644 --- a/apps/web/src/components/sections-editor/schema-form.test.ts +++ b/apps/web/src/components/sections-editor/schema-form.test.ts @@ -8,7 +8,7 @@ import { ImageField } from "./fields/image-field"; import { BooleanField } from "./fields/boolean-field"; import { NumberField } from "./fields/number-field"; import { StringField } from "./fields/string-field"; -import type { LiveMeta } from "./resolve-schema"; +import type { LiveMeta, SchemaProperty } from "./resolve-schema"; describe("inferBlockRefArrayItemSchema", () => { test("infers string, number, boolean from first item", () => { @@ -208,6 +208,94 @@ describe("renderField – multivariate widget keeps the prop description", () => }); }); +describe("renderField – multivariate object fills depth-capped union branches", () => { + // `Flag<{ media: Video | Image }>` nested deep (a loader array of flags): the + // variant value is built 3 levels deeper than `plainSchema`, so the depth cap + // leaves the `type`-discriminated branches of `media` without a schema. + const mediaUnion = (withBranchSchemas: boolean): SchemaProperty => ({ + type: "block-ref", + discriminatorKey: "type", + anyOfRefs: [ + { + resolveType: "video", + title: "Video", + discriminatorValue: "video", + schema: withBranchSchemas + ? { + type: "object", + properties: { video: { type: "string", format: "video-uri" } }, + } + : undefined, + }, + { + resolveType: "image", + title: "Image", + discriminatorValue: "image", + schema: withBranchSchemas + ? { + type: "object", + properties: { + image: { type: "string", format: "image-uri" }, + alt: { type: "string" }, + }, + } + : undefined, + }, + ], + }); + const kit = (withBranchSchemas: boolean): SchemaProperty => ({ + type: "object", + properties: { media: mediaUnion(withBranchSchemas) }, + }); + + test("the inner form gets the union branches the variant value lost", () => { + const el = renderField({ + onChange: () => {}, + path: "content", + label: "Content", + value: { media: { type: "image" } }, + schema: { + type: "block-ref", + plainSchema: kit(true), + anyOfRefs: [ + { + resolveType: "site/flags/multivariate/cardContent.ts", + title: "Card Content Variants", + schema: { + type: "object", + properties: { + variants: { + type: "array", + items: { + type: "object", + properties: { value: kit(false) }, + }, + }, + }, + }, + }, + ], + }, + }) as { + props: { renderInnerField: (p: unknown) => unknown }; + } | null; + const inner = el?.props.renderInnerField({ + onChange: () => {}, + path: "content", + label: "Content", + value: { media: { type: "image" } }, + schema: {}, + }) as { props: { schema: SchemaProperty } } | null; + const image = inner?.props.schema.properties?.media?.anyOfRefs?.find( + (r) => r.resolveType === "image", + ); + expect(Object.keys(image?.schema?.properties ?? {})).toEqual([ + "image", + "alt", + ]); + }); +}); + describe("renderField – boolean schema wins over stored value type", () => { const typeOf = (el: unknown) => (el as { type?: unknown } | null)?.type; const baseProps = { diff --git a/apps/web/src/components/sections-editor/schema-form.tsx b/apps/web/src/components/sections-editor/schema-form.tsx index 9fb104d802..1a10d71861 100644 --- a/apps/web/src/components/sections-editor/schema-form.tsx +++ b/apps/web/src/components/sections-editor/schema-form.tsx @@ -24,7 +24,10 @@ import { InlineUnionField } from "./fields/inline-union-field"; import { LocationField } from "./fields/location-field"; import { MapField } from "./fields/map-field"; import { MultivariateFieldWrapper } from "./fields/multivariate-field-wrapper"; -import { expandRecursiveSchema } from "./fields/resolved-schema-cache"; +import { + expandRecursiveSchema, + fillDepthCappedBranches, +} from "./fields/resolved-schema-cache"; import { isSecretBlock } from "@decocms/shared/decofile"; import { isSecretField } from "./fields/secret-field"; import { SecretFieldForBackend } from "./fields/protocol-secret-field"; @@ -312,13 +315,14 @@ function renderFieldBody(fieldProps: FieldProps): ReactNode { const variantValueSchema = ref.schema?.properties?.variants?.items?.properties?.value; // Determine the schema for the inner (plain) field. Priority: - // 1. variant value schema (when fully resolved and non-circular) + // 1. variant value schema (when fully resolved and non-circular), with + // the union branches its depth cap emptied taken from plainSchema // 2. plainSchema on the outer block-ref (non-loader branch preserved // during resolution, e.g. { type: "string", format: "image-uri" }) // 3. plainSchema on the variant value schema (circular block-ref) const innerSchema = variantValueSchema && variantValueSchema.type !== "block-ref" - ? variantValueSchema + ? fillDepthCappedBranches(variantValueSchema, schema.plainSchema) : (schema.plainSchema ?? variantValueSchema?.plainSchema ?? { type: "string" }); // The prop's own @description lives on the outer union, not the widget branch.