diff --git a/src/Field/Configurator/AssociationConfigurator.php b/src/Field/Configurator/AssociationConfigurator.php index f09303af16..fcf2647f07 100644 --- a/src/Field/Configurator/AssociationConfigurator.php +++ b/src/Field/Configurator/AssociationConfigurator.php @@ -3,6 +3,7 @@ namespace EasyCorp\Bundle\EasyAdminBundle\Field\Configurator; use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\PersistentCollection; use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; @@ -56,14 +57,14 @@ public function supports(FieldDto $field, EntityDto $entityDto): bool public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $context): void { $propertyName = $field->getProperty(); - if (!$entityDto->getClassMetadata()->hasAssociation($propertyName)) { + + if (!$this->isAssociation($entityDto->getClassMetadata(), $propertyName)) { throw new \RuntimeException(sprintf('The "%s" field is not a Doctrine association, so it cannot be used as an association field.', $propertyName)); } - $targetEntityFqcn = $entityDto->getClassMetadata()->getAssociationTargetClass($propertyName); // the target CRUD controller can be NULL; in that case, field value doesn't link to the related entity $targetCrudControllerFqcn = $field->getCustomOption(AssociationField::OPTION_EMBEDDED_CRUD_FORM_CONTROLLER) - ?? $context->getCrudControllers()->findCrudFqcnByEntityFqcn($targetEntityFqcn); + ?? $context->getCrudControllers()->findCrudFqcnByEntityFqcn($entityDto->getClassMetadata()->getAssociationTargetClass($propertyName)); if (true === $field->getCustomOption(AssociationField::OPTION_RENDER_AS_EMBEDDED_FORM)) { if (false === $entityDto->getClassMetadata()->isSingleValuedAssociation($propertyName)) { @@ -82,12 +83,18 @@ public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $c 'The "%s" association field of "%s" wants to render its contents using an EasyAdmin CRUD form. However, no CRUD form was found related to this field. You can either create a CRUD controller for the entity "%s" or pass the CRUD controller to use as the first argument of the "renderAsEmbeddedForm()" method.', $field->getProperty(), $context->getCrud()?->getControllerFqcn(), - $targetEntityFqcn + $entityDto->getClassMetadata()->getAssociationTargetClass($propertyName) ) ); } - $this->configureCrudForm($field, $entityDto, $propertyName, $targetEntityFqcn, $targetCrudControllerFqcn); + $this->configureCrudForm( + $field, + $entityDto, + $propertyName, + $entityDto->getClassMetadata()->getAssociationTargetClass($propertyName), + $targetCrudControllerFqcn, + ); return; } @@ -190,6 +197,28 @@ public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $c } } + /** + * Recursive check if a string is a Doctrine association (e.g. "foo") or a nested Doctrine + * association (e.g. "foo.bar"). + */ + private function isAssociation(ClassMetadata $entityClassMetadata, string $property): bool + { + $nestedProperties = explode('.', $property); + + $nextProperty = array_shift($nestedProperties); + + if (!$entityClassMetadata->hasAssociation($nextProperty)) { + return false; + } elseif (0 === \count($nestedProperties)) { + return true; + } + + return $this->isAssociation( + $this->entityFactory->getEntityMetadata($entityClassMetadata->getAssociationTargetClass($nextProperty)), + implode('.', $nestedProperties), + ); + } + private function configureToOneAssociation(FieldDto $field, EntityDto $entityDto): void { $field->setCustomOption(AssociationField::OPTION_DOCTRINE_ASSOCIATION_TYPE, 'toOne'); diff --git a/tests/Field/Configurator/AssociationConfiguratorTest.php b/tests/Field/Configurator/AssociationConfiguratorTest.php index a73ad7db16..c1312df438 100644 --- a/tests/Field/Configurator/AssociationConfiguratorTest.php +++ b/tests/Field/Configurator/AssociationConfiguratorTest.php @@ -3,6 +3,8 @@ namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Field\Configurator; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadata; +use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface; use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; use EasyCorp\Bundle\EasyAdminBundle\Factory\ControllerFactory; @@ -15,8 +17,10 @@ use EasyCorp\Bundle\EasyAdminBundle\Tests\Field\AbstractFieldTest; use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Controller\ProjectDomain\DeveloperCrudController; use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Controller\ProjectDomain\ProjectCrudController; +use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Controller\ProjectDomain\ProjectReleaseCategoryCrudController; use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Entity\ProjectDomain\Developer; use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Entity\ProjectDomain\Project; +use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Entity\ProjectDomain\ProjectReleaseCategory; use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Entity\ProjectDomain\ProjectTag; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\HttpFoundation\RequestStack; @@ -47,42 +51,43 @@ protected function getEntityDto(): EntityDto return $this->projectDto; } - /** - * @dataProvider toOneAssociation - */ - public function testToOneAssociation(FieldInterface $field): void + public function testToOneAssociation(): void { + $field = AssociationField::new('leadDeveloper'); + $entityDto = new EntityDto(Project::class, $this->createStub(ClassMetadata::class)); + $entityDto->setFields(FieldCollection::new([$field])); + $field->getAsDto()->setDoctrineMetadata((array) $this->projectDto->getClassMetadata()->getAssociationMapping($field->getAsDto()->getProperty())); $field->setCustomOption(AssociationField::OPTION_EMBEDDED_CRUD_FORM_CONTROLLER, DeveloperCrudController::class); - $field = $this->configure($field, controllerFqcn: ProjectCrudController::class); - $this->assertSame('toOne', $field->getCustomOption(AssociationField::OPTION_DOCTRINE_ASSOCIATION_TYPE)); - $this->assertSame(EntityType::class, $field->getFormType()); - $this->assertSame(Developer::class, $field->getFormTypeOption('class')); - } - - public static function toOneAssociation(): \Generator - { - yield [AssociationField::new('leadDeveloper')]; + $fieldDto = $this->configure($field, controllerFqcn: ProjectCrudController::class); + $this->assertSame('toOne', $fieldDto->getCustomOption(AssociationField::OPTION_DOCTRINE_ASSOCIATION_TYPE)); + $this->assertSame(EntityType::class, $fieldDto->getFormType()); + $this->assertSame(Developer::class, $fieldDto->getFormTypeOption('class')); } - /** - * @dataProvider toManyAssociation - */ - public function testToManyAssociation(FieldInterface $field): void + public function testToManyAssociation(): void { + $field = AssociationField::new('projectTags'); $field->getAsDto()->setDoctrineMetadata((array) $this->projectDto->getClassMetadata()->getAssociationMapping($field->getAsDto()->getProperty())); $field->setCustomOption(AssociationField::OPTION_EMBEDDED_CRUD_FORM_CONTROLLER, DeveloperCrudController::class); - $field = $this->configure($field, controllerFqcn: ProjectCrudController::class); - $this->assertSame('toMany', $field->getCustomOption(AssociationField::OPTION_DOCTRINE_ASSOCIATION_TYPE)); - $this->assertSame(EntityType::class, $field->getFormType()); - $this->assertSame(ProjectTag::class, $field->getFormTypeOption('class')); + $fieldDto = $this->configure($field, controllerFqcn: ProjectCrudController::class); + $this->assertSame('toMany', $fieldDto->getCustomOption(AssociationField::OPTION_DOCTRINE_ASSOCIATION_TYPE)); + $this->assertSame(EntityType::class, $fieldDto->getFormType()); + $this->assertSame(ProjectTag::class, $fieldDto->getFormTypeOption('class')); } - public static function toManyAssociation(): \Generator + public function testNestedAssociationWithCrudControllerSet(): void { - yield [AssociationField::new('projectTags')]; + $field = AssociationField::new('latestRelease.category') + ->setCrudController(ProjectReleaseCategoryCrudController::class) + ; + + $fieldDto = $this->configure($field); + + $this->assertSame(EntityType::class, $fieldDto->getFormType()); + $this->assertSame(ProjectReleaseCategory::class, $fieldDto->getFormTypeOption('class')); } /** @@ -103,7 +108,7 @@ public static function failsIfPropertyIsNotAssociation(): \Generator { yield [TextField::new('name')]; yield [TextField::new('price')]; - yield [TextField::new('price.currency')]; + yield [TextField::new('price.currency')]; // Doctrine embeddable } /** diff --git a/tests/TestApplication/src/Controller/ProjectDomain/ProjectReleaseCategoryCrudController.php b/tests/TestApplication/src/Controller/ProjectDomain/ProjectReleaseCategoryCrudController.php new file mode 100644 index 0000000000..aee21e3327 --- /dev/null +++ b/tests/TestApplication/src/Controller/ProjectDomain/ProjectReleaseCategoryCrudController.php @@ -0,0 +1,17 @@ + + */ +class ProjectReleaseCategoryCrudController extends AbstractCrudController +{ + public static function getEntityFqcn(): string + { + return ProjectReleaseCategory::class; + } +} diff --git a/tests/TestApplication/src/Entity/ProjectDomain/Project.php b/tests/TestApplication/src/Entity/ProjectDomain/Project.php index f6a997c604..06aa3b8a24 100644 --- a/tests/TestApplication/src/Entity/ProjectDomain/Project.php +++ b/tests/TestApplication/src/Entity/ProjectDomain/Project.php @@ -30,13 +30,13 @@ class Project implements \Stringable /** * @var Collection */ - #[ORM\OneToMany(targetEntity: ProjectIssue::class, mappedBy: 'project', cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OneToMany(mappedBy: 'project', targetEntity: ProjectIssue::class, cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $projectIssues; /** * @var Collection */ - #[ORM\OneToMany(targetEntity: Developer::class, mappedBy: 'favouriteProject')] + #[ORM\OneToMany(mappedBy: 'favouriteProject', targetEntity: Developer::class)] private Collection $favouriteProjectOf; /** diff --git a/tests/TestApplication/src/Entity/ProjectDomain/ProjectRelease.php b/tests/TestApplication/src/Entity/ProjectDomain/ProjectRelease.php index 01bdc1168c..12ac70cc5e 100644 --- a/tests/TestApplication/src/Entity/ProjectDomain/ProjectRelease.php +++ b/tests/TestApplication/src/Entity/ProjectDomain/ProjectRelease.php @@ -15,6 +15,9 @@ class ProjectRelease implements \Stringable #[ORM\Column(length: 255)] private ?string $name = null; + #[ORM\OneToOne] + private ?ProjectReleaseCategory $category = null; + public function __toString(): string { return $this->name; @@ -36,4 +39,16 @@ public function setName(string $name): static return $this; } + + public function getCategory(): ?ProjectReleaseCategory + { + return $this->category; + } + + public function setCategory(?ProjectReleaseCategory $category): static + { + $this->category = $category; + + return $this; + } } diff --git a/tests/TestApplication/src/Entity/ProjectDomain/ProjectReleaseCategory.php b/tests/TestApplication/src/Entity/ProjectDomain/ProjectReleaseCategory.php new file mode 100644 index 0000000000..c894970188 --- /dev/null +++ b/tests/TestApplication/src/Entity/ProjectDomain/ProjectReleaseCategory.php @@ -0,0 +1,39 @@ +name; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } +}