From 8f5f8a8dcb469b59db38a39a6019b1bc466ce5f5 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 8 Oct 2026 15:06:32 -0300 Subject: [PATCH] feat(web): schemaless content editor Adds the schemaless content editor: edit saved blocks as plain fields before a schema exists, keeping every other value as it is. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../content/schemaless-content.test.tsx | 175 +++++ .../sandbox/content/schemaless-content.tsx | 607 ++++++++++++++++++ apps/web/src/i18n/en/sandbox.ts | 18 + apps/web/src/i18n/pt-br/sandbox.ts | 18 + 4 files changed, 818 insertions(+) create mode 100644 apps/web/src/components/sandbox/content/schemaless-content.test.tsx create mode 100644 apps/web/src/components/sandbox/content/schemaless-content.tsx diff --git a/apps/web/src/components/sandbox/content/schemaless-content.test.tsx b/apps/web/src/components/sandbox/content/schemaless-content.test.tsx new file mode 100644 index 0000000000..9290930def --- /dev/null +++ b/apps/web/src/components/sandbox/content/schemaless-content.test.tsx @@ -0,0 +1,175 @@ +import { setupComponentTest } from "../../../../test/setup"; +setupComponentTest(); + +import { describe, expect, mock, test } from "bun:test"; +import { + act, + fireEvent, + render as rtlRender, + within, +} from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { type ReactElement, useState } from "react"; +import { + isNoSchemaMeta, + noSchemaMeta, +} from "@/components/sections-editor/schemaless"; +import type { LiveMeta } from "@/components/sections-editor/resolve-schema"; +import { SchemalessContent } from "./schemaless-content"; + +const hero = { + __resolveType: "site/sections/Hero.tsx", + title: "Welcome", + count: 3, + visible: true, + cta: { label: "Buy", href: "/buy", extra: null }, + tags: ["a", "b"], +}; +const decofile = { + Hero: hero, + "pages-home": { + __resolveType: "website/pages/Page.tsx", + name: "Home", + path: "/", + sections: [{ __resolveType: "Hero" }], + }, + "pages-about": { + __resolveType: "website/pages/Page.tsx", + name: "About", + path: "/about", + sections: [], + }, +}; + +const render = (ui: ReactElement) => + rtlRender( + {ui}, + ); + +/** + * Types into a controlled field. happy-dom doesn't deliver input events to + * React's change handling here, so this calls the field's own `onChange`. + */ +function type(element: HTMLElement, value: string) { + const propsKey = Object.keys(element).find((k) => + k.startsWith("__reactProps$"), + ); + const props = (element as unknown as Record)[ + propsKey ?? "" + ]; + const onChange = props?.onChange as + | ((event: { target: { value: string } }) => void) + | undefined; + if (!onChange) throw new Error("not a controlled field"); + act(() => onChange({ target: { value } })); +} + +const lastSaved = (onChange: ReturnType) => + onChange.mock.calls.at(-1) as [string, Record]; + +describe("SchemalessContent", () => { + test("lists every saved block, grouped by type", () => { + const { getByRole } = render( + {}} />, + ); + const pages = getByRole("region", { name: "website/pages/Page.tsx" }); + expect( + within(pages) + .getAllByRole("button") + .map((b) => b.textContent), + ).toEqual(["About", "Home"]); + const sections = getByRole("region", { name: "site/sections/Hero.tsx" }); + expect( + within(sections).getByRole("button", { name: "Hero" }), + ).toBeInTheDocument(); + }); + + test("opens a block as fields of its values", () => { + const { getByRole, getByLabelText } = render( + {}} />, + ); + fireEvent.click(getByRole("button", { name: "Hero" })); + expect(getByRole("form", { name: "Hero" })).toBeInTheDocument(); + expect((getByLabelText("title") as HTMLInputElement).value).toBe("Welcome"); + expect((getByLabelText("count") as HTMLInputElement).value).toBe("3"); + expect(getByLabelText("visible")).toHaveAttribute("aria-checked", "true"); + expect((getByLabelText("label") as HTMLInputElement).value).toBe("Buy"); + }); + + test("a save changes only the edited value, keeping types and key order", () => { + const onChange = mock(); + const { getByRole, getByLabelText } = render( + , + ); + type(getByLabelText("title"), "Hello"); + let [key, saved] = lastSaved(onChange); + expect(key).toBe("Hero"); + expect(JSON.stringify(saved)).toBe( + JSON.stringify({ ...hero, title: "Hello" }), + ); + + // A number stays a number; text that isn't one yet isn't saved. + const calls = onChange.mock.calls.length; + type(getByLabelText("count"), "4x"); + expect(onChange.mock.calls.length).toBe(calls); + type(getByLabelText("count"), "4"); + [, saved] = lastSaved(onChange); + expect(saved.count).toBe(4); + + // Toggling there and back saves the original values exactly. + type(getByLabelText("title"), "Welcome"); + type(getByLabelText("count"), "3"); + fireEvent.click(getByRole("switch", { name: "visible" })); + fireEvent.click(getByRole("switch", { name: "visible" })); + [, saved] = lastSaved(onChange); + expect(JSON.stringify(saved)).toBe(JSON.stringify(hero)); + }); + + test("shows the banner with the command, until the schema exists", () => { + // Mirrors the Content browser: the schemaless view while the polled + // schema is the empty, marked one; the regular editor once it's real. + function Harness() { + const [meta, setMeta] = useState(noSchemaMeta()); + return ( + <> + + {isNoSchemaMeta(meta) ? ( + {}} /> + ) : ( +

typed forms

+ )} + + ); + } + const { getByRole, getByTestId, queryByTestId, getByText } = render( + , + ); + const banner = getByTestId("schema-pending-banner"); + expect( + within(banner).getByRole("heading", { + name: "Forms get proper fields once the schema exists", + }), + ).toBeInTheDocument(); + expect(banner.textContent).toContain("npx @decocms/blocks schema"); + expect( + within(banner).getByRole("button", { name: /copy/i }), + ).toBeInTheDocument(); + expect(within(banner).getByRole("link")).toHaveAttribute( + "href", + expect.stringContaining("/next/cli#deco-schema"), + ); + + fireEvent.click(getByRole("button", { name: "schema generated" })); + expect(queryByTestId("schema-pending-banner")).toBeNull(); + expect(getByText("typed forms")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/sandbox/content/schemaless-content.tsx b/apps/web/src/components/sandbox/content/schemaless-content.tsx new file mode 100644 index 0000000000..0bac4bcd39 --- /dev/null +++ b/apps/web/src/components/sandbox/content/schemaless-content.tsx @@ -0,0 +1,607 @@ +/** + * The Content tab of a Blocks site with no schema yet (`deco schema` hasn't + * run): every saved block, grouped by `__resolveType`, each opening as plain + * fields inferred from its JSON (text, numbers, switches, nested groups and + * lists), plus the raw JSON editor. An edit replaces only the value it + * touched, so a save writes every other value back exactly as it was read. + * + * A banner explains how to get the typed forms. Nothing here is a mode: the + * editor's poll swaps the real schema in when it appears, and the regular + * editor takes over (see `isNoSchemaMeta`). + */ + +import { lazy, Suspense, useState } from "react"; +import { + ChevronLeft, + Code01, + InfoCircle, + LinkExternal01, + SearchMd, + X, +} from "@untitledui/icons"; +import { Button } from "@decocms/ui/components/button.tsx"; +import { Input } from "@decocms/ui/components/input.tsx"; +import { Label } from "@decocms/ui/components/label.tsx"; +import { Spinner } from "@decocms/ui/components/spinner.tsx"; +import { Switch } from "@decocms/ui/components/switch.tsx"; +import { Textarea } from "@decocms/ui/components/textarea.tsx"; +import { cn } from "@decocms/ui/lib/utils.ts"; +import { useT } from "@/i18n/use-t.ts"; +import { blocksDocs } from "@/lib/blocks-docs"; +import { SCHEMA_COMMAND } from "@/components/sections-editor/deco-serve-connection"; +import { CommandSnippet } from "@/components/sections-editor/deco-serve-notices"; +import { + groupBlocksByType, + type JsonPath, + parseNumberInput, + setAtPath, + typeLabel, +} from "@/components/sections-editor/schemaless"; + +const MonacoCodeEditor = lazy(() => + import("@/components/monaco-editor").then((m) => ({ + default: m.MonacoCodeEditor, + })), +); + +type JsonRecord = Record; + +const isRecord = (value: unknown): value is JsonRecord => + !!value && typeof value === "object" && !Array.isArray(value); + +/** The one notice of a site without a schema: what's missing and the fix. */ +function SchemaPendingBanner({ className }: { className?: string }) { + const t = useT(); + return ( +
+
+
+ +
+ ); +} + +function itemLabel( + value: unknown, + index: number, + t: ReturnType, +): string { + if (isRecord(value)) { + for (const key of ["name", "title", "label"]) { + const text = value[key]; + if (typeof text === "string" && text.trim()) return text.trim(); + } + if (typeof value.__resolveType === "string") { + return typeLabel(value.__resolveType); + } + // Otherwise its first short line of text (a question, a link's label). + for (const [key, text] of Object.entries(value)) { + if (key.startsWith("__") || typeof text !== "string") continue; + const line = text.trim(); + if (line && line.length <= 80 && !line.startsWith("<")) return line; + } + } + return t("sandbox.schemaless.item", { n: index + 1 }); +} + +const fieldId = (path: JsonPath) => `schemaless-${path.join(".")}`; + +function TextValue({ + id, + value, + onChange, +}: { + id: string; + value: string; + onChange: (next: string) => void; +}) { + // Decided once, so typing never swaps the control under the cursor. + const [multiline] = useState(() => value.includes("\n") || value.length > 80); + return multiline ? ( +