diff --git a/config/lattice.php b/config/lattice.php index eb2ddf6b..e911bfcd 100644 --- a/config/lattice.php +++ b/config/lattice.php @@ -51,6 +51,11 @@ 'middleware' => ['web', 'auth'], ], + 'blocks' => [ + 'endpoint' => 'lattice/blocks/render', + 'middleware' => ['web', 'auth'], + ], + 'remote-sources' => [ 'endpoint' => 'lattice/remote-sources/{source}/token', 'middleware' => ['web', 'auth'], diff --git a/docs/fixtures/enums.json b/docs/fixtures/enums.json index 2c1195e2..d24f0d19 100644 --- a/docs/fixtures/enums.json +++ b/docs/fixtures/enums.json @@ -131,6 +131,10 @@ "name": "Builder", "value": "field.builder" }, + { + "name": "BlockEditor", + "value": "field.block-editor" + }, { "name": "Checkbox", "value": "field.checkbox" diff --git a/lang/de/form.php b/lang/de/form.php index 7634657c..7303d690 100644 --- a/lang/de/form.php +++ b/lang/de/form.php @@ -2,6 +2,19 @@ declare(strict_types=1); return [ + 'block-editor' => [ + 'drag' => 'Ziehen', + 'drag-to-reorder' => 'Zum Neuanordnen ziehen', + 'duplicate' => 'Duplizieren', + 'empty-slot' => 'Blöcke hier ablegen', + 'has-errors' => 'Dieser Block enthält Fehler.', + 'move-down' => 'Nach unten', + 'move-up' => 'Nach oben', + 'no-settings' => 'Dieser Block hat keine Einstellungen.', + 'remove' => 'Block entfernen', + 'select-block' => 'Block auswählen, um ihn zu bearbeiten.', + 'unknown-block' => 'Unbekannter Block', + ], 'editor' => [ 'bold' => 'Fett', 'italic' => 'Kursiv', diff --git a/lang/en/form.php b/lang/en/form.php index 5aa7aeff..aef22a5d 100644 --- a/lang/en/form.php +++ b/lang/en/form.php @@ -2,6 +2,19 @@ declare(strict_types=1); return [ + 'block-editor' => [ + 'drag' => 'Drag', + 'drag-to-reorder' => 'Drag to reorder', + 'duplicate' => 'Duplicate', + 'empty-slot' => 'Drop blocks here', + 'has-errors' => 'This block has errors.', + 'move-down' => 'Move down', + 'move-up' => 'Move up', + 'no-settings' => 'This block has no settings.', + 'remove' => 'Remove block', + 'select-block' => 'Select a block to edit it.', + 'unknown-block' => 'Unknown block', + ], 'editor' => [ 'bold' => 'Bold', 'italic' => 'Italic', diff --git a/package-lock.json b/package-lock.json index 97fa3977..feb9ed05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,9 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@internationalized/date": "^3.12.2", "@lattice-php/vite-svg-sprite": "^0.2.2", "@radix-ui/react-checkbox": "^1.1.4", @@ -1568,6 +1571,59 @@ "node": ">=14" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", diff --git a/package.json b/package.json index 53e6e99a..0178bbf0 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,9 @@ "docs:preview": "astro preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@internationalized/date": "^3.12.2", "@lattice-php/vite-svg-sprite": "^0.2.2", "@radix-ui/react-checkbox": "^1.1.4", diff --git a/resources/js/form/components/fields/add-row-menu.tsx b/resources/js/form/components/fields/add-row-menu.tsx index 1472bfb5..3f7e7788 100644 --- a/resources/js/form/components/fields/add-row-menu.tsx +++ b/resources/js/form/components/fields/add-row-menu.tsx @@ -6,7 +6,7 @@ import { DropdownMenuTrigger, } from "@lattice-php/lattice/ui/dropdown-menu"; -export type AddRowOption = { type: string; label: string }; +export type AddRowOption = { type: string; label: string; icon?: string; description?: string }; export function AddRowMenu({ addLabel, @@ -34,9 +34,17 @@ export function AddRowMenu({ onSelect(option.type)} > - {option.label} + {option.description ? ( + + {option.label} + {option.description} + + ) : ( + option.label + )} ))} diff --git a/resources/js/form/components/fields/block-editor/canvas.test.tsx b/resources/js/form/components/fields/block-editor/canvas.test.tsx new file mode 100644 index 00000000..b301a9a1 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/canvas.test.tsx @@ -0,0 +1,211 @@ +import { configure, fireEvent, render, screen, within } from "@testing-library/react"; +import { beforeAll, expect, it, vi } from "vitest"; + +beforeAll(() => configure({ testIdAttribute: "data-test" })); + +vi.mock("@lattice-php/lattice/core/renderer", () => ({ + Renderer: ({ nodes }: { nodes: { props: { text: string } }[] }) => ( + {nodes[0]?.props.text} + ), +})); + +import { BlockCanvas } from "./canvas"; + +const templates = [ + { + type: "hero", + label: "Hero", + icon: "layout-dashboard", + description: "A big heading.", + schema: [], + }, + { type: "text", label: "Text", schema: [] }, + { + type: "columns", + label: "Columns", + schema: [], + slots: [{ name: "main", label: "Main column" }], + }, + { + type: "restricted", + label: "Restricted", + schema: [], + slots: [{ name: "main", blocks: ["hero"] }], + }, +] as never[]; + +function renderCanvas(rows: Record[], overrides: Record = {}) { + const handlers = { + onSelect: vi.fn<(rowId: string) => void>(), + onRemove: vi.fn<(path: unknown) => void>(), + onDuplicate: vi.fn<(path: unknown) => void>(), + onShift: vi.fn<(path: unknown, delta: number) => void>(), + onAppend: vi.fn<(path: unknown, slot: string, type: string) => void>(), + }; + + render( + + (((overrides.wire as Record) ?? {})[id] as never) ?? [] + } + onPreviewSeed={() => {}} + selectedId={(overrides.selectedId as string) ?? null} + errorIds={(overrides.errorIds as Set) ?? new Set()} + onSelect={handlers.onSelect} + onMoveBlock={() => {}} + onRemove={handlers.onRemove} + onDuplicate={handlers.onDuplicate} + onShift={handlers.onShift} + onAppend={handlers.onAppend} + />, + ); + + return handlers; +} + +function openActions(rowId: string): void { + fireEvent.click( + within(screen.getByTestId(`block-shell-${rowId}`)).getByTestId("row-actions-menu"), + ); +} + +it("renders each row's wire and selects on click", () => { + const wire = { + a: [{ type: "heading", props: { text: "Alpha" } }], + b: [{ type: "text", props: { text: "Beta" } }], + }; + + const { onSelect } = renderCanvas( + [ + { rowId: "a", type: "hero" }, + { rowId: "b", type: "text" }, + ], + { wire, selectedId: "a" }, + ); + + expect(screen.getByText("Alpha")).toBeInTheDocument(); + expect(screen.getByTestId("block-shell-a")).toHaveAttribute("aria-selected", "true"); + + fireEvent.click(screen.getByTestId("block-shell-b")); + expect(onSelect).toHaveBeenCalledWith("b"); +}); + +it("removes a block through its action menu", () => { + const { onRemove } = renderCanvas([ + { rowId: "a", type: "hero" }, + { rowId: "b", type: "text" }, + ]); + + openActions("b"); + fireEvent.click(screen.getByTestId("row-action-remove")); + + expect(onRemove).toHaveBeenCalledWith([{ index: 1 }]); +}); + +it("duplicates and reorders a block through its action menu", () => { + const { onDuplicate, onShift } = renderCanvas([ + { rowId: "a", type: "hero" }, + { rowId: "b", type: "text" }, + ]); + + openActions("a"); + fireEvent.click(screen.getByTestId("row-action-duplicate")); + expect(onDuplicate).toHaveBeenCalledWith([{ index: 0 }]); + + openActions("a"); + fireEvent.click(screen.getByTestId("row-action-move-down")); + expect(onShift).toHaveBeenCalledWith([{ index: 0 }], 1); +}); + +it("flags a block whose fields have errors", () => { + renderCanvas([{ rowId: "a", type: "hero" }], { errorIds: new Set(["a"]) }); + + expect(screen.getByTestId("block-error-a")).toBeInTheDocument(); +}); + +it("renders slot areas with nested child shells for a slotted block", () => { + const wire = { h1: [{ type: "heading", props: { text: "Inner" } }] }; + + renderCanvas( + [ + { + rowId: "c1", + type: "columns", + slots: { main: [{ rowId: "h1", type: "hero" }] }, + }, + ], + { wire }, + ); + + expect(screen.getByTestId("block-slot-main")).toBeInTheDocument(); + expect(screen.getByTestId("block-shell-h1")).toBeInTheDocument(); + expect(screen.getByText("Inner")).toBeInTheDocument(); +}); + +it("labels a slot heading and describes blocks in the add menu", () => { + renderCanvas([{ rowId: "c1", type: "columns", slots: { main: [] } }]); + + expect(screen.getByText("Main column")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("builder-add")); + + expect(screen.getByText("A big heading.")).toBeInTheDocument(); +}); + +it("shows an empty-slot drop target when a slot has no children", () => { + renderCanvas([{ rowId: "c1", type: "columns", slots: { main: [] } }]); + + expect(screen.getByText("Drop blocks here")).toBeInTheDocument(); +}); + +it("selects a nested child without selecting its parent", () => { + const { onSelect } = renderCanvas([ + { + rowId: "c1", + type: "columns", + slots: { main: [{ rowId: "h1", type: "hero" }] }, + }, + ]); + + fireEvent.click(screen.getByTestId("block-shell-h1")); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith("h1"); +}); + +it("offers only the allowed blocks in a restricted slot's add menu", () => { + renderCanvas([{ rowId: "r1", type: "restricted", slots: { main: [] } }]); + + fireEvent.click(screen.getByTestId("builder-add")); + + expect(screen.getByTestId("builder-add-hero")).toBeInTheDocument(); + expect(screen.queryByTestId("builder-add-columns")).not.toBeInTheDocument(); + expect(screen.queryByTestId("builder-add-text")).not.toBeInTheDocument(); +}); + +it("appends an allowed block into a slot through its add menu", () => { + const { onAppend } = renderCanvas([{ rowId: "c1", type: "columns", slots: { main: [] } }]); + + fireEvent.click(screen.getByTestId("builder-add")); + fireEvent.click(screen.getByTestId("builder-add-hero")); + + expect(onAppend).toHaveBeenCalledWith([{ index: 0 }], "main", "hero"); +}); + +it("removes a nested child with its slot path", () => { + const { onRemove } = renderCanvas([ + { + rowId: "c1", + type: "columns", + slots: { main: [{ rowId: "h1", type: "hero" }] }, + }, + ]); + + openActions("h1"); + fireEvent.click(screen.getByTestId("row-action-remove")); + + expect(onRemove).toHaveBeenCalledWith([{ index: 0, slot: "main" }, { index: 0 }]); +}); diff --git a/resources/js/form/components/fields/block-editor/canvas.tsx b/resources/js/form/components/fields/block-editor/canvas.tsx new file mode 100644 index 00000000..7298280c --- /dev/null +++ b/resources/js/form/components/fields/block-editor/canvas.tsx @@ -0,0 +1,298 @@ +import { + closestCenter, + DndContext, + KeyboardSensor, + pointerWithin, + PointerSensor, + useDroppable, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import type { CollisionDetection, DragEndEvent } from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { useEffect } from "react"; +import type { Node } from "@lattice-php/lattice/core/types"; +import { Renderer } from "@lattice-php/lattice/core/renderer"; +import { Icon } from "@lattice-php/lattice/icons"; +import { useT } from "@lattice-php/lattice/i18n"; +import { cn } from "@lattice-php/lattice/lib/utils"; +import { AddRowMenu } from "@lattice-php/lattice/form/components/fields/add-row-menu"; +import { RowActions } from "@lattice-php/lattice/form/components/fields/row-actions"; +import { + ROW_ID_KEY, + type RepeaterRow, +} from "@lattice-php/lattice/form/components/fields/repeater-rows"; +import type { + RowTemplate, + RowTemplateSlot, +} from "@lattice-php/lattice/form/components/fields/row-templates"; +import { dropDepth, resolveDrop, slotDropId } from "./dnd"; +import { childList, type BlockPath } from "./tree"; + +type CanvasShared = { + templates: RowTemplate[]; + addLabel: string; + wireFor: (rowId: string) => Node[]; + onPreviewSeed: (row: RepeaterRow) => void; + selectedId: string | null; + errorIds: ReadonlySet; + onSelect: (rowId: string) => void; + onRemove: (path: BlockPath) => void; + onDuplicate: (path: BlockPath) => void; + onShift: (path: BlockPath, delta: number) => void; + onAppend: (path: BlockPath, slot: string, type: string) => void; +}; + +type Props = CanvasShared & { + rows: RepeaterRow[]; + onMoveBlock: (from: BlockPath, to: BlockPath) => void; +}; + +function EmptySlot({ parentRowId, slot }: { parentRowId: string; slot: string }) { + const { setNodeRef, isOver } = useDroppable({ id: slotDropId(parentRowId, slot) }); + const { t } = useT("lattice"); + + return ( +
+ {t("form.block-editor.empty-slot", "Drop blocks here")} +
+ ); +} + +function SlotArea({ + parentPath, + parentRowId, + slot, + rows, + shared, +}: { + parentPath: BlockPath; + parentRowId: string; + slot: RowTemplateSlot; + rows: RepeaterRow[]; + shared: CanvasShared; +}) { + const last = parentPath[parentPath.length - 1]; + const prefix: BlockPath = [...parentPath.slice(0, -1), { ...last, slot: slot.name }]; + const ids = rows.map((row) => String(row[ROW_ID_KEY])); + const allowed = slot.blocks + ? shared.templates.filter((template) => slot.blocks?.includes(template.type)) + : shared.templates; + const options = allowed.map((template) => ({ + type: template.type, + label: template.label, + icon: template.icon, + description: template.description, + })); + + return ( +
+ + {slot.label ?? slot.name} + + +
+ {rows.map((row, index) => ( + + ))} + {rows.length === 0 && } +
+
+ shared.onAppend(parentPath, slot.name, type)} + /> +
+ ); +} + +function Shell({ row, path, shared }: { row: RepeaterRow; path: BlockPath; shared: CanvasShared }) { + const rowId = String(row[ROW_ID_KEY]); + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: rowId }); + const { t } = useT("lattice"); + const template = shared.templates.find((candidate) => candidate.type === row.type); + const slots = template?.slots ?? []; + + useEffect(() => { + if (slots.length === 0) { + shared.onPreviewSeed(row); + } + // fetch once per mount; the preview cache dedupes by content signature + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
{ + e.stopPropagation(); + shared.onSelect(rowId); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (e.key === " ") { + e.preventDefault(); + } + e.stopPropagation(); + shared.onSelect(rowId); + } + }} + tabIndex={0} + className={cn( + "cursor-pointer rounded-lt border p-2", + shared.selectedId === rowId + ? "border-lt-primary ring-1 ring-lt-primary" + : "border-transparent hover:border-lt-border", + )} + > +
+ + + {template?.icon && + {shared.errorIds.has(rowId) && ( + + )} + e.stopPropagation()}> + shared.onShift(path, -1), + }, + { + key: "move-down", + label: t("form.block-editor.move-down", "Move down"), + icon: "arrow-down", + onClick: () => shared.onShift(path, 1), + }, + { + key: "duplicate", + label: t("form.block-editor.duplicate", "Duplicate"), + icon: "copy", + onClick: () => shared.onDuplicate(path), + }, + { + key: "remove", + label: t("form.block-editor.remove", "Remove block"), + icon: "trash-2", + onClick: () => shared.onRemove(path), + destructive: true, + }, + ]} + /> + +
+ {slots.length === 0 ? ( + + ) : ( +
+ {slots.map((slot) => ( + + ))} +
+ )} +
+ ); +} + +export function BlockCanvas({ rows, onMoveBlock, ...shared }: Props) { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + // Prefer the deepest droppable under the pointer so nested shells and slot + // placeholders win over the ancestors that spatially contain them. + const deepestUnderPointer: CollisionDetection = (args) => { + const within = pointerWithin(args).filter((collision) => collision.id !== args.active.id); + + if (within.length > 0) { + return [ + within.reduce((best, collision) => + dropDepth(rows, String(collision.id)) > dropDepth(rows, String(best.id)) + ? collision + : best, + ), + ]; + } + + return closestCenter(args); + }; + + const onDragEnd = (event: DragEndEvent): void => { + const over = event.over; + + if (!over || over.id === event.active.id) { + return; + } + + const move = resolveDrop(rows, String(event.active.id), String(over.id)); + + if (move) { + onMoveBlock(move.from, move.to); + } + }; + + const ids = rows.map((row) => String(row[ROW_ID_KEY])); + + return ( + + +
+ {rows.map((row, index) => ( + + ))} +
+
+
+ ); +} diff --git a/resources/js/form/components/fields/block-editor/dnd.test.ts b/resources/js/form/components/fields/block-editor/dnd.test.ts new file mode 100644 index 00000000..86710445 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/dnd.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { dropDepth, resolveDrop, slotDropId } from "./dnd"; + +const rows = () => [ + { rowId: "a", type: "text" }, + { + rowId: "cols", + type: "columns", + slots: { + main: [{ rowId: "m1", type: "text" }], + side: [], + }, + }, +]; + +describe("resolveDrop", () => { + it("resolves a drop onto another shell to its path", () => { + expect(resolveDrop(rows(), "a", "m1")).toEqual({ + from: [{ index: 0 }], + to: [{ index: 1, slot: "main" }, { index: 0 }], + }); + }); + + it("resolves a drop onto an empty slot placeholder to its first position", () => { + expect(resolveDrop(rows(), "m1", slotDropId("cols", "side"))).toEqual({ + from: [{ index: 1, slot: "main" }, { index: 0 }], + to: [{ index: 1, slot: "side" }, { index: 0 }], + }); + }); + + it("returns null when the dragged block is unknown", () => { + expect(resolveDrop(rows(), "gone", "a")).toBeNull(); + }); + + it("returns null when the target is unknown", () => { + expect(resolveDrop(rows(), "a", slotDropId("gone", "main"))).toBeNull(); + }); +}); + +describe("dropDepth", () => { + it("ranks nested shells deeper than top-level shells", () => { + expect(dropDepth(rows(), "m1")).toBeGreaterThan(dropDepth(rows(), "cols")); + }); + + it("ranks a slot placeholder deeper than its parent shell", () => { + expect(dropDepth(rows(), slotDropId("cols", "side"))).toBeGreaterThan( + dropDepth(rows(), "cols"), + ); + }); + + it("ranks unknown ids lowest", () => { + expect(dropDepth(rows(), "gone")).toBe(0); + }); +}); diff --git a/resources/js/form/components/fields/block-editor/dnd.ts b/resources/js/form/components/fields/block-editor/dnd.ts new file mode 100644 index 00000000..afa8bf45 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/dnd.ts @@ -0,0 +1,63 @@ +import { + ROW_ID_KEY, + type RepeaterRow, +} from "@lattice-php/lattice/form/components/fields/repeater-rows"; +import { walkBlocks, type BlockPath } from "./tree"; + +/** + * Drag identities are row ids: they stay stable while the tree mutates, unlike + * position paths. Empty slots get a synthetic droppable id so blocks can be + * dropped into them. + */ +const SLOT_DROP_PREFIX = "slot:"; + +export function slotDropId(parentRowId: string, slot: string): string { + return `${SLOT_DROP_PREFIX}${parentRowId}:${slot}`; +} + +function pathsByRowId(rows: RepeaterRow[]): Map { + return new Map(walkBlocks(rows).map(({ row, path }) => [String(row[ROW_ID_KEY]), path])); +} + +function slotDropPath(paths: Map, id: string): BlockPath | null { + const separator = id.lastIndexOf(":"); + const parent = paths.get(id.slice(SLOT_DROP_PREFIX.length, separator)); + + if (!parent) { + return null; + } + + const last = parent[parent.length - 1]; + + return [...parent.slice(0, -1), { ...last, slot: id.slice(separator + 1) }, { index: 0 }]; +} + +export function resolveDrop( + rows: RepeaterRow[], + activeId: string, + overId: string, +): { from: BlockPath; to: BlockPath } | null { + const paths = pathsByRowId(rows); + const from = paths.get(activeId); + + if (!from) { + return null; + } + + const to = overId.startsWith(SLOT_DROP_PREFIX) + ? slotDropPath(paths, overId) + : (paths.get(overId) ?? null); + + return to ? { from, to } : null; +} + +/** How deep a droppable sits in the tree, so collisions prefer the most specific target. */ +export function dropDepth(rows: RepeaterRow[], id: string): number { + const paths = pathsByRowId(rows); + + if (id.startsWith(SLOT_DROP_PREFIX)) { + return (slotDropPath(paths, id)?.length ?? 0) + 1; + } + + return paths.get(id)?.length ?? 0; +} diff --git a/resources/js/form/components/fields/block-editor/index.test.tsx b/resources/js/form/components/fields/block-editor/index.test.tsx new file mode 100644 index 00000000..52dda318 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/index.test.tsx @@ -0,0 +1,188 @@ +import { configure, fireEvent, render, screen, within } from "@testing-library/react"; +import { beforeAll, expect, it, vi } from "vitest"; + +beforeAll(() => configure({ testIdAttribute: "data-test" })); + +vi.mock("@lattice-php/lattice/core/renderer", () => ({ + Renderer: ({ nodes }: { nodes: { props?: { text?: string } }[] }) => ( + {nodes[0]?.props?.text ?? ""} + ), + RenderNode: () => , +})); +vi.mock("@lattice-php/lattice/core/api", () => ({ + apiJson: vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue({ wire: [] }), +})); + +import type React from "react"; +import { FormProvider } from "@lattice-php/lattice/form/hooks/context"; +import { FormValuesProvider } from "@lattice-php/lattice/form/hooks/values"; +import { BlockEditorComponent } from "./index"; + +function wrap( + ui: React.ReactNode, + initial: Record, + errors: Record = {}, +) { + return render( + {}, + componentRef: "", + errors, + fieldLabels: {}, + precognitive: false, + processing: false, + validate: () => {}, + }} + > + {ui} + , + ); +} + +const baseNode = { + id: "content", + type: "field.block-editor", + props: { name: "content", ref: "sealed", endpoint: "/lattice/blocks/render", defaultItems: 0 }, + templates: [ + { + type: "hero", + label: "Hero", + schema: [{ id: "t", type: "field.text-input", props: { name: "title" } }], + }, + { type: "columns", label: "Columns", schema: [], slots: [{ name: "main" }] }, + ], + rendered: [{ wire: [{ type: "heading", props: { text: "Stored" } }], slots: {} }], +}; +const node = baseNode as never; + +it("renders stored blocks on the canvas from the rendered prop", () => { + wrap({null}, { + content: [{ rowId: "a", type: "hero", title: "Stored" }], + }); + + expect(screen.getByText("Stored")).toBeInTheDocument(); + expect(screen.getByTestId("block-shell-a")).toBeInTheDocument(); +}); + +it("renders nothing when its visible condition fails", () => { + const hiddenNode = { + ...baseNode, + props: { + ...baseNode.props, + conditions: { visible: [{ field: "status", operator: "eq", value: "live" }] }, + }, + } as never; + + wrap({null}, { + content: [{ rowId: "a", type: "hero", title: "Stored" }], + }); + + expect(screen.queryByTestId("block-editor-inspector")).not.toBeInTheDocument(); +}); + +it("shows the field error from the form context", () => { + wrap( + {null}, + { content: [{ rowId: "a", type: "hero", title: "Stored" }] }, + { content: "At least one block is required." }, + ); + + expect(screen.getByText("At least one block is required.")).toBeInTheDocument(); +}); + +const slottedValue = () => ({ + content: [ + { + rowId: "c1", + type: "columns", + slots: { main: [{ rowId: "h1", type: "hero", title: "Inner" }] }, + }, + ], +}); + +const slottedNode = { + ...baseNode, + rendered: [ + { + wire: [{ type: "grid", props: {} }], + slots: { main: [{ wire: [{ type: "heading", props: { text: "Inner" } }], slots: {} }] }, + }, + ], +} as never; + +it("renders slot children as their own shells with nested key inputs", () => { + const { container } = wrap( + {null}, + slottedValue(), + ); + + expect(screen.getByTestId("block-slot-main")).toBeInTheDocument(); + expect(screen.getByTestId("block-shell-h1")).toBeInTheDocument(); + expect(screen.getByText("Inner")).toBeInTheDocument(); + + const typeInput = container.querySelector('input[name="content[0][slots][main][0][type]"]'); + const rowIdInput = container.querySelector('input[name="content[0][slots][main][0][rowId]"]'); + expect(typeInput).toHaveValue("hero"); + expect(rowIdInput).toHaveValue("h1"); +}); + +it("selects a nested child without selecting its parent", () => { + wrap({null}, slottedValue()); + + fireEvent.click(screen.getByTestId("block-shell-h1")); + + expect(screen.getByTestId("block-shell-h1")).toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId("block-shell-c1")).toHaveAttribute("aria-selected", "false"); +}); + +it("removes a nested block from the value", () => { + const { container } = wrap( + {null}, + slottedValue(), + ); + + fireEvent.click(within(screen.getByTestId("block-shell-h1")).getByTestId("row-actions-menu")); + fireEvent.click(screen.getByTestId("row-action-remove")); + + expect(screen.queryByTestId("block-shell-h1")).not.toBeInTheDocument(); + expect(screen.getByText("Drop blocks here")).toBeInTheDocument(); + expect( + container.querySelector('input[name="content[0][slots][main][0][type]"]'), + ).not.toBeInTheDocument(); +}); + +it("selects a newly added block", () => { + wrap({null}, { content: [] }); + + fireEvent.click(screen.getByTestId("builder-add")); + fireEvent.click(screen.getByTestId("builder-add-hero")); + + const shell = screen + .getByTestId("block-editor-inspector") + .ownerDocument.querySelector('[data-test^="block-shell-"]'); + expect(shell).toHaveAttribute("aria-selected", "true"); +}); + +it("duplicates a block with a fresh identity through its action menu", () => { + wrap({null}, { + content: [{ rowId: "a", type: "hero", title: "Stored" }], + }); + + fireEvent.click(within(screen.getByTestId("block-shell-a")).getByTestId("row-actions-menu")); + fireEvent.click(screen.getByTestId("row-action-duplicate")); + + const shells = screen.getAllByTestId(/^block-shell-/); + expect(shells).toHaveLength(2); + expect(shells[1].getAttribute("data-test")).not.toBe("block-shell-a"); +}); + +it("flags the block owning a field error on the canvas", () => { + wrap({null}, slottedValue(), { + "content.0.slots.main.0.title": "Required.", + }); + + expect(screen.getByTestId("block-error-h1")).toBeInTheDocument(); + expect(screen.queryByTestId("block-error-c1")).not.toBeInTheDocument(); +}); diff --git a/resources/js/form/components/fields/block-editor/index.tsx b/resources/js/form/components/fields/block-editor/index.tsx new file mode 100644 index 00000000..3ee79fba --- /dev/null +++ b/resources/js/form/components/fields/block-editor/index.tsx @@ -0,0 +1,263 @@ +import { useMemo, useState } from "react"; +import type { Node, RendererComponent } from "@lattice-php/lattice/core/types"; +import { useT } from "@lattice-php/lattice/i18n"; +import { FormFieldFrame } from "@lattice-php/lattice/form/components/base/field"; +import { appendPath, toHtmlName } from "@lattice-php/lattice/form/lib/form-path"; +import { useFormContext } from "@lattice-php/lattice/form/hooks/context"; +import { useDependentField } from "@lattice-php/lattice/form/hooks/use-dependent-field"; +import { useSetFormValue } from "@lattice-php/lattice/form/hooks/values"; +import { + AddRowMenu, + type AddRowOption, +} from "@lattice-php/lattice/form/components/fields/add-row-menu"; +import { + ROW_ID_KEY, + withRowId, + type RepeaterRow, +} from "@lattice-php/lattice/form/components/fields/repeater-rows"; +import { RowKeyInputs } from "@lattice-php/lattice/form/components/fields/row-key-inputs"; +import { + rowTemplatesOf, + type RowTemplate, +} from "@lattice-php/lattice/form/components/fields/row-templates"; +import { useRowCollection } from "@lattice-php/lattice/form/components/fields/use-row-collection"; +import { BlockCanvas } from "./canvas"; +import { BlockInspector } from "./inspector"; +import { moveBlock } from "./move-block"; +import { + appendBlockAt, + blockAt, + duplicateBlockAt, + removeBlockAt, + shiftBlockAt, + slotAllowedTypes, + updateBlockAt, + walkBlocks, + type BlockPath, +} from "./tree"; +import { useBlockPreview, type BlockSource, type RenderedBlock } from "./use-block-preview"; + +function rowAttributes(row: RepeaterRow): Record { + return Object.fromEntries(Object.entries(row).filter(([key]) => key !== ROW_ID_KEY)); +} + +function rowSlots(row: RepeaterRow): Record { + return (row.slots ?? {}) as Record; +} + +export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ node }) => { + const props = node.props; + const name = props.name; + const templates = rowTemplatesOf(node) ?? []; + const rendered = (node as unknown as { rendered?: RenderedBlock[] }).rendered ?? []; + + const { errors } = useFormContext(); + const { hidden, required } = useDependentField(node); + const { path, rows, append } = useRowCollection(name, props.defaultItems ?? 0); + const setValue = useSetFormValue(); + const { t } = useT("lattice"); + + const seeds = useMemo(() => { + const wire: Record = {}; + const sources: Record = {}; + const walk = (seedRows: RepeaterRow[], seedRendered: RenderedBlock[]): void => { + seedRows.forEach((row, i) => { + const rowId = String(row[ROW_ID_KEY]); + wire[rowId] = seedRendered[i]?.wire ?? []; + sources[rowId] = { type: String(row.type ?? ""), attributes: rowAttributes(row) }; + + for (const [slot, children] of Object.entries(rowSlots(row))) { + if (Array.isArray(children)) { + walk(children, seedRendered[i]?.slots?.[slot] ?? []); + } + } + }); + }; + walk(rows, rendered); + + return { wire, sources }; + // seed once from the server-rendered payload + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const { wireFor, refresh } = useBlockPreview({ + endpoint: props.endpoint ?? "", + ref: props.ref ?? "", + initial: seeds.wire, + renderedWith: seeds.sources, + }); + + const [selectedId, setSelectedId] = useState(null); + + if (hidden) { + return null; + } + + const templateFor = (type: unknown): RowTemplate | undefined => + templates.find((template) => template.type === type); + const options: AddRowOption[] = templates.map((template) => ({ + type: template.type, + label: template.label, + icon: template.icon, + description: template.description, + })); + const atMax = props.maxItems != null && rows.length >= props.maxItems; + + const entries = walkBlocks(rows); + const selectedRow = + entries.find(({ row }) => String(row[ROW_ID_KEY]) === selectedId)?.row ?? null; + + const asRows = (prev: unknown): RepeaterRow[] => + Array.isArray(prev) ? (prev as RepeaterRow[]) : []; + + const onMoveBlock = (from: BlockPath, to: BlockPath): void => { + setValue(path, (prev: unknown) => { + const current = asRows(prev); + const moved = blockAt(current, from); + const allowed = slotAllowedTypes(templates, current, to); + + if (moved && allowed && !allowed.includes(String(moved.type))) { + return current; + } + + return moveBlock(current, from, to); + }); + }; + + const onFieldAt = (blockPath: BlockPath, field: string, value: unknown): void => { + setValue(path, (prev: unknown) => updateBlockAt(asRows(prev), blockPath, field, value)); + }; + + const onRemove = (blockPath: BlockPath): void => { + setValue(path, (prev: unknown) => removeBlockAt(asRows(prev), blockPath)); + }; + + const onDuplicate = (blockPath: BlockPath): void => { + setValue(path, (prev: unknown) => duplicateBlockAt(asRows(prev), blockPath)); + }; + + const onShift = (blockPath: BlockPath, delta: number): void => { + setValue(path, (prev: unknown) => shiftBlockAt(asRows(prev), blockPath, delta)); + }; + + const addRow = (type: string): void => { + const row = withRowId({ type }); + append(row); + setSelectedId(String(row[ROW_ID_KEY])); + }; + + const onAppend = (parentPath: BlockPath, slot: string, type: string): void => { + const row = withRowId({ type }); + setValue(path, (prev: unknown) => appendBlockAt(asRows(prev), parentPath, slot, row)); + setSelectedId(String(row[ROW_ID_KEY])); + }; + + /** The store path of the list containing the block at the given path. */ + const listBaseFor = (blockPath: BlockPath): string => { + let current = path; + + for (let i = 0; i < blockPath.length - 1; i++) { + const step = blockPath[i]; + current = appendPath(current, step.index, "slots", String(step.slot)); + } + + return current; + }; + + // A block is flagged when one of its own keys errs; descendant errors flag the descendant. + const errorIds = new Set( + entries + .filter(({ path: blockPath }) => { + const prefix = appendPath(listBaseFor(blockPath), blockPath[blockPath.length - 1].index); + + return Object.entries(errors).some( + ([key, message]) => + Boolean(message) && + key.startsWith(`${prefix}.`) && + !key.slice(prefix.length + 1).includes("."), + ); + }) + .map(({ row }) => String(row[ROW_ID_KEY])), + ); + + const refreshRow = (row: RepeaterRow): void => { + void refresh(String(row[ROW_ID_KEY]), String(row.type ?? ""), rowAttributes(row)); + }; + + return ( + + + + + {entries + .filter((entry) => entry.path.length > 1) + .flatMap(({ row, path: blockPath }) => { + const rowBase = appendPath(listBaseFor(blockPath), blockPath[blockPath.length - 1].index); + + return [ROW_ID_KEY, "type"].map((key) => ( + + )); + })} + +
+
+ + {!atMax && ( + + )} +
+ +
+ {entries.map(({ row, path: blockPath }) => { + const rowId = String(row[ROW_ID_KEY]); + const template = templateFor(row.type); + + return ( +
+ onFieldAt(blockPath, field, value)} + onCommit={() => refreshRow(row)} + /> +
+ ); + })} + {!selectedRow && ( +

+ {t("form.block-editor.select-block", "Select a block to edit it.")} +

+ )} +
+
+
+ ); +}; diff --git a/resources/js/form/components/fields/block-editor/inspector.test.tsx b/resources/js/form/components/fields/block-editor/inspector.test.tsx new file mode 100644 index 00000000..01f6f707 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/inspector.test.tsx @@ -0,0 +1,107 @@ +import { configure, fireEvent, render, screen } from "@testing-library/react"; +import type React from "react"; +import { beforeAll, expect, it, vi } from "vitest"; + +beforeAll(() => configure({ testIdAttribute: "data-test" })); + +vi.mock("@lattice-php/lattice/core/renderer", () => ({ + RenderNode: ({ node }: { node: { props: { name: string } } }) => ( + + ), +})); + +import { FormProvider } from "@lattice-php/lattice/form/hooks/context"; +import { FormValuesProvider } from "@lattice-php/lattice/form/hooks/values"; +import { BlockInspector } from "./inspector"; + +// Copy the full FormProvider value shape from builder.test.tsx (see Global Constraints). +function wrap(ui: React.ReactNode) { + return render( + {}, + componentRef: "", + errors: {}, + fieldLabels: {}, + precognitive: false, + processing: false, + validate: () => {}, + }} + > + {ui} + , + ); +} + +const template = [{ id: "t", type: "field.text-input", props: { name: "title" } }] as never; + +it("renders the block fields and commits on blur", () => { + const onCommit = vi.fn<() => void>(); + + wrap( + void>()} + onCommit={onCommit} + />, + ); + + expect(screen.getByTestId("fld-title")).toBeInTheDocument(); + + fireEvent.blur(screen.getByTestId("block-inspector")); + expect(onCommit).toHaveBeenCalledOnce(); +}); + +it("does not commit when focus moves between fields inside the inspector", () => { + const onCommit = vi.fn<() => void>(); + + wrap( + void>()} + onCommit={onCommit} + />, + ); + + fireEvent.blur(screen.getByTestId("block-inspector"), { + relatedTarget: screen.getByTestId("fld-title"), + }); + + expect(onCommit).not.toHaveBeenCalled(); +}); + +it("shows an unknown-block note when there is no template", () => { + wrap( + void>()} + onCommit={vi.fn<() => void>()} + />, + ); + + expect(screen.getByTestId("block-inspector-unknown")).toBeInTheDocument(); +}); + +it("shows a no-settings note for a block without attributes", () => { + wrap( + void>()} + onCommit={vi.fn<() => void>()} + />, + ); + + expect(screen.getByText("This block has no settings.")).toBeInTheDocument(); +}); diff --git a/resources/js/form/components/fields/block-editor/inspector.tsx b/resources/js/form/components/fields/block-editor/inspector.tsx new file mode 100644 index 00000000..e0d4f215 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/inspector.tsx @@ -0,0 +1,57 @@ +import type { Node } from "@lattice-php/lattice/core/types"; +import { RenderNode } from "@lattice-php/lattice/core/renderer"; +import { useT } from "@lattice-php/lattice/i18n"; +import { FieldScopeProvider } from "@lattice-php/lattice/form/hooks/field-scope"; +import type { RepeaterRow } from "@lattice-php/lattice/form/components/fields/repeater-rows"; + +type Props = { + base: string; + index: number; + row: RepeaterRow; + template?: Node[]; + onField: (field: string, value: unknown) => void; + onCommit: () => void; +}; + +export function BlockInspector({ base, index, row, template, onField, onCommit }: Props) { + const { t } = useT("lattice"); + + if (!template) { + return ( +
+ {t("form.block-editor.unknown-block", "Unknown block")} +
+ ); + } + + if (template.length === 0) { + return ( +

+ {t("form.block-editor.no-settings", "This block has no settings.")} +

+ ); + } + + return ( +
{ + const focusStaysInside = + event.relatedTarget instanceof Element && + event.currentTarget.contains(event.relatedTarget); + + if (!focusStaysInside) { + onCommit(); + } + }} + > + +
+ {template.map((child) => ( + + ))} +
+
+
+ ); +} diff --git a/resources/js/form/components/fields/block-editor/move-block.test.ts b/resources/js/form/components/fields/block-editor/move-block.test.ts new file mode 100644 index 00000000..191d9726 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/move-block.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { moveBlock } from "./move-block"; + +const rows = () => [ + { rowId: "a", type: "text", body: "A" }, + { + rowId: "cols", + type: "columns", + slots: { + left: [{ rowId: "l1", type: "text", body: "L1" }], + right: [{ rowId: "r1", type: "text", body: "R1" }], + }, + }, +]; + +describe("moveBlock", () => { + it("reorders at the top level", () => { + const out = moveBlock(rows(), [{ index: 0 }], [{ index: 1 }]); + expect(out.map((r) => r.rowId)).toEqual(["cols", "a"]); + }); + + it("moves a top-level block into a slot", () => { + const out = moveBlock(rows(), [{ index: 0 }], [{ index: 1, slot: "left" }, { index: 1 }]); + const cols = out.find((r) => r.rowId === "cols") as Record; + expect(cols.slots.left.map((r: any) => r.rowId)).toEqual(["l1", "a"]); + expect(out.map((r) => r.rowId)).toEqual(["cols"]); + }); + + it("moves a block between slots", () => { + const out = moveBlock( + rows(), + [{ index: 1, slot: "left" }, { index: 0 }], + [{ index: 1, slot: "right" }, { index: 0 }], + ); + const cols = out.find((r) => r.rowId === "cols") as Record; + expect(cols.slots.left).toEqual([]); + expect(cols.slots.right.map((r: any) => r.rowId)).toEqual(["l1", "r1"]); + }); + + it("moves a block out of a slot back to the top level", () => { + const out = moveBlock(rows(), [{ index: 1, slot: "left" }, { index: 0 }], [{ index: 0 }]); + expect(out.map((r) => r.rowId)).toEqual(["l1", "a", "cols"]); + }); + + it("returns rows unchanged for an invalid source", () => { + const input = rows(); + expect(moveBlock(input, [{ index: 9 }], [{ index: 0 }])).toBe(input); + }); + + it("moves a block into a deeper nested container without dropping a later sibling", () => { + const nested = [ + { + rowId: "outer", + slots: { + left: [{ rowId: "P" }, { rowId: "Q", slots: { inner: [{ rowId: "R" }] } }], + }, + }, + ]; + + const out = moveBlock( + nested, + [{ index: 0, slot: "left" }, { index: 0 }], + [{ index: 0, slot: "left" }, { index: 1, slot: "inner" }, { index: 0 }], + ); + + const outer = out.find((r) => r.rowId === "outer") as Record; + expect(outer.slots.left.map((r: any) => r.rowId)).toEqual(["Q"]); + const q = outer.slots.left.find((r: any) => r.rowId === "Q"); + expect(q.slots.inner.map((r: any) => r.rowId)).toEqual(["P", "R"]); + }); + + it("returns rows unchanged for an invalid target", () => { + const input = rows(); + const out = moveBlock( + input, + [{ index: 1, slot: "left" }, { index: 0 }], + [{ index: 5, slot: "left" }, { index: 0 }], + ); + expect(out).toBe(input); + }); + + it("refuses to move a block into its own subtree", () => { + const input = rows(); + const out = moveBlock(input, [{ index: 1 }], [{ index: 1, slot: "left" }, { index: 0 }]); + expect(out).toBe(input); + }); + + it("refuses to move a block into a deeper descendant of itself", () => { + const nested = [ + { + rowId: "outer", + slots: { + left: [{ rowId: "Q", slots: { inner: [{ rowId: "R" }] } }], + }, + }, + ]; + + const out = moveBlock( + nested, + [{ index: 0 }], + [{ index: 0, slot: "left" }, { index: 0, slot: "inner" }, { index: 0 }], + ); + expect(out).toBe(nested); + }); +}); diff --git a/resources/js/form/components/fields/block-editor/move-block.ts b/resources/js/form/components/fields/block-editor/move-block.ts new file mode 100644 index 00000000..1d1206be --- /dev/null +++ b/resources/js/form/components/fields/block-editor/move-block.ts @@ -0,0 +1,83 @@ +import type { RepeaterRow } from "@lattice-php/lattice/form/components/fields/repeater-rows"; +import { getContainer, replaceContainer, type BlockPath, type BlockStep } from "./tree"; + +export type { BlockPath, BlockStep } from "./tree"; + +function sameStep(a: BlockStep, b: BlockStep): boolean { + return a.index === b.index && a.slot === b.slot; +} + +/** A target below the moved block itself would orphan the subtree into a cycle. */ +function descendsIntoMoved(from: BlockPath, to: BlockPath): boolean { + if (to.length < from.length) { + return false; + } + + for (let i = 0; i < from.length - 1; i++) { + if (!sameStep(from[i], to[i])) { + return false; + } + } + + const branch = to[from.length - 1]; + + return branch.index === from[from.length - 1].index && branch.slot !== undefined; +} + +function adjustToForRemoval(from: BlockPath, to: BlockPath): BlockPath { + const sourceDepth = from.length - 1; + + if (to.length <= sourceDepth) { + return to; + } + + for (let i = 0; i < sourceDepth; i++) { + if (!sameStep(from[i], to[i])) { + return to; + } + } + + if (to.length === sourceDepth + 1) { + return to; + } + + const fromIndex = from[sourceDepth].index; + const toStep = to[sourceDepth]; + + if (fromIndex >= toStep.index) { + return to; + } + + return [ + ...to.slice(0, sourceDepth), + { ...toStep, index: toStep.index - 1 }, + ...to.slice(sourceDepth + 1), + ]; +} + +export function moveBlock(rows: RepeaterRow[], from: BlockPath, to: BlockPath): RepeaterRow[] { + const source = getContainer(rows, from); + const fromIndex = from[from.length - 1]?.index ?? -1; + + if (!source || fromIndex < 0 || fromIndex >= source.length || descendsIntoMoved(from, to)) { + return rows; + } + + const moved = source[fromIndex]; + + const removed = replaceContainer(rows, from, (list) => list.filter((_, i) => i !== fromIndex)); + + const adjustedTo = adjustToForRemoval(from, to); + + if (getContainer(removed, adjustedTo) === null) { + return rows; + } + + return replaceContainer(removed, adjustedTo, (list) => { + const target = adjustedTo[adjustedTo.length - 1]?.index ?? list.length; + const next = [...list]; + next.splice(Math.max(0, Math.min(target, next.length)), 0, moved); + + return next; + }); +} diff --git a/resources/js/form/components/fields/block-editor/tree.test.ts b/resources/js/form/components/fields/block-editor/tree.test.ts new file mode 100644 index 00000000..278c8bf9 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/tree.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import { + appendBlockAt, + blockAt, + duplicateBlockAt, + removeBlockAt, + shiftBlockAt, + slotAllowedTypes, + updateBlockAt, + walkBlocks, +} from "./tree"; + +const rows = () => [ + { rowId: "a", type: "text", body: "A" }, + { + rowId: "cols", + type: "columns", + slots: { + main: [{ rowId: "m1", type: "text", body: "M1" }], + }, + }, +]; + +describe("blockAt", () => { + it("resolves a top-level block", () => { + expect(blockAt(rows(), [{ index: 1 }])?.rowId).toBe("cols"); + }); + + it("resolves a nested block", () => { + expect(blockAt(rows(), [{ index: 1, slot: "main" }, { index: 0 }])?.rowId).toBe("m1"); + }); + + it("returns null for a missing path", () => { + expect(blockAt(rows(), [{ index: 5 }])).toBeNull(); + }); +}); + +describe("updateBlockAt", () => { + it("updates a field on a nested block without touching siblings", () => { + const input = rows(); + const out = updateBlockAt(input, [{ index: 1, slot: "main" }, { index: 0 }], "body", "Edited"); + + expect(blockAt(out, [{ index: 1, slot: "main" }, { index: 0 }])?.body).toBe("Edited"); + expect(out[0]).toBe(input[0]); + }); +}); + +describe("removeBlockAt", () => { + it("removes a nested block", () => { + const out = removeBlockAt(rows(), [{ index: 1, slot: "main" }, { index: 0 }]); + + expect(blockAt(out, [{ index: 1 }])?.slots).toEqual({ main: [] }); + }); + + it("removes a top-level block", () => { + const out = removeBlockAt(rows(), [{ index: 0 }]); + + expect(out.map((row) => row.rowId)).toEqual(["cols"]); + }); +}); + +describe("appendBlockAt", () => { + it("appends a row to a named slot of the addressed block", () => { + const out = appendBlockAt(rows(), [{ index: 1 }], "main", { rowId: "m2", type: "text" }); + + const slot = blockAt(out, [{ index: 1 }])?.slots as Record; + expect(slot.main.map((row) => row.rowId)).toEqual(["m1", "m2"]); + }); + + it("creates the slot list when the block has none yet", () => { + const out = appendBlockAt(rows(), [{ index: 0 }], "main", { rowId: "new", type: "text" }); + + const slot = blockAt(out, [{ index: 0 }])?.slots as Record; + expect(slot.main.map((row) => row.rowId)).toEqual(["new"]); + }); +}); + +describe("duplicateBlockAt", () => { + it("inserts a copy after the original with fresh row ids at every depth", () => { + const out = duplicateBlockAt(rows(), [{ index: 1 }]); + + expect(out).toHaveLength(3); + expect(out[2].type).toBe("columns"); + expect(out[2].rowId).not.toBe("cols"); + + const copyChildren = (out[2].slots as Record).main; + expect(copyChildren[0].body).toBe("M1"); + expect(copyChildren[0].rowId).not.toBe("m1"); + }); + + it("duplicates a nested block in place", () => { + const out = duplicateBlockAt(rows(), [{ index: 1, slot: "main" }, { index: 0 }]); + + const children = (out[1].slots as Record).main; + expect(children).toHaveLength(2); + expect(children[1].rowId).not.toBe(children[0].rowId); + }); +}); + +describe("shiftBlockAt", () => { + it("moves a block within its container", () => { + const out = shiftBlockAt(rows(), [{ index: 0 }], 1); + + expect(out.map((row) => row.rowId)).toEqual(["cols", "a"]); + }); + + it("clamps at the container edges", () => { + const input = rows(); + + expect(shiftBlockAt(input, [{ index: 0 }], -1)).toBe(input); + expect(shiftBlockAt(input, [{ index: 1 }], 1)).toBe(input); + }); +}); + +describe("slotAllowedTypes", () => { + const templates = [ + { type: "hero", label: "Hero", schema: [] }, + { type: "columns", label: "Columns", schema: [], slots: [{ name: "main" }] }, + { + type: "restricted", + label: "Restricted", + schema: [], + slots: [{ name: "main", blocks: ["hero"] }], + }, + ] as never[]; + + it("returns the restriction of the containing slot", () => { + const value = [{ rowId: "r1", type: "restricted", slots: { main: [] } }]; + + expect( + slotAllowedTypes(templates as never, value, [{ index: 0, slot: "main" }, { index: 0 }]), + ).toEqual(["hero"]); + }); + + it("returns null for an unrestricted slot", () => { + expect( + slotAllowedTypes(templates as never, rows(), [{ index: 1, slot: "main" }, { index: 0 }]), + ).toBeNull(); + }); + + it("returns null at the top level", () => { + expect(slotAllowedTypes(templates as never, rows(), [{ index: 0 }])).toBeNull(); + }); +}); + +describe("walkBlocks", () => { + it("flattens the tree depth-first with a path per block", () => { + const entries = walkBlocks(rows()); + + expect(entries.map((entry) => entry.row.rowId)).toEqual(["a", "cols", "m1"]); + expect(entries[2].path).toEqual([{ index: 1, slot: "main" }, { index: 0 }]); + }); +}); diff --git a/resources/js/form/components/fields/block-editor/tree.ts b/resources/js/form/components/fields/block-editor/tree.ts new file mode 100644 index 00000000..3fd97905 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/tree.ts @@ -0,0 +1,192 @@ +import { + ROW_ID_KEY, + type RepeaterRow, +} from "@lattice-php/lattice/form/components/fields/repeater-rows"; +import type { RowTemplate } from "@lattice-php/lattice/form/components/fields/row-templates"; + +export type BlockStep = { index: number; slot?: string }; +export type BlockPath = BlockStep[]; + +export function childList(row: RepeaterRow, slot: string): RepeaterRow[] { + const slots = (row.slots ?? {}) as Record; + + return Array.isArray(slots[slot]) ? slots[slot] : []; +} + +export function withChildList(row: RepeaterRow, slot: string, list: RepeaterRow[]): RepeaterRow { + const slots = { ...((row.slots ?? {}) as Record), [slot]: list }; + + return { ...row, slots }; +} + +export function getContainer(rows: RepeaterRow[], path: BlockPath): RepeaterRow[] | null { + let list = rows; + + for (let i = 0; i < path.length - 1; i++) { + const step = path[i]; + const parent = list[step.index]; + + if (!parent || step.slot === undefined) { + return null; + } + + list = childList(parent, step.slot); + } + + return list; +} + +export function replaceContainer( + rows: RepeaterRow[], + path: BlockPath, + update: (list: RepeaterRow[]) => RepeaterRow[], +): RepeaterRow[] { + if (path.length <= 1) { + return update(rows); + } + + const [head, ...rest] = path; + const slot = head.slot; + + if (slot === undefined) { + return rows; + } + + return rows.map((row, i) => + i === head.index + ? withChildList(row, slot, replaceContainer(childList(row, slot), rest, update)) + : row, + ); +} + +export function blockAt(rows: RepeaterRow[], path: BlockPath): RepeaterRow | null { + const container = getContainer(rows, path); + const index = path[path.length - 1]?.index ?? -1; + + return container?.[index] ?? null; +} + +export function updateBlockAt( + rows: RepeaterRow[], + path: BlockPath, + field: string, + value: unknown, +): RepeaterRow[] { + const index = path[path.length - 1]?.index ?? -1; + + return replaceContainer(rows, path, (list) => + list.map((row, i) => (i === index ? { ...row, [field]: value } : row)), + ); +} + +function remintRowIds(row: RepeaterRow): RepeaterRow { + const next: RepeaterRow = { ...row, [ROW_ID_KEY]: crypto.randomUUID() }; + const slots = row.slots as Record | undefined; + + if (slots && typeof slots === "object") { + next.slots = Object.fromEntries( + Object.entries(slots).map(([slot, children]) => [ + slot, + Array.isArray(children) ? children.map(remintRowIds) : children, + ]), + ); + } + + return next; +} + +/** Insert a deep copy after the original; every copied row gets a fresh id. */ +export function duplicateBlockAt(rows: RepeaterRow[], path: BlockPath): RepeaterRow[] { + const index = path[path.length - 1]?.index ?? -1; + + return replaceContainer(rows, path, (list) => { + const source = list[index]; + + if (!source) { + return list; + } + + const next = [...list]; + next.splice(index + 1, 0, remintRowIds(source)); + + return next; + }); +} + +/** Move a block within its own container by the given offset, clamped at the edges. */ +export function shiftBlockAt(rows: RepeaterRow[], path: BlockPath, delta: number): RepeaterRow[] { + const index = path[path.length - 1]?.index ?? -1; + + return replaceContainer(rows, path, (list) => { + const target = index + delta; + + if (index < 0 || index >= list.length || target < 0 || target >= list.length) { + return list; + } + + const next = [...list]; + const [moved] = next.splice(index, 1); + next.splice(target, 0, moved); + + return next; + }); +} + +export function removeBlockAt(rows: RepeaterRow[], path: BlockPath): RepeaterRow[] { + const index = path[path.length - 1]?.index ?? -1; + + return replaceContainer(rows, path, (list) => list.filter((_, i) => i !== index)); +} + +export function appendBlockAt( + rows: RepeaterRow[], + path: BlockPath, + slot: string, + row: RepeaterRow, +): RepeaterRow[] { + const index = path[path.length - 1]?.index ?? -1; + + return replaceContainer(rows, path, (list) => + list.map((parent, i) => + i === index ? withChildList(parent, slot, [...childList(parent, slot), row]) : parent, + ), + ); +} + +/** + * The block types the slot containing the given path accepts, or null when the + * path is top-level or the slot is unrestricted. + */ +export function slotAllowedTypes( + templates: RowTemplate[], + rows: RepeaterRow[], + path: BlockPath, +): string[] | null { + if (path.length < 2) { + return null; + } + + const parentStep = path[path.length - 2]; + + if (parentStep.slot === undefined) { + return null; + } + + const parent = blockAt(rows, [...path.slice(0, -2), { index: parentStep.index }]); + const template = templates.find((candidate) => candidate.type === parent?.type); + + return template?.slots?.find((slot) => slot.name === parentStep.slot)?.blocks ?? null; +} + +export type BlockWalkEntry = { row: RepeaterRow; path: BlockPath }; + +/** Every block in the tree, depth-first, with the path addressing it. */ +export function walkBlocks(rows: RepeaterRow[], prefix: BlockPath = []): BlockWalkEntry[] { + return rows.flatMap((row, index) => { + const nested = Object.keys((row.slots ?? {}) as Record).flatMap((slot) => + walkBlocks(childList(row, slot), [...prefix, { index, slot }]), + ); + + return [{ row, path: [...prefix, { index }] }, ...nested]; + }); +} diff --git a/resources/js/form/components/fields/block-editor/use-block-preview.test.ts b/resources/js/form/components/fields/block-editor/use-block-preview.test.ts new file mode 100644 index 00000000..77a1d6af --- /dev/null +++ b/resources/js/form/components/fields/block-editor/use-block-preview.test.ts @@ -0,0 +1,99 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; + +const apiJson = vi.fn<(...args: unknown[]) => Promise>(); +vi.mock("@lattice-php/lattice/core/api", () => ({ apiJson: (...a: unknown[]) => apiJson(...a) })); + +import { useBlockPreview } from "./use-block-preview"; + +beforeEach(() => apiJson.mockReset()); + +it("seeds initial wire and refreshes a block on demand", async () => { + apiJson.mockResolvedValueOnce({ wire: [{ type: "heading", props: { text: "New" } }] }); + + const { result } = renderHook(() => + useBlockPreview({ + endpoint: "/lattice/blocks/render", + ref: "sealed", + initial: { a: [{ type: "heading", props: { text: "Old" } }] }, + renderedWith: {}, + }), + ); + + expect(result.current.wireFor("a")).toEqual([{ type: "heading", props: { text: "Old" } }]); + + await act(async () => { + await result.current.refresh("a", "hero", { title: "New" }); + }); + + expect(apiJson).toHaveBeenCalledWith("/lattice/blocks/render", { + method: "POST", + ref: "sealed", + body: JSON.stringify({ type: "hero", attributes: { title: "New" } }), + }); + expect(result.current.wireFor("a")).toEqual([{ type: "heading", props: { text: "New" } }]); +}); + +it("keeps the previous wire when a refresh fails", async () => { + apiJson.mockRejectedValueOnce(new Error("boom")); + + const { result } = renderHook(() => + useBlockPreview({ + endpoint: "/e", + ref: "r", + initial: { a: [{ type: "text", props: { text: "keep" } }] }, + renderedWith: {}, + }), + ); + + await act(async () => { + await result.current.refresh("a", "text", {}); + }); + + expect(result.current.wireFor("a")).toEqual([{ type: "text", props: { text: "keep" } }]); +}); + +it("skips the request when a block is unchanged since its last render", async () => { + apiJson.mockResolvedValue({ wire: [] }); + + const { result } = renderHook(() => + useBlockPreview({ + endpoint: "/e", + ref: "r", + initial: { a: [{ type: "heading", props: { text: "Seeded" } }] }, + renderedWith: { a: { type: "hero", attributes: { title: "Seeded" } } }, + }), + ); + + await act(async () => { + await result.current.refresh("a", "hero", { title: "Seeded" }); + }); + expect(apiJson).not.toHaveBeenCalled(); + + await act(async () => { + await result.current.refresh("a", "hero", { title: "Changed" }); + }); + expect(apiJson).toHaveBeenCalledTimes(1); + + await act(async () => { + await result.current.refresh("a", "hero", { title: "Changed" }); + }); + expect(apiJson).toHaveBeenCalledTimes(1); +}); + +it("retries an unchanged block after a failed render", async () => { + apiJson.mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce({ wire: [] }); + + const { result } = renderHook(() => + useBlockPreview({ endpoint: "/e", ref: "r", initial: {}, renderedWith: {} }), + ); + + await act(async () => { + await result.current.refresh("a", "hero", { title: "x" }); + }); + await act(async () => { + await result.current.refresh("a", "hero", { title: "x" }); + }); + + expect(apiJson).toHaveBeenCalledTimes(2); +}); diff --git a/resources/js/form/components/fields/block-editor/use-block-preview.ts b/resources/js/form/components/fields/block-editor/use-block-preview.ts new file mode 100644 index 00000000..8339c2aa --- /dev/null +++ b/resources/js/form/components/fields/block-editor/use-block-preview.ts @@ -0,0 +1,56 @@ +import { useCallback, useRef, useState } from "react"; +import { apiJson } from "@lattice-php/lattice/core/api"; +import type { Node } from "@lattice-php/lattice/core/types"; + +export type BlockSource = { type: string; attributes: Record }; + +/** The server-rendered preview for one block row, mirroring the value's slot nesting. */ +export type RenderedBlock = { wire: Node[]; slots?: Record }; + +type Options = { + endpoint: string; + ref: string; + initial: Record; + renderedWith: Record; +}; + +export function useBlockPreview({ endpoint, ref, initial, renderedWith }: Options): { + wireFor: (rowId: string) => Node[]; + refresh: (rowId: string, type: string, attributes: Record) => Promise; +} { + const [wire, setWire] = useState>(initial); + + const lastRendered = useRef | null>(null); + lastRendered.current ??= Object.fromEntries( + Object.entries(renderedWith).map(([rowId, source]) => [rowId, JSON.stringify(source)]), + ); + + const wireFor = useCallback((rowId: string): Node[] => wire[rowId] ?? [], [wire]); + + const refresh = useCallback( + async (rowId: string, type: string, attributes: Record): Promise => { + const rendered = (lastRendered.current ??= {}); + const source = JSON.stringify({ type, attributes } satisfies BlockSource); + + if (rendered[rowId] === source) { + return; + } + + try { + const { wire: next } = await apiJson<{ wire: Node[] }>(endpoint, { + method: "POST", + ref, + body: JSON.stringify({ type, attributes }), + }); + + rendered[rowId] = source; + setWire((prev) => ({ ...prev, [rowId]: next })); + } catch { + // keep the last-good wire on failure; the next commit retries + } + }, + [endpoint, ref], + ); + + return { wireFor, refresh }; +} diff --git a/resources/js/form/components/fields/row-templates.ts b/resources/js/form/components/fields/row-templates.ts index b6338dd7..23d6c511 100644 --- a/resources/js/form/components/fields/row-templates.ts +++ b/resources/js/form/components/fields/row-templates.ts @@ -1,6 +1,15 @@ import type { Node } from "@lattice-php/lattice/core/types"; -export type RowTemplate = { type: string; label: string; schema: Node[] }; +export type RowTemplateSlot = { name: string; label?: string; blocks?: string[] }; + +export type RowTemplate = { + type: string; + label: string; + icon?: string; + description?: string; + schema: Node[]; + slots?: RowTemplateSlot[]; +}; export function rowTemplatesOf(node: Node): RowTemplate[] | undefined { return (node as unknown as { templates?: RowTemplate[] }).templates; diff --git a/resources/js/form/components/index.ts b/resources/js/form/components/index.ts index 61707690..a065a149 100644 --- a/resources/js/form/components/index.ts +++ b/resources/js/form/components/index.ts @@ -1,3 +1,4 @@ +export { BlockEditorComponent } from "./fields/block-editor"; export { BuilderComponent } from "./fields/builder"; export { CheckboxComponent } from "./fields/checkbox"; export { ChoiceComponent } from "./fields/choice"; diff --git a/resources/js/form/lib/prefill-targets.ts b/resources/js/form/lib/prefill-targets.ts index b3f60285..4a033e1d 100644 --- a/resources/js/form/lib/prefill-targets.ts +++ b/resources/js/form/lib/prefill-targets.ts @@ -16,7 +16,7 @@ type PrefillSnapshot = { values: Record; }; -const ROW_COLLECTION_TYPES = new Set(["field.builder", "field.repeater"]); +const ROW_COLLECTION_TYPES = new Set(["field.block-editor", "field.builder", "field.repeater"]); export { getPath } from "./form-path"; diff --git a/resources/js/form/plugin.ts b/resources/js/form/plugin.ts index 11814b6d..f48342cd 100644 --- a/resources/js/form/plugin.ts +++ b/resources/js/form/plugin.ts @@ -5,6 +5,7 @@ import { } from "@lattice-php/lattice/core/registry"; import type { FormNodeType } from "@lattice-php/lattice/types/generated"; import { + BlockEditorComponent, BuilderComponent, CheckboxComponent, ChoiceComponent, @@ -28,6 +29,7 @@ import { RichEditorComponent } from "./components/fields/rich-editor"; export const formComponents = createPlugin({ components: { form: eagerComponent(FormComponent), + "field.block-editor": eagerComponent(BlockEditorComponent), "field.builder": eagerComponent(BuilderComponent), "field.checkbox": eagerComponent(CheckboxComponent), "field.choice": eagerComponent(ChoiceComponent), diff --git a/resources/js/types/generated.ts b/resources/js/types/generated.ts index 6f1212d8..b2497295 100644 --- a/resources/js/types/generated.ts +++ b/resources/js/types/generated.ts @@ -38,6 +38,35 @@ export type BadgeColumn = { toggleable: boolean; width: ColumnWidth; }; +export type BlockEditor = { + addLabel: string | null; + columnWidth: ColumnWidth; + conditions: { + visible?: Condition[]; + required?: Condition[]; + readOnly?: Condition[]; + disabled?: Condition[]; + } | null; + defaultItems: number; + dependsOnAny: boolean; + dependsOnKeys: string[] | null; + disabled: boolean; + editablePrefill: boolean; + endpoint: string | null; + helperText: string | null; + label: string | null; + maxItems: number | null; + minItems: number | null; + name: string; + prefillRefreshOn: string[] | null; + prefillResetOn: string[] | null; + readOnly: boolean; + ref: string | null; + reorderable: boolean; + required: boolean; + tooltip: string | null; + value: unknown; +}; export type BooleanColumn = { align: ColumnAlign; filter: ColumnFilter | null; @@ -286,6 +315,7 @@ export type ComponentPropsMap = { "chat.part.tool-call": ToolCallPart; collapsible: Collapsible; dropdown: Dropdown; + "field.block-editor": BlockEditor; "field.builder": Builder; "field.checkbox": Checkbox; "field.choice": Choice; @@ -435,6 +465,7 @@ export type EffectPropsMap = { }; export type FieldType = | "field.builder" + | "field.block-editor" | "field.checkbox" | "field.choice" | "field.date-input" @@ -533,6 +564,7 @@ export type Form = { validationTimeout: number | null; }; export type FormFieldNodeType = + | "field.block-editor" | "field.builder" | "field.checkbox" | "field.choice" @@ -551,6 +583,7 @@ export type FormFieldNodeType = | "field.time-input" | "field.toggle"; export type FormNodeType = + | "field.block-editor" | "field.builder" | "field.checkbox" | "field.choice" @@ -711,6 +744,7 @@ export type NodeType = | "chat.part.tool-call" | "collapsible" | "dropdown" + | "field.block-editor" | "field.builder" | "field.checkbox" | "field.choice" diff --git a/routes/web.php b/routes/web.php index 2efc5e50..7a5ae493 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,7 @@ use Illuminate\Support\Facades\Route; use Lattice\Lattice\Http\Controllers\ActionController; +use Lattice\Lattice\Http\Controllers\BlockRenderController; use Lattice\Lattice\Http\Controllers\BulkActionController; use Lattice\Lattice\Http\Controllers\FormController; use Lattice\Lattice\Http\Controllers\FragmentController; @@ -25,6 +26,10 @@ ->where('fragment', '.*') ->name('lattice.fragments.show'); +Route::middleware(config('lattice.blocks.middleware', ['web', 'auth'])) + ->post(config('lattice.blocks.endpoint', 'lattice/blocks/render'), BlockRenderController::class) + ->name('lattice.blocks.render'); + Route::middleware(config('lattice.remote-sources.middleware', ['web', 'auth'])) ->post(config('lattice.remote-sources.endpoint', 'lattice/remote-sources/{source}/token'), RemoteSourceTokenController::class) ->where('source', '.*') diff --git a/src/Attributes/AsBlock.php b/src/Attributes/AsBlock.php new file mode 100644 index 00000000..61d4842a --- /dev/null +++ b/src/Attributes/AsBlock.php @@ -0,0 +1,9 @@ + + */ + abstract public function attributes(): array; + + abstract public function render(FormData $data, BlockSlots $slots): PageSchema; + + /** + * The human-readable name shown in the editor; defaults to a headline of + * the registry key. + */ + public function label(): ?string + { + return null; + } + + /** + * The icon shown next to the block in the editor. + */ + public function icon(): Icon|string|null + { + return null; + } + + /** + * A short explanation shown in the editor's add menu. + */ + public function description(): ?string + { + return null; + } + + /** + * @return array + */ + public function inlineText(): array + { + return []; + } + + /** + * The named child-row lists of this block: plain names for unrestricted + * slots, or {@see Slot} instances to restrict a slot to certain blocks. + * + * @return array + */ + public function slots(): array + { + return []; + } + + /** + * @param array $attributes + * @return array + */ + public function migrate(array $attributes, int $from): array + { + return $attributes; + } +} diff --git a/src/Blocks/BlockRegistry.php b/src/Blocks/BlockRegistry.php new file mode 100644 index 00000000..7d95d147 --- /dev/null +++ b/src/Blocks/BlockRegistry.php @@ -0,0 +1,40 @@ + + */ +final class BlockRegistry extends DefinitionRegistry +{ + /** + * @return class-string + */ + protected function definitionClass(): string + { + return BlockDefinition::class; + } + + /** + * @return class-string + */ + public function attributeClass(): string + { + return AsBlock::class; + } + + protected function name(): string + { + return 'block'; + } + + public function group(): string + { + return 'blocks'; + } +} diff --git a/src/Blocks/BlockRenderer.php b/src/Blocks/BlockRenderer.php new file mode 100644 index 00000000..0795a708 --- /dev/null +++ b/src/Blocks/BlockRenderer.php @@ -0,0 +1,79 @@ +> $rows + */ + public function render(array $rows): PageSchema + { + $schema = PageSchema::make(); + + foreach ($this->components($rows) as $component) { + $schema->component($component); + } + + return $schema; + } + + /** + * @param array> $rows + * @return array + */ + private function components(array $rows): array + { + $components = []; + + foreach ($rows as $row) { + foreach ($this->componentsForRow($row) as $component) { + $components[] = $component; + } + } + + return $components; + } + + /** + * @param array $row + * @return array + */ + private function componentsForRow(array $row): array + { + $type = is_string($row['type'] ?? null) ? $row['type'] : ''; + + try { + $block = $this->blocks->resolve($type); + } catch (UnknownComponent) { + return [Text::make("Unknown block [{$type}]")]; + } + + return $block->render(FormData::make($row), $this->slotsFor($block, $row))->renderable(); + } + + /** + * @param array $row + */ + private function slotsFor(BlockDefinition $block, array $row): BlockSlots + { + $rendered = []; + + foreach ($block->slots() as $slot) { + $name = $slot instanceof Slot ? $slot->name : $slot; + $childRows = $row['slots'][$name] ?? []; + $rendered[$name] = is_array($childRows) ? $this->components($childRows) : []; + } + + return new BlockSlots($rendered); + } +} diff --git a/src/Blocks/BlockSlots.php b/src/Blocks/BlockSlots.php new file mode 100644 index 00000000..42a50560 --- /dev/null +++ b/src/Blocks/BlockSlots.php @@ -0,0 +1,22 @@ +> $slots + */ + public function __construct(private array $slots = []) {} + + /** + * @return array + */ + public function get(string $name): array + { + return $this->slots[$name] ?? []; + } +} diff --git a/src/Blocks/Casts/AsBlocks.php b/src/Blocks/Casts/AsBlocks.php new file mode 100644 index 00000000..d4352f05 --- /dev/null +++ b/src/Blocks/Casts/AsBlocks.php @@ -0,0 +1,37 @@ +>, iterable>> + */ +final class AsBlocks implements CastsAttributes +{ + /** + * @param array $attributes + * @return array> + */ + public function get(Model $model, string $key, mixed $value, array $attributes): array + { + if (! is_string($value) || $value === '') { + return []; + } + + $decoded = json_decode($value, true); + + return is_array($decoded) ? array_values($decoded) : []; + } + + /** + * @param array $attributes + * @return array + */ + public function set(Model $model, string $key, mixed $value, array $attributes): array + { + return [$key => json_encode(array_values((array) ($value ?? [])))]; + } +} diff --git a/src/Blocks/Concerns/HasBlocks.php b/src/Blocks/Concerns/HasBlocks.php new file mode 100644 index 00000000..35c38060 --- /dev/null +++ b/src/Blocks/Concerns/HasBlocks.php @@ -0,0 +1,17 @@ +{$attribute}; + + return app(BlockRenderer::class)->render(is_array($rows) ? $rows : []); + } +} diff --git a/src/Blocks/Slot.php b/src/Blocks/Slot.php new file mode 100644 index 00000000..b375e8a3 --- /dev/null +++ b/src/Blocks/Slot.php @@ -0,0 +1,65 @@ +> + */ + private array $blocks = []; + + private ?string $label = null; + + private function __construct(public readonly string $name) {} + + public static function make(string $name): self + { + return new self($name); + } + + /** + * The heading shown above the slot in the editor; defaults to the name. + */ + public function label(string $label): self + { + $this->label = $label; + + return $this; + } + + public function labelText(): ?string + { + return $this->label; + } + + /** + * Restrict the slot to the given block definitions. + * + * @param array> $blocks + */ + public function blocks(array $blocks): self + { + $this->blocks = array_values($blocks); + + return $this; + } + + /** + * The allowed block definitions, empty when the slot is unrestricted. + * + * @return array> + */ + public function allowedBlocks(): array + { + return $this->blocks; + } +} diff --git a/src/Core/DefinitionRegistry.php b/src/Core/DefinitionRegistry.php index 39b8bb8a..d8be7935 100644 --- a/src/Core/DefinitionRegistry.php +++ b/src/Core/DefinitionRegistry.php @@ -101,7 +101,7 @@ protected function registeredKeyFor(string $definition): string /** * @param class-string $definition */ - protected function keyFor(string $definition): string + public function keyFor(string $definition): string { if (! is_subclass_of($definition, $this->definitionClass())) { throw new InvalidArgumentException("Lattice {$this->name()} [{$definition}] must extend [".$this->definitionClass().'].'); diff --git a/src/Forms/Components/BlockEditor.php b/src/Forms/Components/BlockEditor.php new file mode 100644 index 00000000..83f22bcc --- /dev/null +++ b/src/Forms/Components/BlockEditor.php @@ -0,0 +1,264 @@ +> $blocks + */ + public function blocks(array $blocks): static + { + $registry = app(BlockRegistry::class); + + $this->templates(array_map( + function (string $block) use ($registry): RowTemplate { + $definition = app($block); + $icon = $definition->icon(); + + $template = RowTemplate::make($registry->keyFor($block)) + ->schema($definition->attributes()) + ->icon($icon instanceof Icon ? $icon->value : $icon) + ->description($definition->description()) + ->slots(array_map( + static fn (Slot|string $slot): array|string => $slot instanceof Slot + ? [ + 'name' => $slot->name, + ...($slot->labelText() === null ? [] : ['label' => $slot->labelText()]), + ...($slot->allowedBlocks() === [] + ? [] + : ['blocks' => array_map($registry->keyFor(...), $slot->allowedBlocks())]), + ] + : $slot, + $definition->slots(), + )); + + // Block keys are vendor-dotted; the bare RowTemplate fallback would + // headline the whole key ("Workbench.hero"), so derive from the last segment. + $template->label($definition->label() ?? Str::headline(Str::afterLast($template->type, '.'))); + + return $template; + }, + $blocks, + )); + + $editorTypes = array_map(static fn (RowTemplate $template): string => $template->type, $this->templates); + + foreach ($this->templates as $template) { + foreach ($template->fields() as $field) { + if ($field->name() === self::SLOTS) { + throw new LogicException(sprintf( + 'Block schemas must not declare a [%s] field: the key is reserved for nested block rows.', + self::SLOTS, + )); + } + } + + foreach ($template->slotNames() as $slot) { + foreach ($template->slotAllowedTypes($slot) ?? [] as $type) { + if (! in_array($type, $editorTypes, true)) { + throw new LogicException(sprintf( + 'Slot [%s] of block [%s] allows [%s], which the editor does not offer.', + $slot, + $template->type, + $type, + )); + } + } + } + } + + $this->id ??= $this->name; + $this->endpoint ??= '/'.ltrim((string) config('lattice.blocks.endpoint', 'lattice/blocks/render'), '/'); + $this->signedAs('block-editor'); + $this->context(['allowedBlocks' => array_map( + static fn (RowTemplate $template): string => $template->type, + $this->templates, + )]); + + return $this; + } + + /** + * @param array $row + */ + private function templateForRow(array $row): ?RowTemplate + { + $type = $row[self::TYPE] ?? null; + + foreach ($this->templates as $template) { + if ($template->type === $type) { + return $template; + } + } + + return null; + } + + /** + * @param array $row + * @return array + */ + private function slotNamesFor(array $row): array + { + return $this->templateForRow($row)?->slotNames() ?? []; + } + + #[\Override] + protected function rowRulesAt(string $prefix, array $row, FormData $data, Request $request): array + { + $rules = parent::rowRulesAt($prefix, $row, $data, $request); + $template = $this->templateForRow($row); + $slots = $template?->slotNames() ?? []; + + if ($slots === []) { + return $rules; + } + + $rules["{$prefix}.".self::SLOTS] = ['sometimes', 'array']; + + foreach ($slots as $slot) { + $rules["{$prefix}.".self::SLOTS.".{$slot}"] = ['sometimes', 'array']; + + $allowedTypes = $template->slotAllowedTypes($slot); + $childRows = $row[self::SLOTS][$slot] ?? []; + + foreach (array_values(is_array($childRows) ? $childRows : []) as $index => $childRow) { + $childPrefix = "{$prefix}.".self::SLOTS.".{$slot}.{$index}"; + + $rules = [...$rules, ...$this->rowRulesAt( + $childPrefix, + is_array($childRow) ? $childRow : [], + $data, + $request, + )]; + + if ($allowedTypes !== null) { + $rules["{$childPrefix}.".self::TYPE] = ['required', Rule::in($allowedTypes)]; + } + } + } + + return $rules; + } + + #[\Override] + protected function castRow(array $castRow, mixed $original): array + { + $castRow = parent::castRow($castRow, $original); + $row = is_array($original) ? $original : []; + $slots = $this->slotNamesFor($row); + + if ($slots === []) { + return $castRow; + } + + $originalSlots = is_array($row[self::SLOTS] ?? null) ? $row[self::SLOTS] : []; + $cast = []; + + foreach ($slots as $slot) { + $cast[$slot] = $this->castValue($originalSlots[$slot] ?? []); + } + + return [...$castRow, self::SLOTS => $cast]; + } + + #[\Override] + protected function decorateProps(array $props): array + { + $props = $this->decorateInteractiveProps($props); + + if (is_array($props['value'] ?? null)) { + $props['value'] = array_map( + fn (mixed $row): mixed => is_array($row) ? $this->withNestedRowIds($row) : $row, + $props['value'], + ); + } + + return $props; + } + + /** + * @param array $row + * @return array + */ + private function withNestedRowIds(array $row): array + { + foreach ($this->slotNamesFor($row) as $slot) { + $childRows = $row[self::SLOTS][$slot] ?? []; + + $row[self::SLOTS][$slot] = array_map( + fn (mixed $child): mixed => is_array($child) + ? $this->withNestedRowIds([self::ROW_ID => self::rowIdOf($child), ...$child]) + : $child, + array_values(is_array($childRows) ? $childRows : []), + ); + } + + return $row; + } + + /** + * @param array $data + * @return array + */ + #[SerializationHook(priority: 350)] + protected function serialiseRenderedRows(array $data): array + { + $rows = is_array($this->value) ? array_values($this->value) : []; + + return [...$data, 'rendered' => $this->renderedTree($rows)]; + } + + /** + * The per-row previews the canvas seeds from, mirroring the value's slot + * nesting so every block at any depth ships its own rendered wire. + * + * @param array $rows + * @return array, slots: array}> + */ + private function renderedTree(array $rows): array + { + $renderer = app(BlockRenderer::class); + + return array_map(function (mixed $row) use ($renderer): array { + $row = is_array($row) ? $row : []; + $slots = []; + + foreach ($this->slotNamesFor($row) as $slot) { + $childRows = $row[self::SLOTS][$slot] ?? []; + $slots[$slot] = $this->renderedTree(is_array($childRows) ? array_values($childRows) : []); + } + + return [ + 'wire' => $renderer->render([$row])->renderable(), + 'slots' => $slots, + ]; + }, $rows); + } +} diff --git a/src/Forms/Components/RowTemplate.php b/src/Forms/Components/RowTemplate.php index 13ef5e4f..3d1b14df 100644 --- a/src/Forms/Components/RowTemplate.php +++ b/src/Forms/Components/RowTemplate.php @@ -11,7 +11,8 @@ /** * A typed row template for a TypedRowsField: the schema of child Fields a row * of this type is built, validated, and cast from. Serializes as - * `{type, label, schema}`. + * `{type, label, schema}`, plus `slots` when the row type declares named + * child-row lists. * * @api */ @@ -21,6 +22,15 @@ final class RowTemplate implements JsonSerializable private ?string $label = null; + private ?string $icon = null; + + private ?string $description = null; + + /** + * @var array}> + */ + private array $slots = []; + private function __construct(public readonly string $type) {} public static function make(string $type): self @@ -35,6 +45,61 @@ public function label(string $label): self return $this; } + public function icon(?string $icon): self + { + $this->icon = $icon; + + return $this; + } + + public function description(?string $description): self + { + $this->description = $description; + + return $this; + } + + /** + * Declare the row type's named child-row lists: plain names for + * unrestricted slots, or shapes carrying a label and the row types the + * slot is restricted to. + * + * @param array}|string> $slots + */ + public function slots(array $slots): self + { + $this->slots = array_values(array_map( + static fn (array|string $slot): array => is_string($slot) ? ['name' => $slot] : $slot, + $slots, + )); + + return $this; + } + + /** + * @return array + */ + public function slotNames(): array + { + return array_column($this->slots, 'name'); + } + + /** + * The row types a slot accepts, or null when the slot is unrestricted. + * + * @return array|null + */ + public function slotAllowedTypes(string $name): ?array + { + foreach ($this->slots as $slot) { + if ($slot['name'] === $name) { + return $slot['blocks'] ?? null; + } + } + + return null; + } + /** * @return array */ @@ -47,14 +112,17 @@ public function fields(): array } /** - * @return array{type: string, label: string, schema: array} + * @return array{type: string, label: string, icon?: string, description?: string, schema: array, slots?: array}>} */ public function jsonSerialize(): array { return [ 'type' => $this->type, 'label' => $this->label ?? Str::headline($this->type), + ...($this->icon === null ? [] : ['icon' => $this->icon]), + ...($this->description === null ? [] : ['description' => $this->description]), 'schema' => $this->renderableChildren(), + ...($this->slots === [] ? [] : ['slots' => $this->slots]), ]; } } diff --git a/src/Forms/Components/RowsField.php b/src/Forms/Components/RowsField.php index 599fb9c2..23785f00 100644 --- a/src/Forms/Components/RowsField.php +++ b/src/Forms/Components/RowsField.php @@ -115,31 +115,45 @@ protected function rulesForRows(array $rows, FormData $data, Request $request): $rules = []; foreach ($rows as $index => $row) { - $row = is_array($row) ? $row : []; - $scope = $this->rowScope($data, $row); - - foreach ($this->rowFields($row) as $child) { - if ($child->name() === self::ROW_ID) { - throw new LogicException(sprintf( - 'Row schemas must not declare a [%s] field: the key is reserved for the per-row identity.', - self::ROW_ID, - )); - } + $rules = [...$rules, ...$this->rowRulesAt("{$this->name}.{$index}", is_array($row) ? $row : [], $data, $request)]; + } - if (! $child->isVisible($scope)) { - continue; - } + return $rules; + } - $childRules = $child->resolvedRulesWithRequired($scope, $request); + /** + * The rules for one row at the given dot-notation prefix; the seam nested + * row structures extend to validate rows below the top level. + * + * @param array $row + * @return array> + */ + protected function rowRulesAt(string $prefix, array $row, FormData $data, Request $request): array + { + $rules = []; + $scope = $this->rowScope($data, $row); + + foreach ($this->rowFields($row) as $child) { + if ($child->name() === self::ROW_ID) { + throw new LogicException(sprintf( + 'Row schemas must not declare a [%s] field: the key is reserved for the per-row identity.', + self::ROW_ID, + )); + } - // excludeUnvalidatedArrayKeys drops a row's unruled keys once a sibling - // (e.g. the type discriminator) has a rule, so give every field a passthrough. - $rules["{$this->name}.{$index}.{$child->name()}"] = $childRules !== [] ? $childRules : ['sometimes', 'nullable']; + if (! $child->isVisible($scope)) { + continue; } - $rules["{$this->name}.{$index}.".self::ROW_ID] = ['sometimes', 'nullable', 'uuid']; + $childRules = $child->resolvedRulesWithRequired($scope, $request); + + // excludeUnvalidatedArrayKeys drops a row's unruled keys once a sibling + // (e.g. the type discriminator) has a rule, so give every field a passthrough. + $rules["{$prefix}.{$child->name()}"] = $childRules !== [] ? $childRules : ['sometimes', 'nullable']; } + $rules["{$prefix}.".self::ROW_ID] = ['sometimes', 'nullable', 'uuid']; + return $rules; } diff --git a/src/Forms/Components/TypedRowsField.php b/src/Forms/Components/TypedRowsField.php index d0e073bc..210b0cf6 100644 --- a/src/Forms/Components/TypedRowsField.php +++ b/src/Forms/Components/TypedRowsField.php @@ -53,19 +53,15 @@ public function rowFields(array $row): array } #[\Override] - protected function rulesForRows(array $rows, FormData $data, Request $request): array + protected function rowRulesAt(string $prefix, array $row, FormData $data, Request $request): array { - $rules = parent::rulesForRows($rows, $data, $request); + $rules = parent::rowRulesAt($prefix, $row, $data, $request); - $typeRules = [ + $rules["{$prefix}.".self::TYPE] = [ 'required', Rule::in(array_map(static fn (RowTemplate $template): string => $template->type, $this->templates)), ]; - foreach (array_keys($rows) as $index) { - $rules["{$this->name}.{$index}.".self::TYPE] = $typeRules; - } - return $rules; } diff --git a/src/Forms/Enums/FieldType.php b/src/Forms/Enums/FieldType.php index 0c34a243..33009c56 100644 --- a/src/Forms/Enums/FieldType.php +++ b/src/Forms/Enums/FieldType.php @@ -11,6 +11,7 @@ enum FieldType: string private const string Prefix = 'field.'; case Builder = 'field.builder'; + case BlockEditor = 'field.block-editor'; case Checkbox = 'field.checkbox'; case Choice = 'field.choice'; case DateInput = 'field.date-input'; diff --git a/src/Http/Controllers/BlockRenderController.php b/src/Http/Controllers/BlockRenderController.php new file mode 100644 index 00000000..da82b37c --- /dev/null +++ b/src/Http/Controllers/BlockRenderController.php @@ -0,0 +1,68 @@ +references->trustedContext($request, 'field.block-editor', 'block-editor'); + + $type = (string) $request->input('type'); + $allowed = is_array($context['allowedBlocks'] ?? null) ? $context['allowedBlocks'] : []; + $attributes = is_array($request->input('attributes')) ? $request->input('attributes') : []; + + foreach ([$type, ...$this->slotTypes($attributes)] as $rowType) { + abort_unless(in_array($rowType, $allowed, true), 403); + } + + try { + $this->blocks->resolve($type); + } catch (UnknownComponent) { + abort(404); + } + + $wire = $this->renderer->render([['type' => $type, ...$attributes]])->renderable(); + + return response()->json(['wire' => $wire]); + } + + /** + * Every block type nested in the row's slots, at any depth. + * + * @param array $attributes + * @return array + */ + private function slotTypes(array $attributes): array + { + $slots = is_array($attributes['slots'] ?? null) ? $attributes['slots'] : []; + $types = []; + + foreach ($slots as $rows) { + foreach (is_array($rows) ? $rows : [] as $row) { + if (! is_array($row)) { + continue; + } + + $types[] = $row['type'] ?? null; + $types = [...$types, ...$this->slotTypes($row)]; + } + } + + return $types; + } +} diff --git a/src/LatticeServiceProvider.php b/src/LatticeServiceProvider.php index 20eb8f4d..f886229d 100644 --- a/src/LatticeServiceProvider.php +++ b/src/LatticeServiceProvider.php @@ -14,12 +14,14 @@ use Lattice\Lattice\Actions\ActionRegistry; use Lattice\Lattice\Actions\BulkActionRegistry; use Lattice\Lattice\Attributes\AsAction; +use Lattice\Lattice\Attributes\AsBlock; use Lattice\Lattice\Attributes\AsBulkAction; use Lattice\Lattice\Attributes\AsForm; use Lattice\Lattice\Attributes\AsFragment; use Lattice\Lattice\Attributes\AsLayout; use Lattice\Lattice\Attributes\AsRemoteSource; use Lattice\Lattice\Attributes\AsTable; +use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Console\Commands\DiscoverCacheCommand; use Lattice\Lattice\Console\Commands\DiscoverClearCommand; use Lattice\Lattice\Console\Commands\MakeActionCommand; @@ -98,12 +100,14 @@ public function packageRegistered(): void DiscoveryKinds::register('actions', AsAction::class); DiscoveryKinds::register('bulk-actions', AsBulkAction::class); DiscoveryKinds::register('fragments', AsFragment::class); + DiscoveryKinds::register('blocks', AsBlock::class); DiscoveryKinds::register('remote-sources', AsRemoteSource::class); DiscoveryKinds::register('layouts', AsLayout::class); $this->app->singleton(FormRegistry::class); $this->app->singleton(TableRegistry::class); $this->app->singleton(FragmentRegistry::class); + $this->app->singleton(BlockRegistry::class); $this->app->singleton(LayoutRegistry::class); $this->app->singleton(ActionRegistry::class); $this->app->singleton(BulkActionRegistry::class); diff --git a/tests/Browser/Blocks/BlockEditorTest.php b/tests/Browser/Blocks/BlockEditorTest.php new file mode 100644 index 00000000..4b8f5467 --- /dev/null +++ b/tests/Browser/Blocks/BlockEditorTest.php @@ -0,0 +1,192 @@ +assertSee('Block Editor Demo') + ->assertSee('Add block'); + + $page->click('@builder-add') + ->click('[data-test="builder-add-workbench.hero"]'); + + $page->assertPresent('[data-test^="block-shell-"]'); + + $page->click('[data-test^="block-shell-"]'); + $page->fill('input[name="content[0][title]"]', 'Hello world'); + $page->click('Block Editor Demo'); + + retryUntil(function () use ($page): void { + $page->assertSee('Hello world'); + }); + + $page->click('Save'); + + retryUntil(function () use ($page): void { + $page->assertSee('Saved: Hello world'); + }); + + $page->assertNoSmoke() + ->assertNoJavaScriptErrors(); +}); + +it('nests a block inside a columns slot and persists the tree', function (): void { + actingAs(workbenchTestUser()); + + $page = visit('/block-editor'); + + $page->assertSee('Block Editor Demo'); + + $page->click('@builder-add') + ->click('[data-test="builder-add-workbench.columns"]'); + + $page->assertPresent('[data-test="block-slot-main"]'); + + $page->click('[data-test="block-slot-main"] [data-test="builder-add"]'); + + $page->assertNotPresent('[data-test="builder-add-workbench.columns"]'); + + $page->click('[data-test="builder-add-workbench.hero"]'); + + $page->assertPresent('[data-test="block-slot-main"] [data-test^="block-shell-"]'); + + $page->click('[data-test="block-slot-main"] [data-test^="block-shell-"]'); + $page->fill('input[name="content[0][slots][main][0][title]"]', 'Nested hello'); + $page->click('Block Editor Demo'); + + retryUntil(function () use ($page): void { + $page->assertSee('Nested hello'); + }); + + $page->click('Save'); + + retryUntil(function () use ($page): void { + $page->assertSee('Saved: Nested hello'); + }); + + $page->assertNoSmoke() + ->assertNoJavaScriptErrors(); +}); + +it('drags a top-level block into an empty slot', function (): void { + actingAs(workbenchTestUser()); + + $page = visit('/block-editor'); + + $page->click('@builder-add') + ->click('[data-test="builder-add-workbench.hero"]'); + $page->click('[data-test^="block-shell-"]'); + $page->fill('input[name="content[0][title]"]', 'Dragged in'); + $page->click('Block Editor Demo'); + + $page->click('@builder-add') + ->click('[data-test="builder-add-workbench.columns"]'); + + $page->assertPresent('[data-test="block-slot-drop-main"]'); + + $page->drag( + '[role="listbox"] > [data-test^="block-shell-"]:first-child [data-test^="block-drag-"]', + '[data-test="block-slot-drop-main"]', + ); + + retryUntil(function () use ($page): void { + $page->assertPresent('[data-test="block-slot-main"] [data-test^="block-shell-"]'); + }); + + $page->click('Save'); + + retryUntil(function () use ($page): void { + $page->assertSee('Saved: Dragged in'); + }); + + $page->assertNoSmoke() + ->assertNoJavaScriptErrors(); +}); + +it('removes a block through its action menu', function (): void { + actingAs(workbenchTestUser()); + + $page = visit('/block-editor'); + + $page->click('@builder-add') + ->click('[data-test="builder-add-workbench.hero"]'); + + $page->assertPresent('[data-test^="block-shell-"]'); + + $page->click('[data-test="row-actions-menu"]') + ->click('[data-test="row-action-remove"]'); + + $page->assertNotPresent('[data-test^="block-shell-"]'); + + $page->assertNoJavaScriptErrors(); +}); + +it('duplicates a block with its attributes through its action menu', function (): void { + actingAs(workbenchTestUser()); + + $page = visit('/block-editor'); + + $page->click('@builder-add') + ->click('[data-test="builder-add-workbench.hero"]'); + $page->fill('input[name="content[0][title]"]', 'Original'); + $page->click('Block Editor Demo'); + + $page->click('[data-test="row-actions-menu"]') + ->click('[data-test="row-action-duplicate"]'); + + retryUntil(function () use ($page): void { + $page->assertValue('input[name="content[1][title]"]', 'Original'); + }); + + $page->click('Save'); + + retryUntil(function () use ($page): void { + $page->assertSee('Saved: Original, Original'); + }); + + $page->assertNoSmoke() + ->assertNoJavaScriptErrors(); +}); + +it('persists attributes for every block, not just the selected one', function (): void { + actingAs(workbenchTestUser()); + + $page = visit('/block-editor'); + + $page->click('@builder-add') + ->click('[data-test="builder-add-workbench.hero"]'); + $page->click('@builder-add') + ->click('[data-test="builder-add-workbench.hero"]'); + + $page->assertPresent('[data-test^="block-shell-"]:nth-child(1)') + ->assertPresent('[data-test^="block-shell-"]:nth-child(2)'); + + $page->click('[data-test^="block-shell-"]:nth-child(1)'); + $page->fill('input[name="content[0][title]"]', 'First title'); + $page->click('[data-test^="block-shell-"]:nth-child(2)'); + + retryUntil(function () use ($page): void { + $page->assertSee('First title'); + }); + + $page->fill('input[name="content[1][title]"]', 'Second title'); + $page->click('Block Editor Demo'); + + retryUntil(function () use ($page): void { + $page->assertSee('First title'); + $page->assertSee('Second title'); + }); + + $page->click('Save'); + + retryUntil(function () use ($page): void { + $page->assertSee('Saved: First title, Second title'); + }); + + $page->assertNoSmoke() + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Feature/Blocks/BlockDiscoveryTest.php b/tests/Feature/Blocks/BlockDiscoveryTest.php new file mode 100644 index 00000000..231d2734 --- /dev/null +++ b/tests/Feature/Blocks/BlockDiscoveryTest.php @@ -0,0 +1,17 @@ +key)->toBe('hero'); +}); + +test('the service provider registers the blocks discovery group', function (): void { + expect(DiscoveryKinds::components()) + ->toHaveKey('blocks') + ->and(DiscoveryKinds::components()['blocks'])->toBe(AsBlock::class); +}); diff --git a/tests/Feature/Blocks/BlockEditorFieldTest.php b/tests/Feature/Blocks/BlockEditorFieldTest.php new file mode 100644 index 00000000..53710833 --- /dev/null +++ b/tests/Feature/Blocks/BlockEditorFieldTest.php @@ -0,0 +1,223 @@ +blocks([EditorHeroBlock::class]); + + $wire = wire($field); + + expect($wire['type'])->toBe('field.block-editor') + ->and($wire['props']['endpoint'])->toBe('/lattice/blocks/render') + ->and($wire['templates'])->toHaveCount(1) + ->and($wire['templates'][0]['type'])->toBe('editor.hero') + ->and($wire['templates'][0]['schema'][0]['props']['name'])->toBe('title'); +}); + +test('serializes the declared slots on each template', function (): void { + $field = BlockEditor::make('content')->blocks([EditorHeroBlock::class, EditorColumnsBlock::class]); + + $wire = wire($field); + + expect($wire['templates'][0])->not->toHaveKey('slots') + ->and($wire['templates'][1]['type'])->toBe('editor.columns') + ->and($wire['templates'][1]['slots'])->toBe([['name' => 'main']]); +}); + +test('serializes the block metadata onto its template', function (): void { + $field = BlockEditor::make('content')->blocks([EditorLabelledBlock::class]); + + $wire = wire($field); + + expect($wire['templates'][0]['label'])->toBe('Big hero') + ->and($wire['templates'][0]['icon'])->toBe('layout-dashboard') + ->and($wire['templates'][0]['description'])->toBe('A prominent heading.'); +}); + +test('falls back to a headline label and omits missing metadata', function (): void { + $field = BlockEditor::make('content')->blocks([EditorHeroBlock::class]); + + $wire = wire($field); + + expect($wire['templates'][0]['label'])->toBe('Hero') + ->and($wire['templates'][0])->not->toHaveKey('icon') + ->and($wire['templates'][0])->not->toHaveKey('description'); +}); + +test('serializes the label of a labelled slot', function (): void { + $field = BlockEditor::make('content')->blocks([EditorHeroBlock::class, EditorLabelledColumnsBlock::class]); + + $wire = wire($field); + + expect($wire['templates'][1]['slots'])->toBe([ + ['name' => 'main', 'label' => 'Main column', 'blocks' => ['editor.hero']], + ]); +}); + +test('serializes the allowed block types of a restricted slot', function (): void { + $field = BlockEditor::make('content')->blocks([EditorHeroBlock::class, EditorRestrictedColumnsBlock::class]); + + $wire = wire($field); + + expect($wire['templates'][1]['slots'])->toBe([['name' => 'main', 'blocks' => ['editor.hero']]]); +}); + +test('rejects a slot allowing a block the editor does not offer', function (): void { + BlockEditor::make('content')->blocks([EditorRestrictedColumnsBlock::class]); +})->throws(LogicException::class, 'editor.hero'); + +test('serializes rendered wire for each stored row aligned by index', function (): void { + app(BlockRegistry::class)->register([EditorHeroBlock::class]); + + $field = BlockEditor::make('content') + ->blocks([EditorHeroBlock::class]) + ->value([ + ['type' => 'editor.hero', 'title' => 'First'], + ['type' => 'editor.hero', 'title' => 'Second'], + ]); + + $wire = wire($field); + + expect($wire['rendered'])->toHaveCount(2) + ->and($wire['rendered'][0]['wire'][0]['type'])->toBe('heading') + ->and($wire['rendered'][0]['wire'][0]['props']['text'])->toBe('First') + ->and($wire['rendered'][1]['wire'][0]['props']['text'])->toBe('Second'); +}); + +test('serializes a rendered tree for slot children', function (): void { + app(BlockRegistry::class)->register([EditorHeroBlock::class, EditorColumnsBlock::class]); + + $field = BlockEditor::make('content') + ->blocks([EditorHeroBlock::class, EditorColumnsBlock::class]) + ->value([ + [ + 'type' => 'editor.columns', + 'slots' => ['main' => [['type' => 'editor.hero', 'title' => 'Nested']]], + ], + ]); + + $wire = wire($field); + + expect($wire['rendered'][0]['wire'][0]['type'])->toBe('grid') + ->and($wire['rendered'][0]['slots']['main'])->toHaveCount(1) + ->and($wire['rendered'][0]['slots']['main'][0]['wire'][0]['type'])->toBe('heading') + ->and($wire['rendered'][0]['slots']['main'][0]['wire'][0]['props']['text'])->toBe('Nested'); +}); + +#[AsBlock('editor.hero')] +final class EditorHeroBlock extends BlockDefinition +{ + public function attributes(): array + { + return [TextInput::make('title')]; + } + + public function render(FormData $data, BlockSlots $slots): PageSchema + { + return PageSchema::make()->component(Heading::make($data->string('title'))); + } +} + +#[AsBlock('editor.columns')] +final class EditorColumnsBlock extends BlockDefinition +{ + public function attributes(): array + { + return []; + } + + #[Override] + public function slots(): array + { + return ['main']; + } + + public function render(FormData $data, BlockSlots $slots): PageSchema + { + return PageSchema::make()->component(Grid::make()->schema($slots->get('main'))); + } +} + +#[AsBlock('editor.labelled')] +final class EditorLabelledBlock extends BlockDefinition +{ + #[Override] + public function label(): string + { + return 'Big hero'; + } + + #[Override] + public function icon(): Icon + { + return Icon::LayoutDashboard; + } + + #[Override] + public function description(): string + { + return 'A prominent heading.'; + } + + public function attributes(): array + { + return [TextInput::make('title')]; + } + + public function render(FormData $data, BlockSlots $slots): PageSchema + { + return PageSchema::make()->component(Heading::make($data->string('title'))); + } +} + +#[AsBlock('editor.labelled-columns')] +final class EditorLabelledColumnsBlock extends BlockDefinition +{ + public function attributes(): array + { + return []; + } + + #[Override] + public function slots(): array + { + return [Slot::make('main')->label('Main column')->blocks([EditorHeroBlock::class])]; + } + + public function render(FormData $data, BlockSlots $slots): PageSchema + { + return PageSchema::make()->component(Grid::make()->schema($slots->get('main'))); + } +} + +#[AsBlock('editor.restricted-columns')] +final class EditorRestrictedColumnsBlock extends BlockDefinition +{ + public function attributes(): array + { + return []; + } + + #[Override] + public function slots(): array + { + return [Slot::make('main')->blocks([EditorHeroBlock::class])]; + } + + public function render(FormData $data, BlockSlots $slots): PageSchema + { + return PageSchema::make()->component(Grid::make()->schema($slots->get('main'))); + } +} diff --git a/tests/Feature/Blocks/BlockEditorValidationTest.php b/tests/Feature/Blocks/BlockEditorValidationTest.php new file mode 100644 index 00000000..eb00eef6 --- /dev/null +++ b/tests/Feature/Blocks/BlockEditorValidationTest.php @@ -0,0 +1,248 @@ +register([ValidationHeroBlock::class, ValidationColumnsBlock::class]); +}); + +function validationEditor(): BlockEditor +{ + return BlockEditor::make('content')->blocks([ValidationHeroBlock::class, ValidationColumnsBlock::class]); +} + +test('rejects a row whose type is not an allowed block', function (): void { + $field = validationEditor(); + + $data = FormData::make(['content' => [['type' => 'not-a-block', 'title' => 'x']]]); + $rules = $field->nestedRules($data, Request::create('/')); + + expect($rules)->toHaveKey('content.0.type') + ->and($rules['content.0.type'][0])->toBe('required') + ->and((string) $rules['content.0.type'][1])->toBe('in:"validation.hero","validation.columns"'); +}); + +test('slot child rows validate through their own template', function (): void { + $field = validationEditor(); + + $data = FormData::make(['content' => [[ + 'type' => 'validation.columns', + 'slots' => ['main' => [['type' => 'validation.hero', 'title' => 'Nested']]], + ]]]); + $rules = $field->nestedRules($data, Request::create('/')); + + expect($rules)->toHaveKey('content.0.slots') + ->toHaveKey('content.0.slots.main') + ->toHaveKey('content.0.slots.main.0.type') + ->toHaveKey('content.0.slots.main.0.title') + ->toHaveKey('content.0.slots.main.0.rowId') + ->and((string) $rules['content.0.slots.main.0.type'][1])->toBe('in:"validation.hero","validation.columns"'); +}); + +test('a restricted slot limits its child types to the allowed blocks', function (): void { + app(BlockRegistry::class)->register([ValidationRestrictedColumnsBlock::class]); + + $field = BlockEditor::make('content')->blocks([ + ValidationHeroBlock::class, + ValidationColumnsBlock::class, + ValidationRestrictedColumnsBlock::class, + ]); + + $data = FormData::make(['content' => [[ + 'type' => 'validation.restricted-columns', + 'slots' => ['main' => [['type' => 'validation.columns']]], + ]]]); + $rules = $field->nestedRules($data, Request::create('/')); + + expect((string) $rules['content.0.slots.main.0.type'][1])->toBe('in:"validation.hero"'); +}); + +test('an unrestricted slot accepts every editor block', function (): void { + $field = validationEditor(); + + $data = FormData::make(['content' => [[ + 'type' => 'validation.columns', + 'slots' => ['main' => [['type' => 'validation.columns']]], + ]]]); + $rules = $field->nestedRules($data, Request::create('/')); + + expect((string) $rules['content.0.slots.main.0.type'][1]) + ->toBe('in:"validation.hero","validation.columns"'); +}); + +test('a slotless row type gets no slot rules', function (): void { + $field = validationEditor(); + + $data = FormData::make(['content' => [['type' => 'validation.hero', 'slots' => ['main' => []]]]]); + $rules = $field->nestedRules($data, Request::create('/')); + + expect($rules)->not->toHaveKey('content.0.slots'); +}); + +test('casting preserves declared slot children and stamps row ids', function (): void { + $field = validationEditor(); + + $cast = $field->castValue([[ + 'type' => 'validation.columns', + 'slots' => ['main' => [ + ['type' => 'validation.hero', 'title' => 'Nested', 'stray' => 'dropped'], + ]], + ]]); + + $child = $cast[0]['slots']['main'][0]; + + expect($cast[0]['type'])->toBe('validation.columns') + ->and($child['type'])->toBe('validation.hero') + ->and($child['title'])->toBe('Nested') + ->and($child)->not->toHaveKey('stray') + ->and($child['rowId'])->toBeUuid(); +}); + +test('casting drops stray slots on a slotless row type', function (): void { + $field = validationEditor(); + + $cast = $field->castValue([[ + 'type' => 'validation.hero', + 'title' => 'Solo', + 'slots' => ['main' => [['type' => 'validation.hero', 'title' => 'Smuggled']]], + ]]); + + expect($cast[0])->not->toHaveKey('slots'); +}); + +test('a block schema must not declare a slots field', function (): void { + app(BlockRegistry::class)->register([ValidationSlotsFieldBlock::class]); + + BlockEditor::make('content')->blocks([ValidationSlotsFieldBlock::class]); +})->throws(LogicException::class, 'slots'); + +test('submitted slot children survive validation and casting end to end', function (): void { + Lattice::forms([ValidationBlockForm::class]); + + $this->submitForm(ValidationBlockForm::class, ['content' => [ + [ + 'type' => 'validation.columns', + 'slots' => ['main' => [['type' => 'validation.hero', 'title' => 'Nested title']]], + ], + ]])->assertRedirect('/blocks-saved'); + + $saved = session('validation-block-form.saved'); + + expect($saved[0]['type'])->toBe('validation.columns') + ->and($saved[0]['slots']['main'][0]['title'])->toBe('Nested title') + ->and($saved[0]['slots']['main'][0]['rowId'])->toBeUuid(); +}); + +test('an invalid slot child field fails validation with a nested error key', function (): void { + Lattice::forms([ValidationBlockForm::class]); + + $this->submitForm(ValidationBlockForm::class, ['content' => [ + [ + 'type' => 'validation.columns', + 'slots' => ['main' => [['type' => 'not-a-block', 'title' => 'x']]], + ], + ]])->assertUnprocessable()->assertJsonValidationErrors('content.0.slots.main.0.type'); +}); + +#[AsBlock('validation.hero')] +final class ValidationHeroBlock extends BlockDefinition +{ + public function attributes(): array + { + return [TextInput::make('title')]; + } + + public function render(FormData $data, BlockSlots $slots): PageSchema + { + return PageSchema::make()->component(Heading::make($data->string('title'))); + } +} + +#[AsBlock('validation.columns')] +final class ValidationColumnsBlock extends BlockDefinition +{ + public function attributes(): array + { + return []; + } + + #[Override] + public function slots(): array + { + return ['main']; + } + + public function render(FormData $data, BlockSlots $slots): PageSchema + { + return PageSchema::make()->component(Grid::make()->schema($slots->get('main'))); + } +} + +#[AsBlock('validation.restricted-columns')] +final class ValidationRestrictedColumnsBlock extends BlockDefinition +{ + public function attributes(): array + { + return []; + } + + #[Override] + public function slots(): array + { + return [Slot::make('main')->blocks([ValidationHeroBlock::class])]; + } + + public function render(FormData $data, BlockSlots $slots): PageSchema + { + return PageSchema::make()->component(Grid::make()->schema($slots->get('main'))); + } +} + +#[AsBlock('validation.slots-field')] +final class ValidationSlotsFieldBlock extends BlockDefinition +{ + public function attributes(): array + { + return [TextInput::make('slots')]; + } + + public function render(FormData $data, BlockSlots $slots): PageSchema + { + return PageSchema::make(); + } +} + +#[AsForm('validation.block-form')] +final class ValidationBlockForm extends FormDefinition +{ + public function definition(Form $form, Request $request): Form + { + return $form->schema([ + BlockEditor::make('content')->blocks([ValidationHeroBlock::class, ValidationColumnsBlock::class]), + ]); + } + + public function handle(Request $request): Response + { + session()->flash('validation-block-form.saved', $this->validate($request)['content'] ?? []); + + return redirect('/blocks-saved'); + } +} diff --git a/tests/Feature/Blocks/BlockNestingTest.php b/tests/Feature/Blocks/BlockNestingTest.php new file mode 100644 index 00000000..078eb49e --- /dev/null +++ b/tests/Feature/Blocks/BlockNestingTest.php @@ -0,0 +1,73 @@ +register([NestingColumnsBlock::class, NestingTextBlock::class]); + + $schema = app(BlockRenderer::class)->render([ + [ + 'type' => 'nesting.columns', + 'slots' => [ + 'left' => [['type' => 'nesting.text', 'body' => 'Left side']], + 'right' => [['type' => 'nesting.text', 'body' => 'Right side']], + ], + ], + ]); + + $wire = wire($schema->renderable()); + + expect($wire)->toHaveCount(1) + ->and($wire[0]['type'])->toBe('grid') + ->and($wire[0]['schema'])->toHaveCount(2) + ->and($wire[0]['schema'][0]['props']['text'])->toBe('Left side') + ->and($wire[0]['schema'][1]['props']['text'])->toBe('Right side'); +}); + +#[AsBlock('nesting.columns')] +final class NestingColumnsBlock extends BlockDefinition +{ + public function attributes(): array + { + return []; + } + + #[Override] + public function slots(): array + { + return ['left', 'right']; + } + + public function render(FormData $data, BlockSlots $slots): PageSchema + { + return PageSchema::make()->component( + Grid::make()->schema([ + ...$slots->get('left'), + ...$slots->get('right'), + ]), + ); + } +} + +#[AsBlock('nesting.text')] +final class NestingTextBlock extends BlockDefinition +{ + public function attributes(): array + { + return []; + } + + public function render(FormData $data, BlockSlots $slots): PageSchema + { + return PageSchema::make()->component(Text::make($data->string('body'))); + } +} diff --git a/tests/Feature/Blocks/BlockRegistryTest.php b/tests/Feature/Blocks/BlockRegistryTest.php new file mode 100644 index 00000000..b38989d6 --- /dev/null +++ b/tests/Feature/Blocks/BlockRegistryTest.php @@ -0,0 +1,63 @@ +register([RegistryHeroBlock::class]); + + $block = app(BlockRegistry::class)->resolve('registry.hero'); + + expect($block)->toBeInstanceOf(RegistryHeroBlock::class); +}); + +test('resolving an unknown block throws', function (): void { + app(BlockRegistry::class)->resolve('registry.missing'); +})->throws(UnknownComponent::class); + +test('registering a block without the AsBlock attribute throws', function (): void { + app(BlockRegistry::class)->register(RegistryUnmarkedBlock::class); +})->throws(InvalidArgumentException::class); + +test('block hooks default to empty', function (): void { + $block = new RegistryHeroBlock; + + expect($block->slots())->toBe([]) + ->and($block->inlineText())->toBe([]) + ->and($block->migrate(['title' => 'x'], 1))->toBe(['title' => 'x']); +}); + +#[AsBlock('registry.hero')] +final class RegistryHeroBlock extends BlockDefinition +{ + public function attributes(): array + { + return [TextInput::make('title')]; + } + + public function render(FormData $data, BlockSlots $slots): PageSchema + { + return PageSchema::make()->component(Heading::make($data->string('title'))); + } +} + +final class RegistryUnmarkedBlock extends BlockDefinition +{ + public function attributes(): array + { + return []; + } + + public function render(FormData $data, BlockSlots $slots): PageSchema + { + return PageSchema::make(); + } +} diff --git a/tests/Feature/Blocks/BlockRenderEndpointTest.php b/tests/Feature/Blocks/BlockRenderEndpointTest.php new file mode 100644 index 00000000..5197c3a3 --- /dev/null +++ b/tests/Feature/Blocks/BlockRenderEndpointTest.php @@ -0,0 +1,133 @@ +register([EndpointHeroBlock::class]); +}); + +test('renders a block via the signed endpoint and returns wire', function (): void { + $field = BlockEditor::make('content')->blocks([EndpointHeroBlock::class])->id('content'); + $ref = componentRef(wire($field)); + + latticePost('/lattice/blocks/render', $ref, [ + 'type' => 'endpoint.hero', + 'attributes' => ['title' => 'Live'], + ]) + ->assertOk() + ->assertJsonPath('wire.0.type', 'heading') + ->assertJsonPath('wire.0.props.text', 'Live'); +}); + +test('rejects an unsigned request', function (): void { + postJson('/lattice/blocks/render', ['type' => 'endpoint.hero', 'attributes' => []]) + ->assertForbidden(); +}); + +test('forbids rendering a block the field does not allow', function (): void { + app(BlockRegistry::class)->register([EndpointHeroBlock::class, EndpointOtherBlock::class]); + $field = BlockEditor::make('content')->blocks([EndpointHeroBlock::class])->id('content'); + $ref = componentRef(wire($field)); + + latticePost('/lattice/blocks/render', $ref, [ + 'type' => 'endpoint.other', + 'attributes' => [], + ])->assertForbidden(); +}); + +test('renders allowed slot children nested in the attributes', function (): void { + app(BlockRegistry::class)->register([EndpointHeroBlock::class, EndpointColumnsBlock::class]); + $field = BlockEditor::make('content') + ->blocks([EndpointHeroBlock::class, EndpointColumnsBlock::class]) + ->id('content'); + $ref = componentRef(wire($field)); + + latticePost('/lattice/blocks/render', $ref, [ + 'type' => 'endpoint.columns', + 'attributes' => [ + 'slots' => ['main' => [['type' => 'endpoint.hero', 'title' => 'Nested']]], + ], + ]) + ->assertOk() + ->assertJsonPath('wire.0.type', 'grid') + ->assertJsonPath('wire.0.schema.0.props.text', 'Nested'); +}); + +test('forbids slot children the field does not allow', function (): void { + app(BlockRegistry::class)->register([ + EndpointHeroBlock::class, + EndpointColumnsBlock::class, + EndpointOtherBlock::class, + ]); + $field = BlockEditor::make('content') + ->blocks([EndpointHeroBlock::class, EndpointColumnsBlock::class]) + ->id('content'); + $ref = componentRef(wire($field)); + + latticePost('/lattice/blocks/render', $ref, [ + 'type' => 'endpoint.columns', + 'attributes' => [ + 'slots' => ['main' => [['type' => 'endpoint.other']]], + ], + ])->assertForbidden(); +}); + +#[AsBlock('endpoint.hero')] +final class EndpointHeroBlock extends BlockDefinition +{ + public function attributes(): array + { + return [TextInput::make('title')]; + } + + public function render(FormData $data, BlockSlots $slots): PageSchema + { + return PageSchema::make()->component(Heading::make($data->string('title'))); + } +} + +#[AsBlock('endpoint.other')] +final class EndpointOtherBlock extends BlockDefinition +{ + public function attributes(): array + { + return []; + } + + public function render(FormData $data, BlockSlots $slots): PageSchema + { + return PageSchema::make(); + } +} + +#[AsBlock('endpoint.columns')] +final class EndpointColumnsBlock extends BlockDefinition +{ + public function attributes(): array + { + return []; + } + + #[Override] + public function slots(): array + { + return ['main']; + } + + public function render(FormData $data, BlockSlots $slots): PageSchema + { + return PageSchema::make()->component(Grid::make()->schema($slots->get('main'))); + } +} diff --git a/tests/Feature/Blocks/BlockRendererTest.php b/tests/Feature/Blocks/BlockRendererTest.php new file mode 100644 index 00000000..f841cf50 --- /dev/null +++ b/tests/Feature/Blocks/BlockRendererTest.php @@ -0,0 +1,67 @@ +register([RendererHeroBlock::class]); + + $schema = app(BlockRenderer::class)->render([ + ['type' => 'renderer.hero', 'title' => 'First', 'body' => 'One'], + ['type' => 'renderer.hero', 'title' => 'Second', 'body' => 'Two'], + ]); + + $wire = wire($schema->renderable()); + + expect($wire)->toHaveCount(2) + ->and($wire[0]['type'])->toBe('section') + ->and($wire[0]['schema'][0]['type'])->toBe('heading') + ->and($wire[0]['schema'][0]['props']['text'])->toBe('First') + ->and($wire[0]['schema'][1]['props']['text'])->toBe('One') + ->and($wire[1]['schema'][0]['props']['text'])->toBe('Second'); +}); + +test('renders a placeholder for an unknown block type without throwing', function (): void { + app(BlockRegistry::class)->register([RendererHeroBlock::class]); + + $schema = app(BlockRenderer::class)->render([ + ['type' => 'renderer.hero', 'title' => 'Kept', 'body' => 'Body'], + ['type' => 'renderer.gone', 'headline' => 'Legacy data'], + ]); + + $wire = wire($schema->renderable()); + + expect($wire)->toHaveCount(2) + ->and($wire[0]['type'])->toBe('section') + ->and($wire[1]['type'])->toBe('text') + ->and($wire[1]['props']['text'])->toBe('Unknown block [renderer.gone]'); +}); + +#[AsBlock('renderer.hero')] +final class RendererHeroBlock extends BlockDefinition +{ + public function attributes(): array + { + return [TextInput::make('title'), TextInput::make('body')]; + } + + public function render(FormData $data, BlockSlots $slots): PageSchema + { + return PageSchema::make()->component( + Section::make()->schema([ + Heading::make($data->string('title')), + Text::make($data->string('body')), + ]), + ); + } +} diff --git a/tests/Feature/Blocks/HasBlocksTest.php b/tests/Feature/Blocks/HasBlocksTest.php new file mode 100644 index 00000000..cdee8740 --- /dev/null +++ b/tests/Feature/Blocks/HasBlocksTest.php @@ -0,0 +1,70 @@ +id(); + $table->json('content')->nullable(); + $table->timestamps(); + }); +}); + +test('persists a block tree and renders it back through the registry', function (): void { + app(BlockRegistry::class)->register([HasBlocksHeroBlock::class]); + + $page = HasBlocksPage::create([ + 'content' => [ + ['type' => 'has-blocks.hero', 'title' => 'Stored heading'], + ], + ]); + + $reloaded = HasBlocksPage::query()->whereKey($page->getKey())->firstOrFail(); + $wire = wire($reloaded->renderBlocks('content')->renderable()); + + expect($reloaded->getAttribute('content'))->toBe([['type' => 'has-blocks.hero', 'title' => 'Stored heading']]) + ->and($wire)->toHaveCount(1) + ->and($wire[0]['type'])->toBe('heading') + ->and($wire[0]['props']['text'])->toBe('Stored heading'); +}); + +class HasBlocksPage extends Model +{ + use HasBlocks; + + protected $table = 'landing_pages'; + + protected $guarded = []; + + #[Override] + protected function casts(): array + { + return ['content' => AsBlocks::class]; + } +} + +#[AsBlock('has-blocks.hero')] +final class HasBlocksHeroBlock extends BlockDefinition +{ + public function attributes(): array + { + return [TextInput::make('title')]; + } + + public function render(FormData $data, BlockSlots $slots): PageSchema + { + return PageSchema::make()->component(Heading::make($data->string('title'))); + } +} diff --git a/tests/Pest.php b/tests/Pest.php index cd305b56..328ed90a 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -21,6 +21,7 @@ use Workbench\App\Models\User; use function Pest\Laravel\getJson; +use function Pest\Laravel\postJson; uses(TestCase::class)->in('Feature', 'Unit'); uses(BrowserTestCase::class)->in('Browser'); @@ -316,6 +317,17 @@ function latticeGet(string $url, string $ref): TestResponse return getJson($url, latticeHeaders($ref)); } +/** + * Perform a JSON POST request with a Lattice component ref header. + * + * @param array $body + * @return TestResponse + */ +function latticePost(string $url, string $ref, array $body = []): TestResponse +{ + return postJson($url, $body, latticeHeaders($ref)); +} + /** * Guard a docs/fixtures/.json file against the payload its test builds, so * the docs site renders real, test-generated examples instead of hand-maintained diff --git a/tests/Unit/Blocks/AsBlocksCastTest.php b/tests/Unit/Blocks/AsBlocksCastTest.php new file mode 100644 index 00000000..fa28cc52 --- /dev/null +++ b/tests/Unit/Blocks/AsBlocksCastTest.php @@ -0,0 +1,30 @@ +get($model, 'content', '[{"type":"hero","title":"Hi"}]', []); + + expect($rows)->toBe([['type' => 'hero', 'title' => 'Hi']]); +}); + +test('returns an empty list for null', function (): void { + $cast = new AsBlocks; + $model = new class extends Model {}; + + expect($cast->get($model, 'content', null, []))->toBe([]); +}); + +test('encodes a list of rows to json for storage', function (): void { + $cast = new AsBlocks; + $model = new class extends Model {}; + + $stored = $cast->set($model, 'content', [['type' => 'hero', 'title' => 'Hi']], []); + + expect($stored)->toBe(['content' => '[{"type":"hero","title":"Hi"}]']); +}); diff --git a/tests/Unit/Blocks/BlockSlotsTest.php b/tests/Unit/Blocks/BlockSlotsTest.php new file mode 100644 index 00000000..6ded990a --- /dev/null +++ b/tests/Unit/Blocks/BlockSlotsTest.php @@ -0,0 +1,18 @@ + [$text]]); + + expect($slots->get('left'))->toBe([$text]); +}); + +test('returns an empty array for an unknown slot', function (): void { + $slots = new BlockSlots; + + expect($slots->get('missing'))->toBe([]); +}); diff --git a/tests/Unit/Forms/BlockEditorFieldTypeTest.php b/tests/Unit/Forms/BlockEditorFieldTypeTest.php new file mode 100644 index 00000000..c46000b4 --- /dev/null +++ b/tests/Unit/Forms/BlockEditorFieldTypeTest.php @@ -0,0 +1,9 @@ +value)->toBe('field.block-editor') + ->and(FieldType::wireType(FieldType::BlockEditor))->toBe('field.block-editor'); +}); diff --git a/vite.config.ts b/vite.config.ts index f5bed096..68d2def4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -333,6 +333,7 @@ export default defineConfig(({ mode }) => { /^@internationalized\/date($|\/)/, /^@lattice-php\/vite-svg-sprite($|\/)/, /^@radix-ui\//, + /^@dnd-kit\//, /^@tiptap\//, /^@zag-js\//, /^clsx($|\/)/, diff --git a/workbench/app/Blocks/ColumnsBlock.php b/workbench/app/Blocks/ColumnsBlock.php new file mode 100644 index 00000000..51d32b72 --- /dev/null +++ b/workbench/app/Blocks/ColumnsBlock.php @@ -0,0 +1,32 @@ +blocks([HeroBlock::class])]; + } + + public function render(FormData $data, BlockSlots $slots): PageSchema + { + return PageSchema::make()->component(Grid::make()->schema($slots->get('main'))); + } +} diff --git a/workbench/app/Blocks/HeroBlock.php b/workbench/app/Blocks/HeroBlock.php new file mode 100644 index 00000000..c1a32a0d --- /dev/null +++ b/workbench/app/Blocks/HeroBlock.php @@ -0,0 +1,26 @@ +component(Heading::make($data->string('title'))); + } +} diff --git a/workbench/app/Forms/BlockPageForm.php b/workbench/app/Forms/BlockPageForm.php new file mode 100644 index 00000000..9dbc9eca --- /dev/null +++ b/workbench/app/Forms/BlockPageForm.php @@ -0,0 +1,40 @@ +precognitive(300) + ->schema([ + Card::make(__('workbench.forms.block-editor.card'))->schema([ + BlockEditor::make('content') + ->blocks([HeroBlock::class, ColumnsBlock::class]) + ->addLabel(__('workbench.common.add-block')), + ]), + ]); + } + + public function handle(Request $request): Response + { + $data = $this->validate($request); + + session()->flash('block-editor.saved', $data['content'] ?? []); + + return redirect('/block-editor'); + } +} diff --git a/workbench/app/Pages/BlockPageEditorPage.php b/workbench/app/Pages/BlockPageEditorPage.php new file mode 100644 index 00000000..ceedb025 --- /dev/null +++ b/workbench/app/Pages/BlockPageEditorPage.php @@ -0,0 +1,68 @@ +blockTitles($saved)); + + return $schema->schema([ + Stack::make('block-editor-page') + ->gap(Gap::Large) + ->schema([ + Heading::make(__('workbench.pages.block-editor.heading')), + Form::use(BlockPageForm::class) + ->method(HttpMethod::Post) + ->submitLabel(__('workbench.pages.block-editor.submit')), + ...($saved !== null + ? [Text::make(__('workbench.pages.block-editor.saved', ['titles' => $savedTitles->implode(', ')]))] + : []), + ]), + ]); + } + + /** + * @return array + */ + private function blockTitles(mixed $rows): array + { + $titles = []; + + foreach (is_array($rows) ? $rows : [] as $row) { + if (! is_array($row)) { + continue; + } + + if (is_string($row['title'] ?? null) && $row['title'] !== '') { + $titles[] = $row['title']; + } + + foreach (is_array($row['slots'] ?? null) ? $row['slots'] : [] as $childRows) { + $titles = [...$titles, ...$this->blockTitles($childRows)]; + } + } + + return $titles; + } +} diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php index 90415a9c..8fb617e2 100644 --- a/workbench/app/Providers/WorkbenchServiceProvider.php +++ b/workbench/app/Providers/WorkbenchServiceProvider.php @@ -134,6 +134,7 @@ private function keepLatticeEndpointsPublic(): void 'lattice.forms.middleware' => ['web'], 'lattice.tables.middleware' => ['web'], 'lattice.fragments.middleware' => ['web'], + 'lattice.blocks.middleware' => ['web'], 'lattice.remote-sources.middleware' => ['web'], 'lattice.actions.middleware' => ['web'], 'lattice.bulk-actions.middleware' => ['web'], diff --git a/workbench/lang/de/workbench.php b/workbench/lang/de/workbench.php index 092a278d..815b0194 100644 --- a/workbench/lang/de/workbench.php +++ b/workbench/lang/de/workbench.php @@ -225,6 +225,9 @@ 'unit-price' => 'Stückpreis', ], 'forms' => [ + 'block-editor' => [ + 'card' => 'Inhalt', + ], 'builder' => [ 'card' => 'Positionen', ], @@ -341,6 +344,12 @@ 'toggle-sidebar' => 'Seitenleiste umschalten', ], 'pages' => [ + 'block-editor' => [ + 'heading' => 'Block-Editor-Demo', + 'saved' => 'Gespeichert: :titles', + 'submit' => 'Speichern', + 'title' => 'Block-Editor-Demo', + ], 'builder' => [ 'heading' => 'Builder-Demo', 'submit' => 'Speichern', diff --git a/workbench/lang/en/workbench.php b/workbench/lang/en/workbench.php index ca30a859..d7bdb493 100644 --- a/workbench/lang/en/workbench.php +++ b/workbench/lang/en/workbench.php @@ -225,6 +225,9 @@ 'unit-price' => 'Unit price', ], 'forms' => [ + 'block-editor' => [ + 'card' => 'Content', + ], 'builder' => [ 'card' => 'Line items', ], @@ -341,6 +344,12 @@ 'toggle-sidebar' => 'Toggle sidebar', ], 'pages' => [ + 'block-editor' => [ + 'heading' => 'Block Editor Demo', + 'saved' => 'Saved: :titles', + 'submit' => 'Save', + 'title' => 'Block Editor Demo', + ], 'builder' => [ 'heading' => 'Builder Demo', 'submit' => 'Save',