feature: Grouped custom rich editor blocks (#19653)

* feature: Grouped custom rich editor blocks

* screenshot

* Update RichContentAttribute.php
This commit is contained in:
Dan Harrin
2026-04-08 08:55:33 +01:00
committed by GitHub
parent 3afd12bcad
commit 3f5935f69a
21 changed files with 1033 additions and 41 deletions
@@ -2,9 +2,13 @@
namespace App\Livewire\Forms\Fields;
use App\RichContentBlocks\AlertBlock;
use App\RichContentBlocks\BannerBlock;
use App\RichContentBlocks\CallToActionBlock;
use App\RichContentBlocks\HeroBlock;
use App\RichContentBlocks\ImageGalleryBlock;
use App\RichContentBlocks\TestimonialBlock;
use App\RichContentBlocks\VideoEmbedBlock;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\RichEditor\MentionProvider;
use Filament\Forms\Components\RichEditor\TextColor;
@@ -122,6 +126,28 @@ class RichEditorSchema
TestimonialBlock::class,
]),
]),
Group::make()
->id('richEditorGroupedCustomBlocks')
->extraAttributes([
'class' => 'p-16 max-w-5xl',
])
->schema([
RichEditor::make('richEditorGroupedCustomBlocks')
->label('Content')
->customBlocks([
AlertBlock::class,
'Marketing' => [
HeroBlock::class,
CallToActionBlock::class,
BannerBlock::class,
TestimonialBlock::class,
],
'Media' => [
ImageGalleryBlock::class,
VideoEmbedBlock::class,
],
]),
]),
Group::make()
->id('richEditorFloatingToolbar')
->extraAttributes([
@@ -0,0 +1,18 @@
<?php
namespace App\RichContentBlocks;
use Filament\Forms\Components\RichEditor\RichContentCustomBlock;
class AlertBlock extends RichContentCustomBlock
{
public static function getId(): string
{
return 'alert';
}
public static function getLabel(): string
{
return 'Alert';
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\RichContentBlocks;
use Filament\Forms\Components\RichEditor\RichContentCustomBlock;
class BannerBlock extends RichContentCustomBlock
{
public static function getId(): string
{
return 'banner';
}
public static function getLabel(): string
{
return 'Banner';
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\RichContentBlocks;
use Filament\Forms\Components\RichEditor\RichContentCustomBlock;
class ImageGalleryBlock extends RichContentCustomBlock
{
public static function getId(): string
{
return 'image-gallery';
}
public static function getLabel(): string
{
return 'Image gallery';
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\RichContentBlocks;
use Filament\Forms\Components\RichEditor\RichContentCustomBlock;
class VideoEmbedBlock extends RichContentCustomBlock
{
public static function getId(): string
{
return 'video-embed';
}
public static function getLabel(): string
{
return 'Video embed';
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

+20
View File
@@ -1706,6 +1706,26 @@ export default {
await new Promise((resolve) => setTimeout(resolve, 500))
},
},
'forms/fields/rich-editor/grouped-custom-blocks': {
url: 'forms/fields/rich-editor',
selector: '#richEditorGroupedCustomBlocks',
viewport: {
width: 1920,
height: 640,
deviceScaleFactor: 3,
},
before: async (page) => {
// Scroll into view so JS initializes.
await page.evaluate(() => {
document.querySelector('#richEditorGroupedCustomBlocks').scrollIntoView()
})
await new Promise((resolve) => setTimeout(resolve, 1000))
// Click the custom blocks toolbar button to open the side panel.
await page.click('#richEditorGroupedCustomBlocks button[aria-label="Blocks"]')
await new Promise((resolve) => setTimeout(resolve, 500))
},
},
'forms/fields/rich-editor/floating-toolbar': {
url: 'forms/fields/rich-editor',
selector: '#richEditorFloatingToolbar',
+44
View File
@@ -598,6 +598,50 @@ RichContentRenderer::make($record->content)
->toHtml()
```
### Grouping custom blocks
You can organize custom blocks into groups using string keys in the `customBlocks()` array. Blocks passed directly (without a string key) are ungrouped and appear first in the panel:
```php
use Filament\Forms\Components\RichEditor;
RichEditor::make('content')
->customBlocks([
AlertBlock::class,
DividerBlock::class,
'Marketing' => [
HeroBlock::class,
CallToActionBlock::class,
BannerBlock::class,
],
'Media' => [
ImageGalleryBlock::class,
VideoEmbedBlock::class,
],
])
```
<AutoScreenshot name="forms/fields/rich-editor/grouped-custom-blocks" alt="Rich editor with grouped custom blocks panel open" version="4.x" />
Groups are displayed in the order they are defined in the array, with sticky headings in the side panel.
When rendering content with grouped blocks, you can pass the same grouped array structure to the `RichContentRenderer`. Groups are ignored during rendering — only the block classes are used:
```php
use Filament\Forms\Components\RichEditor\RichContentRenderer;
RichContentRenderer::make($record->content)
->customBlocks([
'Marketing' => [
HeroBlock::class => [
'categoryUrl' => $record->category->getUrl(),
],
CallToActionBlock::class,
],
])
->toHtml()
```
### Opening the custom blocks panel by default
If you want the custom blocks panel to be open by default when the rich editor is loaded, you can use the `activePanel('customBlocks')` method:
@@ -135,10 +135,27 @@
@apply cursor-move rounded-lg bg-white p-1 text-start text-sm text-gray-600 ring-1 ring-gray-600/10 dark:bg-gray-400/10 dark:text-gray-200 dark:ring-gray-400/20;
}
& .fi-fo-rich-editor-custom-blocks-ctn {
@apply grid overflow-y-auto;
}
& .fi-fo-rich-editor-custom-blocks-group-header {
@apply sticky -top-px z-10 border-b border-gray-200 bg-gray-50 px-4 py-2 text-sm font-semibold text-gray-950 capitalize dark:border-white/10 dark:bg-gray-800 dark:text-white;
&:not(:first-child) {
@apply border-t;
}
}
& .fi-fo-rich-editor-custom-blocks-list {
@apply flex flex-wrap gap-2 px-4 py-3;
}
&:has(.fi-fo-rich-editor-custom-blocks-group-header)
.fi-fo-rich-editor-custom-blocks-list {
@apply bg-white dark:bg-gray-900;
}
& .fi-fo-rich-editor-custom-block-btn {
@apply flex cursor-move gap-1.5 rounded-lg bg-white px-2 py-1 text-start text-sm text-gray-600 ring-1 ring-gray-600/10 dark:bg-gray-400/10 dark:text-gray-200 dark:ring-gray-400/20;
}
@@ -1,5 +1,6 @@
@php
$customBlocks = $getCustomBlocks();
$groupedCustomBlocks = $getGroupedCustomBlocks();
$extraAttributeBag = $getExtraAttributeBag();
$fieldWrapperView = $getFieldWrapperView();
$id = $getId();
@@ -172,38 +173,52 @@
</div>
</div>
<div class="fi-fo-rich-editor-custom-blocks-list">
@foreach ($customBlocks as $block)
@php
$blockId = $block::getId();
@endphp
<div class="fi-fo-rich-editor-custom-blocks-ctn">
@foreach ($groupedCustomBlocks as $customBlockGroupLabel => $groupBlocks)
@if (filled($customBlockGroupLabel))
<h4
class="fi-fo-rich-editor-custom-blocks-group-header"
>
{{ $customBlockGroupLabel }}
</h4>
@endif
<button
draggable="true"
type="button"
x-data="{ isLoading: false }"
x-on:click="
isLoading = true
$wire.mountAction(
'customBlock',
{ editorSelection, id: @js($blockId), mode: 'insert' },
{ schemaComponent: @js($key) },
)
"
x-on:dragstart="$event.dataTransfer.setData('customBlock', @js($blockId))"
x-on:open-modal.window="isLoading = false"
x-on:run-rich-editor-commands.window="isLoading = false"
class="fi-fo-rich-editor-custom-block-btn"
<div
class="fi-fo-rich-editor-custom-blocks-list"
>
{{
\Filament\Support\generate_loading_indicator_html((new \Illuminate\View\ComponentAttributeBag([
'x-show' => 'isLoading',
])))
}}
@foreach ($groupBlocks as $block)
@php
$blockId = $block::getId();
@endphp
{{ $block::getLabel() }}
</button>
<button
draggable="true"
type="button"
x-data="{ isLoading: false }"
x-on:click="
isLoading = true
$wire.mountAction(
'customBlock',
{ editorSelection, id: @js($blockId), mode: 'insert' },
{ schemaComponent: @js($key) },
)
"
x-on:dragstart="$event.dataTransfer.setData('customBlock', @js($blockId))"
x-on:open-modal.window="isLoading = false"
x-on:run-rich-editor-commands.window="isLoading = false"
class="fi-fo-rich-editor-custom-block-btn"
>
{{
\Filament\Support\generate_loading_indicator_html((new \Illuminate\View\ComponentAttributeBag([
'x-show' => 'isLoading',
])))
}}
{{ $block::getLabel() }}
</button>
@endforeach
</div>
@endforeach
</div>
</div>
+62 -2
View File
@@ -30,6 +30,7 @@ use Filament\Support\Icons\Heroicon;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Livewire\Attributes\Renderless;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
@@ -1040,7 +1041,7 @@ class RichEditor extends Field implements Contracts\CanBeLengthConstrained
}
/**
* @param array<class-string<RichContentCustomBlock>> | Closure | null $blocks
* @param array<class-string<RichContentCustomBlock> | array<class-string<RichContentCustomBlock>>> | Closure | null $blocks
*/
public function customBlocks(array | Closure | null $blocks): static
{
@@ -1049,12 +1050,33 @@ class RichEditor extends Field implements Contracts\CanBeLengthConstrained
return $this;
}
/**
* @return array<class-string<RichContentCustomBlock> | array<class-string<RichContentCustomBlock>>>
*/
protected function resolveCustomBlocks(): array
{
return $this->evaluate($this->customBlocks) ?? $this->getContentAttribute()?->getCustomBlocksConfig() ?? [];
}
/**
* @return array<class-string<RichContentCustomBlock>>
*/
public function getCustomBlocks(): array
{
return $this->evaluate($this->customBlocks) ?? $this->getContentAttribute()?->getCustomBlocks() ?? [];
$blocks = $this->resolveCustomBlocks();
$result = [];
foreach ($blocks as $value) {
if (is_array($value)) {
foreach ($value as $innerKey => $innerValue) {
$result[] = is_string($innerKey) ? $innerKey : $innerValue;
}
} else {
$result[] = $value;
}
}
return $result;
}
/**
@@ -1066,6 +1088,8 @@ class RichEditor extends Field implements Contracts\CanBeLengthConstrained
return $this->cachedCustomBlocks;
}
$this->cachedCustomBlocks = [];
foreach ($this->getCustomBlocks() as $block) {
$this->cachedCustomBlocks[$block::getId()] = $block;
}
@@ -1081,6 +1105,42 @@ class RichEditor extends Field implements Contracts\CanBeLengthConstrained
return $this->getCachedCustomBlocks()[$id] ?? null;
}
/**
* @return Collection<string, Collection<int, class-string<RichContentCustomBlock>>>
*/
public function getGroupedCustomBlocks(): Collection
{
$blocks = $this->resolveCustomBlocks();
$ungrouped = [];
$groups = collect();
foreach ($blocks as $key => $value) {
if (is_string($key) && is_array($value)) {
$groupBlocks = [];
foreach ($value as $innerKey => $innerValue) {
$groupBlocks[] = is_string($innerKey) ? $innerKey : $innerValue;
}
$groups->put($key, collect($groupBlocks));
} elseif (is_array($value)) {
foreach ($value as $innerKey => $innerValue) {
$ungrouped[] = is_string($innerKey) ? $innerKey : $innerValue;
}
} else {
$ungrouped[] = $value;
}
}
$result = collect();
if (! empty($ungrouped)) {
$result->put('', collect($ungrouped));
}
return $result->merge($groups);
}
/**
* @param array<string, array<string | ToolbarButtonGroup>> | Closure | null $toolbars
*/
@@ -94,7 +94,7 @@ class CustomBlockAction
}
// Fixes an issue where the editor selection is sent as text instead of a node,
// which causes the block update to fail when though the block is selected.
// which causes the block update to fail even though the block is selected.
if (
(($arguments['mode'] ?? null) === 'edit') &&
($arguments['editorSelection']['type'] !== 'node')
@@ -39,7 +39,7 @@ class RichContentAttribute implements Htmlable
protected ?array $mentionProviders = null;
/**
* @var ?array<class-string<RichContentCustomBlock> | array<string, mixed> | Closure>
* @var ?array<class-string<RichContentCustomBlock> | array<int | string, mixed> | Closure>
*/
protected ?array $customBlocks = null;
@@ -229,7 +229,7 @@ class RichContentAttribute implements Htmlable
}
/**
* @param ?array<class-string<RichContentCustomBlock> | array<string, mixed> | Closure> $blocks
* @param ?array<class-string<RichContentCustomBlock> | array<class-string<RichContentCustomBlock>> | array<string, mixed> | Closure> $blocks
*/
public function customBlocks(?array $blocks): static
{
@@ -250,12 +250,30 @@ class RichContentAttribute implements Htmlable
$blocks = [];
foreach ($this->customBlocks as $key => $block) {
$blocks[] = is_string($key) ? $key : $block;
if (is_string($key) && is_a($key, RichContentCustomBlock::class, allow_string: true)) {
// Data association: `BlockClass::class => $data`
$blocks[] = $key;
} elseif (is_array($block)) {
// Group or ungrouped section: `'Label' => [...]` or `[...]`
foreach ($block as $innerKey => $innerValue) {
$blocks[] = is_string($innerKey) ? $innerKey : $innerValue;
}
} else {
$blocks[] = $block;
}
}
return $blocks;
}
/**
* @return array<class-string<RichContentCustomBlock> | array<class-string<RichContentCustomBlock>>>
*/
public function getCustomBlocksConfig(): array
{
return $this->customBlocks ?? [];
}
public function json(bool $condition = true): static
{
$this->isJson = $condition;
@@ -542,10 +542,34 @@ class RichContentRenderer implements Htmlable
}
/**
* @param ?array<class-string<RichContentCustomBlock> | array<string, mixed> | Closure> $blocks
* @param ?array<class-string<RichContentCustomBlock> | array<class-string<RichContentCustomBlock> | array<string, mixed> | Closure> | array<string, mixed> | Closure> $blocks
*/
public function customBlocks(?array $blocks): static
{
if ($blocks !== null) {
$flattened = [];
foreach ($blocks as $key => $value) {
if (is_string($key) && is_a($key, RichContentCustomBlock::class, allow_string: true)) {
// Data association: `BlockClass::class => $data`
$flattened[$key] = $value;
} elseif (is_array($value)) {
// Group or ungrouped section: `'Label' => [...]` or `[...]`
foreach ($value as $innerKey => $innerValue) {
if (is_string($innerKey)) {
$flattened[$innerKey] = $innerValue;
} else {
$flattened[] = $innerValue;
}
}
} else {
$flattened[] = $value;
}
}
$blocks = $flattened;
}
$this->customBlocks = $blocks;
return $this;
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
<?php
use Filament\Forms\Components\RichEditor\RichContentAttribute;
use Filament\Forms\Components\RichEditor\RichContentCustomBlock;
use Filament\Forms\Components\RichEditor\TextColor;
use Filament\Tests\Fixtures\Models\Post;
use Filament\Tests\TestCase;
@@ -80,13 +81,92 @@ describe('`getCustomBlocks()` logic', function (): void {
$post = Post::factory()->create();
$attribute = RichContentAttribute::make($post, 'content')
->customBlocks([
'App\\Blocks\\CalloutBlock' => ['color' => 'blue'],
'App\\Blocks\\QuoteBlock' => ['style' => 'italic'],
AttributeTestBlockA::class => ['color' => 'blue'],
AttributeTestBlockB::class => ['style' => 'italic'],
]);
$blocks = $attribute->getCustomBlocks();
expect($blocks)->toBe(['App\\Blocks\\CalloutBlock', 'App\\Blocks\\QuoteBlock']);
expect($blocks)->toBe([AttributeTestBlockA::class, AttributeTestBlockB::class]);
});
it('flattens grouped blocks into class names', function (): void {
$post = Post::factory()->create();
$attribute = RichContentAttribute::make($post, 'content')
->customBlocks([
'App\\Blocks\\AlertBlock',
'Marketing' => [
'App\\Blocks\\HeroBlock',
'App\\Blocks\\BannerBlock',
],
]);
$blocks = $attribute->getCustomBlocks();
expect($blocks)->toBe([
'App\\Blocks\\AlertBlock',
'App\\Blocks\\HeroBlock',
'App\\Blocks\\BannerBlock',
]);
});
it('handles mixed ungrouped data associations and groups', function (): void {
$post = Post::factory()->create();
$attribute = RichContentAttribute::make($post, 'content')
->customBlocks([
AttributeTestBlockA::class => ['color' => 'red'],
'Marketing' => [
AttributeTestBlockB::class,
],
]);
$blocks = $attribute->getCustomBlocks();
expect($blocks)->toBe([
AttributeTestBlockA::class,
AttributeTestBlockB::class,
]);
});
it('flattens grouped blocks with data associations into class names', function (): void {
$post = Post::factory()->create();
$attribute = RichContentAttribute::make($post, 'content')
->customBlocks([
'Marketing' => [
AttributeTestBlockA::class => ['url' => '/'],
AttributeTestBlockB::class,
],
]);
$blocks = $attribute->getCustomBlocks();
expect($blocks)->toBe([
AttributeTestBlockA::class,
AttributeTestBlockB::class,
]);
});
});
describe('`getCustomBlocksConfig()` logic', function (): void {
it('returns an empty array when no custom blocks are set', function (): void {
$post = Post::factory()->create();
$attribute = RichContentAttribute::make($post, 'content');
expect($attribute->getCustomBlocksConfig())->toBe([]);
});
it('returns the raw blocks array as-is', function (): void {
$post = Post::factory()->create();
$blocks = [
'App\\Blocks\\AlertBlock',
'Marketing' => [
'App\\Blocks\\HeroBlock',
],
];
$attribute = RichContentAttribute::make($post, 'content')
->customBlocks($blocks);
expect($attribute->getCustomBlocksConfig())->toBe($blocks);
});
});
@@ -217,3 +297,29 @@ describe('fluent API', function (): void {
expect($attribute->customTextColors())->toBe($attribute);
});
});
class AttributeTestBlockA extends RichContentCustomBlock
{
public static function getId(): string
{
return 'attribute-block-a';
}
public static function toHtml(array $config, array $data): ?string
{
return '<div>A</div>';
}
}
class AttributeTestBlockB extends RichContentCustomBlock
{
public static function getId(): string
{
return 'attribute-block-b';
}
public static function toHtml(array $config, array $data): ?string
{
return '<div>B</div>';
}
}
@@ -6,7 +6,8 @@ use Filament\Tests\TestCase;
uses(TestCase::class);
// Concrete subclass for testing the abstract base class
// Concrete subclasses for testing the abstract base class
class TestCalloutBlock extends RichContentCustomBlock
{
public static function getId(): string
@@ -23,6 +24,34 @@ class TestSimpleBlock extends RichContentCustomBlock
}
}
class TestBlockWithPreview extends RichContentCustomBlock
{
public static function getId(): string
{
return 'with-preview';
}
public static function toPreviewHtml(array $config): ?string
{
return '<p>' . ($config['text'] ?? '') . '</p>';
}
public static function getPreviewLabel(array $config): string
{
return 'Preview: ' . ($config['text'] ?? 'empty');
}
public static function toHtml(array $config, array $data): ?string
{
return '<div>' . ($config['text'] ?? '') . '</div>';
}
public static function configureEditorAction(Action $action): Action
{
return $action;
}
}
describe('`getLabel()` logic', function (): void {
it('auto-generates label from kebab-case ID', function (): void {
expect(TestCalloutBlock::getLabel())->toBe('Callout Block');
@@ -53,3 +82,34 @@ describe('default implementations', function (): void {
expect($result)->toBe($action);
});
});
describe('`toPreviewHtml()` with config', function (): void {
it('receives `$config` and can use it in output', function (): void {
expect(TestBlockWithPreview::toPreviewHtml(['text' => 'Hello']))->toBe('<p>Hello</p>');
});
});
describe('`getPreviewLabel()` with config', function (): void {
it('can return a dynamic label based on `$config`', function (): void {
expect(TestBlockWithPreview::getPreviewLabel(['text' => 'World']))->toBe('Preview: World');
});
it('handles missing config keys gracefully', function (): void {
expect(TestBlockWithPreview::getPreviewLabel([]))->toBe('Preview: empty');
});
});
describe('`toHtml()` with config and data', function (): void {
it('receives both `$config` and `$data`', function (): void {
expect(TestBlockWithPreview::toHtml(['text' => 'Test'], ['extra' => 'value']))->toBe('<div>Test</div>');
});
});
describe('`configureEditorAction()`', function (): void {
it('can return the action without hiding the modal', function (): void {
$action = Action::make('test');
$result = TestBlockWithPreview::configureEditorAction($action);
expect($result)->toBe($action);
});
});
@@ -928,6 +928,196 @@ describe('`getCustomBlockHtml()` logic', function (): void {
expect($renderer->getCustomBlockHtml('banner', ['text' => 'Hi']))->toBe('<div class="banner">Hi</div>');
expect($renderer->getCustomBlockHtml('alert', ['type' => 'error']))->toBe('<div class="alert-error"></div>');
});
it('flattens grouped blocks and finds them by ID', function (): void {
$renderer = RichContentRenderer::make('')
->customBlocks([
'Alerts' => [RendererTestAlertBlock::class],
RendererTestBannerBlock::class,
]);
expect($renderer->getCustomBlockHtml('alert', ['type' => 'info']))->toBe('<div class="alert-info"></div>');
expect($renderer->getCustomBlockHtml('banner', ['text' => 'Hi']))->toBe('<div class="banner">Hi</div>');
});
it('preserves data associations alongside groups', function (): void {
$renderer = RichContentRenderer::make('')
->customBlocks([
RendererTestAlertBlock::class => ['message' => 'With data'],
'Banners' => [
RendererTestBannerBlock::class,
],
]);
expect($renderer->getCustomBlockHtml('alert', ['type' => 'warning']))->toBe('<div class="alert-warning">With data</div>');
expect($renderer->getCustomBlockHtml('banner', ['text' => 'Hi']))->toBe('<div class="banner">Hi</div>');
});
it('preserves data associations within groups', function (): void {
$renderer = RichContentRenderer::make('')
->customBlocks([
'Alerts' => [
RendererTestAlertBlock::class => ['message' => 'From group'],
],
]);
expect($renderer->getCustomBlockHtml('alert', ['type' => 'warning']))->toBe('<div class="alert-warning">From group</div>');
});
});
describe('`processCustomBlocks()` end-to-end rendering', function (): void {
it('renders custom block HTML in output', function (): void {
$renderer = RichContentRenderer::make([
'type' => 'doc',
'content' => [
[
'type' => 'customBlock',
'attrs' => [
'id' => 'alert',
'config' => ['type' => 'warning'],
'label' => 'Alert',
'preview' => '',
],
],
],
]);
$renderer->customBlocks([RendererTestAlertBlock::class]);
$html = $renderer->toUnsafeHtml();
expect($html)->toContain('<div class="alert-warning"></div>');
});
it('renders custom block with keyed data in output', function (): void {
$renderer = RichContentRenderer::make([
'type' => 'doc',
'content' => [
[
'type' => 'customBlock',
'attrs' => [
'id' => 'alert',
'config' => ['type' => 'info'],
'label' => 'Alert',
'preview' => '',
],
],
],
]);
$renderer->customBlocks([RendererTestAlertBlock::class => ['message' => 'Hello World']]);
$html = $renderer->toUnsafeHtml();
expect($html)->toContain('<div class="alert-info">Hello World</div>');
});
it('does not crash when content contains an unknown custom block ID', function (): void {
$renderer = RichContentRenderer::make([
'type' => 'doc',
'content' => [
[
'type' => 'customBlock',
'attrs' => [
'id' => 'nonexistent',
'config' => [],
'label' => 'Missing',
'preview' => '',
],
],
],
]);
$renderer->customBlocks([RendererTestAlertBlock::class]);
$html = $renderer->toUnsafeHtml();
expect($html)->toBeString();
});
it('renders multiple custom blocks in content', function (): void {
$renderer = RichContentRenderer::make([
'type' => 'doc',
'content' => [
[
'type' => 'customBlock',
'attrs' => [
'id' => 'alert',
'config' => ['type' => 'error'],
'label' => 'Alert',
'preview' => '',
],
],
[
'type' => 'paragraph',
'content' => [
['type' => 'text', 'text' => 'Between blocks'],
],
],
[
'type' => 'customBlock',
'attrs' => [
'id' => 'banner',
'config' => ['text' => 'Welcome'],
'label' => 'Banner',
'preview' => '',
],
],
],
]);
$renderer->customBlocks([RendererTestAlertBlock::class, RendererTestBannerBlock::class]);
$html = $renderer->toUnsafeHtml();
expect($html)
->toContain('<div class="alert-error"></div>')
->toContain('Between blocks')
->toContain('<div class="banner">Welcome</div>');
});
it('skips custom blocks when no blocks are registered', function (): void {
$renderer = RichContentRenderer::make([
'type' => 'doc',
'content' => [
[
'type' => 'customBlock',
'attrs' => [
'id' => 'alert',
'config' => ['type' => 'info'],
'label' => 'Alert',
'preview' => '',
],
],
],
]);
$html = $renderer->toUnsafeHtml();
expect($html)->toBeString();
});
it('skips custom block nodes without an ID', function (): void {
$renderer = RichContentRenderer::make([
'type' => 'doc',
'content' => [
[
'type' => 'customBlock',
'attrs' => [
'config' => [],
'label' => 'No ID',
'preview' => '',
],
],
],
]);
$renderer->customBlocks([RendererTestAlertBlock::class]);
$html = $renderer->toUnsafeHtml();
expect($html)->toBeString();
});
});
describe('`getMentionProvider()` logic', function (): void {
@@ -2,6 +2,7 @@
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\RichEditor\Plugins\Contracts\HasFileAttachmentProvider;
use Filament\Forms\Components\RichEditor\RichContentCustomBlock;
use Filament\Forms\Components\RichEditor\RichContentRenderer;
use Filament\Schemas\Schema;
use Filament\Tests\Fixtures\Forms\RichEditor\PluginWithFileAttachmentProvider;
@@ -1032,6 +1033,304 @@ describe('rendering', function (): void {
});
});
describe('custom blocks', function (): void {
it('returns an empty array when no custom blocks are registered', function (): void {
$richEditor = Schema::make(Livewire::make())
->statePath('data')
->components([
RichEditor::make('content'),
])
->getComponents()[0];
expect($richEditor->getCustomBlocks())->toBe([]);
});
it('can register custom blocks using `customBlocks()`', function (): void {
$richEditor = Schema::make(Livewire::make())
->statePath('data')
->components([
RichEditor::make('content')
->customBlocks([
RichEditorTestBlockA::class,
RichEditorTestBlockB::class,
]),
])
->getComponents()[0];
expect($richEditor->getCustomBlocks())->toBe([
RichEditorTestBlockA::class,
RichEditorTestBlockB::class,
]);
});
it('can register custom blocks using a `Closure`', function (): void {
$richEditor = Schema::make(Livewire::make())
->statePath('data')
->components([
RichEditor::make('content')
->customBlocks(static fn (): array => [
RichEditorTestBlockA::class,
]),
])
->getComponents()[0];
expect($richEditor->getCustomBlocks())->toBe([
RichEditorTestBlockA::class,
]);
});
it('flattens grouped blocks in `getCustomBlocks()`', function (): void {
$richEditor = Schema::make(Livewire::make())
->statePath('data')
->components([
RichEditor::make('content')
->customBlocks([
RichEditorTestBlockA::class,
'Group' => [
RichEditorTestBlockB::class,
RichEditorTestBlockC::class,
],
]),
])
->getComponents()[0];
expect($richEditor->getCustomBlocks())->toBe([
RichEditorTestBlockA::class,
RichEditorTestBlockB::class,
RichEditorTestBlockC::class,
]);
});
it('returns a keyed array from `getCachedCustomBlocks()`', function (): void {
$richEditor = Schema::make(Livewire::make())
->statePath('data')
->components([
RichEditor::make('content')
->customBlocks([
RichEditorTestBlockA::class,
RichEditorTestBlockB::class,
]),
])
->getComponents()[0];
expect($richEditor->getCachedCustomBlocks())->toBe([
'block-a' => RichEditorTestBlockA::class,
'block-b' => RichEditorTestBlockB::class,
]);
});
it('returns an empty array from `getCachedCustomBlocks()` when no blocks are registered', function (): void {
$richEditor = Schema::make(Livewire::make())
->statePath('data')
->components([
RichEditor::make('content'),
])
->getComponents()[0];
expect($richEditor->getCachedCustomBlocks())->toBeEmpty();
});
it('can retrieve a custom block by ID using `getCustomBlock()`', function (): void {
$richEditor = Schema::make(Livewire::make())
->statePath('data')
->components([
RichEditor::make('content')
->customBlocks([
RichEditorTestBlockA::class,
RichEditorTestBlockB::class,
]),
])
->getComponents()[0];
expect($richEditor->getCustomBlock('block-a'))->toBe(RichEditorTestBlockA::class);
expect($richEditor->getCustomBlock('block-b'))->toBe(RichEditorTestBlockB::class);
});
it('returns `null` from `getCustomBlock()` for an unknown ID', function (): void {
$richEditor = Schema::make(Livewire::make())
->statePath('data')
->components([
RichEditor::make('content')
->customBlocks([
RichEditorTestBlockA::class,
]),
])
->getComponents()[0];
expect($richEditor->getCustomBlock('nonexistent'))->toBeNull();
});
it('includes `customBlocks` toolbar button when blocks are registered', function (): void {
$richEditor = Schema::make(Livewire::make())
->statePath('data')
->components([
RichEditor::make('content')
->customBlocks([
RichEditorTestBlockA::class,
]),
])
->getComponents()[0];
$flatButtons = array_merge(...$richEditor->getToolbarButtons());
expect($flatButtons)->toContain('customBlocks');
});
it('does not include `customBlocks` toolbar button when no blocks are registered', function (): void {
$richEditor = Schema::make(Livewire::make())
->statePath('data')
->components([
RichEditor::make('content'),
])
->getComponents()[0];
$flatButtons = array_merge(...$richEditor->getToolbarButtons());
expect($flatButtons)->not->toContain('customBlocks');
});
});
describe('custom block grouping', function (): void {
it('returns all blocks in a single ungrouped collection when no groups are used', function (): void {
$richEditor = Schema::make(Livewire::make())
->statePath('data')
->components([
RichEditor::make('content')
->customBlocks([
RichEditorTestBlockA::class,
RichEditorTestBlockB::class,
]),
])
->getComponents()[0];
$grouped = $richEditor->getGroupedCustomBlocks();
expect($grouped)->toHaveCount(1);
expect($grouped->keys()->first())->toBe('');
expect($grouped->first()->all())->toBe([
RichEditorTestBlockA::class,
RichEditorTestBlockB::class,
]);
});
it('places ungrouped blocks before grouped blocks', function (): void {
$richEditor = Schema::make(Livewire::make())
->statePath('data')
->components([
RichEditor::make('content')
->customBlocks([
RichEditorTestBlockA::class,
'Group' => [
RichEditorTestBlockB::class,
],
]),
])
->getComponents()[0];
$grouped = $richEditor->getGroupedCustomBlocks();
$keys = $grouped->keys()->all();
expect($keys[0])->toBe('');
expect($keys[1])->toBe('Group');
expect($grouped['']->all())->toBe([RichEditorTestBlockA::class]);
});
it('groups blocks under string-keyed arrays', function (): void {
$richEditor = Schema::make(Livewire::make())
->statePath('data')
->components([
RichEditor::make('content')
->customBlocks([
'Alpha' => [RichEditorTestBlockA::class],
'Beta' => [RichEditorTestBlockB::class, RichEditorTestBlockC::class],
]),
])
->getComponents()[0];
$grouped = $richEditor->getGroupedCustomBlocks();
expect($grouped)->toHaveCount(2);
expect($grouped->has('Alpha'))->toBeTrue();
expect($grouped->has('Beta'))->toBeTrue();
expect($grouped['Beta']->all())->toBe([RichEditorTestBlockB::class, RichEditorTestBlockC::class]);
});
it('preserves group definition order', function (): void {
$richEditor = Schema::make(Livewire::make())
->statePath('data')
->components([
RichEditor::make('content')
->customBlocks([
'Zebras' => [RichEditorTestBlockA::class],
'Apples' => [RichEditorTestBlockB::class],
'Middle' => [RichEditorTestBlockC::class],
]),
])
->getComponents()[0];
$grouped = $richEditor->getGroupedCustomBlocks();
$keys = $grouped->keys()->all();
expect($keys[0])->toBe('Zebras');
expect($keys[1])->toBe('Apples');
expect($keys[2])->toBe('Middle');
});
it('treats numeric-keyed nested arrays as ungrouped', function (): void {
$richEditor = Schema::make(Livewire::make())
->statePath('data')
->components([
RichEditor::make('content')
->customBlocks([
[RichEditorTestBlockA::class, RichEditorTestBlockB::class],
'Group' => [RichEditorTestBlockC::class],
]),
])
->getComponents()[0];
$grouped = $richEditor->getGroupedCustomBlocks();
$keys = $grouped->keys()->all();
expect($keys[0])->toBe('');
expect($keys[1])->toBe('Group');
expect($grouped['']->all())->toBe([RichEditorTestBlockA::class, RichEditorTestBlockB::class]);
});
it('collects all ungrouped blocks together', function (): void {
$richEditor = Schema::make(Livewire::make())
->statePath('data')
->components([
RichEditor::make('content')
->customBlocks([
RichEditorTestBlockA::class,
'Group' => [RichEditorTestBlockC::class],
RichEditorTestBlockB::class,
]),
])
->getComponents()[0];
$grouped = $richEditor->getGroupedCustomBlocks();
expect($grouped['']->all())->toBe([
RichEditorTestBlockA::class,
RichEditorTestBlockB::class,
]);
});
it('returns an empty collection when no blocks are registered', function (): void {
$richEditor = Schema::make(Livewire::make())
->statePath('data')
->components([
RichEditor::make('content'),
])
->getComponents()[0];
$grouped = $richEditor->getGroupedCustomBlocks();
expect($grouped)->toBeEmpty();
});
});
it('can render `RichEditor` in the browser', function (): void {
retry(10, function (): void {
$this->actingAs(User::factory()->create());
@@ -1344,3 +1643,44 @@ class RenderRichEditorWithPluginDisabledButtons extends Livewire
])->statePath('data');
}
}
// Custom block test fixtures
class RichEditorTestBlockA extends RichContentCustomBlock
{
public static function getId(): string
{
return 'block-a';
}
public static function toHtml(array $config, array $data): ?string
{
return '<div>A</div>';
}
}
class RichEditorTestBlockB extends RichContentCustomBlock
{
public static function getId(): string
{
return 'block-b';
}
public static function toHtml(array $config, array $data): ?string
{
return '<div>B</div>';
}
}
class RichEditorTestBlockC extends RichContentCustomBlock
{
public static function getId(): string
{
return 'block-c';
}
public static function toHtml(array $config, array $data): ?string
{
return '<div>C</div>';
}
}