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
22 changes: 22 additions & 0 deletions apps/web/src/components/sections-editor/block-type-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,28 @@ describe("block-type-utils", () => {
expect(isSavedBlockResolveType("")).toBe(false);
});

it("isSavedBlockResolveType reads a manifest key as a block type", () => {
const v8: LiveMeta = {
manifest: {
blocks: {
sections: { hero: { $ref: "#/definitions/aGVybw==" } },
matchers: { always: { $ref: "#/definitions/YWx3YXlz" } },
},
},
schema: {},
};
expect(isSavedBlockResolveType("hero", v8)).toBe(false);
expect(isSavedBlockResolveType("always", v8)).toBe(false);
// Not in the manifest: a saved block.
expect(isSavedBlockResolveType("home-hero", v8)).toBe(true);
// Without the manifest, the legacy heuristic is unchanged.
expect(isSavedBlockResolveType("hero")).toBe(true);
expect(isSavedBlockResolveType("Header", meta)).toBe(true);
expect(
isSavedBlockResolveType("site/sections/Header/Header.tsx", meta),
).toBe(false);
});

it("isAutoPreviewBlockKey detects generated preview stubs", () => {
expect(isAutoPreviewBlockKey("Preview%20%2Fsections%2FFooter.tsx")).toBe(
true,
Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/components/sections-editor/block-type-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,19 @@ export function isManifestMatcherResolveType(
return blockType !== null && blockType.includes("matchers");
}

/** Block id reference (no module path) — e.g. `Header`, not `site/sections/Header.tsx`. */
export function isSavedBlockResolveType(resolveType: string): boolean {
/**
* Block id reference (no module path) — e.g. `Header`, not
* `site/sections/Header.tsx`. A key in the manifest is a block type, whatever
* its shape: next-major Blocks names types with short keys like `hero`. Pass
* `meta` wherever it's at hand; without it (or for keys outside the
* manifest) the module-path heuristic decides, as it always has.
*/
export function isSavedBlockResolveType(
resolveType: string,
meta?: LiveMeta | null,
): boolean {
if (!resolveType) return false;
if (meta && getManifestBlockType(meta, resolveType) !== null) return false;
// Module paths end with a file extension (e.g. site/sections/Header.tsx)
if (/\.\w+$/.test(resolveType)) return false;
if (resolveType === "__proto__" || resolveType === "constructor") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,22 @@ describe("wrapAsMultivariate / flattenMultivariate round-trip", () => {
});
});

describe("multivariate (next-major short name)", () => {
test("keeps each variant's value in a lazy block", () => {
const wrapped = wrapAsMultivariate("Sale!", "multivariate");
expect(wrapped.variants.map((v) => v.value)).toEqual([
{ __resolveType: "lazy", value: "Sale!" },
{ __resolveType: "lazy", value: "Sale!" },
]);
expect(flattenMultivariate(wrapped)).toBe("Sale!");
});

test("legacy names keep plain values", () => {
const wrapped = wrapAsMultivariate("Sale!", RESOLVE_TYPE);
expect(wrapped.variants[0]?.value).toBe("Sale!");
});
});

describe("flattenMultivariate", () => {
test("picks the always variant", () => {
const wrapper: MultivariateWrapper = {
Expand Down
23 changes: 18 additions & 5 deletions apps/web/src/components/sections-editor/fields/media-variants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { defaultVariantRule } from "../section-types";
import { isDefaultVariantRule } from "../section-variants";
import { unwrapLazy, wrapLazy } from "../lazy-value";

/**
* Next-major Blocks' built-in `multivariate` keeps each variant's value in a
* `lazy` block, so only the chosen one resolves. The legacy names
* (`website/flags/multivariate*.ts`) store plain values.
*/
const LAZY_VALUES_MULTIVARIATE = "multivariate";

export interface MultivariateVariant {
rule: Record<string, unknown>;
Expand All @@ -25,11 +33,15 @@ export function wrapAsMultivariate(
value: unknown,
resolveType: string,
): MultivariateWrapper {
const stored = () =>
resolveType === LAZY_VALUES_MULTIVARIATE
? wrapLazy(structuredClone(value))
: structuredClone(value);
return {
__resolveType: resolveType,
variants: [
{ rule: defaultVariantRule(), value: structuredClone(value) },
{ rule: defaultVariantRule(), value: structuredClone(value) },
{ rule: defaultVariantRule(), value: stored() },
{ rule: defaultVariantRule(), value: stored() },
],
};
}
Expand All @@ -42,9 +54,10 @@ export function flattenMultivariate(wrapper: MultivariateWrapper): unknown {
const always = variants.find((v) =>
isDefaultVariantRule(v.rule as Record<string, unknown> | undefined),
);
if (always) return always.value;

return variants[variants.length - 1]?.value;
const picked = always ?? variants[variants.length - 1];
return wrapper.__resolveType === LAZY_VALUES_MULTIVARIATE
? unwrapLazy(picked?.value)
: picked?.value;
}

/** Add a variant cloned from last. */
Expand Down
73 changes: 73 additions & 0 deletions apps/web/src/components/sections-editor/lazy-value.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test";
import {
isLazyFieldSchema,
isLazyWrapper,
unwrapLazy,
wrapLazy,
} from "./lazy-value";
import { resolveSchema, type LiveMeta } from "./resolve-schema";

/** What `deco schema` writes for a `Lazy<T>` field. */
const lazySchema = (value: Record<string, unknown>) => ({
type: "object",
required: ["__resolveType", "value"],
properties: {
__resolveType: { type: "string", enum: ["lazy"], default: "lazy" },
value,
},
});

describe("Lazy<T> values", () => {
test("wraps and unwraps", () => {
expect(wrapLazy("hi")).toEqual({ __resolveType: "lazy", value: "hi" });
expect(wrapLazy(["a"])).toEqual({ __resolveType: "lazy", value: ["a"] });
expect(wrapLazy(undefined)).toBeUndefined();
expect(unwrapLazy({ __resolveType: "lazy", value: 3 })).toBe(3);
// A value that isn't wrapped (legacy content) reads as is.
expect(unwrapLazy("plain")).toBe("plain");
// Wrapping twice doesn't nest.
expect(wrapLazy(wrapLazy("x"))).toEqual({
__resolveType: "lazy",
value: "x",
});
});

test("tells the wrapper from other blocks", () => {
expect(isLazyWrapper({ __resolveType: "lazy", value: null })).toBe(true);
expect(isLazyWrapper({ __resolveType: "lazy" })).toBe(false);
expect(isLazyWrapper({ __resolveType: "hero", value: 1 })).toBe(false);
expect(isLazyWrapper([{ __resolveType: "lazy", value: 1 }])).toBe(false);
});

test("recognizes a resolved Lazy<T> field, including in a list", () => {
const meta: LiveMeta = {
manifest: {
blocks: { sections: { shelf: { $ref: "#/definitions/c2hlbGY=" } } },
},
schema: {
definitions: {
"c2hlbGY=": {
type: "object",
properties: {
__resolveType: { type: "string", enum: ["shelf"] },
products: lazySchema({
type: "array",
items: { type: "string" },
}),
teasers: {
type: "array",
items: lazySchema({ type: "string" }),
},
title: { type: "string" },
},
},
},
},
};
const props = resolveSchema("shelf", meta)?.properties;
expect(isLazyFieldSchema(props!.products!)).toBe(true);
expect(props!.products!.properties!.value!.type).toBe("array");
expect(isLazyFieldSchema(props!.teasers!.items!)).toBe(true);
expect(isLazyFieldSchema(props!.title!)).toBe(false);
});
});
42 changes: 42 additions & 0 deletions apps/web/src/components/sections-editor/lazy-value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* `Lazy<T>` fields: `deco schema` writes one as a `lazy` block whose `value`
* has the form of `T`. Editors fill in a `T`; the form writes the block
* around it and never shows the wrapper.
*/

import type { SchemaProperty } from "./resolve-schema";

const LAZY = "lazy";

/** Whether a stored value is the `lazy` block the editor writes around a `Lazy<T>` field. */
export function isLazyWrapper(
value: unknown,
): value is { __resolveType: "lazy"; value: unknown } {
return (
!!value &&
typeof value === "object" &&
!Array.isArray(value) &&
(value as Record<string, unknown>).__resolveType === LAZY &&
"value" in value
);
}

/** The value inside a `lazy` block; anything else as is. */
export function unwrapLazy(value: unknown): unknown {
return isLazyWrapper(value) ? value.value : value;
}

/** Wraps a value in the `lazy` block; `undefined` stays unset. */
export function wrapLazy(value: unknown): unknown {
if (value === undefined) return undefined;
return isLazyWrapper(value) ? value : { __resolveType: LAZY, value };
}

/** Whether a field's schema is a `lazy` block (`Lazy<T>`), with `value` holding `T`'s form. */
export function isLazyFieldSchema(
schema: SchemaProperty,
): schema is SchemaProperty & {
properties: { value: SchemaProperty };
} {
return schema.lazy === true && !!schema.properties?.value;
}
9 changes: 9 additions & 0 deletions apps/web/src/components/sections-editor/page-sections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ describe("page-sections", () => {
expect(validateBlockId("MyNewBlock", decofile)).toBeNull();
// Block keys may contain spaces (see deco-block-key.ts).
expect(validateBlockId("PLP Air Fryer", decofile)).toBeNull();
const meta = {
manifest: {
blocks: { sections: { hero: { $ref: "#/definitions/aGVybw==" } } },
},
schema: {},
};
expect(validateBlockId("hero", decofile, meta)).toContain("block type");
expect(validateBlockId("hero", decofile)).toBeNull();
expect(validateBlockId("Hero Home", decofile, meta)).toBeNull();
});

it("canMakeSectionReusable rejects saved, multivariate, and hidden sections", () => {
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/components/sections-editor/page-sections.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { RawSection } from "./section-types";
import type { PageVariant } from "./page-variants";
import { translate } from "@/i18n/use-t";
import { isSavedBlockResolveType } from "./block-type-utils";
import type { LiveMeta } from "./resolve-schema";

export type { PageVariant };

Expand Down Expand Up @@ -61,6 +63,8 @@ export function suggestBlockId(label: string): string {
export function validateBlockId(
blockId: string,
decofile: Record<string, unknown>,
/** A saved block can't share a name with a block type (the lookup rule). */
meta?: LiveMeta | null,
): string | null {
const trimmed = blockId.trim();
if (!trimmed) {
Expand All @@ -75,5 +79,8 @@ export function validateBlockId(
if (!/^[A-Za-z][A-Za-z0-9_ -]*$/.test(trimmed)) {
return translate("sectionsEditor.pageSections.blockNameInvalidChars");
}
if (meta && !isSavedBlockResolveType(trimmed, meta)) {
return translate("sectionsEditor.pageSections.blockNameIsBlockType");
}
return null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, test } from "bun:test";
import { resolveSchema, type LiveMeta } from "./resolve-schema";

/**
* Next-major Blocks names block types with short keys (`hero`, `banner`), and
* `deco schema` writes each as a flat definition whose `__resolveType` enum is
* that key. They are blocks, not embedded unions, even without a `/`.
*/
describe("resolveSchema – short block type names", () => {
const meta: LiveMeta = {
manifest: {
blocks: {
sections: {
page: { $ref: "#/definitions/cGFnZQ==" },
hero: { $ref: "#/definitions/aGVybw==" },
banner: { $ref: "#/definitions/YmFubmVy" },
},
},
},
schema: {
definitions: {
Resolvable: { title: "Resolvable" },
"cGFnZQ==": {
title: "page",
type: "object",
properties: {
__resolveType: { type: "string", enum: ["page"] },
hero: {
title: "Hero",
anyOf: [
{ $ref: "#/definitions/Resolvable" },
{ $ref: "#/definitions/aGVybw==" },
{ $ref: "#/definitions/YmFubmVy" },
],
},
},
},
"aGVybw==": {
title: "hero",
type: "object",
properties: {
__resolveType: { type: "string", enum: ["hero"] },
title: { type: "string" },
},
},
YmFubmVy: {
title: "banner",
type: "object",
properties: {
__resolveType: { type: "string", enum: ["banner"] },
image: { type: "string", format: "image-uri" },
},
},
},
},
};

test("offers every block that fits, by its short name", () => {
const field = resolveSchema("page", meta)?.properties?.hero;
expect(field?.type).toBe("block-ref");
expect(field?.anyOfRefs?.map((ref) => ref.resolveType)).toEqual([
"hero",
"banner",
]);
expect(field?.anyOfRefs?.[0]?.discriminatorValue).toBeUndefined();
});
});
Loading
Loading