Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
a14e0b6
feat: add AsBlock attribute and blocks discovery group
bambamboole Jul 8, 2026
83e77c3
feat: add BlockSlots value object
bambamboole Jul 8, 2026
8a8c8fe
feat: add BlockDefinition and BlockRegistry with discovery wiring
bambamboole Jul 8, 2026
289a508
feat: add BlockRenderer for flat block rows
bambamboole Jul 8, 2026
17a3878
feat: render a placeholder for unknown block types
bambamboole Jul 8, 2026
dc08e64
feat: render restricted-nesting block slots
bambamboole Jul 8, 2026
b81b2f0
feat: add AsBlocks eloquent cast
bambamboole Jul 8, 2026
8cef185
feat: add HasBlocks trait for model-attached block rendering
bambamboole Jul 8, 2026
e953b51
test: add Override attributes for rector compliance
bambamboole Jul 8, 2026
ff4e7c9
feat: add block-editor field type
bambamboole Jul 9, 2026
e8afd5e
feat: add BlockEditor field building templates from AsBlock classes
bambamboole Jul 9, 2026
aa358f5
feat: serialize rendered wire per BlockEditor row
bambamboole Jul 9, 2026
2234983
feat: add signed block render endpoint for live preview
bambamboole Jul 9, 2026
8f6edc0
test: BlockEditor inherits per-row block validation
bambamboole Jul 9, 2026
98b0c9d
fix: widen BlockEditor::blocks param for LSP compatibility
bambamboole Jul 9, 2026
b310377
chore: sync generated types and enum fixture for BlockEditor
bambamboole Jul 9, 2026
8fa6b2d
build: add dnd-kit as an external dependency
bambamboole Jul 9, 2026
260152d
feat: add move-block pure tree move for the block editor
bambamboole Jul 9, 2026
26814ab
fix: generalize move-block ancestor-index shift and guard invalid target
bambamboole Jul 9, 2026
4c6689d
feat: add on-blur block preview hook
bambamboole Jul 9, 2026
6a4510f
feat: add block editor inspector panel
bambamboole Jul 9, 2026
13457c6
feat: add block editor canvas with selection
bambamboole Jul 9, 2026
cab8790
fix: pair listbox/option roles and restore canvas test types
bambamboole Jul 9, 2026
68675a7
feat: compose and register the BlockEditor field component
bambamboole Jul 9, 2026
c83887a
feat: add nested drag-and-drop to the block editor canvas
bambamboole Jul 9, 2026
afd208c
fix: give block editor shells a keyboard-accessible drag handle
bambamboole Jul 9, 2026
56fdcb9
test: end-to-end browser coverage for the block editor
bambamboole Jul 9, 2026
d240879
fix: submit all BlockEditor block attributes, not just the selected b…
bambamboole Jul 9, 2026
dc917f8
refactor: use validated data in the block editor workbench demo
bambamboole Jul 9, 2026
f458785
fix: serialize the block render endpoint as an absolute path
bambamboole Jul 9, 2026
715c847
refactor: reuse the registry key lookup and resolve the renderer once
bambamboole Jul 9, 2026
fddd41c
fix: build slot hidden-input names with the form-path helpers
bambamboole Jul 9, 2026
edf1cef
fix: honor dependent state, errors, and maxItems in the block editor
bambamboole Jul 9, 2026
9851693
fix: re-render a block on blur only when it actually changed
bambamboole Jul 9, 2026
d55a981
fix: translate the block editor UI strings
bambamboole Jul 9, 2026
0a7a170
refactor!: register blocks on the BlockRegistry instead of the Lattic…
bambamboole Jul 9, 2026
39f6333
refactor: adapt the block editor to the RowsField substrate
bambamboole Jul 9, 2026
a3061ca
refactor: adapt the block editor to the Ui\Components namespace move
bambamboole Jul 11, 2026
8577ee0
refactor: adapt the block editor to the form module restructure
bambamboole Jul 11, 2026
597130e
feat: serialize declared slots on block editor templates
bambamboole Jul 11, 2026
1380c47
feat: validate and cast nested slot rows in the block editor
bambamboole Jul 11, 2026
379ac5f
feat: serialize a rendered preview tree for slot children
bambamboole Jul 11, 2026
4cf9e16
fix: enforce the allow-list for nested preview slot rows
bambamboole Jul 11, 2026
bee5370
feat: add block tree helpers and a moveBlock cycle guard
bambamboole Jul 11, 2026
da75421
feat: edit slot children in the block editor canvas
bambamboole Jul 11, 2026
337d2b3
test: browser coverage for slot editing and block removal
bambamboole Jul 11, 2026
41358c4
feat: restrict block editor slots to allowed blocks
bambamboole Jul 11, 2026
01a142c
fix: drive block editor drag-and-drop by stable row ids
bambamboole Jul 11, 2026
558bfb4
test: browser coverage for slot restrictions and drag into slots
bambamboole Jul 11, 2026
2a34211
fix: align RowTemplate slot types with the descriptor shape
bambamboole Jul 11, 2026
ecffe22
feat: block labels, icons, and descriptions in the editor chrome
bambamboole Jul 11, 2026
67d565a
feat: block action menu, error badges, and auto-select on add
bambamboole Jul 11, 2026
94f4cba
test: narrow the labelled block fixture return types
bambamboole Jul 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config/lattice.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
4 changes: 4 additions & 0 deletions docs/fixtures/enums.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@
"name": "Builder",
"value": "field.builder"
},
{
"name": "BlockEditor",
"value": "field.block-editor"
},
{
"name": "Checkbox",
"value": "field.checkbox"
Expand Down
13 changes: 13 additions & 0 deletions lang/de/form.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
13 changes: 13 additions & 0 deletions lang/en/form.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
56 changes: 56 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 10 additions & 2 deletions resources/js/form/components/fields/add-row-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -34,9 +34,17 @@ export function AddRowMenu({
<DropdownMenuItem
key={option.type}
data-test={`builder-add-${option.type}`}
icon={option.icon}
onClick={() => onSelect(option.type)}
>
{option.label}
{option.description ? (
<span className="flex flex-col">
<span>{option.label}</span>
<span className="text-xs text-lt-muted-fg">{option.description}</span>
</span>
) : (
option.label
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
Expand Down
211 changes: 211 additions & 0 deletions resources/js/form/components/fields/block-editor/canvas.test.tsx
Original file line number Diff line number Diff line change
@@ -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 } }[] }) => (
<span>{nodes[0]?.props.text}</span>
),
}));

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<string, unknown>[], overrides: Record<string, unknown> = {}) {
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(
<BlockCanvas
rows={rows}
templates={templates as never}
addLabel="Add"
wireFor={(id: string) =>
(((overrides.wire as Record<string, unknown[]>) ?? {})[id] as never) ?? []
}
onPreviewSeed={() => {}}
selectedId={(overrides.selectedId as string) ?? null}
errorIds={(overrides.errorIds as Set<string>) ?? 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 }]);
});
Loading
Loading