Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<section @if(!empty($block_anchor)) id="{{ $block_anchor }}" @endif>
{{-- ... --}}
</section>
```

The field labels are translatable. Publish the translations to customise or localise them:

```bash
php artisan vendor:publish --tag="filament-constructor-translations"
```

## Testing

```bash
Expand Down
10 changes: 10 additions & 0 deletions resources/lang/en/block-settings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

return [
'title' => 'Block settings',
'name' => 'Name',
'anchor' => 'Anchor',
'is_active' => 'Active',
'starts_at' => 'Starts at',
'ends_at' => 'Ends at',
];
26 changes: 26 additions & 0 deletions src/Constructor/ConstructorBlockRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace CubeAgency\FilamentConstructor\Constructor;

use Carbon\Carbon;
use CubeAgency\FilamentConstructor\Constructor\Blocks\BlockRendererInterface;
use Illuminate\Support\Arr;

Expand All @@ -14,6 +15,10 @@ public function render(?array $blocks = []): string
$html = '';

foreach ($blocks as $block) {
if (! $this->isBlockVisible($block)) {
continue;
}

$html .= $this->getConstructorBlockHtml($block);
}

Expand All @@ -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);
Expand Down
53 changes: 51 additions & 2 deletions src/Filament/Forms/Components/Constructor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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 = [];
Expand All @@ -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) {
Expand All @@ -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();
}
}
1 change: 1 addition & 0 deletions src/FilamentConstructorServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions tests/Feature/ConstructorBlockRendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
114 changes: 114 additions & 0 deletions tests/Feature/ConstructorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<Block>
Expand All @@ -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<string>
*/
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]);

Expand Down Expand Up @@ -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');
});