diff --git a/Classes/Aspect/HierarchicalAssetCollectionAspect.php b/Classes/Aspect/HierarchicalAssetCollectionAspect.php index 43e10045d..2e5f69a80 100644 --- a/Classes/Aspect/HierarchicalAssetCollectionAspect.php +++ b/Classes/Aspect/HierarchicalAssetCollectionAspect.php @@ -18,6 +18,7 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Flowpack\Media\Ui\Domain\Model\HierarchicalAssetCollectionInterface; +use Flowpack\Media\Ui\Utility\AssetCollectionUtility; use Neos\Flow\Annotations as Flow; use Neos\Flow\Aop\JoinPointInterface; use Neos\Media\Domain\Model\AssetCollection; @@ -42,6 +43,13 @@ class HierarchicalAssetCollectionAspect */ protected $parent; + /** + * @var string + * @ORM\Column(length=1000,nullable=true) + * @Flow\Introduce("class(Neos\Media\Domain\Model\AssetCollection)") + */ + protected $path = null; + /** * @Flow\Around("method(Neos\Media\Domain\Model\AssetCollection->getParent())") */ @@ -64,6 +72,7 @@ public function setParent(JoinPointInterface $joinPoint): void if (!$parentAssetCollection instanceof AssetCollection && $parentAssetCollection !== null) { throw new \InvalidArgumentException('Parent must be an AssetCollection', 1678330583); } + ObjectAccess::setProperty($assetCollection, 'parent', $parentAssetCollection, true); // Throws an error if a circular dependency has been detected @@ -77,6 +86,9 @@ public function setParent(JoinPointInterface $joinPoint): void ), 1678330856); } } + + $path = AssetCollectionUtility::renderValidPath($assetCollection); + ObjectAccess::setProperty($assetCollection, 'path', $path, true); } /** @@ -86,7 +98,21 @@ public function unsetParent(JoinPointInterface $joinPoint): void { /** @var HierarchicalAssetCollectionInterface $assetCollection */ $assetCollection = $joinPoint->getProxy(); + $path = AssetCollectionUtility::renderValidPath($assetCollection); ObjectAccess::setProperty($assetCollection, 'parent', null, true); + ObjectAccess::setProperty($assetCollection, 'path', $path, true); + } + + /** + * @Flow\Around("method(Neos\Media\Domain\Model\AssetCollection->__construct())") + */ + public function updatePathAfterConstruct(JoinPointInterface $joinPoint): void + { + $joinPoint->getAdviceChain()->proceed($joinPoint); + + /** @var HierarchicalAssetCollectionInterface $assetCollection */ + $assetCollection = $joinPoint->getProxy(); + $assetCollection->setPath(AssetCollectionUtility::renderValidPath($assetCollection)); } /** @@ -109,6 +135,18 @@ public function getTitle(JoinPointInterface $joinPoint): string return $assetCollection->getTitle(); } + /** + * @Flow\Around("method(Neos\Media\Domain\Model\AssetCollection->setTitle())") + */ + public function setTitle(JoinPointInterface $joinPoint): void + { + $joinPoint->getAdviceChain()->proceed($joinPoint); + + /** @var HierarchicalAssetCollectionInterface $assetCollection */ + $assetCollection = $joinPoint->getProxy(); + $assetCollection->setPath(AssetCollectionUtility::renderValidPath($assetCollection)); + } + /** * @Flow\Around("method(Neos\Media\Domain\Model\AssetCollection->getTags())") */ @@ -118,4 +156,27 @@ public function getTags(JoinPointInterface $joinPoint): Collection $assetCollection = $joinPoint->getProxy(); return $assetCollection->getTags(); } + + /** + * @Flow\Around("method(Neos\Media\Domain\Model\AssetCollection->getPath())") + */ + public function getPath(JoinPointInterface $joinPoint): ?string + { + /** @var HierarchicalAssetCollectionInterface $assetCollection */ + $assetCollection = $joinPoint->getProxy(); + return ObjectAccess::getProperty($assetCollection, 'path', true); + } + + /** + * @Flow\Around("method(Neos\Media\Domain\Model\AssetCollection->setPath())") + */ + public function setPath(JoinPointInterface $joinPoint): void + { + /** @var HierarchicalAssetCollectionInterface $assetCollection */ + $assetCollection = $joinPoint->getProxy(); + /** @var string $path */ + $path = $joinPoint->getMethodArgument('path'); + ObjectAccess::setProperty($assetCollection, 'path', $path, true); + } + } diff --git a/Classes/Command/AssetCollectionsCommandController.php b/Classes/Command/AssetCollectionsCommandController.php index 6d9428805..dd7ca6ec5 100644 --- a/Classes/Command/AssetCollectionsCommandController.php +++ b/Classes/Command/AssetCollectionsCommandController.php @@ -15,6 +15,8 @@ */ use Flowpack\Media\Ui\Domain\Model\HierarchicalAssetCollectionInterface; +use Flowpack\Media\Ui\Service\AssetCollectionService; +use Flowpack\Media\Ui\Utility\AssetCollectionUtility; use Neos\Flow\Annotations as Flow; use Neos\Flow\Cli\CommandController; use Neos\Flow\Persistence\PersistenceManagerInterface; @@ -40,6 +42,12 @@ class AssetCollectionsCommandController extends CommandController */ protected $persistenceManager; + /** + * @Flow\Inject + * @var AssetCollectionService + */ + protected $assetCollectionService; + public function hierarchyCommand(): void { $rows = array_map(function (HierarchicalAssetCollectionInterface $assetCollection) { @@ -50,12 +58,15 @@ public function hierarchyCommand(): void $assetCollection->getTitle(), $assetCollection->getParent() ? $this->persistenceManager->getIdentifierByObject($assetCollection->getParent()) : 'None', $assetCollection->getParent() ? $assetCollection->getParent()->getTitle() : 'None', - implode(', ', array_map(static fn (AssetCollection $assetCollection) => $assetCollection->getTitle(), $children)), - implode("\n", array_map(static fn (Tag $tag) => $tag->getLabel(), $assetCollection->getTags()->toArray())), + implode(', ', + array_map(static fn(AssetCollection $assetCollection) => $assetCollection->getTitle(), $children)), + implode("\n", + array_map(static fn(Tag $tag) => $tag->getLabel(), $assetCollection->getTags()->toArray())), + $assetCollection->getPath(), ]; }, $this->assetCollectionRepository->findAll()->toArray()); - $this->output->outputTable($rows, ['Id', 'Title', 'ParentId', 'Parent title', 'Children', 'Tags']); + $this->output->outputTable($rows, ['Id', 'Title', 'ParentId', 'Parent title', 'Children', 'Tags', 'Path']); } public function setParentCommand(string $assetCollectionIdentifier, string $parentAssetCollectionIdentifier): void @@ -67,7 +78,26 @@ public function setParentCommand(string $assetCollectionIdentifier, string $pare /** @var HierarchicalAssetCollectionInterface $assetCollection */ $assetCollection->setParent($parentAssetCollection); $this->assetCollectionRepository->update($assetCollection); - $this->outputLine('Asset collection "%s" has been set as child of "%s"', [$assetCollection->getTitle(), $parentAssetCollection ? $parentAssetCollection->getTitle() : 'none']); + $this->assetCollectionService->updatePathForNestedAssetCollections($assetCollection); + $this->outputLine( + 'Asset collection "%s" has been set as child of "%s"', + [$assetCollection->getTitle(), $parentAssetCollection ? $parentAssetCollection->getTitle() : 'none'] + ); + } + + /** + * Recalculates the path of each `AssetCollection` and updates the database. + */ + public function updatePathsCommand(): void + { + $assetCollections = $this->assetCollectionRepository->findAll(); + /** @var HierarchicalAssetCollectionInterface $assetCollection */ + foreach ($assetCollections as $assetCollection) { + $path = AssetCollectionUtility::renderValidPath($assetCollection); + $assetCollection->setPath($path); + $this->assetCollectionRepository->update($assetCollection); + } + $this->outputLine('Paths have been updated for %d asset collections', [$assetCollections->count()]); } } diff --git a/Classes/Domain/Model/HierarchicalAssetCollectionInterface.php b/Classes/Domain/Model/HierarchicalAssetCollectionInterface.php index f72793018..796652516 100644 --- a/Classes/Domain/Model/HierarchicalAssetCollectionInterface.php +++ b/Classes/Domain/Model/HierarchicalAssetCollectionInterface.php @@ -54,4 +54,14 @@ public function unsetParent(); * @return bool */ public function hasParent(); + + /** + * @return string|null + */ + public function getPath(); + + /** + * @return void + */ + public function setPath(?string $path); } diff --git a/Classes/GraphQL/Resolver/Type/MutationResolver.php b/Classes/GraphQL/Resolver/Type/MutationResolver.php index f3124d859..a93db99ba 100644 --- a/Classes/GraphQL/Resolver/Type/MutationResolver.php +++ b/Classes/GraphQL/Resolver/Type/MutationResolver.php @@ -21,16 +21,16 @@ use Flowpack\Media\Ui\Domain\Model\HierarchicalAssetCollectionInterface; use Flowpack\Media\Ui\Exception; use Flowpack\Media\Ui\GraphQL\Context\AssetSourceContext; +use Flowpack\Media\Ui\Service\AssetCollectionService; use Neos\Flow\Annotations as Flow; -use Neos\Flow\I18n\Translator; use Neos\Flow\Persistence\Exception\IllegalObjectTypeException; use Neos\Flow\Persistence\Exception\InvalidQueryException; use Neos\Flow\Persistence\PersistenceManagerInterface; use Neos\Flow\ResourceManagement\ResourceManager; use Neos\Http\Factories\FlowUploadedFile; use Neos\Media\Domain\Model\Asset; -use Neos\Media\Domain\Model\AssetSource\AssetProxy\AssetProxyInterface; use Neos\Media\Domain\Model\AssetCollection; +use Neos\Media\Domain\Model\AssetSource\AssetProxy\AssetProxyInterface; use Neos\Media\Domain\Model\Tag; use Neos\Media\Domain\Repository\AssetCollectionRepository; use Neos\Media\Domain\Repository\AssetRepository; @@ -107,6 +107,12 @@ class MutationResolver implements ResolverInterface */ protected $assetService; + /** + * @Flow\Inject + * @var AssetCollectionService + */ + protected $assetCollectionService; + /** * @throws Exception */ @@ -695,6 +701,7 @@ public function updateAssetCollection($_, array $variables): bool } $this->assetCollectionRepository->update($assetCollection); + $this->assetCollectionService->updatePathForNestedAssetCollections($assetCollection); return true; } @@ -727,6 +734,7 @@ public function setAssetCollectionParent($_, array $variables): bool $assetCollection->setParent(null); } $this->assetCollectionRepository->update($assetCollection); + $this->assetCollectionService->updatePathForNestedAssetCollections($assetCollection); return true; } diff --git a/Classes/Security/Authorization/Privilege/Doctrine/AssetAssetCollectionConditionGenerator.php b/Classes/Security/Authorization/Privilege/Doctrine/AssetAssetCollectionConditionGenerator.php new file mode 100644 index 000000000..a457de951 --- /dev/null +++ b/Classes/Security/Authorization/Privilege/Doctrine/AssetAssetCollectionConditionGenerator.php @@ -0,0 +1,53 @@ + HierarchicalAssetCollection relations + * (M:M relations are not supported by the Flow PropertyConditionGenerator yet) + */ +class AssetAssetCollectionConditionGenerator extends + \Neos\Media\Security\Authorization\Privilege\Doctrine\AssetAssetCollectionConditionGenerator +{ + + /** + * @param DoctrineSqlFilter $sqlFilter + * @param ClassMetadata $targetEntity Metadata object for the target entity to create the constraint for + * @param string $targetTableAlias The target table alias used in the current query + * @return string + */ + public function getSql(DoctrineSqlFilter $sqlFilter, ClassMetadata $targetEntity, $targetTableAlias): string + { + $propertyConditionGenerator = new PropertyConditionGenerator(''); + $collectionTitleOrIdentifier = $propertyConditionGenerator->getValueForOperand($this->collectionTitleOrIdentifier); + if (preg_match(UuidValidator::PATTERN_MATCH_UUID, $collectionTitleOrIdentifier) === 1) { + $whereCondition = $targetTableAlias . '_ac.persistence_object_identifier = ' . $this->entityManager->getConnection()->quote($collectionTitleOrIdentifier); + } else { + $whereCondition = $targetTableAlias . '_ac.path LIKE ' . $this->entityManager->getConnection()->quote($collectionTitleOrIdentifier . '%'); + } + + return $targetTableAlias . '.persistence_object_identifier IN ( + SELECT ' . $targetTableAlias . '_a.persistence_object_identifier + FROM neos_media_domain_model_asset AS ' . $targetTableAlias . '_a + LEFT JOIN neos_media_domain_model_assetcollection_assets_join ' . $targetTableAlias . '_acj ON ' . $targetTableAlias . '_a.persistence_object_identifier = ' . $targetTableAlias . '_acj.media_asset + LEFT JOIN neos_media_domain_model_assetcollection ' . $targetTableAlias . '_ac ON ' . $targetTableAlias . '_ac.persistence_object_identifier = ' . $targetTableAlias . '_acj.media_assetcollection + WHERE ' . $whereCondition . ')'; + } +} diff --git a/Classes/Security/Authorization/Privilege/Doctrine/AssetConditionGenerator.php b/Classes/Security/Authorization/Privilege/Doctrine/AssetConditionGenerator.php new file mode 100644 index 000000000..07a4c13b9 --- /dev/null +++ b/Classes/Security/Authorization/Privilege/Doctrine/AssetConditionGenerator.php @@ -0,0 +1,26 @@ +like($path . '%'); + } +} diff --git a/Classes/Security/Authorization/Privilege/ReadAssetPrivilege.php b/Classes/Security/Authorization/Privilege/ReadAssetPrivilege.php new file mode 100644 index 000000000..04bce4b1d --- /dev/null +++ b/Classes/Security/Authorization/Privilege/ReadAssetPrivilege.php @@ -0,0 +1,28 @@ +assetCollectAssetCountCache[$assetCollectionId] ?? 0; } + + public function updatePathForNestedAssetCollections(HierarchicalAssetCollectionInterface $parentCollection): void + { + $childCollections = $this->assetCollectionRepository->findByParent($parentCollection); + + foreach ($childCollections as $childCollection) { + $childCollection->setPath(AssetCollectionUtility::renderValidPath($childCollection)); + $this->assetCollectionRepository->update($childCollection); + $this->updatePathForNestedAssetCollections($childCollection); + } + } } diff --git a/Classes/Utility/AssetCollectionUtility.php b/Classes/Utility/AssetCollectionUtility.php new file mode 100644 index 000000000..f180c1b0d --- /dev/null +++ b/Classes/Utility/AssetCollectionUtility.php @@ -0,0 +1,62 @@ +getTitle(); + $originalName = $name; + + // Transliterate (transform 北京 to 'Bei Jing') + $name = Transliterator::transliterate($name); + + // Urlization (replace spaces with dash, special characters) + $name = Transliterator::urlize($name); + + // Ensure only allowed characters are left + $name = preg_replace('/[^a-z0-9\-]/', '', $name); + + // Make sure we don't have an empty string left. + if ($name === '') { + throw new \RuntimeException('Could not render a valid path for AssetCollection with title "' . $originalName . '".'); + } + + $name = '/' . $name; + + // Add path of the parent collection + /** @var HierarchicalAssetCollectionInterface $parent */ + $parent = $assetCollection->getParent(); + if ($parent !== null) { + // Recursively build parent path without reusing the stored parent path to ensure correct paths + // independent of the order of updates + $name = self::renderValidPath($parent) . $name; + } + + return $name; + } +} diff --git a/Migrations/Mysql/Version20240222092731.php b/Migrations/Mysql/Version20240222092731.php new file mode 100644 index 000000000..93bf15af3 --- /dev/null +++ b/Migrations/Mysql/Version20240222092731.php @@ -0,0 +1,31 @@ +abortIf(!$this->connection->getDatabasePlatform() instanceof MySqlPlatform, 'Migration can only be executed safely on "mysql".'); + + $this->addSql('ALTER TABLE neos_media_domain_model_assetcollection ADD path VARCHAR(1000) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->abortIf(!$this->connection->getDatabasePlatform() instanceof MySqlPlatform, 'Migration can only be executed safely on "mysql".'); + + $this->addSql('ALTER TABLE neos_media_domain_model_assetcollection DROP path'); + } +} diff --git a/Migrations/Postgresql/Version20240223075804.php b/Migrations/Postgresql/Version20240223075804.php new file mode 100644 index 000000000..1fcd4f21e --- /dev/null +++ b/Migrations/Postgresql/Version20240223075804.php @@ -0,0 +1,31 @@ +abortIf(!$this->connection->getDatabasePlatform() instanceof PostgreSQL100Platform, 'Migration can only be executed safely on "postgresql".'); + + $this->addSql('ALTER TABLE neos_media_domain_model_assetcollection ADD path VARCHAR(1000) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->abortIf(!$this->connection->getDatabasePlatform() instanceof PostgreSQL100Platform, 'Migration can only be executed safely on "postgresql".'); + + $this->addSql('ALTER TABLE neos_media_domain_model_assetcollection DROP path'); + } +} diff --git a/Readme.md b/Readme.md index 2fe6b84bd..69cf678aa 100644 --- a/Readme.md +++ b/Readme.md @@ -8,11 +8,35 @@ later replace `neos/media-browser`. If you want to use Neos, please have a look at the [Neos documentation](http://neos.readthedocs.org/en/stable/). +## Screenshots + +### Asset management + +### Asset selection + +### Asset details + ## Installation Run the following command to install it: - composer require flowpack/media-ui +```console +composer require flowpack/media-ui +``` + +Afterward you should execute doctrine migrations, as the package adds new columns to the database: + +```console +./flow doctrine:migrate +``` + +Then you should update the paths of existing asset collections: + +```console +./flow assetcollections:updatepaths +``` + +This is only necessary once after the installation. Afterward the paths will be updated automatically. ### What changes? @@ -63,6 +87,19 @@ this way create a structure comparable with folders in your computer's file syst It is recommended to enable the feature flag `limitToSingleAssetCollectionPerAsset` (see below) for a better experience - see below. +The package also provides the `\Flowpack\Media\Ui\Security\Authorization\Privilege\ReadHierarchicalAssetCollectionPrivilege` +with the additional method `isInPath()`, which can be used to control access to the collections. + +```yaml +privilegeTargets: + 'Flowpack\Media\Ui\Security\Authorization\Privilege\ReadHierarchicalAssetCollectionPrivilege': + 'Some.Package:ReadSpecialAssetCollectionAndSubCollections': + matcher: 'isInPath("/important-collections")' + 'Flowpack\Media\Ui\Security\Authorization\Privilege\ReadAssetPrivilege': + 'Some.Package:ReadSpecialAssets': + matcher: 'isInCollectionPath("/important-collections")' +``` + ## Optional features ### Limit assets to be only assigned to one AssetCollection @@ -320,7 +357,7 @@ This way the bundle size is kept to a minimum. #### Patches -Several [patches](patches) are applied during installation via [patch-package](https://github.com/ds300/patch-package). +Several [patches](.yarn/patches) are applied during installation via [patch-package](https://github.com/ds300/patch-package). Additional patches can be generated and stored there with the same tool if necessary. #### Connection between the backend module and UI plugin diff --git a/Resources/Private/GraphQL/schema.root.graphql b/Resources/Private/GraphQL/schema.root.graphql index 41114f4ee..579d8ac42 100644 --- a/Resources/Private/GraphQL/schema.root.graphql +++ b/Resources/Private/GraphQL/schema.root.graphql @@ -275,6 +275,7 @@ type AssetCollection { parent: AssetCollection tags: [Tag!]! assetCount: Int! + path: AssetCollectionPath } """ @@ -438,6 +439,11 @@ Unique identifier of an Asset collection (e.g. "neos") """ scalar AssetCollectionId +""" +Absolute path of an Asset collection (e.g. "/photos/trees") +""" +scalar AssetCollectionPath + """ Headers for asset usage metadata """ diff --git a/Resources/Private/JavaScript/asset-collections/src/fragments/assetCollection.ts b/Resources/Private/JavaScript/asset-collections/src/fragments/assetCollection.ts index 19a0bdff3..d4795b015 100644 --- a/Resources/Private/JavaScript/asset-collections/src/fragments/assetCollection.ts +++ b/Resources/Private/JavaScript/asset-collections/src/fragments/assetCollection.ts @@ -13,6 +13,7 @@ export const ASSET_COLLECTION_FRAGMENT = gql` ...TagProps } assetCount + path } ${TAG_FRAGMENT} `; diff --git a/Resources/Private/JavaScript/asset-collections/typings/AssetCollection.ts b/Resources/Private/JavaScript/asset-collections/typings/AssetCollection.ts index 72ef20661..10d78908d 100644 --- a/Resources/Private/JavaScript/asset-collections/typings/AssetCollection.ts +++ b/Resources/Private/JavaScript/asset-collections/typings/AssetCollection.ts @@ -12,4 +12,5 @@ interface AssetCollection extends GraphQlEntity { } | null; tags?: Tag[]; assetCount: number; + path?: string; } diff --git a/Tests/Functional/Domain/Model/AssetCollectionTest.php b/Tests/Functional/Domain/Model/AssetCollectionTest.php index 59dee0239..25f5a5656 100644 --- a/Tests/Functional/Domain/Model/AssetCollectionTest.php +++ b/Tests/Functional/Domain/Model/AssetCollectionTest.php @@ -14,6 +14,7 @@ */ use Flowpack\Media\Ui\Domain\Model\HierarchicalAssetCollectionInterface; +use Flowpack\Media\Ui\Service\AssetCollectionService; use Flowpack\Media\Ui\Tests\Functional\AbstractTest; use Neos\Flow\Persistence\Doctrine\PersistenceManager; use Neos\Media\Domain\Model\AssetCollection; @@ -31,6 +32,11 @@ class AssetCollectionTest extends AbstractTest */ protected $assetCollectionRepository; + /** + * @var AssetCollectionService + */ + protected $assetCollectionService; + public function setUp(): void { parent::setUp(); @@ -39,6 +45,7 @@ public function setUp(): void } $this->assetCollectionRepository = $this->objectManager->get(AssetCollectionRepository::class); + $this->assetCollectionService = $this->objectManager->get(AssetCollectionService::class); } /** @@ -175,4 +182,89 @@ public function hasParentReturnsTrueIfParentIsSet(): void self::assertTrue($persistedChild->hasParent()); self::assertFalse($persistedParent->hasParent()); } + + /** + * @test + */ + public function newCollectionHasPathBasedOnTitle(): void + { + $collection = new AssetCollection('My Collection'); + $this->assetCollectionRepository->add($collection); + $this->persistenceManager->persistAll(); + $this->persistenceManager->clearState(); + + $persistedCollection = $this->assetCollectionRepository->findOneByTitle('My Collection'); + self::assertEquals('/my-collection', $persistedCollection->getPath()); + } + + /** + * @test + */ + public function updatingTitleUpdatesPathOfCollection(): void + { + $collection = new AssetCollection('My Collection'); + $this->assetCollectionRepository->add($collection); + $this->persistenceManager->persistAll(); + $this->persistenceManager->clearState(); + + $persistedCollection = $this->assetCollectionRepository->findOneByTitle('My Collection'); + $persistedCollection->setTitle('New Title'); + $this->assetCollectionRepository->update($persistedCollection); + $this->persistenceManager->persistAll(); + $this->persistenceManager->clearState(); + + $persistedCollection = $this->assetCollectionRepository->findOneByTitle('New Title'); + self::assertEquals('/new-title', $persistedCollection->getPath()); + } + + /** + * @test + */ + public function pathOfSubCollectionContainsPathOfParentCollection(): void + { + $parent = new AssetCollection('Parent'); + $child = new AssetCollection('Child'); + $child->setParent($parent); + + $this->assetCollectionRepository->add($parent); + $this->assetCollectionRepository->add($child); + $this->persistenceManager->persistAll(); + $this->persistenceManager->clearState(); + + $persistedParent = $this->assetCollectionRepository->findOneByTitle('Parent'); + $persistedChild = $this->assetCollectionRepository->findOneByTitle('Child'); + + self::assertEquals('/parent', $persistedParent->getPath()); + self::assertEquals('/parent/child', $persistedChild->getPath()); + } + + /** + * @test + */ + public function pathOfSubCollectionUpdatesWhenParentIsRenamed(): void + { + $parent = new AssetCollection('Parent'); + $child = new AssetCollection('Child'); + $child->setParent($parent); + + $this->assetCollectionRepository->add($parent); + $this->assetCollectionRepository->add($child); + $this->persistenceManager->persistAll(); + $this->persistenceManager->clearState(); + + $persistedParent = $this->assetCollectionRepository->findOneByTitle('Parent'); + + $persistedParent->setTitle('New Parent Title'); + $this->assetCollectionService->updatePathForNestedAssetCollections($persistedParent); + + $this->assetCollectionRepository->update($persistedParent); + $this->persistenceManager->persistAll(); + $this->persistenceManager->clearState(); + + $persistedParent = $this->assetCollectionRepository->findOneByTitle('New Parent Title'); + $persistedChild = $this->assetCollectionRepository->findOneByTitle('Child'); + + self::assertEquals('/new-parent-title', $persistedParent->getPath()); + self::assertEquals('/new-parent-title/child', $persistedChild->getPath()); + } }