Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,93 @@ export function expandRecursiveSchema(
}
return expanded;
}

const filledCache = new WeakMap<
SchemaProperty,
WeakMap<SchemaProperty, SchemaProperty>
>();

/**
* `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 }),
};
}
90 changes: 89 additions & 1 deletion apps/web/src/components/sections-editor/schema-form.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 = {
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/components/sections-editor/schema-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down
Loading