Refactor RichEditor further to allow hook overrides

This commit is contained in:
Dan Harrin
2026-04-13 19:17:37 +01:00
parent 6eec522aa2
commit 413e9a953b
4 changed files with 384 additions and 73 deletions
+42 -73
View File
@@ -395,62 +395,8 @@ class RichEditor extends Field implements Contracts\CanBeLengthConstrained
->iconAlias('forms:components.rich-editor.toolbar.clear_formatting'),
]);
$this->beforeStateDehydrated(function (RichEditor $component, ?array $rawState, ?Model $record): void {
$fileAttachmentProvider = $component->getFileAttachmentProvider();
if ($fileAttachmentProvider?->isExistingRecordRequiredToSaveNewFileAttachments() && (! $record)) {
return;
}
$fileAttachmentIds = [];
$component->rawState(
$component->getTipTapEditor()
->setContent($rawState ?? [
'type' => 'doc',
'content' => [],
])
->descendants(function (object &$node) use ($component, &$fileAttachmentIds): void {
if ($node->type !== 'image') {
return;
}
if (blank($node->attrs->id ?? null)) {
return;
}
$attachment = $component->getUploadedFileAttachment($node->attrs->id);
if ($attachment) {
$node->attrs->id = $component->saveUploadedFileAttachment($attachment);
$node->attrs->src = $component->getFileAttachmentUrl($node->attrs->id);
$fileAttachmentIds[] = $node->attrs->id;
return;
}
if (filled($component->getFileAttachmentUrl($node->attrs->id))) {
$fileAttachmentIds[] = $node->attrs->id;
return;
}
$fileAttachmentIdFromAnotherRecord = $component->saveFileAttachmentFromAnotherRecord($node->attrs->id);
if (blank($fileAttachmentIdFromAnotherRecord)) {
$fileAttachmentIds[] = $node->attrs->id;
return;
}
$node->attrs->id = $fileAttachmentIdFromAnotherRecord;
$node->attrs->src = $component->getFileAttachmentUrl($fileAttachmentIdFromAnotherRecord) ?? $node->attrs->src ?? null;
})
->getDocument(),
);
$fileAttachmentProvider?->cleanUpFileAttachments(exceptIds: $fileAttachmentIds);
$this->beforeStateDehydrated(static function (RichEditor $component): void {
$component->saveFileAttachments();
}, shouldUpdateValidatedStateAfter: true);
$this->saveRelationshipsUsing(static function (RichEditor $component): void {
@@ -458,24 +404,11 @@ class RichEditor extends Field implements Contracts\CanBeLengthConstrained
});
}
public function saveFileAttachmentsToRecord(): void
/**
* @return array<string>
*/
public function resolveFileAttachmentIds(): array
{
$fileAttachmentProvider = $this->getFileAttachmentProvider();
if (! $fileAttachmentProvider) {
return;
}
if (! $fileAttachmentProvider->isExistingRecordRequiredToSaveNewFileAttachments()) {
return;
}
$record = $this->getRecord();
if (! $record->wasRecentlyCreated) {
return;
}
$fileAttachmentIds = [];
$this->rawState(
@@ -524,6 +457,42 @@ class RichEditor extends Field implements Contracts\CanBeLengthConstrained
->getDocument(),
);
return $fileAttachmentIds;
}
public function saveFileAttachments(): void
{
$fileAttachmentProvider = $this->getFileAttachmentProvider();
if ($fileAttachmentProvider?->isExistingRecordRequiredToSaveNewFileAttachments() && (! $this->getRecord())) {
return;
}
$fileAttachmentIds = $this->resolveFileAttachmentIds();
$fileAttachmentProvider?->cleanUpFileAttachments(exceptIds: $fileAttachmentIds);
}
public function saveFileAttachmentsToRecord(): void
{
$fileAttachmentProvider = $this->getFileAttachmentProvider();
if (! $fileAttachmentProvider) {
return;
}
if (! $fileAttachmentProvider->isExistingRecordRequiredToSaveNewFileAttachments()) {
return;
}
$record = $this->getRecord();
if (! $record->wasRecentlyCreated) {
return;
}
$fileAttachmentIds = $this->resolveFileAttachmentIds();
$record->setAttribute($this->getContentAttribute()->getName(), $this->getState());
$record->save();
@@ -0,0 +1,95 @@
<?php
namespace Filament\Tests\Fixtures\Livewire;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Schemas\Schema;
use Filament\Tests\Fixtures\Models\MediaPostWithRichContent;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Storage;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class RichEditorFileAttachmentForm extends Component implements HasActions, HasSchemas
{
use InteractsWithActions;
use InteractsWithSchemas;
public array $data = [];
public ?int $recordId = null;
public array $componentFileAttachments = [];
public function mount(?int $recordId = null): void
{
$this->recordId = $recordId;
$this->form->fill();
}
public function form(Schema $form): Schema
{
return $form
->schema([
TextInput::make('title'),
RichEditor::make('content'),
])
->model($this->recordId ? MediaPostWithRichContent::find($this->recordId) : MediaPostWithRichContent::class)
->statePath('data');
}
public function createWithAttachments(array $attachmentIds, array $formData): void
{
foreach ($attachmentIds as $id) {
$this->prepareFileAttachment($id);
}
$this->data = $formData;
$data = $this->form->getState();
$record = MediaPostWithRichContent::create($data);
$this->form->model($record)->saveRelationships();
$this->recordId = $record->getKey();
}
public function saveWithAttachments(array $attachmentIds, array $formData): void
{
foreach ($attachmentIds as $id) {
$this->prepareFileAttachment($id);
}
$this->data = $formData;
$record = MediaPostWithRichContent::find($this->recordId);
$data = $this->form->getState();
$record->update($data);
$this->form->model($record)->saveRelationships();
}
protected function prepareFileAttachment(string $id): void
{
$image = imagecreatetruecolor(10, 10);
ob_start();
imagejpeg($image);
$content = ob_get_clean();
imagedestroy($image);
Storage::disk('tmp-for-tests')->put('livewire-tmp/' . $id . '.jpg', $content);
data_set(
$this->componentFileAttachments,
'data.content.' . $id,
TemporaryUploadedFile::createFromLivewire($id . '.jpg'),
);
}
public function render(): View
{
return view('livewire.form');
}
}
@@ -0,0 +1,24 @@
<?php
namespace Filament\Tests\Fixtures\Models;
use Filament\Forms\Components\RichEditor\FileAttachmentProviders\SpatieMediaLibraryFileAttachmentProvider;
use Filament\Forms\Components\RichEditor\Models\Concerns\InteractsWithRichContent;
use Filament\Forms\Components\RichEditor\Models\Contracts\HasRichContent;
use Filament\Tests\Fixtures\Forms\RichEditor\PluginWithFileAttachmentProvider;
class MediaPostWithRichContent extends MediaPost implements HasRichContent
{
use InteractsWithRichContent;
protected function setUpRichContent(): void
{
$this
->registerRichContent('content')
->plugins([
PluginWithFileAttachmentProvider::make(
SpatieMediaLibraryFileAttachmentProvider::make(),
),
]);
}
}
@@ -0,0 +1,223 @@
<?php
use Filament\Tests\Fixtures\Livewire\RichEditorFileAttachmentForm;
use Filament\Tests\Fixtures\Models\MediaPostWithRichContent;
use Filament\Tests\TestCase;
use Illuminate\Support\Facades\Storage;
use function Filament\Tests\livewire;
uses(TestCase::class);
beforeEach(function (): void {
Storage::fake('tmp-for-tests');
Storage::fake('public');
});
function makeTipTapDoc(array $content = []): array
{
return [
'type' => 'doc',
'content' => $content,
];
}
function makeImage(string $id, ?string $src = null): array
{
return [
'type' => 'image',
'attrs' => [
'id' => $id,
'src' => $src,
],
];
}
function makeParagraph(string $text): array
{
return [
'type' => 'paragraph',
'content' => [
['type' => 'text', 'text' => $text],
],
];
}
describe('create', function (): void {
test('it creates a record with a file attachment as Spatie media', function (): void {
livewire(RichEditorFileAttachmentForm::class)
->call('createWithAttachments', ['temp-upload-1'], [
'title' => 'Post With Image',
'content' => makeTipTapDoc([
makeParagraph('Hello world'),
makeImage('temp-upload-1', 'blob:temporary'),
]),
]);
$record = MediaPostWithRichContent::first();
expect($record)->not->toBeNull();
expect($record->title)->toBe('Post With Image');
expect($record->getMedia('content'))->toHaveCount(1);
});
test('it stores the media UUID in the saved content', function (): void {
livewire(RichEditorFileAttachmentForm::class)
->call('createWithAttachments', ['temp-upload-1'], [
'title' => 'Post With Image',
'content' => makeTipTapDoc([
makeParagraph('Before image'),
makeImage('temp-upload-1', 'blob:temporary'),
]),
]);
$record = MediaPostWithRichContent::first();
$media = $record->getMedia('content')->first();
expect($record->content)->toContain($media->uuid);
});
test('it creates multiple media from multiple file attachments', function (): void {
livewire(RichEditorFileAttachmentForm::class)
->call('createWithAttachments', ['temp-1', 'temp-2', 'temp-3'], [
'title' => 'Post With Multiple Images',
'content' => makeTipTapDoc([
makeImage('temp-1', 'blob:temp'),
makeParagraph('Between images'),
makeImage('temp-2', 'blob:temp'),
makeImage('temp-3', 'blob:temp'),
]),
]);
$record = MediaPostWithRichContent::first();
expect($record->getMedia('content'))->toHaveCount(3);
});
test('it creates a record with text-only content and no media', function (): void {
livewire(RichEditorFileAttachmentForm::class)
->call('createWithAttachments', [], [
'title' => 'Text Only Post',
'content' => makeTipTapDoc([
makeParagraph('Just text, no images'),
]),
]);
$record = MediaPostWithRichContent::first();
expect($record)->not->toBeNull();
expect($record->getMedia('content'))->toHaveCount(0);
});
});
describe('update', function (): void {
test('it adds new media when adding an image to existing content', function (): void {
$record = MediaPostWithRichContent::create([
'title' => 'Original',
'content' => json_encode(makeTipTapDoc([
makeParagraph('Original text'),
])),
]);
livewire(RichEditorFileAttachmentForm::class, ['recordId' => $record->id])
->call('saveWithAttachments', ['new-image-1'], [
'title' => 'Updated',
'content' => makeTipTapDoc([
makeParagraph('Updated text'),
makeImage('new-image-1', 'blob:temporary'),
]),
]);
$record->refresh();
expect($record->title)->toBe('Updated');
expect($record->getMedia('content'))->toHaveCount(1);
});
test('it removes orphaned media when removing an image from content', function (): void {
$record = MediaPostWithRichContent::create(['title' => 'Original']);
$media = $record
->addMediaFromString('existing image content')
->usingFileName('existing.jpg')
->toMediaCollection('content');
$record->update([
'content' => json_encode(makeTipTapDoc([
makeParagraph('Text with image'),
makeImage($media->uuid, $media->getUrl()),
])),
]);
livewire(RichEditorFileAttachmentForm::class, ['recordId' => $record->id])
->call('saveWithAttachments', [], [
'title' => 'Updated',
'content' => makeTipTapDoc([
makeParagraph('Text without image'),
]),
]);
$record->refresh();
expect($record->getMedia('content'))->toHaveCount(0);
});
test('it keeps existing media and adds new media when editing content', function (): void {
$record = MediaPostWithRichContent::create(['title' => 'Original']);
$existingMedia = $record
->addMediaFromString('existing image content')
->usingFileName('existing.jpg')
->toMediaCollection('content');
$record->update([
'content' => json_encode(makeTipTapDoc([
makeImage($existingMedia->uuid, $existingMedia->getUrl()),
])),
]);
livewire(RichEditorFileAttachmentForm::class, ['recordId' => $record->id])
->call('saveWithAttachments', ['new-upload'], [
'title' => 'Updated',
'content' => makeTipTapDoc([
makeImage($existingMedia->uuid, $existingMedia->getUrl()),
makeImage('new-upload', 'blob:temporary'),
]),
]);
$record->refresh();
expect($record->getMedia('content'))->toHaveCount(2);
$mediaUuids = $record->getMedia('content')->pluck('uuid')->all();
expect($mediaUuids)->toContain($existingMedia->uuid);
});
test('it replaces media when swapping one image for another', function (): void {
$record = MediaPostWithRichContent::create(['title' => 'Original']);
$oldMedia = $record
->addMediaFromString('old image')
->usingFileName('old.jpg')
->toMediaCollection('content');
$record->update([
'content' => json_encode(makeTipTapDoc([
makeImage($oldMedia->uuid, $oldMedia->getUrl()),
])),
]);
livewire(RichEditorFileAttachmentForm::class, ['recordId' => $record->id])
->call('saveWithAttachments', ['replacement'], [
'title' => 'Updated',
'content' => makeTipTapDoc([
makeImage('replacement', 'blob:temporary'),
]),
]);
$record->refresh();
expect($record->getMedia('content'))->toHaveCount(1);
expect($record->getMedia('content')->first()->uuid)->not->toBe($oldMedia->uuid);
});
});