From 98c7e29b12b3dddc08484c5b3ba46dae6db9ff50 Mon Sep 17 00:00:00 2001 From: Dmitrijs Mihailovs Date: Tue, 7 Jul 2026 18:37:03 +0300 Subject: [PATCH 1/4] Add block_name and block_anchor fields to all blocks Every constructor block now gets a collapsible 'Block settings' section with a block_name and block_anchor field, injected centrally in the Constructor component so no per-block or stub changes are required. - block_name is used as the block's builder label, falling back to the block title() when empty - block_anchor is live-slugified, dehydrated to a slug (empty -> null), and validated as a slug - both values persist in the block data and are available in views as $block_name / $block_anchor - labels are package-owned translations (filament-constructor::block-settings.*) registered via hasTranslations() and publishable --- README.md | 21 +++++++ resources/lang/en/block-settings.php | 7 +++ src/Filament/Forms/Components/Constructor.php | 39 +++++++++++- src/FilamentConstructorServiceProvider.php | 1 + tests/Feature/ConstructorTest.php | 63 +++++++++++++++++++ 5 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 resources/lang/en/block-settings.php diff --git a/README.md b/README.md index 47d874a..d172e8f 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,27 @@ To add Builder options to the constructor, add them when defining the constructo ->collapsed() ``` +## Block settings + +Every block automatically gets a collapsible **Block settings** section with two extra fields, so you can tell blocks apart at a glance: + +- `block_name` – a free-text name for the block. It is used as the block's label in the builder (falling back to the block `title()` when empty). +- `block_anchor` – a URL slug, auto-generated as you type. Handy as an `id` for in-page anchor links. + +Both values are stored in the block's data and are available in the block view: + +```blade +
+ {{-- ... --}} +
+``` + +The field labels are translatable. Publish the translations to customise or localise them: + +```bash +php artisan vendor:publish --tag="filament-constructor-translations" +``` + ## Testing ```bash diff --git a/resources/lang/en/block-settings.php b/resources/lang/en/block-settings.php new file mode 100644 index 0000000..4e9c455 --- /dev/null +++ b/resources/lang/en/block-settings.php @@ -0,0 +1,7 @@ + 'Block settings', + 'name' => 'Name', + 'anchor' => 'Anchor', +]; diff --git a/src/Filament/Forms/Components/Constructor.php b/src/Filament/Forms/Components/Constructor.php index e0b1311..a9e013d 100644 --- a/src/Filament/Forms/Components/Constructor.php +++ b/src/Filament/Forms/Components/Constructor.php @@ -4,6 +4,11 @@ use CubeAgency\FilamentConstructor\Constructor\Blocks\BlockRenderer; use Filament\Forms\Components\Builder; +use Filament\Forms\Components\TextInput; +use Filament\Schemas\Components\Grid; +use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Utilities\Set; +use Illuminate\Support\Str; class Constructor extends Builder { @@ -23,6 +28,11 @@ public function use(array $blocks): static return $this; } + public static function slugifyAnchor(?string $state): ?string + { + return filled($state) ? Str::slug($state) : null; + } + protected function setBlocks(array $blocks): void { $blockFields = []; @@ -33,8 +43,13 @@ protected function setBlocks(array $blocks): void */ $blockRenderer = resolve($block); $block = Builder\Block::make($blockRenderer->name()) - ->label($blockRenderer->title()) - ->schema($blockRenderer->schema()); + ->label(fn (?array $state): string => filled($state['block_name'] ?? null) + ? $state['block_name'] + : $blockRenderer->title()) + ->schema([ + ...$blockRenderer->schema(), + $this->blockSettingsSection(), + ]); $icon = $blockRenderer->icon(); if ($icon) { @@ -57,4 +72,24 @@ protected function setBlocks(array $blocks): void $this->blocks($blockFields); $this->blockIcons(); } + + protected function blockSettingsSection(): Section + { + return Section::make(__('filament-constructor::block-settings.title')) + ->schema([ + Grid::make() + ->schema([ + TextInput::make('block_name') + ->label(__('filament-constructor::block-settings.name')), + TextInput::make('block_anchor') + ->label(__('filament-constructor::block-settings.anchor')) + ->live(debounce: 500) + ->afterStateUpdated(fn (?string $state, Set $set) => $set('block_anchor', static::slugifyAnchor($state))) + ->dehydrateStateUsing(fn (?string $state) => static::slugifyAnchor($state)) + ->rules(['regex:/^([a-z0-9]+(?:-[a-z0-9]+)*)?$/']), + ]), + ]) + ->collapsible() + ->collapsed(); + } } diff --git a/src/FilamentConstructorServiceProvider.php b/src/FilamentConstructorServiceProvider.php index 32b1b57..7c07030 100644 --- a/src/FilamentConstructorServiceProvider.php +++ b/src/FilamentConstructorServiceProvider.php @@ -15,6 +15,7 @@ class FilamentConstructorServiceProvider extends PackageServiceProvider public function configurePackage(Package $package): void { $package->name(static::$name) + ->hasTranslations() ->hasCommands($this->getCommands()) ->hasInstallCommand(function (InstallCommand $command) { $command diff --git a/tests/Feature/ConstructorTest.php b/tests/Feature/ConstructorTest.php index 6897c0b..751f693 100644 --- a/tests/Feature/ConstructorTest.php +++ b/tests/Feature/ConstructorTest.php @@ -5,6 +5,8 @@ use CubeAgency\FilamentConstructor\Tests\Fixtures\Blocks\TestBlock; use Filament\Forms\Components\Builder; use Filament\Forms\Components\Builder\Block; +use Filament\Forms\Components\Field; +use Filament\Schemas\Schema; /** * @return array @@ -22,6 +24,32 @@ function constructorBlockNames(Constructor $constructor): array return array_map(fn (Block $block) => $block->getName(), constructorBlocks($constructor)); } +/** + * Recursively collect the names of every field within a component's schema. + * + * @return array + */ +function blockFieldNames($component): array +{ + $names = []; + + $children = $component->getDefaultChildComponents(); + + if ($children instanceof Schema) { + $children = $children->getComponents(); + } + + foreach ($children as $child) { + if ($child instanceof Field) { + $names[] = $child->getName(); + } + + $names = array_merge($names, blockFieldNames($child)); + } + + return $names; +} + it('is a filament builder', function () { config()->set('filament-constructor.blocks', ['test_block' => TestBlock::class]); @@ -54,3 +82,38 @@ function constructorBlockNames(Constructor $constructor): array ->and($block->getMaxItems())->toBe(3) ->and($block->getColumns('lg'))->toBe(2); }); + +it('adds block name and anchor fields to every block', function () { + config()->set('filament-constructor.blocks', ['test_block' => TestBlock::class]); + + $block = constructorBlocks(Constructor::make('content'))[0]; + + expect(blockFieldNames($block)) + ->toContain('heading') // the renderer's own field is preserved + ->toContain('block_name') + ->toContain('block_anchor'); +}); + +it('uses the block name as the label and falls back to the title', function () { + config()->set('filament-constructor.blocks', ['test_block' => TestBlock::class]); + + $block = constructorBlocks(Constructor::make('content'))[0]; + + expect($block->getLabel(null))->toBe('Test Block') // block picker + ->and($block->getLabel([]))->toBe('Test Block') // new, unnamed block + ->and($block->getLabel(['block_name' => '']))->toBe('Test Block') + ->and($block->getLabel(['block_name' => 'Hero header']))->toBe('Hero header'); +}); + +it('slugifies the block anchor and nullifies empty values', function () { + expect(Constructor::slugifyAnchor('Hello World'))->toBe('hello-world') + ->and(Constructor::slugifyAnchor('Already-Slugged'))->toBe('already-slugged') + ->and(Constructor::slugifyAnchor(''))->toBeNull() + ->and(Constructor::slugifyAnchor(null))->toBeNull(); +}); + +it('registers the block settings translations', function () { + expect(__('filament-constructor::block-settings.title'))->toBe('Block settings') + ->and(__('filament-constructor::block-settings.name'))->toBe('Name') + ->and(__('filament-constructor::block-settings.anchor'))->toBe('Anchor'); +}); From d68410e5858b09e6f88cb2875c429eefa4ba51f4 Mon Sep 17 00:00:00 2001 From: Dmitrijs Mihailovs Date: Fri, 10 Jul 2026 14:21:46 +0300 Subject: [PATCH 2/4] Add is_active, starts_at, ends_at fields to block settings --- resources/lang/en/block-settings.php | 3 ++ src/Filament/Forms/Components/Constructor.php | 14 +++++ tests/Feature/ConstructorTest.php | 51 +++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/resources/lang/en/block-settings.php b/resources/lang/en/block-settings.php index 4e9c455..a8eaba9 100644 --- a/resources/lang/en/block-settings.php +++ b/resources/lang/en/block-settings.php @@ -4,4 +4,7 @@ 'title' => 'Block settings', 'name' => 'Name', 'anchor' => 'Anchor', + 'is_active' => 'Active', + 'starts_at' => 'Starts at', + 'ends_at' => 'Ends at', ]; diff --git a/src/Filament/Forms/Components/Constructor.php b/src/Filament/Forms/Components/Constructor.php index a9e013d..dd79496 100644 --- a/src/Filament/Forms/Components/Constructor.php +++ b/src/Filament/Forms/Components/Constructor.php @@ -4,6 +4,8 @@ use CubeAgency\FilamentConstructor\Constructor\Blocks\BlockRenderer; use Filament\Forms\Components\Builder; +use Filament\Forms\Components\Checkbox; +use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\TextInput; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; @@ -77,6 +79,9 @@ protected function blockSettingsSection(): Section { return Section::make(__('filament-constructor::block-settings.title')) ->schema([ + Checkbox::make('is_active') + ->label(__('filament-constructor::block-settings.is_active')) + ->default(true), Grid::make() ->schema([ TextInput::make('block_name') @@ -88,6 +93,15 @@ protected function blockSettingsSection(): Section ->dehydrateStateUsing(fn (?string $state) => static::slugifyAnchor($state)) ->rules(['regex:/^([a-z0-9]+(?:-[a-z0-9]+)*)?$/']), ]), + Grid::make() + ->schema([ + DateTimePicker::make('starts_at') + ->label(__('filament-constructor::block-settings.starts_at')) + ->before('ends_at'), + DateTimePicker::make('ends_at') + ->label(__('filament-constructor::block-settings.ends_at')) + ->after('starts_at'), + ]), ]) ->collapsible() ->collapsed(); diff --git a/tests/Feature/ConstructorTest.php b/tests/Feature/ConstructorTest.php index 751f693..1d45f7d 100644 --- a/tests/Feature/ConstructorTest.php +++ b/tests/Feature/ConstructorTest.php @@ -50,6 +50,30 @@ function blockFieldNames($component): array return $names; } +/** + * Find a field by name anywhere within a component's schema. + */ +function findBlockField($component, string $name): ?Field +{ + $children = $component->getDefaultChildComponents(); + + if ($children instanceof Schema) { + $children = $children->getComponents(); + } + + foreach ($children as $child) { + if ($child instanceof Field && $child->getName() === $name) { + return $child; + } + + if ($found = findBlockField($child, $name)) { + return $found; + } + } + + return null; +} + it('is a filament builder', function () { config()->set('filament-constructor.blocks', ['test_block' => TestBlock::class]); @@ -117,3 +141,30 @@ function blockFieldNames($component): array ->and(__('filament-constructor::block-settings.name'))->toBe('Name') ->and(__('filament-constructor::block-settings.anchor'))->toBe('Anchor'); }); + +it('adds the visibility fields to every block', function () { + config()->set('filament-constructor.blocks', ['test_block' => TestBlock::class]); + + $block = constructorBlocks(Constructor::make('content'))[0]; + + expect(blockFieldNames($block)) + ->toContain('is_active') + ->toContain('starts_at') + ->toContain('ends_at'); +}); + +it('defaults is_active to true', function () { + config()->set('filament-constructor.blocks', ['test_block' => TestBlock::class]); + + $block = constructorBlocks(Constructor::make('content'))[0]; + $isActive = findBlockField($block, 'is_active'); + + expect($isActive)->not->toBeNull() + ->and($isActive->getDefaultState())->toBeTrue(); +}); + +it('registers the visibility field translations', function () { + expect(__('filament-constructor::block-settings.is_active'))->toBe('Active') + ->and(__('filament-constructor::block-settings.starts_at'))->toBe('Starts at') + ->and(__('filament-constructor::block-settings.ends_at'))->toBe('Ends at'); +}); From 903464b3bbf85261d58f2cf9bfe0fbd09a2c28c7 Mon Sep 17 00:00:00 2001 From: Dmitrijs Mihailovs Date: Fri, 10 Jul 2026 14:32:24 +0300 Subject: [PATCH 3/4] Skip inactive and out-of-window blocks when rendering --- src/Constructor/ConstructorBlockRenderer.php | 26 ++++++++ .../Feature/ConstructorBlockRendererTest.php | 64 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/Constructor/ConstructorBlockRenderer.php b/src/Constructor/ConstructorBlockRenderer.php index 614c76a..7ed786d 100644 --- a/src/Constructor/ConstructorBlockRenderer.php +++ b/src/Constructor/ConstructorBlockRenderer.php @@ -2,6 +2,7 @@ namespace CubeAgency\FilamentConstructor\Constructor; +use Carbon\Carbon; use CubeAgency\FilamentConstructor\Constructor\Blocks\BlockRendererInterface; use Illuminate\Support\Arr; @@ -14,6 +15,10 @@ public function render(?array $blocks = []): string $html = ''; foreach ($blocks as $block) { + if (! $this->isBlockVisible($block)) { + continue; + } + $html .= $this->getConstructorBlockHtml($block); } @@ -32,6 +37,27 @@ protected function getConstructorBlockHtml(object $block): string return ''; } + protected function isBlockVisible(object $block): bool + { + $data = (array) ($block->data ?? []); + + if (array_key_exists('is_active', $data) && ! $data['is_active']) { + return false; + } + + $now = now(); + + if (filled($data['starts_at'] ?? null) && $now->lt(Carbon::parse($data['starts_at']))) { + return false; + } + + if (filled($data['ends_at'] ?? null) && $now->gt(Carbon::parse($data['ends_at']))) { + return false; + } + + return true; + } + protected function getBlockRendererClass(string $name): string { return Arr::get($this->blocks, $name); diff --git a/tests/Feature/ConstructorBlockRendererTest.php b/tests/Feature/ConstructorBlockRendererTest.php index 0b75162..621fc10 100644 --- a/tests/Feature/ConstructorBlockRendererTest.php +++ b/tests/Feature/ConstructorBlockRendererTest.php @@ -29,3 +29,67 @@ expect($renderer->render([]))->toBe(''); }); + +it('renders an active block', function () { + $renderer = new ConstructorBlockRenderer(['test_block' => TestBlock::class]); + + $html = $renderer->render([ + (object) ['type' => 'test_block', 'data' => ['heading' => 'Visible', 'is_active' => true]], + ]); + + expect($html)->toContain('Visible'); +}); + +it('renders a block that has no is_active key (backward compatible)', function () { + $renderer = new ConstructorBlockRenderer(['test_block' => TestBlock::class]); + + $html = $renderer->render([ + (object) ['type' => 'test_block', 'data' => ['heading' => 'Legacy']], + ]); + + expect($html)->toContain('Legacy'); +}); + +it('hides an inactive block', function () { + $renderer = new ConstructorBlockRenderer(['test_block' => TestBlock::class]); + + $html = $renderer->render([ + (object) ['type' => 'test_block', 'data' => ['heading' => 'Hidden', 'is_active' => false]], + ]); + + expect($html)->not->toContain('Hidden'); +}); + +it('hides a block before its start time and shows it at the boundary', function () { + $renderer = new ConstructorBlockRenderer(['test_block' => TestBlock::class]); + $block = (object) ['type' => 'test_block', 'data' => ['heading' => 'Scheduled', 'starts_at' => '2026-07-10 12:00:00']]; + + $this->travelTo('2026-07-10 11:59:59'); + expect($renderer->render([$block]))->not->toContain('Scheduled'); + + $this->travelTo('2026-07-10 12:00:00'); + expect($renderer->render([$block]))->toContain('Scheduled'); +}); + +it('hides a block after its end time and shows it at the boundary', function () { + $renderer = new ConstructorBlockRenderer(['test_block' => TestBlock::class]); + $block = (object) ['type' => 'test_block', 'data' => ['heading' => 'Expiring', 'ends_at' => '2026-07-10 12:00:00']]; + + $this->travelTo('2026-07-10 12:00:00'); + expect($renderer->render([$block]))->toContain('Expiring'); + + $this->travelTo('2026-07-10 12:00:01'); + expect($renderer->render([$block]))->not->toContain('Expiring'); +}); + +it('renders a block inside its window and with empty dates', function () { + $renderer = new ConstructorBlockRenderer(['test_block' => TestBlock::class]); + + $this->travelTo('2026-07-10 12:00:00'); + + $windowed = (object) ['type' => 'test_block', 'data' => ['heading' => 'InWindow', 'starts_at' => '2026-07-10 00:00:00', 'ends_at' => '2026-07-10 23:59:59']]; + $unbounded = (object) ['type' => 'test_block', 'data' => ['heading' => 'Unbounded', 'starts_at' => null, 'ends_at' => null]]; + + expect($renderer->render([$windowed]))->toContain('InWindow') + ->and($renderer->render([$unbounded]))->toContain('Unbounded'); +}); From b3e15a8aa09531485b0a901871d72d1ac7117024 Mon Sep 17 00:00:00 2001 From: Dmitrijs Mihailovs Date: Fri, 10 Jul 2026 14:39:42 +0300 Subject: [PATCH 4/4] Document block visibility settings in README --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d172e8f..e572475 100644 --- a/README.md +++ b/README.md @@ -159,12 +159,16 @@ To add Builder options to the constructor, add them when defining the constructo ## Block settings -Every block automatically gets a collapsible **Block settings** section with two extra fields, so you can tell blocks apart at a glance: +Every block automatically gets a collapsible **Block settings** section with extra fields, so you can label, anchor, and control the visibility of each block: - `block_name` – a free-text name for the block. It is used as the block's label in the builder (falling back to the block `title()` when empty). - `block_anchor` – a URL slug, auto-generated as you type. Handy as an `id` for in-page anchor links. +- `is_active` – a checkbox, checked by default. When unchecked the block is not rendered. +- `starts_at` / `ends_at` – optional datetimes. The block is only rendered while the current time is within the window (both bounds inclusive). Leave either empty for an open-ended schedule. -Both values are stored in the block's data and are available in the block view: +`ConstructorBlockRenderer` skips any block that is inactive or outside its `starts_at`/`ends_at` window, so no extra checks are needed in your views. Blocks saved before these fields existed have no `is_active` value and remain visible. + +All values are stored in the block's data and are also available in the block view: ```blade