diff --git a/apps/web/src/components/sections-editor/schemaless.test.ts b/apps/web/src/components/sections-editor/schemaless.test.ts new file mode 100644 index 0000000000..865397ad18 --- /dev/null +++ b/apps/web/src/components/sections-editor/schemaless.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "bun:test"; +import { ContentProtocolError, ErrorCode } from "@decocms/blocks/protocol"; +import { + groupBlocksByType, + isNoSchemaMeta, + isSchemaAbsent, + noSchemaMeta, + parseNumberInput, + setAtPath, + typeLabel, +} from "./schemaless"; + +describe("isSchemaAbsent", () => { + it("reads schema: null and an older server's NotFound as no schema", () => { + expect( + isSchemaAbsent({ + ok: true, + result: { + notModified: false, + version: null, + resolvedRef: null, + schema: null, + }, + }), + ).toBe(true); + expect( + isSchemaAbsent({ + ok: false, + error: new ContentProtocolError(ErrorCode.NotFound, "no schema"), + }), + ).toBe(true); + }); + + it("is false for a schema, a 'not modified' and any other error", () => { + expect( + isSchemaAbsent({ + ok: true, + result: { + notModified: false, + version: "v1", + resolvedRef: null, + schema: {}, + }, + }), + ).toBe(false); + expect( + isSchemaAbsent({ + ok: true, + result: { notModified: true, version: "v1" }, + }), + ).toBe(false); + expect( + isSchemaAbsent({ + ok: false, + error: new ContentProtocolError(ErrorCode.Unavailable, "busy"), + }), + ).toBe(false); + }); +}); + +describe("noSchemaMeta", () => { + it("is an empty schema the editor can tell apart", () => { + const meta = noSchemaMeta(); + expect(meta.manifest.blocks).toEqual({}); + expect(isNoSchemaMeta(meta)).toBe(true); + expect(isNoSchemaMeta({ manifest: { blocks: {} }, schema: {} })).toBe( + false, + ); + expect(isNoSchemaMeta(undefined)).toBe(false); + }); +}); + +describe("groupBlocksByType", () => { + it("groups every block by __resolveType, sorted by name, untyped last", () => { + const groups = groupBlocksByType({ + "pages-home": { __resolveType: "website/pages/Page.tsx", name: "Home" }, + "pages-about": { __resolveType: "website/pages/Page.tsx", name: "About" }, + Header: { __resolveType: "site/sections/Header.tsx" }, + loose: { title: "no type" }, + }); + expect(groups).toEqual([ + { + resolveType: "site/sections/Header.tsx", + blocks: [{ key: "Header", label: "Header" }], + }, + { + resolveType: "website/pages/Page.tsx", + blocks: [ + { key: "pages-about", label: "About" }, + { key: "pages-home", label: "Home" }, + ], + }, + { resolveType: null, blocks: [{ key: "loose", label: "loose" }] }, + ]); + }); + + it("names a type by its last path segment", () => { + expect(typeLabel("site/sections/Header.tsx")).toBe("Header"); + expect(typeLabel("website/loaders/redirects.ts")).toBe("redirects"); + }); +}); + +describe("setAtPath", () => { + it("replaces one leaf and keeps every other value and the key order", () => { + const block = { + __resolveType: "site/sections/Hero.tsx", + title: "Hi", + items: [ + { n: 1, on: true }, + { n: 2, on: false }, + ], + nested: { a: null, b: "x" }, + }; + const next = setAtPath(block, ["items", 1, "n"], 3); + expect(JSON.stringify(next)).toBe( + JSON.stringify({ + ...block, + items: [ + { n: 1, on: true }, + { n: 3, on: false }, + ], + }), + ); + // The original is untouched. + expect(block.items[1]!.n).toBe(2); + }); +}); + +describe("parseNumberInput", () => { + it("reads finite numbers only", () => { + expect(parseNumberInput("42")).toBe(42); + expect(parseNumberInput("-1.5")).toBe(-1.5); + expect(parseNumberInput("")).toBeNull(); + expect(parseNumberInput("4a")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/sections-editor/schemaless.ts b/apps/web/src/components/sections-editor/schemaless.ts new file mode 100644 index 0000000000..dbee54f257 --- /dev/null +++ b/apps/web/src/components/sections-editor/schemaless.ts @@ -0,0 +1,137 @@ +/** + * A Blocks site whose schema isn't generated yet (`deco schema` hasn't run). + * + * The content protocol still lists and saves every block without a schema; + * only the typed forms need it. The editor reads "no schema" as an empty + * schema carrying a marker (`noSchemaMeta`), so the poll keeps one shape and + * the real schema replaces it on its own once it appears. Surfaces that would + * otherwise build forms from the schema show the blocks grouped by type and a + * form inferred from each block's JSON instead. + * + * Pure: unit-tested without mocks. + */ + +import { type BatchOutcome, ErrorCode } from "@decocms/blocks/protocol"; +import type { LiveMeta } from "./resolve-schema"; + +const NO_SCHEMA_MARKER = "__noSchema"; + +/** The schema of a site with none yet: empty, and marked as such. */ +export function noSchemaMeta(): LiveMeta { + return { + manifest: { blocks: {} }, + schema: { definitions: {}, root: {} }, + [NO_SCHEMA_MARKER]: true, + } as LiveMeta; +} + +/** Whether `meta` stands in for a site with no schema yet. */ +export function isNoSchemaMeta(meta: LiveMeta | null | undefined): boolean { + return ( + !!meta && + (meta as unknown as Record)[NO_SCHEMA_MARKER] === true + ); +} + +/** + * Whether a `schema.get` outcome says there's no schema yet: `schema: null`, + * or NotFound from a `deco serve` older than that result. + */ +export function isSchemaAbsent(outcome: BatchOutcome): boolean { + if (!outcome.ok) return outcome.error.code === ErrorCode.NotFound; + const result = outcome.result as { notModified?: boolean; schema?: unknown }; + return result.notModified === false && result.schema === null; +} + +export interface SchemalessBlock { + key: string; + label: string; +} + +export interface SchemalessGroup { + /** The blocks' `__resolveType`; `null` for blocks without one. */ + resolveType: string | null; + blocks: SchemalessBlock[]; +} + +function blockLabel(key: string, block: Record): string { + const name = typeof block.name === "string" ? block.name.trim() : ""; + return name || key; +} + +const byLabel = (a: SchemalessBlock, b: SchemalessBlock) => + a.label.localeCompare(b.label) || a.key.localeCompare(b.key); + +/** Every saved block, grouped by `__resolveType` and sorted by name. */ +export function groupBlocksByType( + decofile: Record, +): SchemalessGroup[] { + const groups = new Map(); + for (const [key, value] of Object.entries(decofile)) { + const block = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + const resolveType = + typeof block.__resolveType === "string" ? block.__resolveType : null; + const list = groups.get(resolveType) ?? []; + list.push({ key, label: blockLabel(key, block) }); + groups.set(resolveType, list); + } + return [...groups.entries()] + .map(([resolveType, blocks]) => ({ + resolveType, + blocks: blocks.sort(byLabel), + })) + .sort((a, b) => + a.resolveType === null + ? 1 + : b.resolveType === null + ? -1 + : typeLabel(a.resolveType).localeCompare(typeLabel(b.resolveType)) || + a.resolveType.localeCompare(b.resolveType), + ); +} + +/** `site/sections/Header.tsx` → `Header`. */ +export function typeLabel(resolveType: string): string { + const last = resolveType.split("/").filter(Boolean).at(-1) ?? resolveType; + return last.replace(/\.(tsx?|jsx?)$/, "") || resolveType; +} + +export type JsonPath = readonly (string | number)[]; + +/** + * `value` with the leaf at `path` replaced; everything else, key order + * included, stays exactly as it was. + */ +export function setAtPath( + value: unknown, + path: JsonPath, + leaf: unknown, +): unknown { + if (path.length === 0) return leaf; + const [head, ...rest] = path; + if (Array.isArray(value) && typeof head === "number") { + const next = value.slice(); + next[head] = setAtPath(value[head], rest, leaf); + return next; + } + if (value && typeof value === "object" && typeof head === "string") { + const record = value as Record; + const next: Record = {}; + for (const key of Object.keys(record)) { + next[key] = + key === head ? setAtPath(record[key], rest, leaf) : record[key]; + } + return next; + } + return value; +} + +/** A number typed into a field, or `null` while it isn't one yet. */ +export function parseNumberInput(text: string): number | null { + if (text.trim() === "") return null; + const parsed = Number(text); + return Number.isFinite(parsed) ? parsed : null; +}