From 9195430bb4eddaa6caa32675172dec060390f1ba Mon Sep 17 00:00:00 2001 From: Alexander Schranz Date: Tue, 22 Oct 2024 15:52:00 +0200 Subject: [PATCH] Add ImageMap, Collection Property Resolver and ResourceLoader (#4) * Add ImageMap Property Resolver * Add single collection and multi collection property resolver --- .../CollectionSelectionPropertyResolver.php | 50 ++++ .../ImageMapPropertyResolver.php | 155 +++++++++++ ...gleCollectionSelectionPropertyResolver.php | 50 ++++ .../CollectionResourceLoader.php | 53 ++++ ...ollectionSelectionPropertyResolverTest.php | 114 ++++++++ .../ImageMapPropertyResolverTest.php | 261 ++++++++++++++++++ ...ollectionSelectionPropertyResolverTest.php | 105 +++++++ .../CollectionResourceLoaderTest.php | 72 +++++ 8 files changed, 860 insertions(+) create mode 100644 src/Sulu/Bundle/MediaBundle/Infrastructure/Sulu/Content/PropertyResolver/CollectionSelectionPropertyResolver.php create mode 100644 src/Sulu/Bundle/MediaBundle/Infrastructure/Sulu/Content/PropertyResolver/ImageMapPropertyResolver.php create mode 100644 src/Sulu/Bundle/MediaBundle/Infrastructure/Sulu/Content/PropertyResolver/SingleCollectionSelectionPropertyResolver.php create mode 100644 src/Sulu/Bundle/MediaBundle/Infrastructure/Sulu/Content/ResourceLoader/CollectionResourceLoader.php create mode 100644 src/Sulu/Bundle/MediaBundle/Tests/Unit/Infrastructure/Sulu/Content/PropertyResolver/CollectionSelectionPropertyResolverTest.php create mode 100644 src/Sulu/Bundle/MediaBundle/Tests/Unit/Infrastructure/Sulu/Content/PropertyResolver/ImageMapPropertyResolverTest.php create mode 100644 src/Sulu/Bundle/MediaBundle/Tests/Unit/Infrastructure/Sulu/Content/PropertyResolver/SingleCollectionSelectionPropertyResolverTest.php create mode 100644 src/Sulu/Bundle/MediaBundle/Tests/Unit/Infrastructure/Sulu/Content/ResourceLoader/CollectionResourceLoaderTest.php diff --git a/src/Sulu/Bundle/MediaBundle/Infrastructure/Sulu/Content/PropertyResolver/CollectionSelectionPropertyResolver.php b/src/Sulu/Bundle/MediaBundle/Infrastructure/Sulu/Content/PropertyResolver/CollectionSelectionPropertyResolver.php new file mode 100644 index 00000000000..0e53347c263 --- /dev/null +++ b/src/Sulu/Bundle/MediaBundle/Infrastructure/Sulu/Content/PropertyResolver/CollectionSelectionPropertyResolver.php @@ -0,0 +1,50 @@ + [], ...$params]); + } + + /** @var string $resourceLoaderKey */ + $resourceLoaderKey = $params['resourceLoader'] ?? CollectionResourceLoader::getKey(); + + return ContentView::createResolvables( + $data, + $resourceLoaderKey, + ['ids' => $data, ...$params], + ); + } + + public static function getType(): string + { + return 'collection_selection'; + } +} diff --git a/src/Sulu/Bundle/MediaBundle/Infrastructure/Sulu/Content/PropertyResolver/ImageMapPropertyResolver.php b/src/Sulu/Bundle/MediaBundle/Infrastructure/Sulu/Content/PropertyResolver/ImageMapPropertyResolver.php new file mode 100644 index 00000000000..49cd053ab2f --- /dev/null +++ b/src/Sulu/Bundle/MediaBundle/Infrastructure/Sulu/Content/PropertyResolver/ImageMapPropertyResolver.php @@ -0,0 +1,155 @@ +metadataResolver = $metadataResolver; + } + + public function resolve(mixed $data, string $locale, array $params = []): ContentView + { + $hotspots = (\is_array($data) && isset($data['hotspots']) && \is_array($data['hotspots'])) && \array_is_list($data['hotspots']) + ? $data['hotspots'] + : []; + + $hotspots = [] !== $hotspots ? $this->resolveHotspots($hotspots, $locale, $params) : ContentView::create([], []); + + $returnedParams = $params; + unset($returnedParams['metadata']); // TODO we may should implement a PropertyResolverAwareMetadataInterface + + if (!\is_array($data) + || !isset($data['imageId']) + || !\is_numeric($data['imageId']) + ) { + return ContentView::create([ + 'image' => null, + 'hotspots' => $hotspots->getContent(), + ], [ + 'imageId' => null, + 'hotspots' => $hotspots->getView(), + ...$returnedParams, + ]); + } + + /** @var string $resourceLoaderKey */ + $resourceLoaderKey = $params['resourceLoader'] ?? MediaResourceLoader::getKey(); + $imageId = (int) $data['imageId']; + + return ContentView::create( + [ + 'image' => new ResolvableResource($imageId, $resourceLoaderKey), + 'hotspots' => $hotspots->getContent(), + ], + [ + 'imageId' => $imageId, + 'hotspots' => $hotspots->getView(), + ...$returnedParams, + ], + ); + } + + /** + * @param non-empty-array> $hotspots + * @param array $params + */ + private function resolveHotspots(array $hotspots, string $locale, array $params): ContentView + { + $metadata = $params['metadata'] ?? null; + \assert($metadata instanceof FieldMetadata, 'Metadata must be set to resolve hotspots.'); + $metadataTypes = $metadata->getTypes(); + $content = []; + $view = []; + foreach ($hotspots as $key => $block) { + if (!\is_array($block) || !isset($block['type']) || !\is_string($block['type'])) { + continue; + } + if (!isset($block['hotspot']) || !\is_array($block['hotspot'])) { + continue; + } + + $type = $block['type']; + $formMetadata = $metadataTypes[$type] ?? null; + + if (!$formMetadata instanceof FormMetadata) { + $errorMessage = \sprintf( + 'Metadata type "%s" in "%s" not found, founded types are: "%s"', + $type, + $metadata->getName(), + \implode('", "', \array_keys($metadataTypes)), + ); + + $this->logger->error($errorMessage); + + if ($this->debug) { + throw new \UnexpectedValueException($errorMessage); + } + + $type = $metadata->getDefaultType(); + $formMetadata = $metadataTypes[$type] ?? null; + if (!$formMetadata instanceof FormMetadata) { + continue; + } + } + + $content[$key] = [ + 'type' => $type, + 'hotspot' => $block['hotspot'], + ]; + + $view[$key] = []; + + foreach ($this->metadataResolver->resolveItems($formMetadata->getItems(), $block, $locale) as $field => $resolvedItem) { + $content[$key][$field] = $resolvedItem->getContent(); + $view[$key][$field] = $resolvedItem->getView(); + } + } + + return ContentView::create(\array_values($content), \array_values($view)); + } + + public static function getType(): string + { + return 'image_map'; + } +} diff --git a/src/Sulu/Bundle/MediaBundle/Infrastructure/Sulu/Content/PropertyResolver/SingleCollectionSelectionPropertyResolver.php b/src/Sulu/Bundle/MediaBundle/Infrastructure/Sulu/Content/PropertyResolver/SingleCollectionSelectionPropertyResolver.php new file mode 100644 index 00000000000..20c832ea19c --- /dev/null +++ b/src/Sulu/Bundle/MediaBundle/Infrastructure/Sulu/Content/PropertyResolver/SingleCollectionSelectionPropertyResolver.php @@ -0,0 +1,50 @@ + null, ...$params]); + } + + /** @var string $resourceLoaderKey */ + $resourceLoaderKey = $params['resourceLoader'] ?? CollectionResourceLoader::getKey(); + + return ContentView::createResolvable( + (int) $data, + $resourceLoaderKey, + [ + 'id' => $data, + ...$params, + ], + ); + } + + public static function getType(): string + { + return 'single_collection_selection'; + } +} diff --git a/src/Sulu/Bundle/MediaBundle/Infrastructure/Sulu/Content/ResourceLoader/CollectionResourceLoader.php b/src/Sulu/Bundle/MediaBundle/Infrastructure/Sulu/Content/ResourceLoader/CollectionResourceLoader.php new file mode 100644 index 00000000000..62471f3545c --- /dev/null +++ b/src/Sulu/Bundle/MediaBundle/Infrastructure/Sulu/Content/ResourceLoader/CollectionResourceLoader.php @@ -0,0 +1,53 @@ +collectionManager->getById($id, $locale); // TODO load all over one query + $mappedResult[$collection->getId()] = $collection; + } catch (CollectionNotFoundException $e) { + // @ignoreException: do not crash page if selected collection is deleted + } + } + + return $mappedResult; + } + + public static function getKey(): string + { + return self::RESOURCE_LOADER_KEY; + } +} diff --git a/src/Sulu/Bundle/MediaBundle/Tests/Unit/Infrastructure/Sulu/Content/PropertyResolver/CollectionSelectionPropertyResolverTest.php b/src/Sulu/Bundle/MediaBundle/Tests/Unit/Infrastructure/Sulu/Content/PropertyResolver/CollectionSelectionPropertyResolverTest.php new file mode 100644 index 00000000000..2c5af0f4e04 --- /dev/null +++ b/src/Sulu/Bundle/MediaBundle/Tests/Unit/Infrastructure/Sulu/Content/PropertyResolver/CollectionSelectionPropertyResolverTest.php @@ -0,0 +1,114 @@ +resolver = new CollectionSelectionPropertyResolver(); + } + + public function testResolveEmpty(): void + { + $contentView = $this->resolver->resolve([], 'en'); + + $this->assertSame([], $contentView->getContent()); + $this->assertSame(['ids' => []], $contentView->getView()); + } + + public function testResolveParams(): void + { + $contentView = $this->resolver->resolve([], 'en', ['custom' => 'params']); + + $this->assertSame([], $contentView->getContent()); + $this->assertSame([ + 'ids' => [], + 'custom' => 'params', + ], $contentView->getView()); + } + + #[DataProvider('provideUnresolvableData')] + public function testResolveUnresolvableData(mixed $data): void + { + $contentView = $this->resolver->resolve($data, 'en'); + + $this->assertSame([], $contentView->getContent()); + $this->assertSame(['ids' => []], $contentView->getView()); + } + + /** + * @return iterable + */ + public static function provideUnresolvableData(): iterable + { + yield 'null' => [null]; + yield 'smart_content' => [['source' => '123']]; + yield 'single_value' => [1]; + yield 'object' => [(object) [1, 2]]; + } + + /** + * @param array $data + */ + #[DataProvider('provideResolvableData')] + public function testResolveResolvableData(array $data): void + { + $contentView = $this->resolver->resolve($data, 'en'); + + $content = $contentView->getContent(); + $this->assertIsArray($content); + foreach ($data as $key => $value) { + $resolvable = $content[$key] ?? null; + $this->assertInstanceOf(ResolvableResource::class, $resolvable); + $this->assertSame($value, $resolvable->getId()); + $this->assertSame('collection', $resolvable->getResourceLoaderKey()); + } + + $this->assertSame(['ids' => $data], $contentView->getView()); + } + + /** + * @return iterable, + * }> + */ + public static function provideResolvableData(): iterable + { + yield 'empty' => [[]]; + yield 'int_list' => [[1, 2]]; + yield 'string_list' => [['1', '2']]; + } + + public function testCustomResourceLoader(): void + { + $contentView = $this->resolver->resolve([1], 'en', ['resourceLoader' => 'custom_collection']); + + $content = $contentView->getContent(); + $this->assertIsArray($content); + $resolvable = $content[0] ?? null; + $this->assertInstanceOf(ResolvableResource::class, $resolvable); + $this->assertSame(1, $resolvable->getId()); + $this->assertSame('custom_collection', $resolvable->getResourceLoaderKey()); + } +} diff --git a/src/Sulu/Bundle/MediaBundle/Tests/Unit/Infrastructure/Sulu/Content/PropertyResolver/ImageMapPropertyResolverTest.php b/src/Sulu/Bundle/MediaBundle/Tests/Unit/Infrastructure/Sulu/Content/PropertyResolver/ImageMapPropertyResolverTest.php new file mode 100644 index 00000000000..8f8d3b06429 --- /dev/null +++ b/src/Sulu/Bundle/MediaBundle/Tests/Unit/Infrastructure/Sulu/Content/PropertyResolver/ImageMapPropertyResolverTest.php @@ -0,0 +1,261 @@ +logger = new BufferingLogger(); + $this->resolver = new ImageMapPropertyResolver( + $this->logger, + debug: false, + ); + $metadataResolverProperty = new PropertyResolverProvider([ + 'default' => new DefaultPropertyResolver(), + ]); + $metadataResolver = new MetadataResolver($metadataResolverProperty); + $this->resolver->setMetadataResolver($metadataResolver); + } + + public function testResolveEmpty(): void + { + $contentView = $this->resolver->resolve(null, 'en'); + + $this->assertSame(['image' => null, 'hotspots' => []], $contentView->getContent()); + $this->assertSame(['imageId' => null, 'hotspots' => []], $contentView->getView()); + $this->assertCount(0, $this->logger->cleanLogs()); + } + + public function testResolveParams(): void + { + $contentView = $this->resolver->resolve(null, 'en', ['custom' => 'params']); + + $this->assertSame(['image' => null, 'hotspots' => []], $contentView->getContent()); + $this->assertSame([ + 'imageId' => null, + 'hotspots' => [], + 'custom' => 'params', + ], $contentView->getView()); + $this->assertCount(0, $this->logger->cleanLogs()); + } + + #[DataProvider('provideUnresolvableData')] + public function testResolveUnresolvableData(mixed $data): void + { + $contentView = $this->resolver->resolve($data, 'en'); + + $this->assertSame(['image' => null, 'hotspots' => []], $contentView->getContent()); + $this->assertSame(['imageId' => null, 'hotspots' => []], $contentView->getView()); + $this->assertCount(0, $this->logger->cleanLogs()); + } + + /** + * @return iterable + */ + public static function provideUnresolvableData(): iterable + { + yield 'null' => [null]; + yield 'smart_content' => [['source' => '123']]; + yield 'single_value' => [1]; + yield 'object' => [(object) [1, 2]]; + yield 'int_list_not_in_ids' => [[1, 2]]; + yield 'ids_null' => [['ids' => null]]; + yield 'ids_list' => [['ids' => [1, 2]]]; + yield 'id_list' => [['id' => [1, 2]]]; + yield 'non_numeric_image_id' => [['imageId' => 'a']]; + } + + /** + * @param array{ + * imageId?: string|int, + * hotspots?: array, + * } $data + */ + #[DataProvider('provideResolvableData')] + public function testResolveResolvableData(array $data): void + { + $contentView = $this->resolver->resolve($data, 'en', ['metadata' => $this->createMetadata()]); + + $content = $contentView->getContent(); + $this->assertIsArray($content); + $imageId = $data['imageId'] ?? null; + if (null !== $imageId) { + $imageId = (int) $imageId; + $image = $content['image'] ?? null; + $this->assertInstanceOf(ResolvableResource::class, $image); + $this->assertSame($imageId, $image->getId()); + $this->assertSame('media', $image->getResourceLoaderKey()); + } + + $hotspots = $content['hotspots'] ?? []; + $this->assertIsArray($hotspots); + $expectedView = []; + foreach (($data['hotspots'] ?? []) as $key => $hotspot) { + $hotspot = $hotspots[$key] ?? null; + $this->assertIsArray($hotspot); + $this->assertSame($data['hotspots'][$key], $hotspot); + $expectedView[] = ['title' => []]; + } + + $this->assertSame([ + 'imageId' => $imageId, + 'hotspots' => $expectedView, + ], $contentView->getView()); + + $this->assertCount(0, $this->logger->cleanLogs()); + } + + /** + * @return iterable + */ + public static function provideResolvableData(): iterable + { + yield 'empty' => [[]]; + yield 'int_id' => [['imageId' => 1]]; + yield 'int_id_with_hotspots' => [ + ['imageId' => 1, 'hotspots' => [['type' => 'text', 'hotspot' => ['type' => 'circle'], 'title' => 'Title 1'], ['type' => 'text', 'hotspot' => ['type' => 'circle'], 'title' => 'Title 2']]], + ]; + yield 'string_id' => [['imageId' => '1']]; + yield 'string_id_with_hotspots' => [['imageId' => '1', 'hotspots' => [['type' => 'text', 'hotspot' => ['type' => 'circle'], 'title' => 'Title 1'], ['type' => 'text', 'hotspot' => ['type' => 'circle'], 'title' => 'Title 2']]]]; + } + + public function testCustomResourceLoader(): void + { + $contentView = $this->resolver->resolve( + ['imageId' => 1, 'hotspots' => [['type' => 'text', 'title' => 'Title'], ['type' => 'text', 'title' => 'Title']]], + 'en', + [ + 'metadata' => $this->createMetadata(), + 'resourceLoader' => 'custom_media', + ] + ); + + $content = $contentView->getContent(); + $this->assertIsArray($content); + $image = $content['image'] ?? null; + $this->assertInstanceOf(ResolvableResource::class, $image); + $this->assertSame(1, $image->getId()); + $this->assertSame('custom_media', $image->getResourceLoaderKey()); + + $this->assertSame([ + 'imageId' => 1, + 'hotspots' => [], + 'resourceLoader' => 'custom_media', + ], $contentView->getView()); + $this->assertCount(0, $this->logger->cleanLogs()); + } + + /** + * @param array{ + * imageId: int, + * hotspots?: array, + * } $data + */ + #[DataProvider('provideUnresolvableHotspotData')] + public function testResolveUnresolvableHotspotData(mixed $data): void + { + $contentView = $this->resolver->resolve($data, 'en', ['metadata' => $this->createMetadata()]); + + $content = $contentView->getContent(); + $this->assertIsArray($content); + $image = $content['image'] ?? null; + $this->assertInstanceOf(ResolvableResource::class, $image); + $this->assertSame(1, $image->getId()); + $this->assertSame('media', $image->getResourceLoaderKey()); + $hotspots = $content['hotspots'] ?? null; + $this->assertIsArray($hotspots); + + $expectedView = []; + $expectedCount = \count($data['hotspots'] ?? []); + $expectedErrorLogs = 0; + foreach ($data['hotspots'] ?? [] as $hotspot) { + if (!isset($hotspot['type'])) { + --$expectedCount; + continue; + } + if (!isset($hotspot['hotspot'])) { + --$expectedCount; + continue; + } + ++$expectedErrorLogs; + $expectedView[] = ['title' => []]; + } + + $this->assertCount($expectedCount, $hotspots); + + $this->assertSame(['imageId' => 1, 'hotspots' => $expectedView], $contentView->getView()); + $logs = $this->logger->cleanLogs(); + $this->assertCount($expectedErrorLogs, $logs); + } + + /** + * @return iterable + */ + public static function provideUnresolvableHotspotData(): iterable + { + yield 'hotspot_with_not_exist_type' => [['imageId' => 1, 'hotspots' => [['type' => 'not_exist', 'hotspot' => ['type' => 'circle'], 'title' => 'Title']]]]; + yield 'hotspot_with_no_type' => [['imageId' => 1, 'hotspots' => [['hotspot' => ['type' => 'circle'], 'title' => 'Title']]]]; + yield 'hotspot_with_no_hotspot' => [['imageId' => 1, 'hotspots' => [['type' => 'not_exist', 'title' => 'Title']]]]; + } + + private function createMetadata(): FieldMetadata + { + $fieldMetadata = new FieldMetadata('image'); + $fieldMetadata->setType('image_map'); + $fieldMetadata->setDefaultType('text'); + + $textFormMetadata = new FormMetadata(); + $textFormMetadata->setName('text'); + $itemMetadata = new FieldMetadata('title'); + $itemMetadata->setType('text_line'); + $textFormMetadata->addItem($itemMetadata); + + $fieldMetadata->addType($textFormMetadata); + + return $fieldMetadata; + } +} diff --git a/src/Sulu/Bundle/MediaBundle/Tests/Unit/Infrastructure/Sulu/Content/PropertyResolver/SingleCollectionSelectionPropertyResolverTest.php b/src/Sulu/Bundle/MediaBundle/Tests/Unit/Infrastructure/Sulu/Content/PropertyResolver/SingleCollectionSelectionPropertyResolverTest.php new file mode 100644 index 00000000000..9b4d8b77f30 --- /dev/null +++ b/src/Sulu/Bundle/MediaBundle/Tests/Unit/Infrastructure/Sulu/Content/PropertyResolver/SingleCollectionSelectionPropertyResolverTest.php @@ -0,0 +1,105 @@ +resolver = new SingleCollectionSelectionPropertyResolver(); + } + + public function testResolveEmpty(): void + { + $contentView = $this->resolver->resolve(null, 'en'); + + $this->assertNull($contentView->getContent()); + $this->assertSame(['id' => null], $contentView->getView()); + } + + public function testResolveParams(): void + { + $contentView = $this->resolver->resolve(null, 'en', ['custom' => 'params']); + + $this->assertNull($contentView->getContent()); + $this->assertSame([ + 'id' => null, + 'custom' => 'params', + ], $contentView->getView()); + } + + #[DataProvider('provideUnresolvableData')] + public function testResolveUnresolvableData(mixed $data): void + { + $contentView = $this->resolver->resolve($data, 'en'); + + $this->assertNull($contentView->getContent()); + $this->assertSame(['id' => null], $contentView->getView()); + } + + /** + * @return iterable + */ + public static function provideUnresolvableData(): iterable + { + yield 'null' => [null]; + yield 'smart_content' => [['source' => '123']]; + yield 'multi_value' => [[1]]; + yield 'object' => [(object) [1]]; + } + + #[DataProvider('provideResolvableData')] + public function testResolveResolvableData(int|string $data): void + { + $contentView = $this->resolver->resolve($data, 'en'); + + $content = $contentView->getContent(); + $this->assertInstanceOf(ResolvableResource::class, $content); + $this->assertSame((int) $data, $content->getId()); + $this->assertSame('collection', $content->getResourceLoaderKey()); + + $this->assertSame(['id' => $data], $contentView->getView()); + } + + /** + * @return iterable + */ + public static function provideResolvableData(): iterable + { + yield 'int' => [1]; + yield 'string' => ['2']; + } + + public function testCustomResourceLoader(): void + { + $contentView = $this->resolver->resolve(1, 'en', ['resourceLoader' => 'custom_collection']); + + $content = $contentView->getContent(); + + $this->assertInstanceOf(ResolvableResource::class, $content); + $this->assertSame(1, $content->getId()); + $this->assertSame('custom_collection', $content->getResourceLoaderKey()); + } +} diff --git a/src/Sulu/Bundle/MediaBundle/Tests/Unit/Infrastructure/Sulu/Content/ResourceLoader/CollectionResourceLoaderTest.php b/src/Sulu/Bundle/MediaBundle/Tests/Unit/Infrastructure/Sulu/Content/ResourceLoader/CollectionResourceLoaderTest.php new file mode 100644 index 00000000000..d705d047479 --- /dev/null +++ b/src/Sulu/Bundle/MediaBundle/Tests/Unit/Infrastructure/Sulu/Content/ResourceLoader/CollectionResourceLoaderTest.php @@ -0,0 +1,72 @@ + + */ + private ObjectProphecy $collectionManager; + + private CollectionResourceLoader $loader; + + public function setUp(): void + { + $this->collectionManager = $this->prophesize(CollectionManagerInterface::class); + $this->loader = new CollectionResourceLoader($this->collectionManager->reveal()); + } + + public function testGetKey(): void + { + $this->assertSame('collection', $this->loader::getKey()); + } + + public function testLoad(): void + { + $collection1 = $this->createCollection(1); + $collection3 = $this->createCollection(3); + + $this->collectionManager->getById(1, 'en')->willReturn($collection1) + ->shouldBeCalled(); + + $this->collectionManager->getById(3, 'en')->willReturn($collection3) + ->shouldBeCalled(); + + $result = $this->loader->load([1, 3], 'en', []); + + $this->assertSame([ + 1 => $collection1, + 3 => $collection3, + ], $result); + } + + private static function createCollection(int $id): ApiCollection + { + $collection = new Collection(); + static::setPrivateProperty($collection, 'id', $id); + + return new ApiCollection($collection, 'en'); + } +}