Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions src/Field/Configurator/AssociationConfigurator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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');
Expand Down
53 changes: 29 additions & 24 deletions tests/Field/Configurator/AssociationConfiguratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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'));
}

/**
Expand All @@ -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
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Controller\ProjectDomain;

use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Entity\ProjectDomain\ProjectReleaseCategory;

/**
* @extends AbstractCrudController<ProjectReleaseCategory>
*/
class ProjectReleaseCategoryCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return ProjectReleaseCategory::class;
}
}
4 changes: 2 additions & 2 deletions tests/TestApplication/src/Entity/ProjectDomain/Project.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ class Project implements \Stringable
/**
* @var Collection<int, ProjectIssue>
*/
#[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<int, Developer>
*/
#[ORM\OneToMany(targetEntity: Developer::class, mappedBy: 'favouriteProject')]
#[ORM\OneToMany(mappedBy: 'favouriteProject', targetEntity: Developer::class)]
private Collection $favouriteProjectOf;

/**
Expand Down
15 changes: 15 additions & 0 deletions tests/TestApplication/src/Entity/ProjectDomain/ProjectRelease.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Entity\ProjectDomain;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class ProjectReleaseCategory implements \Stringable
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(length: 255)]
private ?string $name = null;

public function __toString(): string
{
return $this->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;
}
}