diff --git a/packages/forms/src/Components/RichEditor.php b/packages/forms/src/Components/RichEditor.php index efe6dba0ed..466cacf8ff 100644 --- a/packages/forms/src/Components/RichEditor.php +++ b/packages/forms/src/Components/RichEditor.php @@ -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 + */ + 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(); diff --git a/tests/src/Fixtures/Livewire/RichEditorFileAttachmentForm.php b/tests/src/Fixtures/Livewire/RichEditorFileAttachmentForm.php new file mode 100644 index 0000000000..c6634d72bc --- /dev/null +++ b/tests/src/Fixtures/Livewire/RichEditorFileAttachmentForm.php @@ -0,0 +1,95 @@ +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'); + } +} diff --git a/tests/src/Fixtures/Models/MediaPostWithRichContent.php b/tests/src/Fixtures/Models/MediaPostWithRichContent.php new file mode 100644 index 0000000000..86e4ff6e7c --- /dev/null +++ b/tests/src/Fixtures/Models/MediaPostWithRichContent.php @@ -0,0 +1,24 @@ +registerRichContent('content') + ->plugins([ + PluginWithFileAttachmentProvider::make( + SpatieMediaLibraryFileAttachmentProvider::make(), + ), + ]); + } +} diff --git a/tests/src/SpatieMediaLibraryPlugin/SpatieRichEditorFileAttachmentTest.php b/tests/src/SpatieMediaLibraryPlugin/SpatieRichEditorFileAttachmentTest.php new file mode 100644 index 0000000000..6e28df2567 --- /dev/null +++ b/tests/src/SpatieMediaLibraryPlugin/SpatieRichEditorFileAttachmentTest.php @@ -0,0 +1,223 @@ + '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); + }); +});