mirror of
https://github.com/filamentphp/filament.git
synced 2026-09-24 15:42:09 +08:00
feature: Grouped custom rich editor blocks (#19653)
* feature: Grouped custom rich editor blocks * screenshot * Update RichContentAttribute.php
This commit is contained in:
@@ -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 |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
+1
-1
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>';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user