diff --git a/README.md b/README.md index 47d874a..e572475 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,31 @@ 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 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. + +`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 +
+ {{-- ... --}} +
+``` + +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..a8eaba9 --- /dev/null +++ b/resources/lang/en/block-settings.php @@ -0,0 +1,10 @@ + 'Block settings', + 'name' => 'Name', + 'anchor' => 'Anchor', + 'is_active' => 'Active', + 'starts_at' => 'Starts at', + 'ends_at' => 'Ends at', +]; 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/src/Filament/Forms/Components/Constructor.php b/src/Filament/Forms/Components/Constructor.php index e0b1311..dd79496 100644 --- a/src/Filament/Forms/Components/Constructor.php +++ b/src/Filament/Forms/Components/Constructor.php @@ -4,6 +4,13 @@ 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; +use Filament\Schemas\Components\Utilities\Set; +use Illuminate\Support\Str; class Constructor extends Builder { @@ -23,6 +30,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 +45,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 +74,36 @@ protected function setBlocks(array $blocks): void $this->blocks($blockFields); $this->blockIcons(); } + + 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') + ->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]+)*)?$/']), + ]), + 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/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/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'); +}); diff --git a/tests/Feature/ConstructorTest.php b/tests/Feature/ConstructorTest.php index 6897c0b..1d45f7d 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,56 @@ 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; +} + +/** + * 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]); @@ -54,3 +106,65 @@ 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'); +}); + +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'); +});