mirror of
https://github.com/filamentphp/filament.git
synced 2026-09-21 13:30:51 +08:00
Refactor RichEditor further to allow hook overrides
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user