From a14e0b65b3d5cca70256cbbf9dfff9b4f52d7141 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Wed, 8 Jul 2026 23:57:53 +0200 Subject: [PATCH 01/53] feat: add AsBlock attribute and blocks discovery group --- src/Attributes/AsBlock.php | 9 +++++++++ src/LatticeServiceProvider.php | 2 ++ tests/Feature/Blocks/BlockDiscoveryTest.php | 17 +++++++++++++++++ 3 files changed, 28 insertions(+) create mode 100644 src/Attributes/AsBlock.php create mode 100644 tests/Feature/Blocks/BlockDiscoveryTest.php 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 @@ +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); +}); From 83e77c322b0e7e0c79c875eaa6601e481a2700f7 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 00:00:52 +0200 Subject: [PATCH 02/53] feat: add BlockSlots value object --- src/Blocks/BlockSlots.php | 22 ++++++++++++++++++++++ tests/Unit/Blocks/BlockSlotsTest.php | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 src/Blocks/BlockSlots.php create mode 100644 tests/Unit/Blocks/BlockSlotsTest.php diff --git a/src/Blocks/BlockSlots.php b/src/Blocks/BlockSlots.php new file mode 100644 index 00000000..5c108c75 --- /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/tests/Unit/Blocks/BlockSlotsTest.php b/tests/Unit/Blocks/BlockSlotsTest.php new file mode 100644 index 00000000..233894de --- /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([]); +}); From 8a8c8fe8998fe4e982791b74c9063bc4c850903e Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 00:06:05 +0200 Subject: [PATCH 03/53] feat: add BlockDefinition and BlockRegistry with discovery wiring --- src/Blocks/BlockDefinition.php | 44 +++++++++++++++ src/Blocks/BlockRegistry.php | 40 ++++++++++++++ src/Facades/Lattice.php | 1 + src/LatticeRegistry.php | 11 ++++ src/LatticeServiceProvider.php | 2 + tests/Feature/Blocks/BlockRegistryTest.php | 64 ++++++++++++++++++++++ 6 files changed, 162 insertions(+) create mode 100644 src/Blocks/BlockDefinition.php create mode 100644 src/Blocks/BlockRegistry.php create mode 100644 tests/Feature/Blocks/BlockRegistryTest.php diff --git a/src/Blocks/BlockDefinition.php b/src/Blocks/BlockDefinition.php new file mode 100644 index 00000000..04313efa --- /dev/null +++ b/src/Blocks/BlockDefinition.php @@ -0,0 +1,44 @@ + + */ + abstract public function attributes(): array; + + abstract public function render(FormData $data, BlockSlots $slots): PageSchema; + + /** + * @return array + */ + public function inlineText(): array + { + return []; + } + + /** + * @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/Facades/Lattice.php b/src/Facades/Lattice.php index a7821207..e4f6425b 100644 --- a/src/Facades/Lattice.php +++ b/src/Facades/Lattice.php @@ -10,6 +10,7 @@ * @method static void forms(class-string<\Lattice\Lattice\Forms\FormDefinition>|array> $forms) * @method static void tables(class-string<\Lattice\Lattice\Tables\TableDefinition>|array> $tables) * @method static void fragments(class-string<\Lattice\Lattice\Fragments\FragmentDefinition>|array> $fragments) + * @method static void blocks(class-string<\Lattice\Lattice\Blocks\BlockDefinition>|array> $blocks) * @method static void layouts(class-string<\Lattice\Lattice\Layouts\LayoutDefinition>|array> $layouts) * @method static \Lattice\Lattice\Layouts\LayoutRegistry layoutRegistry() * @method static void pages(class-string<\Lattice\Lattice\Core\Contracts\PageContract>|array> $pages) diff --git a/src/LatticeRegistry.php b/src/LatticeRegistry.php index 3fe8a9a0..e42c5dd4 100644 --- a/src/LatticeRegistry.php +++ b/src/LatticeRegistry.php @@ -8,6 +8,8 @@ use Lattice\Lattice\Actions\ActionRegistry; use Lattice\Lattice\Actions\BulkActionDefinition; use Lattice\Lattice\Actions\BulkActionRegistry; +use Lattice\Lattice\Blocks\BlockDefinition; +use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Core\Contracts\PageContract; use Lattice\Lattice\Forms\FormDefinition; use Lattice\Lattice\Forms\FormRegistry; @@ -25,6 +27,7 @@ { public function __construct( private ActionRegistry $actions, + private BlockRegistry $blocks, private BulkActionRegistry $bulkActions, private FormRegistry $forms, private FragmentRegistry $fragments, @@ -58,6 +61,14 @@ public function fragments(string|array $fragments): void $this->fragments->register($fragments); } + /** + * @param class-string|array> $blocks + */ + public function blocks(string|array $blocks): void + { + $this->blocks->register($blocks); + } + /** * @param class-string|array> $actions */ diff --git a/src/LatticeServiceProvider.php b/src/LatticeServiceProvider.php index 12e89477..f886229d 100644 --- a/src/LatticeServiceProvider.php +++ b/src/LatticeServiceProvider.php @@ -21,6 +21,7 @@ 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; @@ -106,6 +107,7 @@ public function packageRegistered(): void $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/Feature/Blocks/BlockRegistryTest.php b/tests/Feature/Blocks/BlockRegistryTest.php new file mode 100644 index 00000000..7b826fda --- /dev/null +++ b/tests/Feature/Blocks/BlockRegistryTest.php @@ -0,0 +1,64 @@ +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(); + } +} From 289a508c645eabac77fa923a1509513be4a41e2d Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 00:07:16 +0200 Subject: [PATCH 04/53] feat: add BlockRenderer for flat block rows --- src/Blocks/BlockRenderer.php | 56 ++++++++++++++++++++++ tests/Feature/Blocks/BlockRendererTest.php | 51 ++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/Blocks/BlockRenderer.php create mode 100644 tests/Feature/Blocks/BlockRendererTest.php diff --git a/src/Blocks/BlockRenderer.php b/src/Blocks/BlockRenderer.php new file mode 100644 index 00000000..4bdc5473 --- /dev/null +++ b/src/Blocks/BlockRenderer.php @@ -0,0 +1,56 @@ +> $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'] : ''; + $block = $this->blocks->resolve($type); + + return $block->render(FormData::make($row), new BlockSlots)->renderable(); + } +} diff --git a/tests/Feature/Blocks/BlockRendererTest.php b/tests/Feature/Blocks/BlockRendererTest.php new file mode 100644 index 00000000..acba9b43 --- /dev/null +++ b/tests/Feature/Blocks/BlockRendererTest.php @@ -0,0 +1,51 @@ +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'); +}); + +#[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')), + ]), + ); + } +} From 17a3878e087cdd91c9fd7010ebe284d93b68a45a Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 00:08:50 +0200 Subject: [PATCH 05/53] feat: render a placeholder for unknown block types --- src/Blocks/BlockRenderer.php | 9 ++++++++- tests/Feature/Blocks/BlockRendererTest.php | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Blocks/BlockRenderer.php b/src/Blocks/BlockRenderer.php index 4bdc5473..e27d88ab 100644 --- a/src/Blocks/BlockRenderer.php +++ b/src/Blocks/BlockRenderer.php @@ -4,6 +4,8 @@ namespace Lattice\Lattice\Blocks; use Lattice\Lattice\Core\Components\Component; +use Lattice\Lattice\Core\Components\Text; +use Lattice\Lattice\Core\Exceptions\UnknownComponent; use Lattice\Lattice\Core\PageSchema; use Lattice\Lattice\Forms\FormData; @@ -49,7 +51,12 @@ private function components(array $rows): array private function componentsForRow(array $row): array { $type = is_string($row['type'] ?? null) ? $row['type'] : ''; - $block = $this->blocks->resolve($type); + + try { + $block = $this->blocks->resolve($type); + } catch (UnknownComponent) { + return [Text::make("Unknown block [{$type}]")]; + } return $block->render(FormData::make($row), new BlockSlots)->renderable(); } diff --git a/tests/Feature/Blocks/BlockRendererTest.php b/tests/Feature/Blocks/BlockRendererTest.php index acba9b43..17e82c0e 100644 --- a/tests/Feature/Blocks/BlockRendererTest.php +++ b/tests/Feature/Blocks/BlockRendererTest.php @@ -31,6 +31,22 @@ ->and($wire[1]['schema'][0]['props']['text'])->toBe('Second'); }); +test('renders a placeholder for an unknown block type without throwing', function (): void { + Lattice::blocks([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 { From dc08e64d2f0ecc41e4b02e01d81a224bc5a13f84 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 00:10:22 +0200 Subject: [PATCH 06/53] feat: render restricted-nesting block slots --- src/Blocks/BlockRenderer.php | 17 +++++- tests/Feature/Blocks/BlockNestingTest.php | 72 +++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Blocks/BlockNestingTest.php diff --git a/src/Blocks/BlockRenderer.php b/src/Blocks/BlockRenderer.php index e27d88ab..b72fda1b 100644 --- a/src/Blocks/BlockRenderer.php +++ b/src/Blocks/BlockRenderer.php @@ -58,6 +58,21 @@ private function componentsForRow(array $row): array return [Text::make("Unknown block [{$type}]")]; } - return $block->render(FormData::make($row), new BlockSlots)->renderable(); + 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 $name) { + $childRows = $row['slots'][$name] ?? []; + $rendered[$name] = is_array($childRows) ? $this->components($childRows) : []; + } + + return new BlockSlots($rendered); } } diff --git a/tests/Feature/Blocks/BlockNestingTest.php b/tests/Feature/Blocks/BlockNestingTest.php new file mode 100644 index 00000000..f4a3cb56 --- /dev/null +++ b/tests/Feature/Blocks/BlockNestingTest.php @@ -0,0 +1,72 @@ +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 []; + } + + 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'))); + } +} From b81b2f0e06e85cf8858b696e56fd9dcc2740bd26 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 00:11:42 +0200 Subject: [PATCH 07/53] feat: add AsBlocks eloquent cast --- src/Blocks/Casts/AsBlocks.php | 37 ++++++++++++++++++++++++++ tests/Unit/Blocks/AsBlocksCastTest.php | 30 +++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 src/Blocks/Casts/AsBlocks.php create mode 100644 tests/Unit/Blocks/AsBlocksCastTest.php 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/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"}]']); +}); From 8cef185f28edab021490b851b8a4b3485d434bc2 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 00:12:48 +0200 Subject: [PATCH 08/53] feat: add HasBlocks trait for model-attached block rendering --- src/Blocks/Concerns/HasBlocks.php | 17 +++++++ tests/Feature/Blocks/HasBlocksTest.php | 69 ++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 src/Blocks/Concerns/HasBlocks.php create mode 100644 tests/Feature/Blocks/HasBlocksTest.php 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/tests/Feature/Blocks/HasBlocksTest.php b/tests/Feature/Blocks/HasBlocksTest.php new file mode 100644 index 00000000..4dbcf023 --- /dev/null +++ b/tests/Feature/Blocks/HasBlocksTest.php @@ -0,0 +1,69 @@ +id(); + $table->json('content')->nullable(); + $table->timestamps(); + }); +}); + +test('persists a block tree and renders it back through the registry', function (): void { + Lattice::blocks([HasBlocksHeroBlock::class]); + + $page = HasBlocksPage::create([ + 'content' => [ + ['type' => 'has-blocks.hero', 'title' => 'Stored heading'], + ], + ]); + + $reloaded = HasBlocksPage::findOrFail($page->id); + $wire = wire($reloaded->renderBlocks('content')->renderable()); + + expect($reloaded->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 = []; + + 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'))); + } +} From e953b51258ca0eb62097f4e37c37fa288ef5f355 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 00:16:20 +0200 Subject: [PATCH 09/53] test: add Override attributes for rector compliance --- tests/Feature/Blocks/BlockNestingTest.php | 1 + tests/Feature/Blocks/HasBlocksTest.php | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Feature/Blocks/BlockNestingTest.php b/tests/Feature/Blocks/BlockNestingTest.php index f4a3cb56..eb60c1ae 100644 --- a/tests/Feature/Blocks/BlockNestingTest.php +++ b/tests/Feature/Blocks/BlockNestingTest.php @@ -41,6 +41,7 @@ public function attributes(): array return []; } + #[Override] public function slots(): array { return ['left', 'right']; diff --git a/tests/Feature/Blocks/HasBlocksTest.php b/tests/Feature/Blocks/HasBlocksTest.php index 4dbcf023..f56ee7a1 100644 --- a/tests/Feature/Blocks/HasBlocksTest.php +++ b/tests/Feature/Blocks/HasBlocksTest.php @@ -31,10 +31,10 @@ ], ]); - $reloaded = HasBlocksPage::findOrFail($page->id); + $reloaded = HasBlocksPage::query()->whereKey($page->getKey())->firstOrFail(); $wire = wire($reloaded->renderBlocks('content')->renderable()); - expect($reloaded->content)->toBe([['type' => 'has-blocks.hero', 'title' => 'Stored heading']]) + 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'); @@ -48,6 +48,7 @@ class HasBlocksPage extends Model protected $guarded = []; + #[Override] protected function casts(): array { return ['content' => AsBlocks::class]; From ff4e7c9ebe3cda4fe0c34f5a8c865d940fe7d9f9 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 10:46:25 +0200 Subject: [PATCH 10/53] feat: add block-editor field type --- resources/js/types/generated.ts | 1 + src/Forms/Enums/FieldType.php | 1 + tests/Unit/Forms/BlockEditorFieldTypeTest.php | 9 +++++++++ 3 files changed, 11 insertions(+) create mode 100644 tests/Unit/Forms/BlockEditorFieldTypeTest.php diff --git a/resources/js/types/generated.ts b/resources/js/types/generated.ts index 6f1212d8..e129acec 100644 --- a/resources/js/types/generated.ts +++ b/resources/js/types/generated.ts @@ -435,6 +435,7 @@ export type EffectPropsMap = { }; export type FieldType = | "field.builder" + | "field.block-editor" | "field.checkbox" | "field.choice" | "field.date-input" 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/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'); +}); From e8afd5e161801fdd439b8a5a1711a92c0f3931c3 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 10:47:37 +0200 Subject: [PATCH 11/53] feat: add BlockEditor field building templates from AsBlock classes --- src/Forms/Components/BlockEditor.php | 43 +++++++++++++++++++ tests/Feature/Blocks/BlockEditorFieldTest.php | 36 ++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 src/Forms/Components/BlockEditor.php create mode 100644 tests/Feature/Blocks/BlockEditorFieldTest.php diff --git a/src/Forms/Components/BlockEditor.php b/src/Forms/Components/BlockEditor.php new file mode 100644 index 00000000..ff64951b --- /dev/null +++ b/src/Forms/Components/BlockEditor.php @@ -0,0 +1,43 @@ +> $blocks + */ + #[\Override] + public function blocks(array $blocks): static + { + $this->blocks = array_map( + fn (string $class): Block => Block::make($this->keyFor($class))->schema(app($class)->attributes()), + $blocks, + ); + + return $this; + } + + /** + * @param class-string $class + */ + private function keyFor(string $class): string + { + $attribute = Attributes::get($class, AsBlock::class); + + if (! $attribute instanceof AsBlock) { + throw new InvalidArgumentException("Block [{$class}] is missing the [AsBlock] attribute."); + } + + return $attribute->key; + } +} diff --git a/tests/Feature/Blocks/BlockEditorFieldTest.php b/tests/Feature/Blocks/BlockEditorFieldTest.php new file mode 100644 index 00000000..633e0784 --- /dev/null +++ b/tests/Feature/Blocks/BlockEditorFieldTest.php @@ -0,0 +1,36 @@ +blocks([EditorHeroBlock::class]); + + $wire = wire($field); + + expect($wire['type'])->toBe('field.block-editor') + ->and($wire['blocks'])->toHaveCount(1) + ->and($wire['blocks'][0]['type'])->toBe('editor.hero') + ->and($wire['blocks'][0]['schema'][0]['props']['name'])->toBe('title'); +}); + +#[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'))); + } +} From aa358f5b6b3e0421ecdfb672060d0bfdfde5f27d Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 10:49:46 +0200 Subject: [PATCH 12/53] feat: serialize rendered wire per BlockEditor row --- src/Forms/Components/BlockEditor.php | 19 +++++++++++++++++++ tests/Feature/Blocks/BlockEditorFieldTest.php | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/Forms/Components/BlockEditor.php b/src/Forms/Components/BlockEditor.php index ff64951b..316f4934 100644 --- a/src/Forms/Components/BlockEditor.php +++ b/src/Forms/Components/BlockEditor.php @@ -5,7 +5,9 @@ use InvalidArgumentException; use Lattice\Lattice\Attributes\AsBlock; +use Lattice\Lattice\Attributes\SerializationHook; use Lattice\Lattice\Blocks\BlockDefinition; +use Lattice\Lattice\Blocks\BlockRenderer; use Lattice\Lattice\Forms\Attributes\AsField; use Lattice\Lattice\Forms\Enums\FieldType; use Spatie\Attributes\Attributes; @@ -27,6 +29,23 @@ public function blocks(array $blocks): static return $this; } + /** + * @param array $data + * @return array + */ + #[SerializationHook(priority: 350)] + protected function serialiseRenderedRows(array $data): array + { + $rows = is_array($this->value) ? array_values($this->value) : []; + + $rendered = array_map( + fn (mixed $row): array => app(BlockRenderer::class)->render([is_array($row) ? $row : []])->renderable(), + $rows, + ); + + return [...$data, 'rendered' => $rendered]; + } + /** * @param class-string $class */ diff --git a/tests/Feature/Blocks/BlockEditorFieldTest.php b/tests/Feature/Blocks/BlockEditorFieldTest.php index 633e0784..0c940cb1 100644 --- a/tests/Feature/Blocks/BlockEditorFieldTest.php +++ b/tests/Feature/Blocks/BlockEditorFieldTest.php @@ -6,6 +6,7 @@ use Lattice\Lattice\Blocks\BlockSlots; use Lattice\Lattice\Core\Components\Heading; use Lattice\Lattice\Core\PageSchema; +use Lattice\Lattice\Facades\Lattice; use Lattice\Lattice\Forms\Components\BlockEditor; use Lattice\Lattice\Forms\Components\TextInput; use Lattice\Lattice\Forms\FormData; @@ -21,6 +22,24 @@ ->and($wire['blocks'][0]['schema'][0]['props']['name'])->toBe('title'); }); +test('serializes rendered wire for each stored row aligned by index', function (): void { + Lattice::blocks([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][0]['type'])->toBe('heading') + ->and($wire['rendered'][0][0]['props']['text'])->toBe('First') + ->and($wire['rendered'][1][0]['props']['text'])->toBe('Second'); +}); + #[AsBlock('editor.hero')] final class EditorHeroBlock extends BlockDefinition { From 22349839f7c553d31be4d0ef5e7f9920523f82ce Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 10:56:34 +0200 Subject: [PATCH 13/53] feat: add signed block render endpoint for live preview --- config/lattice.php | 5 ++ routes/web.php | 5 ++ src/Forms/Components/BlockEditor.php | 10 +++ .../Controllers/BlockRenderController.php | 40 ++++++++++ .../Blocks/BlockRenderEndpointTest.php | 75 +++++++++++++++++++ tests/Pest.php | 12 +++ .../Providers/WorkbenchServiceProvider.php | 1 + 7 files changed, 148 insertions(+) create mode 100644 src/Http/Controllers/BlockRenderController.php create mode 100644 tests/Feature/Blocks/BlockRenderEndpointTest.php 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/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/Forms/Components/BlockEditor.php b/src/Forms/Components/BlockEditor.php index 316f4934..2041a23d 100644 --- a/src/Forms/Components/BlockEditor.php +++ b/src/Forms/Components/BlockEditor.php @@ -8,6 +8,7 @@ use Lattice\Lattice\Attributes\SerializationHook; use Lattice\Lattice\Blocks\BlockDefinition; use Lattice\Lattice\Blocks\BlockRenderer; +use Lattice\Lattice\Core\Components\IsInteractive; use Lattice\Lattice\Forms\Attributes\AsField; use Lattice\Lattice\Forms\Enums\FieldType; use Spatie\Attributes\Attributes; @@ -15,6 +16,10 @@ #[AsField(FieldType::BlockEditor)] class BlockEditor extends Builder { + use IsInteractive; + + public ?string $endpoint = null; + /** * @param array> $blocks */ @@ -26,6 +31,11 @@ public function blocks(array $blocks): static $blocks, ); + $this->id ??= $this->name; + $this->endpoint ??= (string) config('lattice.blocks.endpoint', 'lattice/blocks/render'); + $this->signedAs('block-editor'); + $this->context(['allowedBlocks' => array_map(fn (Block $block): string => $block->type, $this->blocks)]); + return $this; } diff --git a/src/Http/Controllers/BlockRenderController.php b/src/Http/Controllers/BlockRenderController.php new file mode 100644 index 00000000..77183805 --- /dev/null +++ b/src/Http/Controllers/BlockRenderController.php @@ -0,0 +1,40 @@ +references->trustedContext($request, 'field.block-editor', 'block-editor'); + + $type = (string) $request->input('type'); + $allowed = is_array($context['allowedBlocks'] ?? null) ? $context['allowedBlocks'] : []; + abort_unless(in_array($type, $allowed, true), 403); + + try { + $this->blocks->resolve($type); + } catch (UnknownComponent) { + abort(404); + } + + $attributes = is_array($request->input('attributes')) ? $request->input('attributes') : []; + $wire = $this->renderer->render([['type' => $type, ...$attributes]])->renderable(); + + return response()->json(['wire' => $wire]); + } +} diff --git a/tests/Feature/Blocks/BlockRenderEndpointTest.php b/tests/Feature/Blocks/BlockRenderEndpointTest.php new file mode 100644 index 00000000..6e2522a4 --- /dev/null +++ b/tests/Feature/Blocks/BlockRenderEndpointTest.php @@ -0,0 +1,75 @@ +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 { + Lattice::blocks([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(); +}); + +#[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(); + } +} 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/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'], From 8f6edc0d994c1550d2787439036a696228d81feb Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 10:57:22 +0200 Subject: [PATCH 14/53] test: BlockEditor inherits per-row block validation --- .../Blocks/BlockEditorValidationTest.php | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tests/Feature/Blocks/BlockEditorValidationTest.php diff --git a/tests/Feature/Blocks/BlockEditorValidationTest.php b/tests/Feature/Blocks/BlockEditorValidationTest.php new file mode 100644 index 00000000..8508a77a --- /dev/null +++ b/tests/Feature/Blocks/BlockEditorValidationTest.php @@ -0,0 +1,41 @@ +blocks([ValidationHeroBlock::class]); + + $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'])->toBe(['required', 'in:validation.hero']); +}); + +#[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'))); + } +} From 98b0c9dc8d130a3552a2cd77ed005acd29d5ccb1 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 11:01:10 +0200 Subject: [PATCH 15/53] fix: widen BlockEditor::blocks param for LSP compatibility --- src/Forms/Components/BlockEditor.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Forms/Components/BlockEditor.php b/src/Forms/Components/BlockEditor.php index 2041a23d..68f48407 100644 --- a/src/Forms/Components/BlockEditor.php +++ b/src/Forms/Components/BlockEditor.php @@ -21,13 +21,15 @@ class BlockEditor extends Builder public ?string $endpoint = null; /** - * @param array> $blocks + * @param array|Block> $blocks */ #[\Override] public function blocks(array $blocks): static { $this->blocks = array_map( - fn (string $class): Block => Block::make($this->keyFor($class))->schema(app($class)->attributes()), + fn (string|Block $block): Block => $block instanceof Block + ? $block + : Block::make($this->keyFor($block))->schema(app($block)->attributes()), $blocks, ); From b310377313270d6bba3c7ee6b40a50b067241082 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 11:02:00 +0200 Subject: [PATCH 16/53] chore: sync generated types and enum fixture for BlockEditor --- docs/fixtures/enums.json | 4 ++++ 1 file changed, 4 insertions(+) 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" From 8fa6b2deb9008c9fc19442ca8d8974739a78f16a Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 12:10:47 +0200 Subject: [PATCH 17/53] build: add dnd-kit as an external dependency --- package-lock.json | 56 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 +++ vite.config.ts | 1 + 3 files changed, 60 insertions(+) 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/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($|\/)/, From 260152de03848a95de9b90c91b4191de75dc7af7 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 12:17:09 +0200 Subject: [PATCH 18/53] feat: add move-block pure tree move for the block editor --- .../fields/block-editor/move-block.test.ts | 49 +++++++++++ .../fields/block-editor/move-block.ts | 87 +++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 resources/js/form/components/fields/block-editor/move-block.test.ts create mode 100644 resources/js/form/components/fields/block-editor/move-block.ts 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..a8e00418 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/move-block.test.ts @@ -0,0 +1,49 @@ +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); + }); +}); 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..6bb01fe4 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/move-block.ts @@ -0,0 +1,87 @@ +import type { RepeaterRow } from "../repeater-rows"; + +export type BlockStep = { index: number; slot?: string }; +export type BlockPath = BlockStep[]; + +function childList(row: RepeaterRow, slot: string): RepeaterRow[] { + const slots = (row.slots ?? {}) as Record; + + return Array.isArray(slots[slot]) ? slots[slot] : []; +} + +function withChildList(row: RepeaterRow, slot: string, list: RepeaterRow[]): RepeaterRow { + const slots = { ...((row.slots ?? {}) as Record), [slot]: list }; + + return { ...row, slots }; +} + +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; +} + +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 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) { + return rows; + } + + const moved = source[fromIndex]; + + const removed = replaceContainer(rows, from, (list) => list.filter((_, i) => i !== fromIndex)); + + // Adjust the "to" path if we removed a top-level element that shifts subsequent indices + // Only adjust if "to" has multiple steps (navigating into a nested container) + let adjustedTo = to; + if (from.length === 1 && to.length > 1) { + const removedIndex = from[0].index; + if (to[0].index > removedIndex) { + adjustedTo = [{ ...to[0], index: to[0].index - 1 }, ...to.slice(1)]; + } + } + + 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; + }); +} From 26814abde5df1208be5bb055fec7b8fdefc1c75d Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 12:27:09 +0200 Subject: [PATCH 19/53] fix: generalize move-block ancestor-index shift and guard invalid target --- .../fields/block-editor/move-block.test.ts | 32 +++++++++++++ .../fields/block-editor/move-block.ts | 47 +++++++++++++++---- 2 files changed, 71 insertions(+), 8 deletions(-) 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 index a8e00418..dec718e3 100644 --- a/resources/js/form/components/fields/block-editor/move-block.test.ts +++ b/resources/js/form/components/fields/block-editor/move-block.test.ts @@ -46,4 +46,36 @@ describe("moveBlock", () => { 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); + }); }); diff --git a/resources/js/form/components/fields/block-editor/move-block.ts b/resources/js/form/components/fields/block-editor/move-block.ts index 6bb01fe4..ba196815 100644 --- a/resources/js/form/components/fields/block-editor/move-block.ts +++ b/resources/js/form/components/fields/block-editor/move-block.ts @@ -55,6 +55,41 @@ function replaceContainer( ); } +function sameStep(a: BlockStep, b: BlockStep): boolean { + return a.index === b.index && a.slot === b.slot; +} + +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; @@ -67,14 +102,10 @@ export function moveBlock(rows: RepeaterRow[], from: BlockPath, to: BlockPath): const removed = replaceContainer(rows, from, (list) => list.filter((_, i) => i !== fromIndex)); - // Adjust the "to" path if we removed a top-level element that shifts subsequent indices - // Only adjust if "to" has multiple steps (navigating into a nested container) - let adjustedTo = to; - if (from.length === 1 && to.length > 1) { - const removedIndex = from[0].index; - if (to[0].index > removedIndex) { - adjustedTo = [{ ...to[0], index: to[0].index - 1 }, ...to.slice(1)]; - } + const adjustedTo = adjustToForRemoval(from, to); + + if (getContainer(removed, adjustedTo) === null) { + return rows; } return replaceContainer(removed, adjustedTo, (list) => { From 4c6689ddbab053f5f003f84a1251d5552d74ff4c Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 12:30:51 +0200 Subject: [PATCH 20/53] feat: add on-blur block preview hook --- .../block-editor/use-block-preview.test.ts | 50 +++++++++++++++++++ .../fields/block-editor/use-block-preview.ts | 37 ++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 resources/js/form/components/fields/block-editor/use-block-preview.test.ts create mode 100644 resources/js/form/components/fields/block-editor/use-block-preview.ts 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..79df58d2 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/use-block-preview.test.ts @@ -0,0 +1,50 @@ +import { act, renderHook } from "@testing-library/react"; +import { 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"; + +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" } }] }, + }), + ); + + 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" } }] }, + }), + ); + + await act(async () => { + await result.current.refresh("a", "text", {}); + }); + + expect(result.current.wireFor("a")).toEqual([{ type: "text", props: { text: "keep" } }]); +}); 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..dd643127 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/use-block-preview.ts @@ -0,0 +1,37 @@ +import { useCallback, useState } from "react"; +import { apiJson } from "@lattice-php/lattice/core/api"; +import type { Node } from "@lattice-php/lattice/core/types"; + +type Options = { + endpoint: string; + ref: string; + initial: Record; +}; + +export function useBlockPreview({ endpoint, ref, initial }: Options): { + wireFor: (rowId: string) => Node[]; + refresh: (rowId: string, type: string, attributes: Record) => Promise; +} { + const [wire, setWire] = useState>(initial); + + const wireFor = useCallback((rowId: string): Node[] => wire[rowId] ?? [], [wire]); + + const refresh = useCallback( + async (rowId: string, type: string, attributes: Record): Promise => { + try { + const { wire: next } = await apiJson<{ wire: Node[] }>(endpoint, { + method: "POST", + ref, + body: JSON.stringify({ type, attributes }), + }); + + setWire((prev) => ({ ...prev, [rowId]: next })); + } catch { + // keep the last-good wire on failure + } + }, + [endpoint, ref], + ); + + return { wireFor, refresh }; +} From 6a4510fd3d8388a979bc7979bc57c8baf347aad7 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 12:33:42 +0200 Subject: [PATCH 21/53] feat: add block editor inspector panel --- .../fields/block-editor/inspector.test.tsx | 71 +++++++++++++++++++ .../fields/block-editor/inspector.tsx | 40 +++++++++++ 2 files changed, 111 insertions(+) create mode 100644 resources/js/form/components/fields/block-editor/inspector.test.tsx create mode 100644 resources/js/form/components/fields/block-editor/inspector.tsx 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..7c7714e9 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/inspector.test.tsx @@ -0,0 +1,71 @@ +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 "../../context"; +import { FormValuesProvider } from "../../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("shows an unknown-block note when there is no template", () => { + wrap( + void>()} + onCommit={vi.fn<() => void>()} + />, + ); + + expect(screen.getByTestId("block-inspector-unknown")).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..7f7c8861 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/inspector.tsx @@ -0,0 +1,40 @@ +import type { Node } from "@lattice-php/lattice/core/types"; +import { RenderNode } from "@lattice-php/lattice/core/renderer"; +import { FieldScopeProvider } from "../../field-scope"; +import type { RepeaterRow } from "../repeater-rows"; + +type Props = { + base: string; + index: number; + row: RepeaterRow; + template?: Node[]; + onField: (index: number, field: string, value: unknown) => void; + onCommit: () => void; +}; + +export function BlockInspector({ base, index, row, template, onField, onCommit }: Props) { + if (!template) { + return ( +
+ Unknown block +
+ ); + } + + return ( +
+ onField(index, field, value)} + > +
+ {template.map((child) => ( + + ))} +
+
+
+ ); +} From 13457c6a4cc8e1cf7f55ca8203b73638fbf1dd32 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 12:39:05 +0200 Subject: [PATCH 22/53] feat: add block editor canvas with selection --- .../fields/block-editor/canvas.test.tsx | 39 ++++++++++++++++ .../components/fields/block-editor/canvas.tsx | 45 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 resources/js/form/components/fields/block-editor/canvas.test.tsx create mode 100644 resources/js/form/components/fields/block-editor/canvas.tsx 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..fcad7336 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/canvas.test.tsx @@ -0,0 +1,39 @@ +import { configure, fireEvent, render, screen } 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"; + +it("renders each row's wire and selects on click", () => { + const onSelect = vi.fn<[string], void>(); + const rows = [ + { __rowId: "a", type: "hero" }, + { __rowId: "b", type: "text" }, + ]; + const wire = { + a: [{ type: "heading", props: { text: "Alpha" } }], + b: [{ type: "text", props: { text: "Beta" } }], + } as never; + + render( + (wire as any)[id]} + selectedId="a" + onSelect={onSelect} + />, + ); + + 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"); +}); 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..a1ea263f --- /dev/null +++ b/resources/js/form/components/fields/block-editor/canvas.tsx @@ -0,0 +1,45 @@ +import type { Node } from "@lattice-php/lattice/core/types"; +import { Renderer } from "@lattice-php/lattice/core/renderer"; +import { cn } from "@lattice-php/lattice/lib/utils"; +import { ROW_ID_KEY, type RepeaterRow } from "../repeater-rows"; + +type Props = { + rows: RepeaterRow[]; + wireFor: (rowId: string) => Node[]; + selectedId: string | null; + onSelect: (rowId: string) => void; +}; + +export function BlockCanvas({ rows, wireFor, selectedId, onSelect }: Props) { + return ( +
+ {rows.map((row) => { + const id = String(row[ROW_ID_KEY]); + + return ( +
onSelect(id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + onSelect(id); + } + }} + tabIndex={0} + className={cn( + "cursor-pointer rounded-lt border p-2", + selectedId === id + ? "border-lt-primary ring-1 ring-lt-primary" + : "border-transparent hover:border-lt-border", + )} + > + +
+ ); + })} +
+ ); +} From cab8790f1272bf0257f8a1a866497fdc7f9f5ffd Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 12:44:27 +0200 Subject: [PATCH 23/53] fix: pair listbox/option roles and restore canvas test types --- .../js/form/components/fields/block-editor/canvas.test.tsx | 2 +- resources/js/form/components/fields/block-editor/canvas.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/resources/js/form/components/fields/block-editor/canvas.test.tsx b/resources/js/form/components/fields/block-editor/canvas.test.tsx index fcad7336..2b140434 100644 --- a/resources/js/form/components/fields/block-editor/canvas.test.tsx +++ b/resources/js/form/components/fields/block-editor/canvas.test.tsx @@ -12,7 +12,7 @@ vi.mock("@lattice-php/lattice/core/renderer", () => ({ import { BlockCanvas } from "./canvas"; it("renders each row's wire and selects on click", () => { - const onSelect = vi.fn<[string], void>(); + const onSelect = vi.fn<(rowId: string) => void>(); const rows = [ { __rowId: "a", type: "hero" }, { __rowId: "b", type: "text" }, diff --git a/resources/js/form/components/fields/block-editor/canvas.tsx b/resources/js/form/components/fields/block-editor/canvas.tsx index a1ea263f..2bddd7ce 100644 --- a/resources/js/form/components/fields/block-editor/canvas.tsx +++ b/resources/js/form/components/fields/block-editor/canvas.tsx @@ -12,7 +12,7 @@ type Props = { export function BlockCanvas({ rows, wireFor, selectedId, onSelect }: Props) { return ( -
+
{rows.map((row) => { const id = String(row[ROW_ID_KEY]); @@ -25,6 +25,9 @@ export function BlockCanvas({ rows, wireFor, selectedId, onSelect }: Props) { onClick={() => onSelect(id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { + if (e.key === " ") { + e.preventDefault(); + } onSelect(id); } }} From 68675a7dc142d99e341d34ea1917b42790b7e545 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 12:49:47 +0200 Subject: [PATCH 24/53] feat: compose and register the BlockEditor field component --- .../fields/block-editor/index.test.tsx | 61 ++++++++++ .../components/fields/block-editor/index.tsx | 104 ++++++++++++++++++ resources/js/form/components/index.ts | 1 + resources/js/form/plugin.ts | 2 + 4 files changed, 168 insertions(+) create mode 100644 resources/js/form/components/fields/block-editor/index.test.tsx create mode 100644 resources/js/form/components/fields/block-editor/index.tsx 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..4be92a16 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/index.test.tsx @@ -0,0 +1,61 @@ +import { configure, render, screen } 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 "../../context"; +import { FormValuesProvider } from "../../values"; +import { BlockEditorComponent } from "./index"; + +function wrap(ui: React.ReactNode, initial: Record) { + return render( + {}, + componentRef: "", + errors: {}, + fieldLabels: {}, + precognitive: false, + processing: false, + validate: () => {}, + }} + > + {ui} + , + ); +} + +const node = { + id: "content", + type: "field.block-editor", + props: { name: "content", ref: "sealed", endpoint: "/lattice/blocks/render", defaultItems: 0 }, + blocks: [ + { + type: "hero", + label: "Hero", + schema: [{ id: "t", type: "field.text-input", props: { name: "title" } }], + }, + ], + rendered: [[{ type: "heading", props: { text: "Stored" } }]], +} 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(); +}); 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..ba8e7296 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/index.tsx @@ -0,0 +1,104 @@ +import { useMemo, useState } from "react"; +import type { Node, RendererComponent } from "@lattice-php/lattice/core/types"; +import { FormFieldFrame } from "../../base/field"; +import { appendPath, toHtmlName } from "../../form-path"; +import { BlockAddMenu, type BlockOption } from "../block-add-menu"; +import { ROW_ID_KEY } from "../repeater-rows"; +import { useRowCollection } from "../use-row-collection"; +import { BlockCanvas } from "./canvas"; +import { BlockInspector } from "./inspector"; +import { useBlockPreview } from "./use-block-preview"; + +type BlockTemplate = { type: string; label: string; schema: Node[] }; + +export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ node }) => { + const props = node.props; + const name = props.name; + const blocks = ((node as unknown as { blocks?: BlockTemplate[] }).blocks ?? + []) as BlockTemplate[]; + const rendered = ((node as unknown as { rendered?: Node[][] }).rendered ?? []) as Node[][]; + + const { path, rows, onField, append } = useRowCollection(name, props.defaultItems ?? 0); + + const initial = useMemo(() => { + const map: Record = {}; + rows.forEach((row, i) => { + map[String(row[ROW_ID_KEY])] = rendered[i] ?? []; + }); + + return map; + // 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, + }); + + const [selectedId, setSelectedId] = useState(null); + const templateFor = (type: unknown): BlockTemplate | undefined => + blocks.find((b) => b.type === type); + const options: BlockOption[] = blocks.map((b) => ({ type: b.type, label: b.label })); + + const selectedIndex = rows.findIndex((row) => String(row[ROW_ID_KEY]) === selectedId); + const selectedRow = selectedIndex >= 0 ? rows[selectedIndex] : null; + const selectedTemplate = selectedRow ? templateFor(selectedRow.type) : undefined; + + return ( + + {rows.map((row, index) => ( + + ))} + +
+
+ + append({ type })} + /> +
+ +
+ {selectedRow ? ( + + refresh( + String(selectedRow[ROW_ID_KEY]), + String(selectedRow.type), + Object.fromEntries(Object.entries(selectedRow).filter(([k]) => k !== ROW_ID_KEY)), + ) + } + /> + ) : ( +

Select a block to edit it.

+ )} +
+
+
+ ); +}; 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/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), From c83887a81ea73612a6dab6e553b8bc0ae9a5c318 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 12:55:09 +0200 Subject: [PATCH 25/53] feat: add nested drag-and-drop to the block editor canvas --- .../fields/block-editor/canvas.test.tsx | 1 + .../components/fields/block-editor/canvas.tsx | 125 +++++++++++++----- .../fields/block-editor/dnd.test.ts | 21 +++ .../components/fields/block-editor/dnd.ts | 30 +++++ .../components/fields/block-editor/index.tsx | 12 +- 5 files changed, 156 insertions(+), 33 deletions(-) create mode 100644 resources/js/form/components/fields/block-editor/dnd.test.ts create mode 100644 resources/js/form/components/fields/block-editor/dnd.ts diff --git a/resources/js/form/components/fields/block-editor/canvas.test.tsx b/resources/js/form/components/fields/block-editor/canvas.test.tsx index 2b140434..9e2d4585 100644 --- a/resources/js/form/components/fields/block-editor/canvas.test.tsx +++ b/resources/js/form/components/fields/block-editor/canvas.test.tsx @@ -28,6 +28,7 @@ it("renders each row's wire and selects on click", () => { wireFor={(id: string) => (wire as any)[id]} selectedId="a" onSelect={onSelect} + onMoveBlock={() => {}} />, ); diff --git a/resources/js/form/components/fields/block-editor/canvas.tsx b/resources/js/form/components/fields/block-editor/canvas.tsx index 2bddd7ce..ca641b31 100644 --- a/resources/js/form/components/fields/block-editor/canvas.tsx +++ b/resources/js/form/components/fields/block-editor/canvas.tsx @@ -1,48 +1,109 @@ +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import type { DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import type { Node } from "@lattice-php/lattice/core/types"; import { Renderer } from "@lattice-php/lattice/core/renderer"; import { cn } from "@lattice-php/lattice/lib/utils"; import { ROW_ID_KEY, type RepeaterRow } from "../repeater-rows"; +import { encodePath, resolveMove } from "./dnd"; +import type { BlockPath } from "./move-block"; type Props = { rows: RepeaterRow[]; wireFor: (rowId: string) => Node[]; selectedId: string | null; onSelect: (rowId: string) => void; + onMoveBlock: (from: BlockPath, to: BlockPath) => void; }; -export function BlockCanvas({ rows, wireFor, selectedId, onSelect }: Props) { +function Shell({ + row, + path, + wireFor, + selectedId, + onSelect, +}: { + row: RepeaterRow; + path: BlockPath; + wireFor: (rowId: string) => Node[]; + selectedId: string | null; + onSelect: (rowId: string) => void; +}) { + const id = encodePath(path); + const rowId = String(row[ROW_ID_KEY]); + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id }); + return ( -
- {rows.map((row) => { - const id = String(row[ROW_ID_KEY]); - - return ( -
onSelect(id)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - if (e.key === " ") { - e.preventDefault(); - } - onSelect(id); - } - }} - tabIndex={0} - className={cn( - "cursor-pointer rounded-lt border p-2", - selectedId === id - ? "border-lt-primary ring-1 ring-lt-primary" - : "border-transparent hover:border-lt-border", - )} - > - -
- ); - })} +
onSelect(rowId)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (e.key === " ") { + e.preventDefault(); + } + onSelect(rowId); + } + }} + tabIndex={0} + className={cn( + "cursor-pointer rounded-lt border p-2", + selectedId === rowId + ? "border-lt-primary ring-1 ring-lt-primary" + : "border-transparent hover:border-lt-border", + )} + > +
); } + +export function BlockCanvas({ rows, wireFor, selectedId, onSelect, onMoveBlock }: Props) { + const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor)); + + const onDragEnd = (event: DragEndEvent): void => { + const over = event.over; + + if (!over || over.id === event.active.id) { + return; + } + + const { from, to } = resolveMove(String(event.active.id), String(over.id)); + onMoveBlock(from, to); + }; + + const ids = rows.map((_, index) => encodePath([{ index }])); + + 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..17981145 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/dnd.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { decodePath, encodePath, resolveMove } from "./dnd"; + +describe("dnd path ids", () => { + it("round-trips a top-level path", () => { + expect(encodePath([{ index: 2 }])).toBe("2"); + expect(decodePath("2")).toEqual([{ index: 2 }]); + }); + + it("round-trips a slot path", () => { + expect(encodePath([{ index: 1, slot: "left" }, { index: 0 }])).toBe("1.left.0"); + expect(decodePath("1.left.0")).toEqual([{ index: 1, slot: "left" }, { index: 0 }]); + }); + + it("resolves a drag between two ids to move arguments", () => { + expect(resolveMove("0", "1.left.0")).toEqual({ + from: [{ index: 0 }], + to: [{ index: 1, slot: "left" }, { index: 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..acaf20bf --- /dev/null +++ b/resources/js/form/components/fields/block-editor/dnd.ts @@ -0,0 +1,30 @@ +import type { BlockPath } from "./move-block"; + +export function encodePath(path: BlockPath): string { + return path + .map((step) => (step.slot === undefined ? String(step.index) : `${step.index}.${step.slot}`)) + .join("."); +} + +export function decodePath(id: string): BlockPath { + const parts = id.split("."); + const path: BlockPath = []; + + for (let i = 0; i < parts.length; ) { + const index = Number(parts[i]); + + if (i + 1 < parts.length && Number.isNaN(Number(parts[i + 1]))) { + path.push({ index, slot: parts[i + 1] }); + i += 2; + } else { + path.push({ index }); + i += 1; + } + } + + return path; +} + +export function resolveMove(activeId: string, overId: string): { from: BlockPath; to: BlockPath } { + return { from: decodePath(activeId), to: decodePath(overId) }; +} diff --git a/resources/js/form/components/fields/block-editor/index.tsx b/resources/js/form/components/fields/block-editor/index.tsx index ba8e7296..1ecbb1d9 100644 --- a/resources/js/form/components/fields/block-editor/index.tsx +++ b/resources/js/form/components/fields/block-editor/index.tsx @@ -2,11 +2,13 @@ import { useMemo, useState } from "react"; import type { Node, RendererComponent } from "@lattice-php/lattice/core/types"; import { FormFieldFrame } from "../../base/field"; import { appendPath, toHtmlName } from "../../form-path"; +import { useSetFormValue } from "../../values"; import { BlockAddMenu, type BlockOption } from "../block-add-menu"; -import { ROW_ID_KEY } from "../repeater-rows"; +import { ROW_ID_KEY, type RepeaterRow } from "../repeater-rows"; import { useRowCollection } from "../use-row-collection"; import { BlockCanvas } from "./canvas"; import { BlockInspector } from "./inspector"; +import { moveBlock, type BlockPath } from "./move-block"; import { useBlockPreview } from "./use-block-preview"; type BlockTemplate = { type: string; label: string; schema: Node[] }; @@ -19,6 +21,7 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ const rendered = ((node as unknown as { rendered?: Node[][] }).rendered ?? []) as Node[][]; const { path, rows, onField, append } = useRowCollection(name, props.defaultItems ?? 0); + const setValue = useSetFormValue(); const initial = useMemo(() => { const map: Record = {}; @@ -46,6 +49,12 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ const selectedRow = selectedIndex >= 0 ? rows[selectedIndex] : null; const selectedTemplate = selectedRow ? templateFor(selectedRow.type) : undefined; + const onMoveBlock = (from: BlockPath, to: BlockPath): void => { + setValue(path, (prev: unknown) => + moveBlock(Array.isArray(prev) ? (prev as RepeaterRow[]) : [], from, to), + ); + }; + return ( = ({ wireFor={wireFor} selectedId={selectedId} onSelect={setSelectedId} + onMoveBlock={onMoveBlock} /> Date: Thu, 9 Jul 2026 13:01:42 +0200 Subject: [PATCH 26/53] fix: give block editor shells a keyboard-accessible drag handle --- .../form/components/fields/block-editor/canvas.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/resources/js/form/components/fields/block-editor/canvas.tsx b/resources/js/form/components/fields/block-editor/canvas.tsx index ca641b31..9d1a8d31 100644 --- a/resources/js/form/components/fields/block-editor/canvas.tsx +++ b/resources/js/form/components/fields/block-editor/canvas.tsx @@ -45,8 +45,6 @@ function Shell({
+
); From 56fdcb976481b7dfeca8fe7b9bf4d7640a310aad Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 13:28:45 +0200 Subject: [PATCH 27/53] test: end-to-end browser coverage for the block editor Wires a workbench form (BlockPageForm) with a BlockEditor field behind two demo blocks (HeroBlock, ColumnsBlock) at /block-editor, and adds a Pest browser test that adds a block, selects it, edits its title, verifies the canvas preview updates on blur, and confirms the submission persists. --- tests/Browser/Blocks/BlockEditorTest.php | 35 ++++++++++++++++ workbench/app/Blocks/ColumnsBlock.php | 31 ++++++++++++++ workbench/app/Blocks/HeroBlock.php | 26 ++++++++++++ workbench/app/Forms/BlockPageForm.php | 44 ++++++++++++++++++++ workbench/app/Pages/BlockPageEditorPage.php | 46 +++++++++++++++++++++ workbench/lang/de/workbench.php | 9 ++++ workbench/lang/en/workbench.php | 9 ++++ 7 files changed, 200 insertions(+) create mode 100644 tests/Browser/Blocks/BlockEditorTest.php create mode 100644 workbench/app/Blocks/ColumnsBlock.php create mode 100644 workbench/app/Blocks/HeroBlock.php create mode 100644 workbench/app/Forms/BlockPageForm.php create mode 100644 workbench/app/Pages/BlockPageEditorPage.php diff --git a/tests/Browser/Blocks/BlockEditorTest.php b/tests/Browser/Blocks/BlockEditorTest.php new file mode 100644 index 00000000..70e02f77 --- /dev/null +++ b/tests/Browser/Blocks/BlockEditorTest.php @@ -0,0 +1,35 @@ +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(); +}); diff --git a/workbench/app/Blocks/ColumnsBlock.php b/workbench/app/Blocks/ColumnsBlock.php new file mode 100644 index 00000000..03dd7d46 --- /dev/null +++ b/workbench/app/Blocks/ColumnsBlock.php @@ -0,0 +1,31 @@ +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..519117b4 --- /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..516d1194 --- /dev/null +++ b/workbench/app/Forms/BlockPageForm.php @@ -0,0 +1,44 @@ +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 + { + $this->validate($request); + + // BlockEditor rows only validate fields with explicit rules (see + // HandlesRowSchemas::rowRules), so an optional field like the hero's + // "title" is absent from validate()'s output. Read the raw, already + // structurally-validated payload back for display/persistence instead. + session()->flash('block-editor.saved', $request->input('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..e33b8a95 --- /dev/null +++ b/workbench/app/Pages/BlockPageEditorPage.php @@ -0,0 +1,46 @@ +pluck('title') + ->filter(fn (mixed $title): bool => is_string($title) && $title !== ''); + + 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(', ')]))] + : []), + ]), + ]); + } +} 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', From d24087986bc694bb1ebbe4e64db383b20ac0e54c Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 14:55:34 +0200 Subject: [PATCH 28/53] fix: submit all BlockEditor block attributes, not just the selected block --- .../block-editor/hidden-inputs.test.tsx | 33 ++++++++++++ .../fields/block-editor/hidden-inputs.tsx | 20 ++++++++ .../components/fields/block-editor/index.tsx | 51 +++++++++++-------- tests/Browser/Blocks/BlockEditorTest.php | 39 ++++++++++++++ 4 files changed, 122 insertions(+), 21 deletions(-) create mode 100644 resources/js/form/components/fields/block-editor/hidden-inputs.test.tsx create mode 100644 resources/js/form/components/fields/block-editor/hidden-inputs.tsx diff --git a/resources/js/form/components/fields/block-editor/hidden-inputs.test.tsx b/resources/js/form/components/fields/block-editor/hidden-inputs.test.tsx new file mode 100644 index 00000000..5991d439 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/hidden-inputs.test.tsx @@ -0,0 +1,33 @@ +import { configure, render } from "@testing-library/react"; +import { beforeAll, describe, expect, it } from "vitest"; +import { hiddenInputsFor } from "./hidden-inputs"; + +beforeAll(() => configure({ testIdAttribute: "data-test" })); + +function renderInputs(name: string, value: unknown) { + return render(<>{hiddenInputsFor(name, value)}).container.querySelectorAll( + 'input[type="hidden"]', + ); +} + +describe("hiddenInputsFor", () => { + it("emits hidden inputs for nested slot rows, skipping __rowId", () => { + const inputs = renderInputs("content[0][slots]", { + left: [{ __rowId: "x", type: "text", body: "L" }], + }); + + const byName = Object.fromEntries( + Array.from(inputs).map((input) => [input.getAttribute("name"), input.getAttribute("value")]), + ); + + expect(byName["content[0][slots][left][0][type]"]).toBe("text"); + expect(byName["content[0][slots][left][0][body]"]).toBe("L"); + expect(byName["content[0][slots][left][0][__rowId]"]).toBeUndefined(); + expect(inputs).toHaveLength(2); + }); + + it("emits nothing for null or undefined", () => { + expect(hiddenInputsFor("content[0][slots]", null)).toHaveLength(0); + expect(hiddenInputsFor("content[0][slots]", undefined)).toHaveLength(0); + }); +}); diff --git a/resources/js/form/components/fields/block-editor/hidden-inputs.tsx b/resources/js/form/components/fields/block-editor/hidden-inputs.tsx new file mode 100644 index 00000000..b1313ad4 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/hidden-inputs.tsx @@ -0,0 +1,20 @@ +import type { ReactElement } from "react"; +import { ROW_ID_KEY } from "../repeater-rows"; + +export function hiddenInputsFor(name: string, value: unknown): ReactElement[] { + if (value == null) { + return []; + } + + if (Array.isArray(value)) { + return value.flatMap((item, index) => hiddenInputsFor(`${name}[${index}]`, item)); + } + + if (typeof value === "object") { + return Object.entries(value as Record).flatMap(([key, child]) => + key === ROW_ID_KEY ? [] : hiddenInputsFor(`${name}[${key}]`, child), + ); + } + + return []; +} diff --git a/resources/js/form/components/fields/block-editor/index.tsx b/resources/js/form/components/fields/block-editor/index.tsx index 1ecbb1d9..b3426fdc 100644 --- a/resources/js/form/components/fields/block-editor/index.tsx +++ b/resources/js/form/components/fields/block-editor/index.tsx @@ -7,6 +7,7 @@ import { BlockAddMenu, type BlockOption } from "../block-add-menu"; import { ROW_ID_KEY, type RepeaterRow } from "../repeater-rows"; import { useRowCollection } from "../use-row-collection"; import { BlockCanvas } from "./canvas"; +import { hiddenInputsFor } from "./hidden-inputs"; import { BlockInspector } from "./inspector"; import { moveBlock, type BlockPath } from "./move-block"; import { useBlockPreview } from "./use-block-preview"; @@ -45,9 +46,7 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ blocks.find((b) => b.type === type); const options: BlockOption[] = blocks.map((b) => ({ type: b.type, label: b.label })); - const selectedIndex = rows.findIndex((row) => String(row[ROW_ID_KEY]) === selectedId); - const selectedRow = selectedIndex >= 0 ? rows[selectedIndex] : null; - const selectedTemplate = selectedRow ? templateFor(selectedRow.type) : undefined; + const selectedRow = rows.find((row) => String(row[ROW_ID_KEY]) === selectedId) ?? null; const onMoveBlock = (from: BlockPath, to: BlockPath): void => { setValue(path, (prev: unknown) => @@ -72,6 +71,10 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ /> ))} + {rows.flatMap((row, index) => + row.slots == null ? [] : hiddenInputsFor(`${path}[${index}][slots]`, row.slots), + )} +
= ({
- {selectedRow ? ( - - refresh( - String(selectedRow[ROW_ID_KEY]), - String(selectedRow.type), - Object.fromEntries(Object.entries(selectedRow).filter(([k]) => k !== ROW_ID_KEY)), - ) - } - /> - ) : ( -

Select a block to edit it.

- )} + {rows.map((row, index) => { + const isSelected = String(row[ROW_ID_KEY]) === selectedId; + const template = templateFor(row.type); + + return ( +
+ + refresh( + String(row[ROW_ID_KEY]), + String(row.type), + Object.fromEntries(Object.entries(row).filter(([k]) => k !== ROW_ID_KEY)), + ) + } + /> +
+ ); + })} + {!selectedRow &&

Select a block to edit it.

}
diff --git a/tests/Browser/Blocks/BlockEditorTest.php b/tests/Browser/Blocks/BlockEditorTest.php index 70e02f77..c76e862f 100644 --- a/tests/Browser/Blocks/BlockEditorTest.php +++ b/tests/Browser/Blocks/BlockEditorTest.php @@ -33,3 +33,42 @@ $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-r0"]') + ->assertPresent('[data-test="block-shell-r1"]'); + + $page->click('[data-test="block-shell-r0"]'); + $page->fill('input[name="content[0][title]"]', 'First title'); + $page->click('[data-test="block-shell-r1"]'); + + 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(); +}); From dc917f862f6383ab5642eaea72620ab87cfa11ea Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 15:04:04 +0200 Subject: [PATCH 29/53] refactor: use validated data in the block editor workbench demo --- workbench/app/Forms/BlockPageForm.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/workbench/app/Forms/BlockPageForm.php b/workbench/app/Forms/BlockPageForm.php index 516d1194..4cbd6f6f 100644 --- a/workbench/app/Forms/BlockPageForm.php +++ b/workbench/app/Forms/BlockPageForm.php @@ -31,13 +31,9 @@ public function definition(FormComponent $form, Request $request): FormComponent public function handle(Request $request): Response { - $this->validate($request); + $data = $this->validate($request); - // BlockEditor rows only validate fields with explicit rules (see - // HandlesRowSchemas::rowRules), so an optional field like the hero's - // "title" is absent from validate()'s output. Read the raw, already - // structurally-validated payload back for display/persistence instead. - session()->flash('block-editor.saved', $request->input('content', [])); + session()->flash('block-editor.saved', $data['content'] ?? []); return redirect('/block-editor'); } From f458785bdceb1eec9b8a68a62a8aff51c0918df2 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 18:54:48 +0200 Subject: [PATCH 30/53] fix: serialize the block render endpoint as an absolute path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire prop carried the raw config value ('lattice/blocks/render'), which the browser resolves relative to the current page URL — previews 404 on any nested route. Normalize with a leading slash, matching DefinitionRegistry::endpointFor(). --- src/Forms/Components/BlockEditor.php | 2 +- tests/Feature/Blocks/BlockEditorFieldTest.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Forms/Components/BlockEditor.php b/src/Forms/Components/BlockEditor.php index 68f48407..01647f7d 100644 --- a/src/Forms/Components/BlockEditor.php +++ b/src/Forms/Components/BlockEditor.php @@ -34,7 +34,7 @@ public function blocks(array $blocks): static ); $this->id ??= $this->name; - $this->endpoint ??= (string) config('lattice.blocks.endpoint', 'lattice/blocks/render'); + $this->endpoint ??= '/'.ltrim((string) config('lattice.blocks.endpoint', 'lattice/blocks/render'), '/'); $this->signedAs('block-editor'); $this->context(['allowedBlocks' => array_map(fn (Block $block): string => $block->type, $this->blocks)]); diff --git a/tests/Feature/Blocks/BlockEditorFieldTest.php b/tests/Feature/Blocks/BlockEditorFieldTest.php index 0c940cb1..70f5421c 100644 --- a/tests/Feature/Blocks/BlockEditorFieldTest.php +++ b/tests/Feature/Blocks/BlockEditorFieldTest.php @@ -17,6 +17,7 @@ $wire = wire($field); expect($wire['type'])->toBe('field.block-editor') + ->and($wire['props']['endpoint'])->toBe('/lattice/blocks/render') ->and($wire['blocks'])->toHaveCount(1) ->and($wire['blocks'][0]['type'])->toBe('editor.hero') ->and($wire['blocks'][0]['schema'][0]['props']['name'])->toBe('title'); From 715c8473088210ca21e905558eb4d0d345c01b7d Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 18:54:48 +0200 Subject: [PATCH 31/53] refactor: reuse the registry key lookup and resolve the renderer once DefinitionRegistry::keyFor already validates the definition subclass and extracts the attribute key; expose it instead of re-implementing the attribute read in BlockEditor, and hoist the BlockRenderer container resolution out of the per-row map. --- src/Core/DefinitionRegistry.php | 2 +- src/Forms/Components/BlockEditor.php | 26 +++++++------------------- 2 files changed, 8 insertions(+), 20 deletions(-) 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 index 01647f7d..a7b253b1 100644 --- a/src/Forms/Components/BlockEditor.php +++ b/src/Forms/Components/BlockEditor.php @@ -3,15 +3,13 @@ namespace Lattice\Lattice\Forms\Components; -use InvalidArgumentException; -use Lattice\Lattice\Attributes\AsBlock; use Lattice\Lattice\Attributes\SerializationHook; use Lattice\Lattice\Blocks\BlockDefinition; +use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Blocks\BlockRenderer; use Lattice\Lattice\Core\Components\IsInteractive; use Lattice\Lattice\Forms\Attributes\AsField; use Lattice\Lattice\Forms\Enums\FieldType; -use Spatie\Attributes\Attributes; #[AsField(FieldType::BlockEditor)] class BlockEditor extends Builder @@ -26,10 +24,12 @@ class BlockEditor extends Builder #[\Override] public function blocks(array $blocks): static { + $registry = app(BlockRegistry::class); + $this->blocks = array_map( fn (string|Block $block): Block => $block instanceof Block ? $block - : Block::make($this->keyFor($block))->schema(app($block)->attributes()), + : Block::make($registry->keyFor($block))->schema(app($block)->attributes()), $blocks, ); @@ -48,27 +48,15 @@ public function blocks(array $blocks): static #[SerializationHook(priority: 350)] protected function serialiseRenderedRows(array $data): array { + $renderer = app(BlockRenderer::class); + $rows = is_array($this->value) ? array_values($this->value) : []; $rendered = array_map( - fn (mixed $row): array => app(BlockRenderer::class)->render([is_array($row) ? $row : []])->renderable(), + fn (mixed $row): array => $renderer->render([is_array($row) ? $row : []])->renderable(), $rows, ); return [...$data, 'rendered' => $rendered]; } - - /** - * @param class-string $class - */ - private function keyFor(string $class): string - { - $attribute = Attributes::get($class, AsBlock::class); - - if (! $attribute instanceof AsBlock) { - throw new InvalidArgumentException("Block [{$class}] is missing the [AsBlock] attribute."); - } - - return $attribute->key; - } } From fddd41c2225f4ca3b39b5aefdfccae8f56c820eb Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 18:55:17 +0200 Subject: [PATCH 32/53] fix: build slot hidden-input names with the form-path helpers Hand-building the bracket syntax from the dot path breaks the submitted name once the editor sits inside a field scope; toHtmlName(appendPath()) matches how the sibling type inputs are named. --- resources/js/form/components/fields/block-editor/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/resources/js/form/components/fields/block-editor/index.tsx b/resources/js/form/components/fields/block-editor/index.tsx index b3426fdc..517e93d1 100644 --- a/resources/js/form/components/fields/block-editor/index.tsx +++ b/resources/js/form/components/fields/block-editor/index.tsx @@ -72,7 +72,9 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ ))} {rows.flatMap((row, index) => - row.slots == null ? [] : hiddenInputsFor(`${path}[${index}][slots]`, row.slots), + row.slots == null + ? [] + : hiddenInputsFor(toHtmlName(appendPath(path, index, "slots")), row.slots), )}
From edf1cef1b157512ac22668978f357b5fa77e4304 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 18:55:49 +0200 Subject: [PATCH 33/53] fix: honor dependent state, errors, and maxItems in the block editor The component inherited Builder's full prop surface but ignored it: visibleWhen/requiredWhen conditions no-opped, validation errors never displayed, and append ignored maxItems. Wire the same hooks BuilderComponent uses. --- .../fields/block-editor/index.test.tsx | 33 ++++++++++++++++--- .../components/fields/block-editor/index.tsx | 27 +++++++++++---- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/resources/js/form/components/fields/block-editor/index.test.tsx b/resources/js/form/components/fields/block-editor/index.test.tsx index 4be92a16..d25fd3b5 100644 --- a/resources/js/form/components/fields/block-editor/index.test.tsx +++ b/resources/js/form/components/fields/block-editor/index.test.tsx @@ -18,14 +18,18 @@ import { FormProvider } from "../../context"; import { FormValuesProvider } from "../../values"; import { BlockEditorComponent } from "./index"; -function wrap(ui: React.ReactNode, initial: Record) { +function wrap( + ui: React.ReactNode, + initial: Record, + errors: Record = {}, +) { return render( {}, componentRef: "", - errors: {}, + errors, fieldLabels: {}, precognitive: false, processing: false, @@ -37,7 +41,7 @@ function wrap(ui: React.ReactNode, initial: Record) { ); } -const node = { +const baseNode = { id: "content", type: "field.block-editor", props: { name: "content", ref: "sealed", endpoint: "/lattice/blocks/render", defaultItems: 0 }, @@ -49,7 +53,8 @@ const node = { }, ], rendered: [[{ type: "heading", props: { text: "Stored" } }]], -} as never; +}; +const node = baseNode as never; it("renders stored blocks on the canvas from the rendered prop", () => { wrap({null}, { @@ -59,3 +64,23 @@ it("renders stored blocks on the canvas from the rendered prop", () => { expect(screen.getByText("Stored")).toBeInTheDocument(); expect(screen.getByTestId("block-shell-a")).toBeInTheDocument(); }); + +it("renders nothing when the field is hidden", () => { + const hiddenNode = { ...baseNode, props: { ...baseNode.props, hidden: true } } 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(); +}); diff --git a/resources/js/form/components/fields/block-editor/index.tsx b/resources/js/form/components/fields/block-editor/index.tsx index 517e93d1..9fb89a0c 100644 --- a/resources/js/form/components/fields/block-editor/index.tsx +++ b/resources/js/form/components/fields/block-editor/index.tsx @@ -1,7 +1,9 @@ import { useMemo, useState } from "react"; import type { Node, RendererComponent } from "@lattice-php/lattice/core/types"; import { FormFieldFrame } from "../../base/field"; +import { useFormContext } from "../../context"; import { appendPath, toHtmlName } from "../../form-path"; +import { useDependentField } from "../../use-dependent-field"; import { useSetFormValue } from "../../values"; import { BlockAddMenu, type BlockOption } from "../block-add-menu"; import { ROW_ID_KEY, type RepeaterRow } from "../repeater-rows"; @@ -21,6 +23,8 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ []) as BlockTemplate[]; const rendered = ((node as unknown as { rendered?: Node[][] }).rendered ?? []) as Node[][]; + const { errors } = useFormContext(); + const { hidden, required } = useDependentField(node); const { path, rows, onField, append } = useRowCollection(name, props.defaultItems ?? 0); const setValue = useSetFormValue(); @@ -42,9 +46,15 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ }); const [selectedId, setSelectedId] = useState(null); + + if (hidden) { + return null; + } + const templateFor = (type: unknown): BlockTemplate | undefined => blocks.find((b) => b.type === type); const options: BlockOption[] = blocks.map((b) => ({ type: b.type, label: b.label })); + const atMax = props.maxItems != null && rows.length >= props.maxItems; const selectedRow = rows.find((row) => String(row[ROW_ID_KEY]) === selectedId) ?? null; @@ -56,11 +66,12 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ return ( {rows.map((row, index) => ( = ({ onSelect={setSelectedId} onMoveBlock={onMoveBlock} /> - append({ type })} - /> + {!atMax && ( + append({ type })} + /> + )}
From 985169398fe0d3e5f2bed87f74dc82352f63cc92 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 18:56:28 +0200 Subject: [PATCH 34/53] fix: re-render a block on blur only when it actually changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blur bubbles from every child, so tabbing between inspector fields fired a render request per focus move. Commit only when focus leaves the inspector, and track each block's last-rendered source (type + attributes, seeded from the server payload) so unchanged commits skip the request — the same change-aware principle precognitive validation follows. Failed renders keep the previous signature and retry on the next commit. --- .../components/fields/block-editor/index.tsx | 26 ++++++---- .../fields/block-editor/inspector.test.tsx | 21 ++++++++ .../fields/block-editor/inspector.tsx | 13 ++++- .../block-editor/use-block-preview.test.ts | 51 ++++++++++++++++++- .../fields/block-editor/use-block-preview.ts | 22 ++++++-- 5 files changed, 117 insertions(+), 16 deletions(-) diff --git a/resources/js/form/components/fields/block-editor/index.tsx b/resources/js/form/components/fields/block-editor/index.tsx index 9fb89a0c..6067792c 100644 --- a/resources/js/form/components/fields/block-editor/index.tsx +++ b/resources/js/form/components/fields/block-editor/index.tsx @@ -12,10 +12,14 @@ import { BlockCanvas } from "./canvas"; import { hiddenInputsFor } from "./hidden-inputs"; import { BlockInspector } from "./inspector"; import { moveBlock, type BlockPath } from "./move-block"; -import { useBlockPreview } from "./use-block-preview"; +import { useBlockPreview, type BlockSource } from "./use-block-preview"; type BlockTemplate = { type: string; label: string; schema: Node[] }; +function rowAttributes(row: RepeaterRow): Record { + return Object.fromEntries(Object.entries(row).filter(([key]) => key !== ROW_ID_KEY)); +} + export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ node }) => { const props = node.props; const name = props.name; @@ -28,13 +32,16 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ const { path, rows, onField, append } = useRowCollection(name, props.defaultItems ?? 0); const setValue = useSetFormValue(); - const initial = useMemo(() => { - const map: Record = {}; + const seeds = useMemo(() => { + const wire: Record = {}; + const sources: Record = {}; rows.forEach((row, i) => { - map[String(row[ROW_ID_KEY])] = rendered[i] ?? []; + const rowId = String(row[ROW_ID_KEY]); + wire[rowId] = rendered[i] ?? []; + sources[rowId] = { type: String(row.type ?? ""), attributes: rowAttributes(row) }; }); - return map; + return { wire, sources }; // seed once from the server-rendered payload // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -42,7 +49,8 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ const { wireFor, refresh } = useBlockPreview({ endpoint: props.endpoint ?? "", ref: props.ref ?? "", - initial, + initial: seeds.wire, + renderedWith: seeds.sources, }); const [selectedId, setSelectedId] = useState(null); @@ -120,11 +128,7 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ template={template?.schema} onField={onField} onCommit={() => - refresh( - String(row[ROW_ID_KEY]), - String(row.type), - Object.fromEntries(Object.entries(row).filter(([k]) => k !== ROW_ID_KEY)), - ) + refresh(String(row[ROW_ID_KEY]), String(row.type), rowAttributes(row)) } />
diff --git a/resources/js/form/components/fields/block-editor/inspector.test.tsx b/resources/js/form/components/fields/block-editor/inspector.test.tsx index 7c7714e9..0aa25325 100644 --- a/resources/js/form/components/fields/block-editor/inspector.test.tsx +++ b/resources/js/form/components/fields/block-editor/inspector.test.tsx @@ -56,6 +56,27 @@ it("renders the block fields and commits on blur", () => { 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( +
{ + const focusStaysInside = + event.relatedTarget instanceof Element && + event.currentTarget.contains(event.relatedTarget); + + if (!focusStaysInside) { + onCommit(); + } + }} + > 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" } }] }); @@ -14,6 +16,7 @@ it("seeds initial wire and refreshes a block on demand", async () => { endpoint: "/lattice/blocks/render", ref: "sealed", initial: { a: [{ type: "heading", props: { text: "Old" } }] }, + renderedWith: {}, }), ); @@ -39,6 +42,7 @@ it("keeps the previous wire when a refresh fails", async () => { endpoint: "/e", ref: "r", initial: { a: [{ type: "text", props: { text: "keep" } }] }, + renderedWith: {}, }), ); @@ -48,3 +52,48 @@ it("keeps the previous wire when a refresh fails", async () => { 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 index dd643127..f6b2d65f 100644 --- a/resources/js/form/components/fields/block-editor/use-block-preview.ts +++ b/resources/js/form/components/fields/block-editor/use-block-preview.ts @@ -1,23 +1,38 @@ -import { useCallback, useState } from "react"; +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 }; + type Options = { endpoint: string; ref: string; initial: Record; + renderedWith: Record; }; -export function useBlockPreview({ endpoint, ref, initial }: Options): { +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", @@ -25,9 +40,10 @@ export function useBlockPreview({ endpoint, ref, initial }: Options): { body: JSON.stringify({ type, attributes }), }); + rendered[rowId] = source; setWire((prev) => ({ ...prev, [rowId]: next })); } catch { - // keep the last-good wire on failure + // keep the last-good wire on failure; the next commit retries } }, [endpoint, ref], From d55a981d19238bf6894f8e6b8df28523a3693f81 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 18:56:39 +0200 Subject: [PATCH 35/53] fix: translate the block editor UI strings Drag handle, empty-inspector hint, and unknown-block note were hardcoded English; route them through useT with en/de entries like the sibling field components. --- lang/de/form.php | 6 ++++++ lang/en/form.php | 6 ++++++ .../js/form/components/fields/block-editor/canvas.tsx | 6 ++++-- .../js/form/components/fields/block-editor/index.tsx | 8 +++++++- .../js/form/components/fields/block-editor/inspector.tsx | 5 ++++- 5 files changed, 27 insertions(+), 4 deletions(-) diff --git a/lang/de/form.php b/lang/de/form.php index 7634657c..995b6edb 100644 --- a/lang/de/form.php +++ b/lang/de/form.php @@ -2,6 +2,12 @@ declare(strict_types=1); return [ + 'block-editor' => [ + 'drag' => 'Ziehen', + 'drag-to-reorder' => 'Zum Neuanordnen ziehen', + '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..9e4af6a1 100644 --- a/lang/en/form.php +++ b/lang/en/form.php @@ -2,6 +2,12 @@ declare(strict_types=1); return [ + 'block-editor' => [ + 'drag' => 'Drag', + 'drag-to-reorder' => 'Drag to reorder', + 'select-block' => 'Select a block to edit it.', + 'unknown-block' => 'Unknown block', + ], 'editor' => [ 'bold' => 'Bold', 'italic' => 'Italic', diff --git a/resources/js/form/components/fields/block-editor/canvas.tsx b/resources/js/form/components/fields/block-editor/canvas.tsx index 9d1a8d31..65eb1b11 100644 --- a/resources/js/form/components/fields/block-editor/canvas.tsx +++ b/resources/js/form/components/fields/block-editor/canvas.tsx @@ -11,6 +11,7 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd- import { CSS } from "@dnd-kit/utilities"; import type { Node } from "@lattice-php/lattice/core/types"; import { Renderer } from "@lattice-php/lattice/core/renderer"; +import { useT } from "@lattice-php/lattice/i18n"; import { cn } from "@lattice-php/lattice/lib/utils"; import { ROW_ID_KEY, type RepeaterRow } from "../repeater-rows"; import { encodePath, resolveMove } from "./dnd"; @@ -40,6 +41,7 @@ function Shell({ const id = encodePath(path); const rowId = String(row[ROW_ID_KEY]); const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id }); + const { t } = useT("lattice"); return (
diff --git a/resources/js/form/components/fields/block-editor/index.tsx b/resources/js/form/components/fields/block-editor/index.tsx index 6067792c..dcc5a0fe 100644 --- a/resources/js/form/components/fields/block-editor/index.tsx +++ b/resources/js/form/components/fields/block-editor/index.tsx @@ -1,5 +1,6 @@ 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 "../../base/field"; import { useFormContext } from "../../context"; import { appendPath, toHtmlName } from "../../form-path"; @@ -31,6 +32,7 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ const { hidden, required } = useDependentField(node); const { path, rows, onField, append } = useRowCollection(name, props.defaultItems ?? 0); const setValue = useSetFormValue(); + const { t } = useT("lattice"); const seeds = useMemo(() => { const wire: Record = {}; @@ -134,7 +136,11 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({
); })} - {!selectedRow &&

Select a block to edit it.

} + {!selectedRow && ( +

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

+ )}
diff --git a/resources/js/form/components/fields/block-editor/inspector.tsx b/resources/js/form/components/fields/block-editor/inspector.tsx index 2974c1fe..059e38bf 100644 --- a/resources/js/form/components/fields/block-editor/inspector.tsx +++ b/resources/js/form/components/fields/block-editor/inspector.tsx @@ -1,5 +1,6 @@ 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 "../../field-scope"; import type { RepeaterRow } from "../repeater-rows"; @@ -13,10 +14,12 @@ type Props = { }; export function BlockInspector({ base, index, row, template, onField, onCommit }: Props) { + const { t } = useT("lattice"); + if (!template) { return (
- Unknown block + {t("form.block-editor.unknown-block", "Unknown block")}
); } From 0a7a17014772f75ed0e9c283c988486371492ddc Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 19:20:17 +0200 Subject: [PATCH 36/53] refactor!: register blocks on the BlockRegistry instead of the Lattice facade Blocks are headed for a first-party package, so core must not hardwire BlockRegistry into LatticeRegistry/the facade. Apps register explicit blocks via app(BlockRegistry::class)->register() (or rely on AsBlock discovery); the extracted package will own any registration sugar. --- src/Facades/Lattice.php | 1 - src/LatticeRegistry.php | 11 ----------- tests/Feature/Blocks/BlockEditorFieldTest.php | 4 ++-- tests/Feature/Blocks/BlockEditorValidationTest.php | 4 ++-- tests/Feature/Blocks/BlockNestingTest.php | 4 ++-- tests/Feature/Blocks/BlockRegistryTest.php | 3 +-- tests/Feature/Blocks/BlockRenderEndpointTest.php | 6 +++--- tests/Feature/Blocks/BlockRendererTest.php | 6 +++--- tests/Feature/Blocks/HasBlocksTest.php | 4 ++-- 9 files changed, 15 insertions(+), 28 deletions(-) diff --git a/src/Facades/Lattice.php b/src/Facades/Lattice.php index e4f6425b..a7821207 100644 --- a/src/Facades/Lattice.php +++ b/src/Facades/Lattice.php @@ -10,7 +10,6 @@ * @method static void forms(class-string<\Lattice\Lattice\Forms\FormDefinition>|array> $forms) * @method static void tables(class-string<\Lattice\Lattice\Tables\TableDefinition>|array> $tables) * @method static void fragments(class-string<\Lattice\Lattice\Fragments\FragmentDefinition>|array> $fragments) - * @method static void blocks(class-string<\Lattice\Lattice\Blocks\BlockDefinition>|array> $blocks) * @method static void layouts(class-string<\Lattice\Lattice\Layouts\LayoutDefinition>|array> $layouts) * @method static \Lattice\Lattice\Layouts\LayoutRegistry layoutRegistry() * @method static void pages(class-string<\Lattice\Lattice\Core\Contracts\PageContract>|array> $pages) diff --git a/src/LatticeRegistry.php b/src/LatticeRegistry.php index e42c5dd4..3fe8a9a0 100644 --- a/src/LatticeRegistry.php +++ b/src/LatticeRegistry.php @@ -8,8 +8,6 @@ use Lattice\Lattice\Actions\ActionRegistry; use Lattice\Lattice\Actions\BulkActionDefinition; use Lattice\Lattice\Actions\BulkActionRegistry; -use Lattice\Lattice\Blocks\BlockDefinition; -use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Core\Contracts\PageContract; use Lattice\Lattice\Forms\FormDefinition; use Lattice\Lattice\Forms\FormRegistry; @@ -27,7 +25,6 @@ { public function __construct( private ActionRegistry $actions, - private BlockRegistry $blocks, private BulkActionRegistry $bulkActions, private FormRegistry $forms, private FragmentRegistry $fragments, @@ -61,14 +58,6 @@ public function fragments(string|array $fragments): void $this->fragments->register($fragments); } - /** - * @param class-string|array> $blocks - */ - public function blocks(string|array $blocks): void - { - $this->blocks->register($blocks); - } - /** * @param class-string|array> $actions */ diff --git a/tests/Feature/Blocks/BlockEditorFieldTest.php b/tests/Feature/Blocks/BlockEditorFieldTest.php index 70f5421c..1b50820e 100644 --- a/tests/Feature/Blocks/BlockEditorFieldTest.php +++ b/tests/Feature/Blocks/BlockEditorFieldTest.php @@ -3,10 +3,10 @@ use Lattice\Lattice\Attributes\AsBlock; use Lattice\Lattice\Blocks\BlockDefinition; +use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Blocks\BlockSlots; use Lattice\Lattice\Core\Components\Heading; use Lattice\Lattice\Core\PageSchema; -use Lattice\Lattice\Facades\Lattice; use Lattice\Lattice\Forms\Components\BlockEditor; use Lattice\Lattice\Forms\Components\TextInput; use Lattice\Lattice\Forms\FormData; @@ -24,7 +24,7 @@ }); test('serializes rendered wire for each stored row aligned by index', function (): void { - Lattice::blocks([EditorHeroBlock::class]); + app(BlockRegistry::class)->register([EditorHeroBlock::class]); $field = BlockEditor::make('content') ->blocks([EditorHeroBlock::class]) diff --git a/tests/Feature/Blocks/BlockEditorValidationTest.php b/tests/Feature/Blocks/BlockEditorValidationTest.php index 8508a77a..2cebfe1f 100644 --- a/tests/Feature/Blocks/BlockEditorValidationTest.php +++ b/tests/Feature/Blocks/BlockEditorValidationTest.php @@ -4,16 +4,16 @@ use Illuminate\Http\Request; use Lattice\Lattice\Attributes\AsBlock; use Lattice\Lattice\Blocks\BlockDefinition; +use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Blocks\BlockSlots; use Lattice\Lattice\Core\Components\Heading; use Lattice\Lattice\Core\PageSchema; -use Lattice\Lattice\Facades\Lattice; use Lattice\Lattice\Forms\Components\BlockEditor; use Lattice\Lattice\Forms\Components\TextInput; use Lattice\Lattice\Forms\FormData; beforeEach(function (): void { - Lattice::blocks([ValidationHeroBlock::class]); + app(BlockRegistry::class)->register([ValidationHeroBlock::class]); }); test('rejects a row whose type is not an allowed block', function (): void { diff --git a/tests/Feature/Blocks/BlockNestingTest.php b/tests/Feature/Blocks/BlockNestingTest.php index eb60c1ae..f3f86c30 100644 --- a/tests/Feature/Blocks/BlockNestingTest.php +++ b/tests/Feature/Blocks/BlockNestingTest.php @@ -3,16 +3,16 @@ use Lattice\Lattice\Attributes\AsBlock; use Lattice\Lattice\Blocks\BlockDefinition; +use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Blocks\BlockRenderer; use Lattice\Lattice\Blocks\BlockSlots; use Lattice\Lattice\Core\Components\Grid; use Lattice\Lattice\Core\Components\Text; use Lattice\Lattice\Core\PageSchema; -use Lattice\Lattice\Facades\Lattice; use Lattice\Lattice\Forms\FormData; test('renders child rows declared in a layout block slot', function (): void { - Lattice::blocks([NestingColumnsBlock::class, NestingTextBlock::class]); + app(BlockRegistry::class)->register([NestingColumnsBlock::class, NestingTextBlock::class]); $schema = app(BlockRenderer::class)->render([ [ diff --git a/tests/Feature/Blocks/BlockRegistryTest.php b/tests/Feature/Blocks/BlockRegistryTest.php index 7b826fda..a9c9f4a3 100644 --- a/tests/Feature/Blocks/BlockRegistryTest.php +++ b/tests/Feature/Blocks/BlockRegistryTest.php @@ -8,12 +8,11 @@ use Lattice\Lattice\Core\Components\Heading; use Lattice\Lattice\Core\Exceptions\UnknownComponent; use Lattice\Lattice\Core\PageSchema; -use Lattice\Lattice\Facades\Lattice; use Lattice\Lattice\Forms\Components\TextInput; use Lattice\Lattice\Forms\FormData; test('registers and resolves a block by its AsBlock key', function (): void { - Lattice::blocks([RegistryHeroBlock::class]); + app(BlockRegistry::class)->register([RegistryHeroBlock::class]); $block = app(BlockRegistry::class)->resolve('registry.hero'); diff --git a/tests/Feature/Blocks/BlockRenderEndpointTest.php b/tests/Feature/Blocks/BlockRenderEndpointTest.php index 6e2522a4..56489325 100644 --- a/tests/Feature/Blocks/BlockRenderEndpointTest.php +++ b/tests/Feature/Blocks/BlockRenderEndpointTest.php @@ -3,10 +3,10 @@ use Lattice\Lattice\Attributes\AsBlock; use Lattice\Lattice\Blocks\BlockDefinition; +use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Blocks\BlockSlots; use Lattice\Lattice\Core\Components\Heading; use Lattice\Lattice\Core\PageSchema; -use Lattice\Lattice\Facades\Lattice; use Lattice\Lattice\Forms\Components\BlockEditor; use Lattice\Lattice\Forms\Components\TextInput; use Lattice\Lattice\Forms\FormData; @@ -14,7 +14,7 @@ use function Pest\Laravel\postJson; beforeEach(function (): void { - Lattice::blocks([EndpointHeroBlock::class]); + app(BlockRegistry::class)->register([EndpointHeroBlock::class]); }); test('renders a block via the signed endpoint and returns wire', function (): void { @@ -36,7 +36,7 @@ }); test('forbids rendering a block the field does not allow', function (): void { - Lattice::blocks([EndpointHeroBlock::class, EndpointOtherBlock::class]); + app(BlockRegistry::class)->register([EndpointHeroBlock::class, EndpointOtherBlock::class]); $field = BlockEditor::make('content')->blocks([EndpointHeroBlock::class])->id('content'); $ref = componentRef(wire($field)); diff --git a/tests/Feature/Blocks/BlockRendererTest.php b/tests/Feature/Blocks/BlockRendererTest.php index 17e82c0e..99b4df88 100644 --- a/tests/Feature/Blocks/BlockRendererTest.php +++ b/tests/Feature/Blocks/BlockRendererTest.php @@ -3,18 +3,18 @@ use Lattice\Lattice\Attributes\AsBlock; use Lattice\Lattice\Blocks\BlockDefinition; +use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Blocks\BlockRenderer; use Lattice\Lattice\Blocks\BlockSlots; use Lattice\Lattice\Core\Components\Heading; use Lattice\Lattice\Core\Components\Section; use Lattice\Lattice\Core\Components\Text; use Lattice\Lattice\Core\PageSchema; -use Lattice\Lattice\Facades\Lattice; use Lattice\Lattice\Forms\Components\TextInput; use Lattice\Lattice\Forms\FormData; test('renders each stored row through its block definition in order', function (): void { - Lattice::blocks([RendererHeroBlock::class]); + app(BlockRegistry::class)->register([RendererHeroBlock::class]); $schema = app(BlockRenderer::class)->render([ ['type' => 'renderer.hero', 'title' => 'First', 'body' => 'One'], @@ -32,7 +32,7 @@ }); test('renders a placeholder for an unknown block type without throwing', function (): void { - Lattice::blocks([RendererHeroBlock::class]); + app(BlockRegistry::class)->register([RendererHeroBlock::class]); $schema = app(BlockRenderer::class)->render([ ['type' => 'renderer.hero', 'title' => 'Kept', 'body' => 'Body'], diff --git a/tests/Feature/Blocks/HasBlocksTest.php b/tests/Feature/Blocks/HasBlocksTest.php index f56ee7a1..ba24c65a 100644 --- a/tests/Feature/Blocks/HasBlocksTest.php +++ b/tests/Feature/Blocks/HasBlocksTest.php @@ -5,12 +5,12 @@ use Illuminate\Support\Facades\Schema; use Lattice\Lattice\Attributes\AsBlock; use Lattice\Lattice\Blocks\BlockDefinition; +use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Blocks\BlockSlots; use Lattice\Lattice\Blocks\Casts\AsBlocks; use Lattice\Lattice\Blocks\Concerns\HasBlocks; use Lattice\Lattice\Core\Components\Heading; use Lattice\Lattice\Core\PageSchema; -use Lattice\Lattice\Facades\Lattice; use Lattice\Lattice\Forms\Components\TextInput; use Lattice\Lattice\Forms\FormData; @@ -23,7 +23,7 @@ }); test('persists a block tree and renders it back through the registry', function (): void { - Lattice::blocks([HasBlocksHeroBlock::class]); + app(BlockRegistry::class)->register([HasBlocksHeroBlock::class]); $page = HasBlocksPage::create([ 'content' => [ From 39f6333eac6afe4bbc68094bb137e3a0e085e7aa Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Thu, 9 Jul 2026 21:58:02 +0200 Subject: [PATCH 37/53] refactor: adapt the block editor to the RowsField substrate - BlockEditor extends TypedRowsField: blocks() builds RowTemplates from the AsBlock classes, and the table/row-action chrome props it never honored drop off its wire type. - The client reads the templates wire key via rowTemplatesOf, submits type + rowId per row through the shared RowKeyInputs, and slot rows keep their rowId on submit (hiddenInputsFor no longer strips it). - Browser tests select block shells positionally since row ids are uuids now; the type in:-rule assertion follows Rule::in. --- .../fields/block-editor/canvas.test.tsx | 4 +-- .../block-editor/hidden-inputs.test.tsx | 8 ++--- .../fields/block-editor/hidden-inputs.tsx | 3 +- .../fields/block-editor/index.test.tsx | 8 ++--- .../components/fields/block-editor/index.tsx | 34 ++++++++----------- .../fields/block-editor/inspector.test.tsx | 6 ++-- .../fields/block-editor/move-block.test.ts | 34 +++++++++---------- src/Forms/Components/BlockEditor.php | 19 ++++++----- tests/Browser/Blocks/BlockEditorTest.php | 8 ++--- tests/Feature/Blocks/BlockEditorFieldTest.php | 6 ++-- .../Blocks/BlockEditorValidationTest.php | 3 +- 11 files changed, 65 insertions(+), 68 deletions(-) diff --git a/resources/js/form/components/fields/block-editor/canvas.test.tsx b/resources/js/form/components/fields/block-editor/canvas.test.tsx index 9e2d4585..e1e6cb7d 100644 --- a/resources/js/form/components/fields/block-editor/canvas.test.tsx +++ b/resources/js/form/components/fields/block-editor/canvas.test.tsx @@ -14,8 +14,8 @@ import { BlockCanvas } from "./canvas"; it("renders each row's wire and selects on click", () => { const onSelect = vi.fn<(rowId: string) => void>(); const rows = [ - { __rowId: "a", type: "hero" }, - { __rowId: "b", type: "text" }, + { rowId: "a", type: "hero" }, + { rowId: "b", type: "text" }, ]; const wire = { a: [{ type: "heading", props: { text: "Alpha" } }], diff --git a/resources/js/form/components/fields/block-editor/hidden-inputs.test.tsx b/resources/js/form/components/fields/block-editor/hidden-inputs.test.tsx index 5991d439..ed508e16 100644 --- a/resources/js/form/components/fields/block-editor/hidden-inputs.test.tsx +++ b/resources/js/form/components/fields/block-editor/hidden-inputs.test.tsx @@ -11,9 +11,9 @@ function renderInputs(name: string, value: unknown) { } describe("hiddenInputsFor", () => { - it("emits hidden inputs for nested slot rows, skipping __rowId", () => { + it("emits hidden inputs for nested slot rows including their rowId", () => { const inputs = renderInputs("content[0][slots]", { - left: [{ __rowId: "x", type: "text", body: "L" }], + left: [{ rowId: "x", type: "text", body: "L" }], }); const byName = Object.fromEntries( @@ -22,8 +22,8 @@ describe("hiddenInputsFor", () => { expect(byName["content[0][slots][left][0][type]"]).toBe("text"); expect(byName["content[0][slots][left][0][body]"]).toBe("L"); - expect(byName["content[0][slots][left][0][__rowId]"]).toBeUndefined(); - expect(inputs).toHaveLength(2); + expect(byName["content[0][slots][left][0][rowId]"]).toBe("x"); + expect(inputs).toHaveLength(3); }); it("emits nothing for null or undefined", () => { diff --git a/resources/js/form/components/fields/block-editor/hidden-inputs.tsx b/resources/js/form/components/fields/block-editor/hidden-inputs.tsx index b1313ad4..06e268cb 100644 --- a/resources/js/form/components/fields/block-editor/hidden-inputs.tsx +++ b/resources/js/form/components/fields/block-editor/hidden-inputs.tsx @@ -1,5 +1,4 @@ import type { ReactElement } from "react"; -import { ROW_ID_KEY } from "../repeater-rows"; export function hiddenInputsFor(name: string, value: unknown): ReactElement[] { if (value == null) { @@ -12,7 +11,7 @@ export function hiddenInputsFor(name: string, value: unknown): ReactElement[] { if (typeof value === "object") { return Object.entries(value as Record).flatMap(([key, child]) => - key === ROW_ID_KEY ? [] : hiddenInputsFor(`${name}[${key}]`, child), + hiddenInputsFor(`${name}[${key}]`, child), ); } diff --git a/resources/js/form/components/fields/block-editor/index.test.tsx b/resources/js/form/components/fields/block-editor/index.test.tsx index d25fd3b5..276dde44 100644 --- a/resources/js/form/components/fields/block-editor/index.test.tsx +++ b/resources/js/form/components/fields/block-editor/index.test.tsx @@ -45,7 +45,7 @@ const baseNode = { id: "content", type: "field.block-editor", props: { name: "content", ref: "sealed", endpoint: "/lattice/blocks/render", defaultItems: 0 }, - blocks: [ + templates: [ { type: "hero", label: "Hero", @@ -58,7 +58,7 @@ 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" }], + content: [{ rowId: "a", type: "hero", title: "Stored" }], }); expect(screen.getByText("Stored")).toBeInTheDocument(); @@ -69,7 +69,7 @@ it("renders nothing when the field is hidden", () => { const hiddenNode = { ...baseNode, props: { ...baseNode.props, hidden: true } } as never; wrap({null}, { - content: [{ __rowId: "a", type: "hero", title: "Stored" }], + content: [{ rowId: "a", type: "hero", title: "Stored" }], }); expect(screen.queryByTestId("block-editor-inspector")).not.toBeInTheDocument(); @@ -78,7 +78,7 @@ it("renders nothing when the field is hidden", () => { it("shows the field error from the form context", () => { wrap( {null}, - { content: [{ __rowId: "a", type: "hero", title: "Stored" }] }, + { content: [{ rowId: "a", type: "hero", title: "Stored" }] }, { content: "At least one block is required." }, ); diff --git a/resources/js/form/components/fields/block-editor/index.tsx b/resources/js/form/components/fields/block-editor/index.tsx index dcc5a0fe..62995d09 100644 --- a/resources/js/form/components/fields/block-editor/index.tsx +++ b/resources/js/form/components/fields/block-editor/index.tsx @@ -2,12 +2,14 @@ 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 "../../base/field"; -import { useFormContext } from "../../context"; import { appendPath, toHtmlName } from "../../form-path"; +import { useFormContext } from "../../context"; import { useDependentField } from "../../use-dependent-field"; import { useSetFormValue } from "../../values"; -import { BlockAddMenu, type BlockOption } from "../block-add-menu"; +import { AddRowMenu, type AddRowOption } from "../add-row-menu"; import { ROW_ID_KEY, type RepeaterRow } from "../repeater-rows"; +import { RowKeyInputs } from "../row-key-inputs"; +import { rowTemplatesOf, type RowTemplate } from "../row-templates"; import { useRowCollection } from "../use-row-collection"; import { BlockCanvas } from "./canvas"; import { hiddenInputsFor } from "./hidden-inputs"; @@ -15,8 +17,6 @@ import { BlockInspector } from "./inspector"; import { moveBlock, type BlockPath } from "./move-block"; import { useBlockPreview, type BlockSource } from "./use-block-preview"; -type BlockTemplate = { type: string; label: string; schema: Node[] }; - function rowAttributes(row: RepeaterRow): Record { return Object.fromEntries(Object.entries(row).filter(([key]) => key !== ROW_ID_KEY)); } @@ -24,8 +24,7 @@ function rowAttributes(row: RepeaterRow): Record { export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ node }) => { const props = node.props; const name = props.name; - const blocks = ((node as unknown as { blocks?: BlockTemplate[] }).blocks ?? - []) as BlockTemplate[]; + const templates = rowTemplatesOf(node) ?? []; const rendered = ((node as unknown as { rendered?: Node[][] }).rendered ?? []) as Node[][]; const { errors } = useFormContext(); @@ -61,9 +60,12 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ return null; } - const templateFor = (type: unknown): BlockTemplate | undefined => - blocks.find((b) => b.type === type); - const options: BlockOption[] = blocks.map((b) => ({ type: b.type, label: b.label })); + const templateFor = (type: unknown): RowTemplate | undefined => + templates.find((template) => template.type === type); + const options: AddRowOption[] = templates.map((template) => ({ + type: template.type, + label: template.label, + })); const atMax = props.maxItems != null && rows.length >= props.maxItems; const selectedRow = rows.find((row) => String(row[ROW_ID_KEY]) === selectedId) ?? null; @@ -83,14 +85,8 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ name={path} required={required} > - {rows.map((row, index) => ( - - ))} + + {rows.flatMap((row, index) => row.slots == null @@ -108,9 +104,9 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ onMoveBlock={onMoveBlock} /> {!atMax && ( - append({ type })} /> )} diff --git a/resources/js/form/components/fields/block-editor/inspector.test.tsx b/resources/js/form/components/fields/block-editor/inspector.test.tsx index 0aa25325..fe652d57 100644 --- a/resources/js/form/components/fields/block-editor/inspector.test.tsx +++ b/resources/js/form/components/fields/block-editor/inspector.test.tsx @@ -43,7 +43,7 @@ it("renders the block fields and commits on blur", () => { void>()} onCommit={onCommit} @@ -63,7 +63,7 @@ it("does not commit when focus moves between fields inside the inspector", () => void>()} onCommit={onCommit} @@ -82,7 +82,7 @@ it("shows an unknown-block note when there is no template", () => { void>()} onCommit={vi.fn<() => void>()} />, 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 index dec718e3..a4ca711e 100644 --- a/resources/js/form/components/fields/block-editor/move-block.test.ts +++ b/resources/js/form/components/fields/block-editor/move-block.test.ts @@ -2,13 +2,13 @@ import { describe, expect, it } from "vitest"; import { moveBlock } from "./move-block"; const rows = () => [ - { __rowId: "a", type: "text", body: "A" }, + { rowId: "a", type: "text", body: "A" }, { - __rowId: "cols", + rowId: "cols", type: "columns", slots: { - left: [{ __rowId: "l1", type: "text", body: "L1" }], - right: [{ __rowId: "r1", type: "text", body: "R1" }], + left: [{ rowId: "l1", type: "text", body: "L1" }], + right: [{ rowId: "r1", type: "text", body: "R1" }], }, }, ]; @@ -16,14 +16,14 @@ const rows = () => [ 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"]); + 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"]); + 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", () => { @@ -32,14 +32,14 @@ describe("moveBlock", () => { [{ index: 1, slot: "left" }, { index: 0 }], [{ index: 1, slot: "right" }, { index: 0 }], ); - const cols = out.find((r) => r.__rowId === "cols") as Record; + 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"]); + 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"]); + expect(out.map((r) => r.rowId)).toEqual(["l1", "a", "cols"]); }); it("returns rows unchanged for an invalid source", () => { @@ -50,9 +50,9 @@ describe("moveBlock", () => { it("moves a block into a deeper nested container without dropping a later sibling", () => { const nested = [ { - __rowId: "outer", + rowId: "outer", slots: { - left: [{ __rowId: "P" }, { __rowId: "Q", slots: { inner: [{ __rowId: "R" }] } }], + left: [{ rowId: "P" }, { rowId: "Q", slots: { inner: [{ rowId: "R" }] } }], }, }, ]; @@ -63,10 +63,10 @@ describe("moveBlock", () => { [{ 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"]); + 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", () => { diff --git a/src/Forms/Components/BlockEditor.php b/src/Forms/Components/BlockEditor.php index a7b253b1..8509e4a9 100644 --- a/src/Forms/Components/BlockEditor.php +++ b/src/Forms/Components/BlockEditor.php @@ -12,31 +12,32 @@ use Lattice\Lattice\Forms\Enums\FieldType; #[AsField(FieldType::BlockEditor)] -class BlockEditor extends Builder +class BlockEditor extends TypedRowsField { use IsInteractive; public ?string $endpoint = null; /** - * @param array|Block> $blocks + * @param array> $blocks */ - #[\Override] public function blocks(array $blocks): static { $registry = app(BlockRegistry::class); - $this->blocks = array_map( - fn (string|Block $block): Block => $block instanceof Block - ? $block - : Block::make($registry->keyFor($block))->schema(app($block)->attributes()), + $this->templates(array_map( + fn (string $block): RowTemplate => RowTemplate::make($registry->keyFor($block)) + ->schema(app($block)->attributes()), $blocks, - ); + )); $this->id ??= $this->name; $this->endpoint ??= '/'.ltrim((string) config('lattice.blocks.endpoint', 'lattice/blocks/render'), '/'); $this->signedAs('block-editor'); - $this->context(['allowedBlocks' => array_map(fn (Block $block): string => $block->type, $this->blocks)]); + $this->context(['allowedBlocks' => array_map( + static fn (RowTemplate $template): string => $template->type, + $this->templates, + )]); return $this; } diff --git a/tests/Browser/Blocks/BlockEditorTest.php b/tests/Browser/Blocks/BlockEditorTest.php index c76e862f..980abfe4 100644 --- a/tests/Browser/Blocks/BlockEditorTest.php +++ b/tests/Browser/Blocks/BlockEditorTest.php @@ -44,12 +44,12 @@ $page->click('@builder-add') ->click('[data-test="builder-add-workbench.hero"]'); - $page->assertPresent('[data-test="block-shell-r0"]') - ->assertPresent('[data-test="block-shell-r1"]'); + $page->assertPresent('[data-test^="block-shell-"]:nth-child(1)') + ->assertPresent('[data-test^="block-shell-"]:nth-child(2)'); - $page->click('[data-test="block-shell-r0"]'); + $page->click('[data-test^="block-shell-"]:nth-child(1)'); $page->fill('input[name="content[0][title]"]', 'First title'); - $page->click('[data-test="block-shell-r1"]'); + $page->click('[data-test^="block-shell-"]:nth-child(2)'); retryUntil(function () use ($page): void { $page->assertSee('First title'); diff --git a/tests/Feature/Blocks/BlockEditorFieldTest.php b/tests/Feature/Blocks/BlockEditorFieldTest.php index 1b50820e..45c2a487 100644 --- a/tests/Feature/Blocks/BlockEditorFieldTest.php +++ b/tests/Feature/Blocks/BlockEditorFieldTest.php @@ -18,9 +18,9 @@ expect($wire['type'])->toBe('field.block-editor') ->and($wire['props']['endpoint'])->toBe('/lattice/blocks/render') - ->and($wire['blocks'])->toHaveCount(1) - ->and($wire['blocks'][0]['type'])->toBe('editor.hero') - ->and($wire['blocks'][0]['schema'][0]['props']['name'])->toBe('title'); + ->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 rendered wire for each stored row aligned by index', function (): void { diff --git a/tests/Feature/Blocks/BlockEditorValidationTest.php b/tests/Feature/Blocks/BlockEditorValidationTest.php index 2cebfe1f..c844933e 100644 --- a/tests/Feature/Blocks/BlockEditorValidationTest.php +++ b/tests/Feature/Blocks/BlockEditorValidationTest.php @@ -23,7 +23,8 @@ $rules = $field->nestedRules($data, Request::create('/')); expect($rules)->toHaveKey('content.0.type') - ->and($rules['content.0.type'])->toBe(['required', 'in:validation.hero']); + ->and($rules['content.0.type'][0])->toBe('required') + ->and((string) $rules['content.0.type'][1])->toBe('in:"validation.hero"'); }); #[AsBlock('validation.hero')] From a3061caa249ddf5f49ce304f1c4801b9c8a3019a Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sat, 11 Jul 2026 14:13:07 +0200 Subject: [PATCH 38/53] refactor: adapt the block editor to the Ui\Components namespace move --- resources/js/types/generated.ts | 33 +++++++++++++++++++ src/Blocks/BlockRenderer.php | 4 +-- src/Blocks/BlockSlots.php | 2 +- src/Forms/Components/BlockEditor.php | 2 +- tests/Feature/Blocks/BlockEditorFieldTest.php | 2 +- .../Blocks/BlockEditorValidationTest.php | 2 +- tests/Feature/Blocks/BlockNestingTest.php | 4 +-- tests/Feature/Blocks/BlockRegistryTest.php | 2 +- .../Blocks/BlockRenderEndpointTest.php | 2 +- tests/Feature/Blocks/BlockRendererTest.php | 6 ++-- tests/Feature/Blocks/HasBlocksTest.php | 2 +- tests/Unit/Blocks/BlockSlotsTest.php | 2 +- workbench/app/Blocks/ColumnsBlock.php | 2 +- workbench/app/Blocks/HeroBlock.php | 2 +- workbench/app/Forms/BlockPageForm.php | 2 +- workbench/app/Pages/BlockPageEditorPage.php | 8 ++--- 16 files changed, 55 insertions(+), 22 deletions(-) diff --git a/resources/js/types/generated.ts b/resources/js/types/generated.ts index e129acec..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; @@ -534,6 +564,7 @@ export type Form = { validationTimeout: number | null; }; export type FormFieldNodeType = + | "field.block-editor" | "field.builder" | "field.checkbox" | "field.choice" @@ -552,6 +583,7 @@ export type FormFieldNodeType = | "field.time-input" | "field.toggle"; export type FormNodeType = + | "field.block-editor" | "field.builder" | "field.checkbox" | "field.choice" @@ -712,6 +744,7 @@ export type NodeType = | "chat.part.tool-call" | "collapsible" | "dropdown" + | "field.block-editor" | "field.builder" | "field.checkbox" | "field.choice" diff --git a/src/Blocks/BlockRenderer.php b/src/Blocks/BlockRenderer.php index b72fda1b..9d687f68 100644 --- a/src/Blocks/BlockRenderer.php +++ b/src/Blocks/BlockRenderer.php @@ -3,11 +3,11 @@ namespace Lattice\Lattice\Blocks; -use Lattice\Lattice\Core\Components\Component; -use Lattice\Lattice\Core\Components\Text; use Lattice\Lattice\Core\Exceptions\UnknownComponent; use Lattice\Lattice\Core\PageSchema; use Lattice\Lattice\Forms\FormData; +use Lattice\Lattice\Ui\Components\Component; +use Lattice\Lattice\Ui\Components\Text; final readonly class BlockRenderer { diff --git a/src/Blocks/BlockSlots.php b/src/Blocks/BlockSlots.php index 5c108c75..42a50560 100644 --- a/src/Blocks/BlockSlots.php +++ b/src/Blocks/BlockSlots.php @@ -3,7 +3,7 @@ namespace Lattice\Lattice\Blocks; -use Lattice\Lattice\Core\Components\Component; +use Lattice\Lattice\Ui\Components\Component; final readonly class BlockSlots { diff --git a/src/Forms/Components/BlockEditor.php b/src/Forms/Components/BlockEditor.php index 8509e4a9..8357d156 100644 --- a/src/Forms/Components/BlockEditor.php +++ b/src/Forms/Components/BlockEditor.php @@ -7,9 +7,9 @@ use Lattice\Lattice\Blocks\BlockDefinition; use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Blocks\BlockRenderer; -use Lattice\Lattice\Core\Components\IsInteractive; use Lattice\Lattice\Forms\Attributes\AsField; use Lattice\Lattice\Forms\Enums\FieldType; +use Lattice\Lattice\Ui\Components\IsInteractive; #[AsField(FieldType::BlockEditor)] class BlockEditor extends TypedRowsField diff --git a/tests/Feature/Blocks/BlockEditorFieldTest.php b/tests/Feature/Blocks/BlockEditorFieldTest.php index 45c2a487..efc5d19f 100644 --- a/tests/Feature/Blocks/BlockEditorFieldTest.php +++ b/tests/Feature/Blocks/BlockEditorFieldTest.php @@ -5,11 +5,11 @@ use Lattice\Lattice\Blocks\BlockDefinition; use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Blocks\BlockSlots; -use Lattice\Lattice\Core\Components\Heading; use Lattice\Lattice\Core\PageSchema; use Lattice\Lattice\Forms\Components\BlockEditor; use Lattice\Lattice\Forms\Components\TextInput; use Lattice\Lattice\Forms\FormData; +use Lattice\Lattice\Ui\Components\Heading; test('serializes as a block-editor field with a template per block', function (): void { $field = BlockEditor::make('content')->blocks([EditorHeroBlock::class]); diff --git a/tests/Feature/Blocks/BlockEditorValidationTest.php b/tests/Feature/Blocks/BlockEditorValidationTest.php index c844933e..ea5e09c0 100644 --- a/tests/Feature/Blocks/BlockEditorValidationTest.php +++ b/tests/Feature/Blocks/BlockEditorValidationTest.php @@ -6,11 +6,11 @@ use Lattice\Lattice\Blocks\BlockDefinition; use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Blocks\BlockSlots; -use Lattice\Lattice\Core\Components\Heading; use Lattice\Lattice\Core\PageSchema; use Lattice\Lattice\Forms\Components\BlockEditor; use Lattice\Lattice\Forms\Components\TextInput; use Lattice\Lattice\Forms\FormData; +use Lattice\Lattice\Ui\Components\Heading; beforeEach(function (): void { app(BlockRegistry::class)->register([ValidationHeroBlock::class]); diff --git a/tests/Feature/Blocks/BlockNestingTest.php b/tests/Feature/Blocks/BlockNestingTest.php index f3f86c30..078eb49e 100644 --- a/tests/Feature/Blocks/BlockNestingTest.php +++ b/tests/Feature/Blocks/BlockNestingTest.php @@ -6,10 +6,10 @@ use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Blocks\BlockRenderer; use Lattice\Lattice\Blocks\BlockSlots; -use Lattice\Lattice\Core\Components\Grid; -use Lattice\Lattice\Core\Components\Text; use Lattice\Lattice\Core\PageSchema; use Lattice\Lattice\Forms\FormData; +use Lattice\Lattice\Ui\Components\Grid; +use Lattice\Lattice\Ui\Components\Text; test('renders child rows declared in a layout block slot', function (): void { app(BlockRegistry::class)->register([NestingColumnsBlock::class, NestingTextBlock::class]); diff --git a/tests/Feature/Blocks/BlockRegistryTest.php b/tests/Feature/Blocks/BlockRegistryTest.php index a9c9f4a3..b38989d6 100644 --- a/tests/Feature/Blocks/BlockRegistryTest.php +++ b/tests/Feature/Blocks/BlockRegistryTest.php @@ -5,11 +5,11 @@ use Lattice\Lattice\Blocks\BlockDefinition; use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Blocks\BlockSlots; -use Lattice\Lattice\Core\Components\Heading; use Lattice\Lattice\Core\Exceptions\UnknownComponent; use Lattice\Lattice\Core\PageSchema; use Lattice\Lattice\Forms\Components\TextInput; use Lattice\Lattice\Forms\FormData; +use Lattice\Lattice\Ui\Components\Heading; test('registers and resolves a block by its AsBlock key', function (): void { app(BlockRegistry::class)->register([RegistryHeroBlock::class]); diff --git a/tests/Feature/Blocks/BlockRenderEndpointTest.php b/tests/Feature/Blocks/BlockRenderEndpointTest.php index 56489325..81f9f78f 100644 --- a/tests/Feature/Blocks/BlockRenderEndpointTest.php +++ b/tests/Feature/Blocks/BlockRenderEndpointTest.php @@ -5,11 +5,11 @@ use Lattice\Lattice\Blocks\BlockDefinition; use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Blocks\BlockSlots; -use Lattice\Lattice\Core\Components\Heading; use Lattice\Lattice\Core\PageSchema; use Lattice\Lattice\Forms\Components\BlockEditor; use Lattice\Lattice\Forms\Components\TextInput; use Lattice\Lattice\Forms\FormData; +use Lattice\Lattice\Ui\Components\Heading; use function Pest\Laravel\postJson; diff --git a/tests/Feature/Blocks/BlockRendererTest.php b/tests/Feature/Blocks/BlockRendererTest.php index 99b4df88..f841cf50 100644 --- a/tests/Feature/Blocks/BlockRendererTest.php +++ b/tests/Feature/Blocks/BlockRendererTest.php @@ -6,12 +6,12 @@ use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Blocks\BlockRenderer; use Lattice\Lattice\Blocks\BlockSlots; -use Lattice\Lattice\Core\Components\Heading; -use Lattice\Lattice\Core\Components\Section; -use Lattice\Lattice\Core\Components\Text; use Lattice\Lattice\Core\PageSchema; use Lattice\Lattice\Forms\Components\TextInput; use Lattice\Lattice\Forms\FormData; +use Lattice\Lattice\Ui\Components\Heading; +use Lattice\Lattice\Ui\Components\Section; +use Lattice\Lattice\Ui\Components\Text; test('renders each stored row through its block definition in order', function (): void { app(BlockRegistry::class)->register([RendererHeroBlock::class]); diff --git a/tests/Feature/Blocks/HasBlocksTest.php b/tests/Feature/Blocks/HasBlocksTest.php index ba24c65a..cdee8740 100644 --- a/tests/Feature/Blocks/HasBlocksTest.php +++ b/tests/Feature/Blocks/HasBlocksTest.php @@ -9,10 +9,10 @@ use Lattice\Lattice\Blocks\BlockSlots; use Lattice\Lattice\Blocks\Casts\AsBlocks; use Lattice\Lattice\Blocks\Concerns\HasBlocks; -use Lattice\Lattice\Core\Components\Heading; use Lattice\Lattice\Core\PageSchema; use Lattice\Lattice\Forms\Components\TextInput; use Lattice\Lattice\Forms\FormData; +use Lattice\Lattice\Ui\Components\Heading; beforeEach(function (): void { Schema::create('landing_pages', function ($table): void { diff --git a/tests/Unit/Blocks/BlockSlotsTest.php b/tests/Unit/Blocks/BlockSlotsTest.php index 233894de..6ded990a 100644 --- a/tests/Unit/Blocks/BlockSlotsTest.php +++ b/tests/Unit/Blocks/BlockSlotsTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); use Lattice\Lattice\Blocks\BlockSlots; -use Lattice\Lattice\Core\Components\Text; +use Lattice\Lattice\Ui\Components\Text; test('returns rendered children for a named slot', function (): void { $text = Text::make('left content'); diff --git a/workbench/app/Blocks/ColumnsBlock.php b/workbench/app/Blocks/ColumnsBlock.php index 03dd7d46..93f9a347 100644 --- a/workbench/app/Blocks/ColumnsBlock.php +++ b/workbench/app/Blocks/ColumnsBlock.php @@ -6,9 +6,9 @@ use Lattice\Lattice\Attributes\AsBlock; use Lattice\Lattice\Blocks\BlockDefinition; use Lattice\Lattice\Blocks\BlockSlots; -use Lattice\Lattice\Core\Components\Grid; use Lattice\Lattice\Core\PageSchema; use Lattice\Lattice\Forms\FormData; +use Lattice\Lattice\Ui\Components\Grid; #[AsBlock('workbench.columns')] final class ColumnsBlock extends BlockDefinition diff --git a/workbench/app/Blocks/HeroBlock.php b/workbench/app/Blocks/HeroBlock.php index 519117b4..c1a32a0d 100644 --- a/workbench/app/Blocks/HeroBlock.php +++ b/workbench/app/Blocks/HeroBlock.php @@ -6,10 +6,10 @@ use Lattice\Lattice\Attributes\AsBlock; use Lattice\Lattice\Blocks\BlockDefinition; use Lattice\Lattice\Blocks\BlockSlots; -use Lattice\Lattice\Core\Components\Heading; use Lattice\Lattice\Core\PageSchema; use Lattice\Lattice\Forms\Components\TextInput; use Lattice\Lattice\Forms\FormData; +use Lattice\Lattice\Ui\Components\Heading; #[AsBlock('workbench.hero')] final class HeroBlock extends BlockDefinition diff --git a/workbench/app/Forms/BlockPageForm.php b/workbench/app/Forms/BlockPageForm.php index 4cbd6f6f..9dbc9eca 100644 --- a/workbench/app/Forms/BlockPageForm.php +++ b/workbench/app/Forms/BlockPageForm.php @@ -5,10 +5,10 @@ use Illuminate\Http\Request; use Lattice\Lattice\Attributes\AsForm; -use Lattice\Lattice\Core\Components\Card; use Lattice\Lattice\Forms\Components\BlockEditor; use Lattice\Lattice\Forms\Components\Form as FormComponent; use Lattice\Lattice\Forms\FormDefinition; +use Lattice\Lattice\Ui\Components\Card; use Symfony\Component\HttpFoundation\Response; use Workbench\App\Blocks\ColumnsBlock; use Workbench\App\Blocks\HeroBlock; diff --git a/workbench/app/Pages/BlockPageEditorPage.php b/workbench/app/Pages/BlockPageEditorPage.php index e33b8a95..590641c1 100644 --- a/workbench/app/Pages/BlockPageEditorPage.php +++ b/workbench/app/Pages/BlockPageEditorPage.php @@ -5,13 +5,13 @@ use Illuminate\Support\Collection; use Lattice\Lattice\Attributes\AsPage; -use Lattice\Lattice\Core\Components\Heading; -use Lattice\Lattice\Core\Components\Stack; -use Lattice\Lattice\Core\Components\Text; -use Lattice\Lattice\Core\Enums\Gap; use Lattice\Lattice\Core\Enums\HttpMethod; use Lattice\Lattice\Core\PageSchema; use Lattice\Lattice\Forms\Components\Form; +use Lattice\Lattice\Ui\Components\Heading; +use Lattice\Lattice\Ui\Components\Stack; +use Lattice\Lattice\Ui\Components\Text; +use Lattice\Lattice\Ui\Enums\Gap; use Workbench\App\Forms\BlockPageForm; #[AsPage(route: '/block-editor')] From 8577ee0680f473e68ab0c5cd3bf7b3671c93fb3c Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sat, 11 Jul 2026 14:22:49 +0200 Subject: [PATCH 39/53] refactor: adapt the block editor to the form module restructure --- .../components/fields/block-editor/canvas.tsx | 5 +++- .../fields/block-editor/index.test.tsx | 14 ++++++--- .../components/fields/block-editor/index.tsx | 29 ++++++++++++------- .../fields/block-editor/inspector.test.tsx | 4 +-- .../fields/block-editor/inspector.tsx | 4 +-- .../fields/block-editor/move-block.ts | 2 +- 6 files changed, 38 insertions(+), 20 deletions(-) diff --git a/resources/js/form/components/fields/block-editor/canvas.tsx b/resources/js/form/components/fields/block-editor/canvas.tsx index 65eb1b11..56c50704 100644 --- a/resources/js/form/components/fields/block-editor/canvas.tsx +++ b/resources/js/form/components/fields/block-editor/canvas.tsx @@ -13,7 +13,10 @@ import type { Node } from "@lattice-php/lattice/core/types"; import { Renderer } from "@lattice-php/lattice/core/renderer"; import { useT } from "@lattice-php/lattice/i18n"; import { cn } from "@lattice-php/lattice/lib/utils"; -import { ROW_ID_KEY, type RepeaterRow } from "../repeater-rows"; +import { + ROW_ID_KEY, + type RepeaterRow, +} from "@lattice-php/lattice/form/components/fields/repeater-rows"; import { encodePath, resolveMove } from "./dnd"; import type { BlockPath } from "./move-block"; diff --git a/resources/js/form/components/fields/block-editor/index.test.tsx b/resources/js/form/components/fields/block-editor/index.test.tsx index 276dde44..fb5c3b7b 100644 --- a/resources/js/form/components/fields/block-editor/index.test.tsx +++ b/resources/js/form/components/fields/block-editor/index.test.tsx @@ -14,8 +14,8 @@ vi.mock("@lattice-php/lattice/core/api", () => ({ })); import type React from "react"; -import { FormProvider } from "../../context"; -import { FormValuesProvider } from "../../values"; +import { FormProvider } from "@lattice-php/lattice/form/hooks/context"; +import { FormValuesProvider } from "@lattice-php/lattice/form/hooks/values"; import { BlockEditorComponent } from "./index"; function wrap( @@ -65,8 +65,14 @@ it("renders stored blocks on the canvas from the rendered prop", () => { expect(screen.getByTestId("block-shell-a")).toBeInTheDocument(); }); -it("renders nothing when the field is hidden", () => { - const hiddenNode = { ...baseNode, props: { ...baseNode.props, hidden: true } } as never; +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" }], diff --git a/resources/js/form/components/fields/block-editor/index.tsx b/resources/js/form/components/fields/block-editor/index.tsx index 62995d09..066b9da6 100644 --- a/resources/js/form/components/fields/block-editor/index.tsx +++ b/resources/js/form/components/fields/block-editor/index.tsx @@ -1,16 +1,25 @@ 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 "../../base/field"; -import { appendPath, toHtmlName } from "../../form-path"; -import { useFormContext } from "../../context"; -import { useDependentField } from "../../use-dependent-field"; -import { useSetFormValue } from "../../values"; -import { AddRowMenu, type AddRowOption } from "../add-row-menu"; -import { ROW_ID_KEY, type RepeaterRow } from "../repeater-rows"; -import { RowKeyInputs } from "../row-key-inputs"; -import { rowTemplatesOf, type RowTemplate } from "../row-templates"; -import { useRowCollection } from "../use-row-collection"; +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, + 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 { hiddenInputsFor } from "./hidden-inputs"; import { BlockInspector } from "./inspector"; diff --git a/resources/js/form/components/fields/block-editor/inspector.test.tsx b/resources/js/form/components/fields/block-editor/inspector.test.tsx index fe652d57..43256346 100644 --- a/resources/js/form/components/fields/block-editor/inspector.test.tsx +++ b/resources/js/form/components/fields/block-editor/inspector.test.tsx @@ -10,8 +10,8 @@ vi.mock("@lattice-php/lattice/core/renderer", () => ({ ), })); -import { FormProvider } from "../../context"; -import { FormValuesProvider } from "../../values"; +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). diff --git a/resources/js/form/components/fields/block-editor/inspector.tsx b/resources/js/form/components/fields/block-editor/inspector.tsx index 059e38bf..af7b0e48 100644 --- a/resources/js/form/components/fields/block-editor/inspector.tsx +++ b/resources/js/form/components/fields/block-editor/inspector.tsx @@ -1,8 +1,8 @@ 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 "../../field-scope"; -import type { RepeaterRow } from "../repeater-rows"; +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; diff --git a/resources/js/form/components/fields/block-editor/move-block.ts b/resources/js/form/components/fields/block-editor/move-block.ts index ba196815..c3f1b437 100644 --- a/resources/js/form/components/fields/block-editor/move-block.ts +++ b/resources/js/form/components/fields/block-editor/move-block.ts @@ -1,4 +1,4 @@ -import type { RepeaterRow } from "../repeater-rows"; +import type { RepeaterRow } from "@lattice-php/lattice/form/components/fields/repeater-rows"; export type BlockStep = { index: number; slot?: string }; export type BlockPath = BlockStep[]; From 597130e31b44183a8821b47f1830748c544152b1 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sat, 11 Jul 2026 16:16:02 +0200 Subject: [PATCH 40/53] feat: serialize declared slots on block editor templates --- .../form/components/fields/row-templates.ts | 2 +- src/Forms/Components/BlockEditor.php | 9 ++++-- src/Forms/Components/RowTemplate.php | 29 +++++++++++++++-- tests/Feature/Blocks/BlockEditorFieldTest.php | 31 +++++++++++++++++++ 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/resources/js/form/components/fields/row-templates.ts b/resources/js/form/components/fields/row-templates.ts index b6338dd7..615a5069 100644 --- a/resources/js/form/components/fields/row-templates.ts +++ b/resources/js/form/components/fields/row-templates.ts @@ -1,6 +1,6 @@ import type { Node } from "@lattice-php/lattice/core/types"; -export type RowTemplate = { type: string; label: string; schema: Node[] }; +export type RowTemplate = { type: string; label: string; schema: Node[]; slots?: string[] }; export function rowTemplatesOf(node: Node): RowTemplate[] | undefined { return (node as unknown as { templates?: RowTemplate[] }).templates; diff --git a/src/Forms/Components/BlockEditor.php b/src/Forms/Components/BlockEditor.php index 8357d156..d8ba0df6 100644 --- a/src/Forms/Components/BlockEditor.php +++ b/src/Forms/Components/BlockEditor.php @@ -26,8 +26,13 @@ public function blocks(array $blocks): static $registry = app(BlockRegistry::class); $this->templates(array_map( - fn (string $block): RowTemplate => RowTemplate::make($registry->keyFor($block)) - ->schema(app($block)->attributes()), + function (string $block) use ($registry): RowTemplate { + $definition = app($block); + + return RowTemplate::make($registry->keyFor($block)) + ->schema($definition->attributes()) + ->slots($definition->slots()); + }, $blocks, )); diff --git a/src/Forms/Components/RowTemplate.php b/src/Forms/Components/RowTemplate.php index 13ef5e4f..01ec38fe 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,11 @@ final class RowTemplate implements JsonSerializable private ?string $label = null; + /** + * @var array + */ + private array $slots = []; + private function __construct(public readonly string $type) {} public static function make(string $type): self @@ -35,6 +41,24 @@ public function label(string $label): self return $this; } + /** + * @param array $names + */ + public function slots(array $names): self + { + $this->slots = array_values($names); + + return $this; + } + + /** + * @return array + */ + public function slotNames(): array + { + return $this->slots; + } + /** * @return array */ @@ -47,7 +71,7 @@ public function fields(): array } /** - * @return array{type: string, label: string, schema: array} + * @return array{type: string, label: string, schema: array, slots?: array} */ public function jsonSerialize(): array { @@ -55,6 +79,7 @@ public function jsonSerialize(): array 'type' => $this->type, 'label' => $this->label ?? Str::headline($this->type), 'schema' => $this->renderableChildren(), + ...($this->slots === [] ? [] : ['slots' => $this->slots]), ]; } } diff --git a/tests/Feature/Blocks/BlockEditorFieldTest.php b/tests/Feature/Blocks/BlockEditorFieldTest.php index efc5d19f..69eec252 100644 --- a/tests/Feature/Blocks/BlockEditorFieldTest.php +++ b/tests/Feature/Blocks/BlockEditorFieldTest.php @@ -9,6 +9,7 @@ use Lattice\Lattice\Forms\Components\BlockEditor; use Lattice\Lattice\Forms\Components\TextInput; use Lattice\Lattice\Forms\FormData; +use Lattice\Lattice\Ui\Components\Grid; use Lattice\Lattice\Ui\Components\Heading; test('serializes as a block-editor field with a template per block', function (): void { @@ -23,6 +24,16 @@ ->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(['main']); +}); + test('serializes rendered wire for each stored row aligned by index', function (): void { app(BlockRegistry::class)->register([EditorHeroBlock::class]); @@ -54,3 +65,23 @@ 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'))); + } +} From 1380c47311daee864987d47ad5edffcb87b1dcda Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sat, 11 Jul 2026 16:22:55 +0200 Subject: [PATCH 41/53] feat: validate and cast nested slot rows in the block editor --- src/Forms/Components/BlockEditor.php | 123 +++++++++++++- src/Forms/Components/RowsField.php | 50 ++++-- src/Forms/Components/TypedRowsField.php | 10 +- .../Blocks/BlockEditorValidationTest.php | 160 +++++++++++++++++- 4 files changed, 314 insertions(+), 29 deletions(-) diff --git a/src/Forms/Components/BlockEditor.php b/src/Forms/Components/BlockEditor.php index d8ba0df6..b44392d1 100644 --- a/src/Forms/Components/BlockEditor.php +++ b/src/Forms/Components/BlockEditor.php @@ -3,18 +3,25 @@ namespace Lattice\Lattice\Forms\Components; +use Illuminate\Http\Request; use Lattice\Lattice\Attributes\SerializationHook; use Lattice\Lattice\Blocks\BlockDefinition; use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Blocks\BlockRenderer; use Lattice\Lattice\Forms\Attributes\AsField; use Lattice\Lattice\Forms\Enums\FieldType; +use Lattice\Lattice\Forms\FormData; use Lattice\Lattice\Ui\Components\IsInteractive; +use LogicException; #[AsField(FieldType::BlockEditor)] class BlockEditor extends TypedRowsField { - use IsInteractive; + use IsInteractive { + decorateProps as decorateInteractiveProps; + } + + public const string SLOTS = 'slots'; public ?string $endpoint = null; @@ -36,6 +43,17 @@ function (string $block) use ($registry): RowTemplate { $blocks, )); + 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, + )); + } + } + } + $this->id ??= $this->name; $this->endpoint ??= '/'.ltrim((string) config('lattice.blocks.endpoint', 'lattice/blocks/render'), '/'); $this->signedAs('block-editor'); @@ -47,6 +65,109 @@ function (string $block) use ($registry): RowTemplate { return $this; } + /** + * @param array $row + * @return array + */ + private function slotNamesFor(array $row): array + { + $type = $row[self::TYPE] ?? null; + + foreach ($this->templates as $template) { + if ($template->type === $type) { + return $template->slotNames(); + } + } + + return []; + } + + #[\Override] + protected function rowRulesAt(string $prefix, array $row, FormData $data, Request $request): array + { + $rules = parent::rowRulesAt($prefix, $row, $data, $request); + $slots = $this->slotNamesFor($row); + + if ($slots === []) { + return $rules; + } + + $rules["{$prefix}.".self::SLOTS] = ['sometimes', 'array']; + + foreach ($slots as $slot) { + $rules["{$prefix}.".self::SLOTS.".{$slot}"] = ['sometimes', 'array']; + + $childRows = $row[self::SLOTS][$slot] ?? []; + + foreach (array_values(is_array($childRows) ? $childRows : []) as $index => $childRow) { + $rules = [...$rules, ...$this->rowRulesAt( + "{$prefix}.".self::SLOTS.".{$slot}.{$index}", + is_array($childRow) ? $childRow : [], + $data, + $request, + )]; + } + } + + 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 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/tests/Feature/Blocks/BlockEditorValidationTest.php b/tests/Feature/Blocks/BlockEditorValidationTest.php index ea5e09c0..f1852ec1 100644 --- a/tests/Feature/Blocks/BlockEditorValidationTest.php +++ b/tests/Feature/Blocks/BlockEditorValidationTest.php @@ -3,28 +3,130 @@ use Illuminate\Http\Request; use Lattice\Lattice\Attributes\AsBlock; +use Lattice\Lattice\Attributes\AsForm; use Lattice\Lattice\Blocks\BlockDefinition; use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Blocks\BlockSlots; use Lattice\Lattice\Core\PageSchema; +use Lattice\Lattice\Facades\Lattice; use Lattice\Lattice\Forms\Components\BlockEditor; +use Lattice\Lattice\Forms\Components\Form; use Lattice\Lattice\Forms\Components\TextInput; use Lattice\Lattice\Forms\FormData; +use Lattice\Lattice\Forms\FormDefinition; +use Lattice\Lattice\Ui\Components\Grid; use Lattice\Lattice\Ui\Components\Heading; +use Symfony\Component\HttpFoundation\Response; beforeEach(function (): void { - app(BlockRegistry::class)->register([ValidationHeroBlock::class]); + app(BlockRegistry::class)->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 = BlockEditor::make('content')->blocks([ValidationHeroBlock::class]); + $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"'); + ->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 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')] @@ -40,3 +142,55 @@ 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.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'); + } +} From 379ac5ffc1a734a8affe70eb6a2ff54acc7a3f38 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sat, 11 Jul 2026 16:25:06 +0200 Subject: [PATCH 42/53] feat: serialize a rendered preview tree for slot children --- .../fields/block-editor/index.test.tsx | 2 +- .../components/fields/block-editor/index.tsx | 27 ++++++++++++---- .../fields/block-editor/use-block-preview.ts | 3 ++ src/Forms/Components/BlockEditor.php | 32 +++++++++++++++---- tests/Feature/Blocks/BlockEditorFieldTest.php | 26 +++++++++++++-- 5 files changed, 73 insertions(+), 17 deletions(-) diff --git a/resources/js/form/components/fields/block-editor/index.test.tsx b/resources/js/form/components/fields/block-editor/index.test.tsx index fb5c3b7b..ac0e6f30 100644 --- a/resources/js/form/components/fields/block-editor/index.test.tsx +++ b/resources/js/form/components/fields/block-editor/index.test.tsx @@ -52,7 +52,7 @@ const baseNode = { schema: [{ id: "t", type: "field.text-input", props: { name: "title" } }], }, ], - rendered: [[{ type: "heading", props: { text: "Stored" } }]], + rendered: [{ wire: [{ type: "heading", props: { text: "Stored" } }], slots: {} }], }; const node = baseNode as never; diff --git a/resources/js/form/components/fields/block-editor/index.tsx b/resources/js/form/components/fields/block-editor/index.tsx index 066b9da6..303605b4 100644 --- a/resources/js/form/components/fields/block-editor/index.tsx +++ b/resources/js/form/components/fields/block-editor/index.tsx @@ -24,17 +24,21 @@ import { BlockCanvas } from "./canvas"; import { hiddenInputsFor } from "./hidden-inputs"; import { BlockInspector } from "./inspector"; import { moveBlock, type BlockPath } from "./move-block"; -import { useBlockPreview, type BlockSource } from "./use-block-preview"; +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?: Node[][] }).rendered ?? []) as Node[][]; + const rendered = (node as unknown as { rendered?: RenderedBlock[] }).rendered ?? []; const { errors } = useFormContext(); const { hidden, required } = useDependentField(node); @@ -45,11 +49,20 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ const seeds = useMemo(() => { const wire: Record = {}; const sources: Record = {}; - rows.forEach((row, i) => { - const rowId = String(row[ROW_ID_KEY]); - wire[rowId] = rendered[i] ?? []; - sources[rowId] = { type: String(row.type ?? ""), attributes: rowAttributes(row) }; - }); + 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 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 index f6b2d65f..8339c2aa 100644 --- a/resources/js/form/components/fields/block-editor/use-block-preview.ts +++ b/resources/js/form/components/fields/block-editor/use-block-preview.ts @@ -4,6 +4,9 @@ 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; diff --git a/src/Forms/Components/BlockEditor.php b/src/Forms/Components/BlockEditor.php index b44392d1..d8cb1bca 100644 --- a/src/Forms/Components/BlockEditor.php +++ b/src/Forms/Components/BlockEditor.php @@ -174,16 +174,36 @@ private function withNestedRowIds(array $row): 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); - $rows = is_array($this->value) ? array_values($this->value) : []; + return array_map(function (mixed $row) use ($renderer): array { + $row = is_array($row) ? $row : []; + $slots = []; - $rendered = array_map( - fn (mixed $row): array => $renderer->render([is_array($row) ? $row : []])->renderable(), - $rows, - ); + foreach ($this->slotNamesFor($row) as $slot) { + $childRows = $row[self::SLOTS][$slot] ?? []; + $slots[$slot] = $this->renderedTree(is_array($childRows) ? array_values($childRows) : []); + } - return [...$data, 'rendered' => $rendered]; + return [ + 'wire' => $renderer->render([$row])->renderable(), + 'slots' => $slots, + ]; + }, $rows); } } diff --git a/tests/Feature/Blocks/BlockEditorFieldTest.php b/tests/Feature/Blocks/BlockEditorFieldTest.php index 69eec252..9fd8c691 100644 --- a/tests/Feature/Blocks/BlockEditorFieldTest.php +++ b/tests/Feature/Blocks/BlockEditorFieldTest.php @@ -47,9 +47,29 @@ $wire = wire($field); expect($wire['rendered'])->toHaveCount(2) - ->and($wire['rendered'][0][0]['type'])->toBe('heading') - ->and($wire['rendered'][0][0]['props']['text'])->toBe('First') - ->and($wire['rendered'][1][0]['props']['text'])->toBe('Second'); + ->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')] From 4cf9e1643fa4caa62bd4d1a5e9c7bf53180ad0e8 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sat, 11 Jul 2026 16:26:21 +0200 Subject: [PATCH 43/53] fix: enforce the allow-list for nested preview slot rows --- .../Controllers/BlockRenderController.php | 32 +++++++++- .../Blocks/BlockRenderEndpointTest.php | 58 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/Http/Controllers/BlockRenderController.php b/src/Http/Controllers/BlockRenderController.php index 77183805..da82b37c 100644 --- a/src/Http/Controllers/BlockRenderController.php +++ b/src/Http/Controllers/BlockRenderController.php @@ -24,7 +24,11 @@ public function __invoke(Request $request): JsonResponse $type = (string) $request->input('type'); $allowed = is_array($context['allowedBlocks'] ?? null) ? $context['allowedBlocks'] : []; - abort_unless(in_array($type, $allowed, true), 403); + $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); @@ -32,9 +36,33 @@ public function __invoke(Request $request): JsonResponse abort(404); } - $attributes = is_array($request->input('attributes')) ? $request->input('attributes') : []; $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/tests/Feature/Blocks/BlockRenderEndpointTest.php b/tests/Feature/Blocks/BlockRenderEndpointTest.php index 81f9f78f..5197c3a3 100644 --- a/tests/Feature/Blocks/BlockRenderEndpointTest.php +++ b/tests/Feature/Blocks/BlockRenderEndpointTest.php @@ -9,6 +9,7 @@ use Lattice\Lattice\Forms\Components\BlockEditor; use Lattice\Lattice\Forms\Components\TextInput; use Lattice\Lattice\Forms\FormData; +use Lattice\Lattice\Ui\Components\Grid; use Lattice\Lattice\Ui\Components\Heading; use function Pest\Laravel\postJson; @@ -46,6 +47,43 @@ ])->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 { @@ -73,3 +111,23 @@ 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'))); + } +} From bee5370e85f039f3a2413dc0f4779b29c5aff946 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sat, 11 Jul 2026 16:28:17 +0200 Subject: [PATCH 44/53] feat: add block tree helpers and a moveBlock cycle guard --- .../fields/block-editor/move-block.test.ts | 24 ++++ .../fields/block-editor/move-block.ts | 65 +++-------- .../fields/block-editor/tree.test.ts | 76 ++++++++++++ .../components/fields/block-editor/tree.ts | 110 ++++++++++++++++++ 4 files changed, 225 insertions(+), 50 deletions(-) create mode 100644 resources/js/form/components/fields/block-editor/tree.test.ts create mode 100644 resources/js/form/components/fields/block-editor/tree.ts 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 index a4ca711e..191d9726 100644 --- a/resources/js/form/components/fields/block-editor/move-block.test.ts +++ b/resources/js/form/components/fields/block-editor/move-block.test.ts @@ -78,4 +78,28 @@ describe("moveBlock", () => { ); 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 index c3f1b437..1d1206be 100644 --- a/resources/js/form/components/fields/block-editor/move-block.ts +++ b/resources/js/form/components/fields/block-editor/move-block.ts @@ -1,62 +1,27 @@ import type { RepeaterRow } from "@lattice-php/lattice/form/components/fields/repeater-rows"; +import { getContainer, replaceContainer, type BlockPath, type BlockStep } from "./tree"; -export type BlockStep = { index: number; slot?: string }; -export type BlockPath = BlockStep[]; +export type { BlockPath, BlockStep } from "./tree"; -function childList(row: RepeaterRow, slot: string): RepeaterRow[] { - const slots = (row.slots ?? {}) as Record; - - return Array.isArray(slots[slot]) ? slots[slot] : []; -} - -function withChildList(row: RepeaterRow, slot: string, list: RepeaterRow[]): RepeaterRow { - const slots = { ...((row.slots ?? {}) as Record), [slot]: list }; - - return { ...row, slots }; -} - -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; +function sameStep(a: BlockStep, b: BlockStep): boolean { + return a.index === b.index && a.slot === b.slot; } -function replaceContainer( - rows: RepeaterRow[], - path: BlockPath, - update: (list: RepeaterRow[]) => RepeaterRow[], -): RepeaterRow[] { - if (path.length <= 1) { - return update(rows); +/** 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; } - const [head, ...rest] = path; - const slot = head.slot; - - if (slot === undefined) { - return rows; + for (let i = 0; i < from.length - 1; i++) { + if (!sameStep(from[i], to[i])) { + return false; + } } - return rows.map((row, i) => - i === head.index - ? withChildList(row, slot, replaceContainer(childList(row, slot), rest, update)) - : row, - ); -} + const branch = to[from.length - 1]; -function sameStep(a: BlockStep, b: BlockStep): boolean { - return a.index === b.index && a.slot === b.slot; + return branch.index === from[from.length - 1].index && branch.slot !== undefined; } function adjustToForRemoval(from: BlockPath, to: BlockPath): BlockPath { @@ -94,7 +59,7 @@ export function moveBlock(rows: RepeaterRow[], from: BlockPath, to: BlockPath): const source = getContainer(rows, from); const fromIndex = from[from.length - 1]?.index ?? -1; - if (!source || fromIndex < 0 || fromIndex >= source.length) { + if (!source || fromIndex < 0 || fromIndex >= source.length || descendsIntoMoved(from, to)) { return rows; } 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..75ef61d6 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/tree.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { appendBlockAt, blockAt, removeBlockAt, 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("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..29032ef1 --- /dev/null +++ b/resources/js/form/components/fields/block-editor/tree.ts @@ -0,0 +1,110 @@ +import type { RepeaterRow } from "@lattice-php/lattice/form/components/fields/repeater-rows"; + +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)), + ); +} + +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, + ), + ); +} + +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]; + }); +} From da75421533d0ec5cad2339437bb641fb8aa938d6 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sat, 11 Jul 2026 16:35:24 +0200 Subject: [PATCH 45/53] feat: edit slot children in the block editor canvas --- lang/de/form.php | 3 + lang/en/form.php | 3 + .../fields/block-editor/canvas.test.tsx | 117 ++++++++++-- .../components/fields/block-editor/canvas.tsx | 178 ++++++++++++++---- .../components/fields/block-editor/dnd.ts | 2 +- .../block-editor/hidden-inputs.test.tsx | 33 ---- .../fields/block-editor/hidden-inputs.tsx | 19 -- .../fields/block-editor/index.test.tsx | 63 ++++++- .../components/fields/block-editor/index.tsx | 84 +++++++-- .../fields/block-editor/inspector.test.tsx | 21 ++- .../fields/block-editor/inspector.tsx | 17 +- resources/js/form/lib/prefill-targets.ts | 2 +- 12 files changed, 406 insertions(+), 136 deletions(-) delete mode 100644 resources/js/form/components/fields/block-editor/hidden-inputs.test.tsx delete mode 100644 resources/js/form/components/fields/block-editor/hidden-inputs.tsx diff --git a/lang/de/form.php b/lang/de/form.php index 995b6edb..f6376b5c 100644 --- a/lang/de/form.php +++ b/lang/de/form.php @@ -5,6 +5,9 @@ 'block-editor' => [ 'drag' => 'Ziehen', 'drag-to-reorder' => 'Zum Neuanordnen ziehen', + 'empty-slot' => 'Blöcke hier ablegen', + 'no-settings' => 'Dieser Block hat keine Einstellungen.', + 'remove' => 'Block entfernen', 'select-block' => 'Block auswählen, um ihn zu bearbeiten.', 'unknown-block' => 'Unbekannter Block', ], diff --git a/lang/en/form.php b/lang/en/form.php index 9e4af6a1..f4ccb150 100644 --- a/lang/en/form.php +++ b/lang/en/form.php @@ -5,6 +5,9 @@ 'block-editor' => [ 'drag' => 'Drag', 'drag-to-reorder' => 'Drag to reorder', + 'empty-slot' => 'Drop blocks here', + 'no-settings' => 'This block has no settings.', + 'remove' => 'Remove block', 'select-block' => 'Select a block to edit it.', 'unknown-block' => 'Unknown block', ], diff --git a/resources/js/form/components/fields/block-editor/canvas.test.tsx b/resources/js/form/components/fields/block-editor/canvas.test.tsx index e1e6cb7d..368e2f3f 100644 --- a/resources/js/form/components/fields/block-editor/canvas.test.tsx +++ b/resources/js/form/components/fields/block-editor/canvas.test.tsx @@ -11,30 +11,121 @@ vi.mock("@lattice-php/lattice/core/renderer", () => ({ import { BlockCanvas } from "./canvas"; -it("renders each row's wire and selects on click", () => { - const onSelect = vi.fn<(rowId: string) => void>(); - const rows = [ - { rowId: "a", type: "hero" }, - { rowId: "b", type: "text" }, - ]; - const wire = { - a: [{ type: "heading", props: { text: "Alpha" } }], - b: [{ type: "text", props: { text: "Beta" } }], - } as never; +const templates = [ + { type: "hero", label: "Hero", schema: [] }, + { type: "text", label: "Text", schema: [] }, + { type: "columns", label: "Columns", schema: [], slots: ["main"] }, +] as never[]; + +function renderCanvas(rows: Record[], overrides: Record = {}) { + const handlers = { + onSelect: vi.fn<(rowId: string) => void>(), + onRemove: vi.fn<(path: unknown) => void>(), + onAppend: vi.fn<(path: unknown, slot: string, type: string) => void>(), + }; render( (wire as any)[id]} - selectedId="a" - onSelect={onSelect} + templates={templates as never} + addLabel="Add" + wireFor={(id: string) => + (((overrides.wire as Record) ?? {})[id] as never) ?? [] + } + onPreviewSeed={() => {}} + selectedId={(overrides.selectedId as string) ?? null} + onSelect={handlers.onSelect} onMoveBlock={() => {}} + onRemove={handlers.onRemove} + onAppend={handlers.onAppend} />, ); + return handlers; +} + +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 shell header", () => { + const { onRemove } = renderCanvas([ + { rowId: "a", type: "hero" }, + { rowId: "b", type: "text" }, + ]); + + fireEvent.click(screen.getByTestId("block-remove-b")); + + expect(onRemove).toHaveBeenCalledWith([{ index: 1 }]); +}); + +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("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("removes a nested child with its slot path", () => { + const { onRemove } = renderCanvas([ + { + rowId: "c1", + type: "columns", + slots: { main: [{ rowId: "h1", type: "hero" }] }, + }, + ]); + + fireEvent.click(screen.getByTestId("block-remove-h1")); + + 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 index 56c50704..0fe14e6a 100644 --- a/resources/js/form/components/fields/block-editor/canvas.tsx +++ b/resources/js/form/components/fields/block-editor/canvas.tsx @@ -3,48 +3,120 @@ import { DndContext, KeyboardSensor, PointerSensor, + useDroppable, useSensor, useSensors, } from "@dnd-kit/core"; import type { DragEndEvent } from "@dnd-kit/core"; import { SortableContext, 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 { 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"; import { encodePath, resolveMove } from "./dnd"; -import type { BlockPath } from "./move-block"; +import { childList, type BlockPath } from "./tree"; -type Props = { - rows: RepeaterRow[]; +type CanvasShared = { + templates: RowTemplate[]; + addLabel: string; wireFor: (rowId: string) => Node[]; + onPreviewSeed: (row: RepeaterRow) => void; selectedId: string | null; onSelect: (rowId: string) => void; + onRemove: (path: BlockPath) => void; + onAppend: (path: BlockPath, slot: string, type: string) => void; +}; + +type Props = CanvasShared & { + rows: RepeaterRow[]; onMoveBlock: (from: BlockPath, to: BlockPath) => void; }; -function Shell({ - row, - path, - wireFor, - selectedId, - onSelect, +function EmptySlot({ prefix }: { prefix: BlockPath }) { + const { setNodeRef, isOver } = useDroppable({ id: encodePath([...prefix, { index: 0 }]) }); + const { t } = useT("lattice"); + + return ( +
+ {t("form.block-editor.empty-slot", "Drop blocks here")} +
+ ); +} + +function SlotArea({ + parentPath, + slot, + rows, + shared, }: { - row: RepeaterRow; - path: BlockPath; - wireFor: (rowId: string) => Node[]; - selectedId: string | null; - onSelect: (rowId: string) => void; + parentPath: BlockPath; + slot: string; + rows: RepeaterRow[]; + shared: CanvasShared; }) { + const last = parentPath[parentPath.length - 1]; + const prefix: BlockPath = [...parentPath.slice(0, -1), { ...last, slot }]; + const ids = rows.map((_, index) => encodePath([...prefix, { index }])); + const options = shared.templates.map((template) => ({ + type: template.type, + label: template.label, + })); + + return ( +
+ {slot} + +
+ {rows.map((row, index) => ( + + ))} + {rows.length === 0 && } +
+
+ shared.onAppend(parentPath, slot, type)} + /> +
+ ); +} + +function Shell({ row, path, shared }: { row: RepeaterRow; path: BlockPath; shared: CanvasShared }) { const id = encodePath(path); const rowId = String(row[ROW_ID_KEY]); const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id }); 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 (
onSelect(rowId)} + aria-selected={shared.selectedId === rowId} + onClick={(e) => { + e.stopPropagation(); + shared.onSelect(rowId); + }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { if (e.key === " ") { e.preventDefault(); } - onSelect(rowId); + e.stopPropagation(); + shared.onSelect(rowId); } }} tabIndex={0} className={cn( "cursor-pointer rounded-lt border p-2", - selectedId === rowId + shared.selectedId === rowId ? "border-lt-primary ring-1 ring-lt-primary" : "border-transparent hover:border-lt-border", )} > - - +
+ + + {template?.label ?? String(row.type ?? "")} + + +
+ {slots.length === 0 ? ( + + ) : ( +
+ {slots.map((slot) => ( + + ))} +
+ )}
); } -export function BlockCanvas({ rows, wireFor, selectedId, onSelect, onMoveBlock }: Props) { +export function BlockCanvas({ rows, onMoveBlock, ...shared }: Props) { const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor)); const onDragEnd = (event: DragEndEvent): void => { @@ -107,14 +214,7 @@ export function BlockCanvas({ rows, wireFor, selectedId, onSelect, onMoveBlock }
{rows.map((row, index) => ( - + ))}
diff --git a/resources/js/form/components/fields/block-editor/dnd.ts b/resources/js/form/components/fields/block-editor/dnd.ts index acaf20bf..c60ed78d 100644 --- a/resources/js/form/components/fields/block-editor/dnd.ts +++ b/resources/js/form/components/fields/block-editor/dnd.ts @@ -1,4 +1,4 @@ -import type { BlockPath } from "./move-block"; +import type { BlockPath } from "./tree"; export function encodePath(path: BlockPath): string { return path diff --git a/resources/js/form/components/fields/block-editor/hidden-inputs.test.tsx b/resources/js/form/components/fields/block-editor/hidden-inputs.test.tsx deleted file mode 100644 index ed508e16..00000000 --- a/resources/js/form/components/fields/block-editor/hidden-inputs.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { configure, render } from "@testing-library/react"; -import { beforeAll, describe, expect, it } from "vitest"; -import { hiddenInputsFor } from "./hidden-inputs"; - -beforeAll(() => configure({ testIdAttribute: "data-test" })); - -function renderInputs(name: string, value: unknown) { - return render(<>{hiddenInputsFor(name, value)}).container.querySelectorAll( - 'input[type="hidden"]', - ); -} - -describe("hiddenInputsFor", () => { - it("emits hidden inputs for nested slot rows including their rowId", () => { - const inputs = renderInputs("content[0][slots]", { - left: [{ rowId: "x", type: "text", body: "L" }], - }); - - const byName = Object.fromEntries( - Array.from(inputs).map((input) => [input.getAttribute("name"), input.getAttribute("value")]), - ); - - expect(byName["content[0][slots][left][0][type]"]).toBe("text"); - expect(byName["content[0][slots][left][0][body]"]).toBe("L"); - expect(byName["content[0][slots][left][0][rowId]"]).toBe("x"); - expect(inputs).toHaveLength(3); - }); - - it("emits nothing for null or undefined", () => { - expect(hiddenInputsFor("content[0][slots]", null)).toHaveLength(0); - expect(hiddenInputsFor("content[0][slots]", undefined)).toHaveLength(0); - }); -}); diff --git a/resources/js/form/components/fields/block-editor/hidden-inputs.tsx b/resources/js/form/components/fields/block-editor/hidden-inputs.tsx deleted file mode 100644 index 06e268cb..00000000 --- a/resources/js/form/components/fields/block-editor/hidden-inputs.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { ReactElement } from "react"; - -export function hiddenInputsFor(name: string, value: unknown): ReactElement[] { - if (value == null) { - return []; - } - - if (Array.isArray(value)) { - return value.flatMap((item, index) => hiddenInputsFor(`${name}[${index}]`, item)); - } - - if (typeof value === "object") { - return Object.entries(value as Record).flatMap(([key, child]) => - hiddenInputsFor(`${name}[${key}]`, child), - ); - } - - return []; -} diff --git a/resources/js/form/components/fields/block-editor/index.test.tsx b/resources/js/form/components/fields/block-editor/index.test.tsx index ac0e6f30..9e12a635 100644 --- a/resources/js/form/components/fields/block-editor/index.test.tsx +++ b/resources/js/form/components/fields/block-editor/index.test.tsx @@ -1,4 +1,4 @@ -import { configure, render, screen } from "@testing-library/react"; +import { configure, fireEvent, render, screen } from "@testing-library/react"; import { beforeAll, expect, it, vi } from "vitest"; beforeAll(() => configure({ testIdAttribute: "data-test" })); @@ -51,6 +51,7 @@ const baseNode = { label: "Hero", schema: [{ id: "t", type: "field.text-input", props: { name: "title" } }], }, + { type: "columns", label: "Columns", schema: [], slots: ["main"] }, ], rendered: [{ wire: [{ type: "heading", props: { text: "Stored" } }], slots: {} }], }; @@ -90,3 +91,63 @@ it("shows the field error from the form context", () => { 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(screen.getByTestId("block-remove-h1")); + + 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(); +}); diff --git a/resources/js/form/components/fields/block-editor/index.tsx b/resources/js/form/components/fields/block-editor/index.tsx index 303605b4..3dae6664 100644 --- a/resources/js/form/components/fields/block-editor/index.tsx +++ b/resources/js/form/components/fields/block-editor/index.tsx @@ -12,6 +12,7 @@ import { } 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"; @@ -21,9 +22,9 @@ import { } 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 { hiddenInputsFor } from "./hidden-inputs"; import { BlockInspector } from "./inspector"; -import { moveBlock, type BlockPath } from "./move-block"; +import { moveBlock } from "./move-block"; +import { appendBlockAt, removeBlockAt, updateBlockAt, walkBlocks, type BlockPath } from "./tree"; import { useBlockPreview, type BlockSource, type RenderedBlock } from "./use-block-preview"; function rowAttributes(row: RepeaterRow): Record { @@ -42,7 +43,7 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ const { errors } = useFormContext(); const { hidden, required } = useDependentField(node); - const { path, rows, onField, append } = useRowCollection(name, props.defaultItems ?? 0); + const { path, rows, append } = useRowCollection(name, props.defaultItems ?? 0); const setValue = useSetFormValue(); const { t } = useT("lattice"); @@ -90,14 +91,47 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ })); const atMax = props.maxItems != null && rows.length >= props.maxItems; - const selectedRow = rows.find((row) => String(row[ROW_ID_KEY]) === selectedId) ?? null; + 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) => moveBlock(asRows(prev), 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 onAppend = (parentPath: BlockPath, slot: string, type: string): void => { setValue(path, (prev: unknown) => - moveBlock(Array.isArray(prev) ? (prev as RepeaterRow[]) : [], from, to), + appendBlockAt(asRows(prev), parentPath, slot, withRowId({ type })), ); }; + /** 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; + }; + + const refreshRow = (row: RepeaterRow): void => { + void refresh(String(row[ROW_ID_KEY]), String(row.type ?? ""), rowAttributes(row)); + }; + return ( = ({ - {rows.flatMap((row, index) => - row.slots == null - ? [] - : hiddenInputsFor(toHtmlName(appendPath(path, index, "slots")), row.slots), - )} + {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 && ( = ({
- {rows.map((row, index) => { - const isSelected = String(row[ROW_ID_KEY]) === selectedId; + {entries.map(({ row, path: blockPath }) => { + const rowId = String(row[ROW_ID_KEY]); const template = templateFor(row.type); return ( -
+
- refresh(String(row[ROW_ID_KEY]), String(row.type), rowAttributes(row)) - } + onField={(field, value) => onFieldAt(blockPath, field, value)} + onCommit={() => refreshRow(row)} />
); diff --git a/resources/js/form/components/fields/block-editor/inspector.test.tsx b/resources/js/form/components/fields/block-editor/inspector.test.tsx index 43256346..01f6f707 100644 --- a/resources/js/form/components/fields/block-editor/inspector.test.tsx +++ b/resources/js/form/components/fields/block-editor/inspector.test.tsx @@ -45,7 +45,7 @@ it("renders the block fields and commits on blur", () => { index={0} row={{ rowId: "a", type: "hero", title: "Hi" }} template={template} - onField={vi.fn<(index: number, field: string, value: unknown) => void>()} + onField={vi.fn<(field: string, value: unknown) => void>()} onCommit={onCommit} />, ); @@ -65,7 +65,7 @@ it("does not commit when focus moves between fields inside the inspector", () => index={0} row={{ rowId: "a", type: "hero", title: "Hi" }} template={template} - onField={vi.fn<(index: number, field: string, value: unknown) => void>()} + onField={vi.fn<(field: string, value: unknown) => void>()} onCommit={onCommit} />, ); @@ -83,10 +83,25 @@ it("shows an unknown-block note when there is no template", () => { base="content" index={0} row={{ rowId: "a", type: "gone" }} - onField={vi.fn<(index: number, field: string, value: unknown) => void>()} + onField={vi.fn<(field: string, value: unknown) => 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 index af7b0e48..e0d4f215 100644 --- a/resources/js/form/components/fields/block-editor/inspector.tsx +++ b/resources/js/form/components/fields/block-editor/inspector.tsx @@ -9,7 +9,7 @@ type Props = { index: number; row: RepeaterRow; template?: Node[]; - onField: (index: number, field: string, value: unknown) => void; + onField: (field: string, value: unknown) => void; onCommit: () => void; }; @@ -24,6 +24,14 @@ export function BlockInspector({ base, index, row, template, onField, onCommit } ); } + if (template.length === 0) { + return ( +

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

+ ); + } + return (
- onField(index, field, value)} - > +
{template.map((child) => ( 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"; From 337d2b352a169adf95ea3bbec1e41e5d628a1aa6 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sat, 11 Jul 2026 16:36:59 +0200 Subject: [PATCH 46/53] test: browser coverage for slot editing and block removal --- tests/Browser/Blocks/BlockEditorTest.php | 52 +++++++++++++++++++++ workbench/app/Pages/BlockPageEditorPage.php | 28 +++++++++-- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/tests/Browser/Blocks/BlockEditorTest.php b/tests/Browser/Blocks/BlockEditorTest.php index 980abfe4..6f739353 100644 --- a/tests/Browser/Blocks/BlockEditorTest.php +++ b/tests/Browser/Blocks/BlockEditorTest.php @@ -34,6 +34,58 @@ ->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"]') + ->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('removes a block from the canvas', 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^="block-remove-"]'); + + $page->assertNotPresent('[data-test^="block-shell-"]'); + + $page->assertNoJavaScriptErrors(); +}); + it('persists attributes for every block, not just the selected one', function (): void { actingAs(workbenchTestUser()); diff --git a/workbench/app/Pages/BlockPageEditorPage.php b/workbench/app/Pages/BlockPageEditorPage.php index 590641c1..ceedb025 100644 --- a/workbench/app/Pages/BlockPageEditorPage.php +++ b/workbench/app/Pages/BlockPageEditorPage.php @@ -25,9 +25,7 @@ public function title(): string public function render(PageSchema $schema): PageSchema { $saved = session('block-editor.saved'); - $savedTitles = Collection::make(is_array($saved) ? $saved : []) - ->pluck('title') - ->filter(fn (mixed $title): bool => is_string($title) && $title !== ''); + $savedTitles = Collection::make($this->blockTitles($saved)); return $schema->schema([ Stack::make('block-editor-page') @@ -43,4 +41,28 @@ public function render(PageSchema $schema): PageSchema ]), ]); } + + /** + * @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; + } } From 41358c43caf58084b429e199039459e731dd2d5f Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sat, 11 Jul 2026 16:49:35 +0200 Subject: [PATCH 47/53] feat: restrict block editor slots to allowed blocks --- .../fields/block-editor/canvas.test.tsx | 27 ++++++++- .../components/fields/block-editor/canvas.tsx | 26 ++++++--- .../fields/block-editor/index.test.tsx | 2 +- .../components/fields/block-editor/index.tsx | 22 ++++++- .../fields/block-editor/tree.test.ts | 40 ++++++++++++- .../components/fields/block-editor/tree.ts | 26 +++++++++ .../form/components/fields/row-templates.ts | 9 ++- src/Blocks/BlockDefinition.php | 5 +- src/Blocks/BlockRenderer.php | 3 +- src/Blocks/Slot.php | 48 ++++++++++++++++ src/Forms/Components/BlockEditor.php | 57 ++++++++++++++++--- src/Forms/Components/RowTemplate.php | 32 +++++++++-- tests/Feature/Blocks/BlockEditorFieldTest.php | 35 +++++++++++- .../Blocks/BlockEditorValidationTest.php | 52 +++++++++++++++++ 14 files changed, 354 insertions(+), 30 deletions(-) create mode 100644 src/Blocks/Slot.php diff --git a/resources/js/form/components/fields/block-editor/canvas.test.tsx b/resources/js/form/components/fields/block-editor/canvas.test.tsx index 368e2f3f..1aa404ef 100644 --- a/resources/js/form/components/fields/block-editor/canvas.test.tsx +++ b/resources/js/form/components/fields/block-editor/canvas.test.tsx @@ -14,7 +14,13 @@ import { BlockCanvas } from "./canvas"; const templates = [ { type: "hero", label: "Hero", schema: [] }, { type: "text", label: "Text", schema: [] }, - { type: "columns", label: "Columns", schema: [], slots: ["main"] }, + { type: "columns", label: "Columns", schema: [], slots: [{ name: "main" }] }, + { + type: "restricted", + label: "Restricted", + schema: [], + slots: [{ name: "main", blocks: ["hero"] }], + }, ] as never[]; function renderCanvas(rows: Record[], overrides: Record = {}) { @@ -116,6 +122,25 @@ it("selects a nested child without selecting its parent", () => { 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([ { diff --git a/resources/js/form/components/fields/block-editor/canvas.tsx b/resources/js/form/components/fields/block-editor/canvas.tsx index 0fe14e6a..95055210 100644 --- a/resources/js/form/components/fields/block-editor/canvas.tsx +++ b/resources/js/form/components/fields/block-editor/canvas.tsx @@ -21,7 +21,10 @@ 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"; +import type { + RowTemplate, + RowTemplateSlot, +} from "@lattice-php/lattice/form/components/fields/row-templates"; import { encodePath, resolveMove } from "./dnd"; import { childList, type BlockPath } from "./tree"; @@ -65,21 +68,26 @@ function SlotArea({ shared, }: { parentPath: BlockPath; - slot: string; + slot: RowTemplateSlot; rows: RepeaterRow[]; shared: CanvasShared; }) { const last = parentPath[parentPath.length - 1]; - const prefix: BlockPath = [...parentPath.slice(0, -1), { ...last, slot }]; + const prefix: BlockPath = [...parentPath.slice(0, -1), { ...last, slot: slot.name }]; const ids = rows.map((_, index) => encodePath([...prefix, { index }])); - const options = shared.templates.map((template) => ({ + 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, })); return ( -
- {slot} +
+ + {slot.name} +
{rows.map((row, index) => ( @@ -96,7 +104,7 @@ function SlotArea({ shared.onAppend(parentPath, slot, type)} + onSelect={(type) => shared.onAppend(parentPath, slot.name, type)} />
); @@ -180,10 +188,10 @@ function Shell({ row, path, shared }: { row: RepeaterRow; path: BlockPath; share
{slots.map((slot) => ( ))} diff --git a/resources/js/form/components/fields/block-editor/index.test.tsx b/resources/js/form/components/fields/block-editor/index.test.tsx index 9e12a635..41441da1 100644 --- a/resources/js/form/components/fields/block-editor/index.test.tsx +++ b/resources/js/form/components/fields/block-editor/index.test.tsx @@ -51,7 +51,7 @@ const baseNode = { label: "Hero", schema: [{ id: "t", type: "field.text-input", props: { name: "title" } }], }, - { type: "columns", label: "Columns", schema: [], slots: ["main"] }, + { type: "columns", label: "Columns", schema: [], slots: [{ name: "main" }] }, ], rendered: [{ wire: [{ type: "heading", props: { text: "Stored" } }], slots: {} }], }; diff --git a/resources/js/form/components/fields/block-editor/index.tsx b/resources/js/form/components/fields/block-editor/index.tsx index 3dae6664..6829be5c 100644 --- a/resources/js/form/components/fields/block-editor/index.tsx +++ b/resources/js/form/components/fields/block-editor/index.tsx @@ -24,7 +24,15 @@ import { useRowCollection } from "@lattice-php/lattice/form/components/fields/us import { BlockCanvas } from "./canvas"; import { BlockInspector } from "./inspector"; import { moveBlock } from "./move-block"; -import { appendBlockAt, removeBlockAt, updateBlockAt, walkBlocks, type BlockPath } from "./tree"; +import { + appendBlockAt, + blockAt, + removeBlockAt, + slotAllowedTypes, + updateBlockAt, + walkBlocks, + type BlockPath, +} from "./tree"; import { useBlockPreview, type BlockSource, type RenderedBlock } from "./use-block-preview"; function rowAttributes(row: RepeaterRow): Record { @@ -99,7 +107,17 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ Array.isArray(prev) ? (prev as RepeaterRow[]) : []; const onMoveBlock = (from: BlockPath, to: BlockPath): void => { - setValue(path, (prev: unknown) => moveBlock(asRows(prev), from, to)); + 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 => { diff --git a/resources/js/form/components/fields/block-editor/tree.test.ts b/resources/js/form/components/fields/block-editor/tree.test.ts index 75ef61d6..3f33b8e8 100644 --- a/resources/js/form/components/fields/block-editor/tree.test.ts +++ b/resources/js/form/components/fields/block-editor/tree.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { appendBlockAt, blockAt, removeBlockAt, updateBlockAt, walkBlocks } from "./tree"; +import { + appendBlockAt, + blockAt, + removeBlockAt, + slotAllowedTypes, + updateBlockAt, + walkBlocks, +} from "./tree"; const rows = () => [ { rowId: "a", type: "text", body: "A" }, @@ -66,6 +73,37 @@ describe("appendBlockAt", () => { }); }); +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()); diff --git a/resources/js/form/components/fields/block-editor/tree.ts b/resources/js/form/components/fields/block-editor/tree.ts index 29032ef1..4d6c9d4a 100644 --- a/resources/js/form/components/fields/block-editor/tree.ts +++ b/resources/js/form/components/fields/block-editor/tree.ts @@ -1,4 +1,5 @@ import 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[]; @@ -96,6 +97,31 @@ export function appendBlockAt( ); } +/** + * 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. */ diff --git a/resources/js/form/components/fields/row-templates.ts b/resources/js/form/components/fields/row-templates.ts index 615a5069..9d1d139f 100644 --- a/resources/js/form/components/fields/row-templates.ts +++ b/resources/js/form/components/fields/row-templates.ts @@ -1,6 +1,13 @@ import type { Node } from "@lattice-php/lattice/core/types"; -export type RowTemplate = { type: string; label: string; schema: Node[]; slots?: string[] }; +export type RowTemplateSlot = { name: string; blocks?: string[] }; + +export type RowTemplate = { + type: string; + label: string; + schema: Node[]; + slots?: RowTemplateSlot[]; +}; export function rowTemplatesOf(node: Node): RowTemplate[] | undefined { return (node as unknown as { templates?: RowTemplate[] }).templates; diff --git a/src/Blocks/BlockDefinition.php b/src/Blocks/BlockDefinition.php index 04313efa..4b49e009 100644 --- a/src/Blocks/BlockDefinition.php +++ b/src/Blocks/BlockDefinition.php @@ -26,7 +26,10 @@ public function inlineText(): array } /** - * @return array + * 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 { diff --git a/src/Blocks/BlockRenderer.php b/src/Blocks/BlockRenderer.php index 9d687f68..0795a708 100644 --- a/src/Blocks/BlockRenderer.php +++ b/src/Blocks/BlockRenderer.php @@ -68,7 +68,8 @@ private function slotsFor(BlockDefinition $block, array $row): BlockSlots { $rendered = []; - foreach ($block->slots() as $name) { + foreach ($block->slots() as $slot) { + $name = $slot instanceof Slot ? $slot->name : $slot; $childRows = $row['slots'][$name] ?? []; $rendered[$name] = is_array($childRows) ? $this->components($childRows) : []; } diff --git a/src/Blocks/Slot.php b/src/Blocks/Slot.php new file mode 100644 index 00000000..f8d98627 --- /dev/null +++ b/src/Blocks/Slot.php @@ -0,0 +1,48 @@ +> + */ + private array $blocks = []; + + private function __construct(public readonly string $name) {} + + public static function make(string $name): self + { + return new self($name); + } + + /** + * 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/Forms/Components/BlockEditor.php b/src/Forms/Components/BlockEditor.php index d8cb1bca..1f2935ba 100644 --- a/src/Forms/Components/BlockEditor.php +++ b/src/Forms/Components/BlockEditor.php @@ -4,10 +4,12 @@ namespace Lattice\Lattice\Forms\Components; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; use Lattice\Lattice\Attributes\SerializationHook; use Lattice\Lattice\Blocks\BlockDefinition; use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Blocks\BlockRenderer; +use Lattice\Lattice\Blocks\Slot; use Lattice\Lattice\Forms\Attributes\AsField; use Lattice\Lattice\Forms\Enums\FieldType; use Lattice\Lattice\Forms\FormData; @@ -38,11 +40,23 @@ function (string $block) use ($registry): RowTemplate { return RowTemplate::make($registry->keyFor($block)) ->schema($definition->attributes()) - ->slots($definition->slots()); + ->slots(array_map( + static fn (Slot|string $slot): array|string => $slot instanceof Slot + ? [ + 'name' => $slot->name, + ...($slot->allowedBlocks() === [] + ? [] + : ['blocks' => array_map($registry->keyFor(...), $slot->allowedBlocks())]), + ] + : $slot, + $definition->slots(), + )); }, $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) { @@ -52,6 +66,19 @@ function (string $block) use ($registry): RowTemplate { )); } } + + 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; @@ -67,26 +94,35 @@ function (string $block) use ($registry): RowTemplate { /** * @param array $row - * @return array */ - private function slotNamesFor(array $row): array + private function templateForRow(array $row): ?RowTemplate { $type = $row[self::TYPE] ?? null; foreach ($this->templates as $template) { if ($template->type === $type) { - return $template->slotNames(); + return $template; } } - return []; + 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); - $slots = $this->slotNamesFor($row); + $template = $this->templateForRow($row); + $slots = $template?->slotNames() ?? []; if ($slots === []) { return $rules; @@ -97,15 +133,22 @@ protected function rowRulesAt(string $prefix, array $row, FormData $data, Reques 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( - "{$prefix}.".self::SLOTS.".{$slot}.{$index}", + $childPrefix, is_array($childRow) ? $childRow : [], $data, $request, )]; + + if ($allowedTypes !== null) { + $rules["{$childPrefix}.".self::TYPE] = ['required', Rule::in($allowedTypes)]; + } } } diff --git a/src/Forms/Components/RowTemplate.php b/src/Forms/Components/RowTemplate.php index 01ec38fe..419eb8ca 100644 --- a/src/Forms/Components/RowTemplate.php +++ b/src/Forms/Components/RowTemplate.php @@ -23,7 +23,7 @@ final class RowTemplate implements JsonSerializable private ?string $label = null; /** - * @var array + * @var array}> */ private array $slots = []; @@ -42,11 +42,17 @@ public function label(string $label): self } /** - * @param array $names + * Declare the row type's named child-row lists: plain names for + * unrestricted slots, or shapes restricting a slot to certain row types. + * + * @param array}|string> $slots */ - public function slots(array $names): self + public function slots(array $slots): self { - $this->slots = array_values($names); + $this->slots = array_values(array_map( + static fn (array|string $slot): array => is_string($slot) ? ['name' => $slot] : $slot, + $slots, + )); return $this; } @@ -56,7 +62,23 @@ public function slots(array $names): self */ public function slotNames(): array { - return $this->slots; + 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; } /** diff --git a/tests/Feature/Blocks/BlockEditorFieldTest.php b/tests/Feature/Blocks/BlockEditorFieldTest.php index 9fd8c691..f6c546e8 100644 --- a/tests/Feature/Blocks/BlockEditorFieldTest.php +++ b/tests/Feature/Blocks/BlockEditorFieldTest.php @@ -5,6 +5,7 @@ use Lattice\Lattice\Blocks\BlockDefinition; use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Blocks\BlockSlots; +use Lattice\Lattice\Blocks\Slot; use Lattice\Lattice\Core\PageSchema; use Lattice\Lattice\Forms\Components\BlockEditor; use Lattice\Lattice\Forms\Components\TextInput; @@ -31,9 +32,21 @@ expect($wire['templates'][0])->not->toHaveKey('slots') ->and($wire['templates'][1]['type'])->toBe('editor.columns') - ->and($wire['templates'][1]['slots'])->toBe(['main']); + ->and($wire['templates'][1]['slots'])->toBe([['name' => 'main']]); }); +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]); @@ -105,3 +118,23 @@ 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 index f1852ec1..eb00eef6 100644 --- a/tests/Feature/Blocks/BlockEditorValidationTest.php +++ b/tests/Feature/Blocks/BlockEditorValidationTest.php @@ -7,6 +7,7 @@ use Lattice\Lattice\Blocks\BlockDefinition; use Lattice\Lattice\Blocks\BlockRegistry; use Lattice\Lattice\Blocks\BlockSlots; +use Lattice\Lattice\Blocks\Slot; use Lattice\Lattice\Core\PageSchema; use Lattice\Lattice\Facades\Lattice; use Lattice\Lattice\Forms\Components\BlockEditor; @@ -55,6 +56,37 @@ function validationEditor(): BlockEditor ->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(); @@ -163,6 +195,26 @@ public function render(FormData $data, BlockSlots $slots): PageSchema } } +#[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 { From 01a142cdd46d3449d0668bc902c56197593048f6 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sat, 11 Jul 2026 16:51:55 +0200 Subject: [PATCH 48/53] fix: drive block editor drag-and-drop by stable row ids --- .../components/fields/block-editor/canvas.tsx | 60 +++++++++++---- .../fields/block-editor/dnd.test.ts | 59 +++++++++++---- .../components/fields/block-editor/dnd.ts | 73 ++++++++++++++----- 3 files changed, 145 insertions(+), 47 deletions(-) diff --git a/resources/js/form/components/fields/block-editor/canvas.tsx b/resources/js/form/components/fields/block-editor/canvas.tsx index 95055210..3d11629b 100644 --- a/resources/js/form/components/fields/block-editor/canvas.tsx +++ b/resources/js/form/components/fields/block-editor/canvas.tsx @@ -2,13 +2,19 @@ import { closestCenter, DndContext, KeyboardSensor, + pointerWithin, PointerSensor, useDroppable, useSensor, useSensors, } from "@dnd-kit/core"; -import type { DragEndEvent } from "@dnd-kit/core"; -import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +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"; @@ -25,7 +31,7 @@ import type { RowTemplate, RowTemplateSlot, } from "@lattice-php/lattice/form/components/fields/row-templates"; -import { encodePath, resolveMove } from "./dnd"; +import { dropDepth, resolveDrop, slotDropId } from "./dnd"; import { childList, type BlockPath } from "./tree"; type CanvasShared = { @@ -44,8 +50,8 @@ type Props = CanvasShared & { onMoveBlock: (from: BlockPath, to: BlockPath) => void; }; -function EmptySlot({ prefix }: { prefix: BlockPath }) { - const { setNodeRef, isOver } = useDroppable({ id: encodePath([...prefix, { index: 0 }]) }); +function EmptySlot({ parentRowId, slot }: { parentRowId: string; slot: string }) { + const { setNodeRef, isOver } = useDroppable({ id: slotDropId(parentRowId, slot) }); const { t } = useT("lattice"); return ( @@ -63,18 +69,20 @@ function EmptySlot({ prefix }: { prefix: BlockPath }) { 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((_, index) => encodePath([...prefix, { index }])); + 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; @@ -98,7 +106,7 @@ function SlotArea({ shared={shared} /> ))} - {rows.length === 0 && } + {rows.length === 0 && }
candidate.type === row.type); const slots = template?.slots ?? []; @@ -190,6 +197,7 @@ function Shell({ row, path, shared }: { row: RepeaterRow; path: BlockPath; share { + 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; @@ -211,14 +240,17 @@ export function BlockCanvas({ rows, onMoveBlock, ...shared }: Props) { return; } - const { from, to } = resolveMove(String(event.active.id), String(over.id)); - onMoveBlock(from, to); + const move = resolveDrop(rows, String(event.active.id), String(over.id)); + + if (move) { + onMoveBlock(move.from, move.to); + } }; - const ids = rows.map((_, index) => encodePath([{ index }])); + 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 index 17981145..86710445 100644 --- a/resources/js/form/components/fields/block-editor/dnd.test.ts +++ b/resources/js/form/components/fields/block-editor/dnd.test.ts @@ -1,21 +1,54 @@ import { describe, expect, it } from "vitest"; -import { decodePath, encodePath, resolveMove } from "./dnd"; +import { dropDepth, resolveDrop, slotDropId } from "./dnd"; -describe("dnd path ids", () => { - it("round-trips a top-level path", () => { - expect(encodePath([{ index: 2 }])).toBe("2"); - expect(decodePath("2")).toEqual([{ index: 2 }]); - }); +const rows = () => [ + { rowId: "a", type: "text" }, + { + rowId: "cols", + type: "columns", + slots: { + main: [{ rowId: "m1", type: "text" }], + side: [], + }, + }, +]; - it("round-trips a slot path", () => { - expect(encodePath([{ index: 1, slot: "left" }, { index: 0 }])).toBe("1.left.0"); - expect(decodePath("1.left.0")).toEqual([{ index: 1, slot: "left" }, { index: 0 }]); +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 drag between two ids to move arguments", () => { - expect(resolveMove("0", "1.left.0")).toEqual({ - from: [{ index: 0 }], - to: [{ index: 1, slot: "left" }, { 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 index c60ed78d..afa8bf45 100644 --- a/resources/js/form/components/fields/block-editor/dnd.ts +++ b/resources/js/form/components/fields/block-editor/dnd.ts @@ -1,30 +1,63 @@ -import type { BlockPath } from "./tree"; +import { + ROW_ID_KEY, + type RepeaterRow, +} from "@lattice-php/lattice/form/components/fields/repeater-rows"; +import { walkBlocks, type BlockPath } from "./tree"; -export function encodePath(path: BlockPath): string { - return path - .map((step) => (step.slot === undefined ? String(step.index) : `${step.index}.${step.slot}`)) - .join("."); +/** + * 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])); } -export function decodePath(id: string): BlockPath { - const parts = id.split("."); - const path: BlockPath = []; +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]; - for (let i = 0; i < parts.length; ) { - const index = Number(parts[i]); + 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 (i + 1 < parts.length && Number.isNaN(Number(parts[i + 1]))) { - path.push({ index, slot: parts[i + 1] }); - i += 2; - } else { - path.push({ index }); - i += 1; - } + if (!from) { + return null; } - return path; + const to = overId.startsWith(SLOT_DROP_PREFIX) + ? slotDropPath(paths, overId) + : (paths.get(overId) ?? null); + + return to ? { from, to } : null; } -export function resolveMove(activeId: string, overId: string): { from: BlockPath; to: BlockPath } { - return { from: decodePath(activeId), to: decodePath(overId) }; +/** 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; } From 558bfb4f8bb8120a7f2c22d40d1087bc46df0196 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sat, 11 Jul 2026 16:54:50 +0200 Subject: [PATCH 49/53] test: browser coverage for slot restrictions and drag into slots --- .../components/fields/block-editor/canvas.tsx | 1 + tests/Browser/Blocks/BlockEditorTest.php | 42 ++++++++++++++++++- workbench/app/Blocks/ColumnsBlock.php | 3 +- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/resources/js/form/components/fields/block-editor/canvas.tsx b/resources/js/form/components/fields/block-editor/canvas.tsx index 3d11629b..d70f85cf 100644 --- a/resources/js/form/components/fields/block-editor/canvas.tsx +++ b/resources/js/form/components/fields/block-editor/canvas.tsx @@ -57,6 +57,7 @@ function EmptySlot({ parentRowId, slot }: { parentRowId: string; slot: string }) return (
assertPresent('[data-test="block-slot-main"]'); - $page->click('[data-test="block-slot-main"] [data-test="builder-add"]') - ->click('[data-test="builder-add-workbench.hero"]'); + $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-"]'); @@ -69,6 +72,41 @@ ->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 from the canvas', function (): void { actingAs(workbenchTestUser()); diff --git a/workbench/app/Blocks/ColumnsBlock.php b/workbench/app/Blocks/ColumnsBlock.php index 93f9a347..51d32b72 100644 --- a/workbench/app/Blocks/ColumnsBlock.php +++ b/workbench/app/Blocks/ColumnsBlock.php @@ -6,6 +6,7 @@ use Lattice\Lattice\Attributes\AsBlock; use Lattice\Lattice\Blocks\BlockDefinition; use Lattice\Lattice\Blocks\BlockSlots; +use Lattice\Lattice\Blocks\Slot; use Lattice\Lattice\Core\PageSchema; use Lattice\Lattice\Forms\FormData; use Lattice\Lattice\Ui\Components\Grid; @@ -21,7 +22,7 @@ public function attributes(): array #[\Override] public function slots(): array { - return ['main']; + return [Slot::make('main')->blocks([HeroBlock::class])]; } public function render(FormData $data, BlockSlots $slots): PageSchema From 2a342110a8a83b38dc99b812349eb3b47c8d6f1e Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sat, 11 Jul 2026 16:57:33 +0200 Subject: [PATCH 50/53] fix: align RowTemplate slot types with the descriptor shape --- src/Forms/Components/BlockEditor.php | 2 +- src/Forms/Components/RowTemplate.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Forms/Components/BlockEditor.php b/src/Forms/Components/BlockEditor.php index 1f2935ba..186ab8bc 100644 --- a/src/Forms/Components/BlockEditor.php +++ b/src/Forms/Components/BlockEditor.php @@ -133,7 +133,7 @@ protected function rowRulesAt(string $prefix, array $row, FormData $data, Reques foreach ($slots as $slot) { $rules["{$prefix}.".self::SLOTS.".{$slot}"] = ['sometimes', 'array']; - $allowedTypes = $template?->slotAllowedTypes($slot); + $allowedTypes = $template->slotAllowedTypes($slot); $childRows = $row[self::SLOTS][$slot] ?? []; foreach (array_values(is_array($childRows) ? $childRows : []) as $index => $childRow) { diff --git a/src/Forms/Components/RowTemplate.php b/src/Forms/Components/RowTemplate.php index 419eb8ca..062a5862 100644 --- a/src/Forms/Components/RowTemplate.php +++ b/src/Forms/Components/RowTemplate.php @@ -93,7 +93,7 @@ public function fields(): array } /** - * @return array{type: string, label: string, schema: array, slots?: array} + * @return array{type: string, label: string, schema: array, slots?: array}>} */ public function jsonSerialize(): array { From ecffe221d9fff521dbcdf6ccdb0c81e1ee57e15d Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sat, 11 Jul 2026 18:52:10 +0200 Subject: [PATCH 51/53] feat: block labels, icons, and descriptions in the editor chrome --- .../form/components/fields/add-row-menu.tsx | 12 ++- .../fields/block-editor/canvas.test.tsx | 25 +++++- .../components/fields/block-editor/canvas.tsx | 7 +- .../components/fields/block-editor/index.tsx | 2 + .../form/components/fields/row-templates.ts | 4 +- src/Blocks/BlockDefinition.php | 26 ++++++ src/Blocks/Slot.php | 17 ++++ src/Forms/Components/BlockEditor.php | 14 +++- src/Forms/Components/RowTemplate.php | 29 ++++++- tests/Feature/Blocks/BlockEditorFieldTest.php | 83 +++++++++++++++++++ 10 files changed, 207 insertions(+), 12 deletions(-) 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 index 1aa404ef..c301044e 100644 --- a/resources/js/form/components/fields/block-editor/canvas.test.tsx +++ b/resources/js/form/components/fields/block-editor/canvas.test.tsx @@ -12,9 +12,20 @@ vi.mock("@lattice-php/lattice/core/renderer", () => ({ import { BlockCanvas } from "./canvas"; const templates = [ - { type: "hero", label: "Hero", schema: [] }, + { + 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" }] }, + { + type: "columns", + label: "Columns", + schema: [], + slots: [{ name: "main", label: "Main column" }], + }, { type: "restricted", label: "Restricted", @@ -101,6 +112,16 @@ it("renders slot areas with nested child shells for a slotted block", () => { 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: [] } }]); diff --git a/resources/js/form/components/fields/block-editor/canvas.tsx b/resources/js/form/components/fields/block-editor/canvas.tsx index d70f85cf..a89f0417 100644 --- a/resources/js/form/components/fields/block-editor/canvas.tsx +++ b/resources/js/form/components/fields/block-editor/canvas.tsx @@ -90,12 +90,14 @@ function SlotArea({ const options = allowed.map((template) => ({ type: template.type, label: template.label, + icon: template.icon, + description: template.description, })); return (
- {slot.name} + {slot.label ?? slot.name}
@@ -174,7 +176,8 @@ function Shell({ row, path, shared }: { row: RepeaterRow; path: BlockPath; share > {t("form.block-editor.drag", "Drag")} - + + {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 ? ( diff --git a/resources/js/form/components/fields/block-editor/index.test.tsx b/resources/js/form/components/fields/block-editor/index.test.tsx index 41441da1..52dda318 100644 --- a/resources/js/form/components/fields/block-editor/index.test.tsx +++ b/resources/js/form/components/fields/block-editor/index.test.tsx @@ -1,4 +1,4 @@ -import { configure, fireEvent, render, screen } from "@testing-library/react"; +import { configure, fireEvent, render, screen, within } from "@testing-library/react"; import { beforeAll, expect, it, vi } from "vitest"; beforeAll(() => configure({ testIdAttribute: "data-test" })); @@ -143,7 +143,8 @@ it("removes a nested block from the value", () => { slottedValue(), ); - fireEvent.click(screen.getByTestId("block-remove-h1")); + 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(); @@ -151,3 +152,37 @@ it("removes a nested block from the value", () => { 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 index 54323b27..3ee79fba 100644 --- a/resources/js/form/components/fields/block-editor/index.tsx +++ b/resources/js/form/components/fields/block-editor/index.tsx @@ -27,7 +27,9 @@ import { moveBlock } from "./move-block"; import { appendBlockAt, blockAt, + duplicateBlockAt, removeBlockAt, + shiftBlockAt, slotAllowedTypes, updateBlockAt, walkBlocks, @@ -130,10 +132,24 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ 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 => { - setValue(path, (prev: unknown) => - appendBlockAt(asRows(prev), parentPath, slot, withRowId({ type })), - ); + 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. */ @@ -148,6 +164,22 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ 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)); }; @@ -188,17 +220,16 @@ export const BlockEditorComponent: RendererComponent<"field.block-editor"> = ({ wireFor={wireFor} onPreviewSeed={refreshRow} selectedId={selectedId} + errorIds={errorIds} onSelect={setSelectedId} onMoveBlock={onMoveBlock} onRemove={onRemove} + onDuplicate={onDuplicate} + onShift={onShift} onAppend={onAppend} /> {!atMax && ( - append({ type })} - /> + )}
diff --git a/resources/js/form/components/fields/block-editor/tree.test.ts b/resources/js/form/components/fields/block-editor/tree.test.ts index 3f33b8e8..278c8bf9 100644 --- a/resources/js/form/components/fields/block-editor/tree.test.ts +++ b/resources/js/form/components/fields/block-editor/tree.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import { appendBlockAt, blockAt, + duplicateBlockAt, removeBlockAt, + shiftBlockAt, slotAllowedTypes, updateBlockAt, walkBlocks, @@ -73,6 +75,43 @@ describe("appendBlockAt", () => { }); }); +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: [] }, diff --git a/resources/js/form/components/fields/block-editor/tree.ts b/resources/js/form/components/fields/block-editor/tree.ts index 4d6c9d4a..3fd97905 100644 --- a/resources/js/form/components/fields/block-editor/tree.ts +++ b/resources/js/form/components/fields/block-editor/tree.ts @@ -1,4 +1,7 @@ -import type { RepeaterRow } from "@lattice-php/lattice/form/components/fields/repeater-rows"; +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 }; @@ -76,6 +79,59 @@ export function updateBlockAt( ); } +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; diff --git a/tests/Browser/Blocks/BlockEditorTest.php b/tests/Browser/Blocks/BlockEditorTest.php index 99772020..4b8f5467 100644 --- a/tests/Browser/Blocks/BlockEditorTest.php +++ b/tests/Browser/Blocks/BlockEditorTest.php @@ -107,7 +107,7 @@ ->assertNoJavaScriptErrors(); }); -it('removes a block from the canvas', function (): void { +it('removes a block through its action menu', function (): void { actingAs(workbenchTestUser()); $page = visit('/block-editor'); @@ -117,13 +117,41 @@ $page->assertPresent('[data-test^="block-shell-"]'); - $page->click('[data-test^="block-remove-"]'); + $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()); From 94f4cba247a27e0574e454f47b16e92862e170a3 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sat, 11 Jul 2026 18:58:48 +0200 Subject: [PATCH 53/53] test: narrow the labelled block fixture return types --- tests/Feature/Blocks/BlockEditorFieldTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Feature/Blocks/BlockEditorFieldTest.php b/tests/Feature/Blocks/BlockEditorFieldTest.php index 4f46646e..53710833 100644 --- a/tests/Feature/Blocks/BlockEditorFieldTest.php +++ b/tests/Feature/Blocks/BlockEditorFieldTest.php @@ -154,19 +154,19 @@ public function render(FormData $data, BlockSlots $slots): PageSchema final class EditorLabelledBlock extends BlockDefinition { #[Override] - public function label(): ?string + public function label(): string { return 'Big hero'; } #[Override] - public function icon(): Icon|string|null + public function icon(): Icon { return Icon::LayoutDashboard; } #[Override] - public function description(): ?string + public function description(): string { return 'A prominent heading.'; }