diff --git a/src/Listeners/UpdateResponsiveReferences.php b/src/Listeners/UpdateResponsiveReferences.php new file mode 100644 index 0000000..fb99acc --- /dev/null +++ b/src/Listeners/UpdateResponsiveReferences.php @@ -0,0 +1,82 @@ +listen(AssetSaved::class, [self::class, 'handleSaved']); + $events->listen(AssetDeleted::class, [self::class, 'handleDeleted']); + } + + /** + * Handle asset saved event. + * + * @param AssetSaved $event + */ + public function handleSaved(AssetSaved $event) + { + $asset = $event->asset; + + $container = $asset->container()->handle(); + $originalPath = $asset->getOriginal('path'); + $newPath = $asset->path(); + + $this->replaceReferences($container, $originalPath, $newPath); + } + + /** + * Handle asset deleted event. + * + * @param AssetDeleted $event + * @return void + */ + public function handleDeleted(AssetDeleted $event) + { + $asset = $event->asset; + + $container = $asset->container()->handle(); + $originalPath = $asset->getOriginal('path'); + $newPath = null; + + $this->replaceReferences($container, $originalPath, $newPath); + } + + /** + * @param $container + * @param $originalPath + * @param $newPath + * @return void + */ + protected function replaceReferences($container, $originalPath, $newPath) + { + if (!$originalPath || $originalPath === $newPath) { + return; + } + + $newValue = $newPath ? "{$container}::{$newPath}" : null; + + $this->getItemsContainingData()->each(function ($item) use ($container, $originalPath, $newValue) { + ResponsiveReferenceUpdater::item($item) + ->filterByContainer($container) + ->updateReferences($container . '::' . $originalPath, $newValue); + }); + } +} diff --git a/src/ResponsiveReferenceUpdater.php b/src/ResponsiveReferenceUpdater.php new file mode 100644 index 0000000..6ad832b --- /dev/null +++ b/src/ResponsiveReferenceUpdater.php @@ -0,0 +1,116 @@ +updateResponsiveFieldValues($fields, $dottedPrefix) + ->updateNestedFieldValues($fields, $dottedPrefix); + } + + /** + * Update assets field values. + * + * @param \Illuminate\Support\Collection $fields + * @param null|string $dottedPrefix + * @return $this + */ + protected function updateResponsiveFieldValues($fields, $dottedPrefix) + { + $fields + ->filter(function ($field) { + return $field->type() === 'responsive' + && $this->getConfiguredAssetsFieldContainer($field) === $this->container; + }) + ->each(function ($field) use ($dottedPrefix) { + $this->updateResponsiveValue($field, $dottedPrefix); + }); + + return $this; + } + + /** + * Update responsive value on item. + * + * @see AssetReferenceUpdater::updateArrayValue() + * @param \Statamic\Fields\Field $field + * @param null|string $dottedPrefix + */ + protected function updateResponsiveValue($field, $dottedPrefix) + { + $data = $this->item->data()->all(); + + $dottedKey = $dottedPrefix.$field->handle(); + + $fieldData = collect( + Arr::get($data, $dottedKey, []) + ); + + $referencesUpdated = 0; + + $fieldData->transform(function ($value, $key) use (&$referencesUpdated) { + if (!str_ends_with($key, 'src')) { + return $value; + } + + // In content files, the src value can be either string or array. + // First handle the string value, and then handle the array value. + // Handle asset deletion, return null now for filtering later. + if ($value === $this->originalValue() && $this->isRemovingValue()) { + $referencesUpdated++; + return null; + } + + if (is_string($value) && $value === $this->originalValue()) { + $referencesUpdated++; + return $this->newValue(); + } + + // Handle array value. + if (is_array($value) && in_array($this->originalValue(), $value)) { + $transformedFieldDataArray = array_map(function ($item) use (&$referencesUpdated) { + // Handle asset deletion, return null now for filtering. + if ($item === $this->originalValue() && $this->isRemovingValue()) { + $referencesUpdated++; + return null; + } + + if ($item === $this->originalValue()) { + $referencesUpdated++; + return $this->newValue(); + } + + return $item; + }, $value); + + return array_filter($transformedFieldDataArray, fn($item) => $item !== null); + } + + return $value; + }); + + $fieldData = $fieldData->filter(fn($item) => $item !== null); + + if ($referencesUpdated === 0) { + return; + } + + Arr::set($data, $dottedKey, $fieldData->all()); + + $this->item->data($data); + + $this->updated = true; + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 4648ec8..6ce81b2 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -11,8 +11,10 @@ use Spatie\ResponsiveImages\GraphQL\ResponsiveFieldType as GraphQLResponsiveFieldType; use Spatie\ResponsiveImages\Jobs\GenerateImageJob; use Spatie\ResponsiveImages\Listeners\GenerateResponsiveVersions; +use Spatie\ResponsiveImages\Listeners\UpdateResponsiveReferences; use Spatie\ResponsiveImages\Tags\ResponsiveTag; use Statamic\Events\AssetUploaded; +use Statamic\Events\AssetSaved; use Statamic\Facades\GraphQL; use Statamic\Providers\AddonServiceProvider; @@ -40,6 +42,10 @@ class ServiceProvider extends AddonServiceProvider ], ]; + protected $subscribe = [ + UpdateResponsiveReferences::class, + ]; + protected $commands = [ GenerateResponsiveVersionsCommand::class, RegenerateResponsiveVersionsCommand::class, @@ -50,6 +56,7 @@ public function boot() parent::boot(); $this + ->bootEvents() ->bootCommands() ->bootAddonViews() ->bootAddonConfig() diff --git a/tests/Feature/AssetReferenceTest.php b/tests/Feature/AssetReferenceTest.php new file mode 100644 index 0000000..0343ffa --- /dev/null +++ b/tests/Feature/AssetReferenceTest.php @@ -0,0 +1,237 @@ + 'responsive', + 'container' => 'test_container', + 'max_files' => 1, + 'use_breakpoints' => true, + 'allow_ratio' => false, + 'allow_fit' => true, + 'restrict' => false, + 'allow_uploads' => true, + 'display' => 'Avatar', + 'icon' => 'assets', + 'listable' => 'hidden', + 'instructions_position' => 'above', + 'visibility' => 'visible', + ]; + + private $entryBlueprintWithSingleResponsiveField; + + protected function setUp(): void + { + parent::setUp(); + + $file = new UploadedFile($this->getTestJpg(), 'test.jpg'); + $path = ltrim('/'.$file->getClientOriginalName(), '/'); + $this->asset = $this->assetContainer->makeAsset($path)->upload($file); + + Stache::clear(); + + $this->entryBlueprintWithSingleResponsiveField = [ + 'fields' => [ + [ + 'handle' => 'avatar', + 'field' => $this->responsiveFieldConfiguration, + ] + ], + ]; + } + + protected function setInBlueprints($namespace, $blueprintContents) + { + $blueprint = tap(Facades\Blueprint::make('set-in-blueprints')->setContents($blueprintContents))->save(); + + Facades\Blueprint::shouldReceive('in')->with($namespace)->andReturn(collect([$blueprint])); + } + + /** + * @param $blueprintConfiguration + * @param $entryData + * @return \Statamic\Entries\Entry + */ + protected function createDummyCollectionEntry($blueprintConfiguration, $entryData) + { + // Create collection + $collection = tap(Facades\Collection::make('articles'))->save(); + + $blueprintContents = $blueprintConfiguration; + + // Create blueprint for collection + $this->setInBlueprints('collections/articles', $blueprintContents); + + // Create entry in the collection + return tap(Facades\Entry::make()->collection($collection)->data($entryData))->save(); + } + + /** @test */ + public function asset_string_reference_gets_updated_after_asset_rename() + { + $entry = $this->createDummyCollectionEntry($this->entryBlueprintWithSingleResponsiveField, [ + 'avatar' => [ + 'src' => 'test_container::test.jpg', + 'ratio' => '16/9', + 'sm:src' => 'test_container::test.jpg', + 'sm:ratio' => '16/9', + ], + ]); + + $this->assertEquals('test_container::test.jpg', Arr::get($entry->get('avatar'), 'src')); + + $this->asset->rename('new-test2'); + + $this->assertEquals('test_container::new-test2.jpg', Arr::get($entry->fresh()->get('avatar'), 'src')); + } + + /** @test */ + public function asset_array_reference_gets_updated_after_asset_rename() + { + $startingAvatarData = [ + 'src' => [ + 'test_container::test.jpg' + ], + 'sm:src' => [ + 'test_container::test.jpg' + ], + ]; + + $entry = $this->createDummyCollectionEntry($this->entryBlueprintWithSingleResponsiveField, [ + 'avatar' => $startingAvatarData, + ]); + + $this->assertEquals($startingAvatarData, $entry->get('avatar')); + + $this->asset->rename('new-test2'); + + $this->assertEquals( + [ + 'src' => [ + 'test_container::new-test2.jpg' + ], + 'sm:src' => [ + 'test_container::new-test2.jpg' + ], + ], + $entry->fresh()->get('avatar') + ); + } + + /** @test */ + public function asset_reference_gets_updated_in_replicator_set_after_asset_rename() + { + $blueprintContents = [ + 'fields' => [ + [ + 'handle' => 'test_replicator_field', + 'field' => [ + 'collapse' => false, + 'previews' => true, + 'sets' => [ + 'new_test_set' => [ + 'display' => 'New Test Set', + 'fields' => [ + [ + 'handle' => 'responsive_test_replicator', + 'field' => $this->responsiveFieldConfiguration, + ], + ], + ], + ], + 'display' => 'Test Replicator Field', + 'type' => 'replicator', + 'icon' => 'replicator', + 'listable' => 'hidden', + 'instructions_position' => 'above', + 'visibility' => 'visible', + ], + ] + ] + ]; + + $entryData = [ + 'test_replicator_field' => [ + [ + 'responsive_test_replicator' => [ + 'src' => [ + 'test_container::test.jpg' + ], + ], + 'type' => 'new_test_set', + 'enabled' => true, + ], + ], + ]; + + $entry = $this->createDummyCollectionEntry($blueprintContents, $entryData); + + $this->assertEquals( + 'test_container::test.jpg', + Arr::get($entry->get('test_replicator_field'), '0.responsive_test_replicator.src.0') + ); + + $this->asset->rename('new-test2'); + + $this->assertEquals( + 'test_container::new-test2.jpg', + Arr::get($entry->fresh()->get('test_replicator_field'), '0.responsive_test_replicator.src.0') + ); + } + + /** @test */ + public function asset_reference_gets_removed_after_asset_deletion() + { + $entry = $this->createDummyCollectionEntry($this->entryBlueprintWithSingleResponsiveField, [ + 'avatar' => [ + 'src' => 'test_container::test.jpg', + 'md:src' => 'test_container::test.jpg', + 'ratio' => '16/9', + 'md:ratio' => '16/9', + 'lg:src' => [ + 'test_container::test.jpg' + ], + ], + ]); + + $this->assertEquals('test_container::test.jpg', Arr::get($entry->get('avatar'), 'src')); + + $this->asset->delete(); + + $this->assertArrayNotHasKey('src', $entry->fresh()->data()->get('avatar')); + $this->assertArrayNotHasKey('md:src', $entry->fresh()->data()->get('avatar')); + $this->assertEmpty(Arr::get($entry->fresh()->data()->get('avatar'), 'lg:src')); + $this->assertEquals('16/9', Arr::get($entry->fresh()->data()->get('avatar'), 'ratio')); + } + + /** @test */ + public function asset_reference_stays_unchanged_after_asset_deletion_when_reference_updating_is_off() + { + config()->set('statamic.system.update_references', false); + // Set up environment again because listeners in UpdateResponsiveReferences@subscribe depend on config value + $this->setUp(); + + $entry = $this->createDummyCollectionEntry($this->entryBlueprintWithSingleResponsiveField, [ + 'avatar' => [ + 'src' => 'test_container::test.jpg', + ], + ]); + + $this->assertEquals('test_container::test.jpg', Arr::get($entry->get('avatar'), 'src')); + + $this->asset->delete(); + + $this->assertEquals('test_container::test.jpg', Arr::get($entry->fresh()->get('avatar'), 'src')); + } +}