vendor/easycorp/easyadmin-bundle/src/Field/Configurator/AssociationConfigurator.php line 204

Open in your IDE?
  1. <?php
  2. namespace EasyCorp\Bundle\EasyAdminBundle\Field\Configurator;
  3. use Doctrine\ORM\EntityRepository;
  4. use Doctrine\ORM\PersistentCollection;
  5. use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection;
  6. use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
  7. use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
  8. use EasyCorp\Bundle\EasyAdminBundle\Config\Option\EA;
  9. use EasyCorp\Bundle\EasyAdminBundle\Config\Option\TextAlign;
  10. use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
  11. use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldConfiguratorInterface;
  12. use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
  13. use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto;
  14. use EasyCorp\Bundle\EasyAdminBundle\Factory\ControllerFactory;
  15. use EasyCorp\Bundle\EasyAdminBundle\Factory\EntityFactory;
  16. use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
  17. use EasyCorp\Bundle\EasyAdminBundle\Form\Type\CrudAutocompleteType;
  18. use EasyCorp\Bundle\EasyAdminBundle\Form\Type\CrudFormType;
  19. use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGeneratorInterface;
  20. use Symfony\Component\HttpFoundation\RequestStack;
  21. use Symfony\Component\PropertyAccess\Exception\UnexpectedTypeException;
  22. use Symfony\Component\PropertyAccess\PropertyAccessor;
  23. use Symfony\Component\Routing\Exception\RouteNotFoundException;
  24. use function Symfony\Component\Translation\t;
  25. /**
  26.  * @author Javier Eguiluz <javier.eguiluz@gmail.com>
  27.  */
  28. final class AssociationConfigurator implements FieldConfiguratorInterface
  29. {
  30.     private EntityFactory $entityFactory;
  31.     private AdminUrlGeneratorInterface $adminUrlGenerator;
  32.     private RequestStack $requestStack;
  33.     private ControllerFactory $controllerFactory;
  34.     public function __construct(EntityFactory $entityFactoryAdminUrlGeneratorInterface $adminUrlGeneratorRequestStack $requestStackControllerFactory $controllerFactory)
  35.     {
  36.         $this->entityFactory $entityFactory;
  37.         $this->adminUrlGenerator $adminUrlGenerator;
  38.         $this->requestStack $requestStack;
  39.         $this->controllerFactory $controllerFactory;
  40.     }
  41.     public function supports(FieldDto $fieldEntityDto $entityDto): bool
  42.     {
  43.         return AssociationField::class === $field->getFieldFqcn();
  44.     }
  45.     public function configure(FieldDto $fieldEntityDto $entityDtoAdminContext $context): void
  46.     {
  47.         $propertyName $field->getProperty();
  48.         if (!$entityDto->isAssociation($propertyName)) {
  49.             throw new \RuntimeException(sprintf('The "%s" field is not a Doctrine association, so it cannot be used as an association field.'$propertyName));
  50.         }
  51.         $targetEntityFqcn $field->getDoctrineMetadata()->get('targetEntity');
  52.         // the target CRUD controller can be NULL; in that case, field value doesn't link to the related entity
  53.         $targetCrudControllerFqcn $field->getCustomOption(AssociationField::OPTION_EMBEDDED_CRUD_FORM_CONTROLLER)
  54.             ?? $context->getCrudControllers()->findCrudFqcnByEntityFqcn($targetEntityFqcn);
  55.         if (true === $field->getCustomOption(AssociationField::OPTION_RENDER_AS_EMBEDDED_FORM)) {
  56.             if (false === $entityDto->isToOneAssociation($propertyName)) {
  57.                 throw new \RuntimeException(
  58.                     sprintf(
  59.                         'The "%s" association field of "%s" is a to-many association but it\'s trying to use the "renderAsEmbeddedForm()" option, which is only available for to-one associations. If you want to use a CRUD form to render to-many associations, use a CollectionField instead of the AssociationField.',
  60.                         $field->getProperty(),
  61.                         $context->getCrud()?->getControllerFqcn(),
  62.                     )
  63.                 );
  64.             }
  65.             if (null === $targetCrudControllerFqcn) {
  66.                 throw new \RuntimeException(
  67.                     sprintf(
  68.                         '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.',
  69.                         $field->getProperty(),
  70.                         $context->getCrud()?->getControllerFqcn(),
  71.                         $targetEntityFqcn
  72.                     )
  73.                 );
  74.             }
  75.             $this->configureCrudForm($field$entityDto$propertyName$targetEntityFqcn$targetCrudControllerFqcn);
  76.             return;
  77.         }
  78.         $field->setCustomOption(AssociationField::OPTION_EMBEDDED_CRUD_FORM_CONTROLLER$targetCrudControllerFqcn);
  79.         if (AssociationField::WIDGET_AUTOCOMPLETE === $field->getCustomOption(AssociationField::OPTION_WIDGET)) {
  80.             $field->setFormTypeOption('attr.data-ea-widget''ea-autocomplete');
  81.         }
  82.         $field->setFormTypeOption('attr.data-ea-autocomplete-render-items-as-html'true === $field->getCustomOption(AssociationField::OPTION_ESCAPE_HTML_CONTENTS) ? 'false' 'true');
  83.         // check for embedded associations
  84.         $propertyNameParts explode('.'$propertyName);
  85.         if (\count($propertyNameParts) > 1) {
  86.             // prepare starting class for association
  87.             $targetEntityFqcn $entityDto->getPropertyMetadata($propertyNameParts[0])->get('targetEntity');
  88.             array_shift($propertyNameParts);
  89.             $metadata $this->entityFactory->getEntityMetadata($targetEntityFqcn);
  90.             foreach ($propertyNameParts as $association) {
  91.                 if (!$metadata->hasAssociation($association)) {
  92.                     throw new \RuntimeException(sprintf('There is no association for the class "%s" with name "%s"'$targetEntityFqcn$association));
  93.                 }
  94.                 // overwrite next class from association
  95.                 $targetEntityFqcn $metadata->getAssociationTargetClass($association);
  96.                 // read next association metadata
  97.                 $metadata $this->entityFactory->getEntityMetadata($targetEntityFqcn);
  98.             }
  99.             $accessor = new PropertyAccessor();
  100.             $targetCrudControllerFqcn $field->getCustomOption(AssociationField::OPTION_EMBEDDED_CRUD_FORM_CONTROLLER);
  101.             $field->setFormTypeOptionIfNotSet('class'$targetEntityFqcn);
  102.             try {
  103.                 if (null !== $entityDto->getInstance()) {
  104.                     $relatedEntityId $accessor->getValue($entityDto->getInstance(), $propertyName.'.'.$metadata->getIdentifierFieldNames()[0]);
  105.                     $relatedEntityDto $this->entityFactory->create($targetEntityFqcn$relatedEntityId);
  106.                     $field->setCustomOption(AssociationField::OPTION_RELATED_URL$this->generateLinkToAssociatedEntity($targetCrudControllerFqcn$relatedEntityDto));
  107.                     $field->setFormattedValue($this->formatAsString($relatedEntityDto->getInstance(), $relatedEntityDto));
  108.                 }
  109.             } catch (UnexpectedTypeException|RouteNotFoundException) {
  110.                 // this may throw an exception if:
  111.                 //   * something in the tree is null; do nothing in that case;
  112.                 //   * the route is not found, which happens when the associated entity is not accessible from this dashboard; do nothing in that case either.
  113.             }
  114.         } else {
  115.             if ($entityDto->isToOneAssociation($propertyName)) {
  116.                 $this->configureToOneAssociation($field);
  117.             }
  118.             if ($entityDto->isToManyAssociation($propertyName)) {
  119.                 $this->configureToManyAssociation($field);
  120.             }
  121.         }
  122.         if (true === $field->getCustomOption(AssociationField::OPTION_AUTOCOMPLETE)) {
  123.             $targetCrudControllerFqcn $field->getCustomOption(AssociationField::OPTION_EMBEDDED_CRUD_FORM_CONTROLLER);
  124.             if (null === $targetCrudControllerFqcn) {
  125.                 throw new \RuntimeException(sprintf('The "%s" field cannot be autocompleted because it doesn\'t define the related CRUD controller FQCN with the "setCrudController()" method.'$field->getProperty()));
  126.             }
  127.             $field->setFormType(CrudAutocompleteType::class);
  128.             try {
  129.                 $autocompleteEndpointUrl $this->adminUrlGenerator
  130.                     ->unsetAll()
  131.                     ->set('page'1// The autocomplete should always start on the first page
  132.                     ->setController($targetCrudControllerFqcn)
  133.                     ->setAction('autocomplete')
  134.                     ->set(AssociationField::PARAM_AUTOCOMPLETE_CONTEXT, [
  135.                         // when using pretty URLs, the data is in the request attributes instead of the autocomplete context
  136.                         EA::CRUD_CONTROLLER_FQCN => $context->getRequest()->attributes->get(EA::CRUD_CONTROLLER_FQCN) ?? $context->getRequest()->query->get(EA::CRUD_CONTROLLER_FQCN),
  137.                         'propertyName' => $propertyName,
  138.                         'originatingPage' => $context->getCrud()->getCurrentPage(),
  139.                     ])
  140.                     ->generateUrl();
  141.             } catch (RouteNotFoundException $e) {
  142.                 // this may throw a "route not found" exception if the associated entity is not
  143.                 // accessible from this dashboard; do nothing in that case.
  144.             }
  145.             $field->setFormTypeOption('attr.data-ea-autocomplete-endpoint-url'$autocompleteEndpointUrl ?? null);
  146.         } else {
  147.             $field->setFormTypeOptionIfNotSet('query_builder', static function (EntityRepository $repository) use ($field) {
  148.                 // TODO: should this use `createIndexQueryBuilder` instead, so we get the default ordering etc.?
  149.                 // it would then be identical to the one used in autocomplete action, but it is a bit complex getting it in here
  150.                 $queryBuilder $repository->createQueryBuilder('entity');
  151.                 if (null !== $queryBuilderCallable $field->getCustomOption(AssociationField::OPTION_QUERY_BUILDER_CALLABLE)) {
  152.                     $queryBuilderCallable($queryBuilder);
  153.                 }
  154.                 return $queryBuilder;
  155.             });
  156.         }
  157.     }
  158.     private function configureToOneAssociation(FieldDto $field): void
  159.     {
  160.         $field->setCustomOption(AssociationField::OPTION_DOCTRINE_ASSOCIATION_TYPE'toOne');
  161.         if (false === $field->getFormTypeOption('required')) {
  162.             $field->setFormTypeOptionIfNotSet('attr.placeholder't('label.form.empty_value', [], 'EasyAdminBundle'));
  163.         }
  164.         $targetEntityFqcn $field->getDoctrineMetadata()->get('targetEntity');
  165.         $targetCrudControllerFqcn $field->getCustomOption(AssociationField::OPTION_EMBEDDED_CRUD_FORM_CONTROLLER);
  166.         $targetEntityDto null === $field->getValue()
  167.             ? $this->entityFactory->create($targetEntityFqcn)
  168.             : $this->entityFactory->createForEntityInstance($field->getValue());
  169.         $field->setFormTypeOptionIfNotSet('class'$targetEntityDto->getFqcn());
  170.         try {
  171.             $field->setCustomOption(AssociationField::OPTION_RELATED_URL$this->generateLinkToAssociatedEntity($targetCrudControllerFqcn$targetEntityDto));
  172.         } catch (RouteNotFoundException $e) {
  173.             // this may throw a "route not found" exception if the associated entity is not
  174.             // accessible from this dashboard; do nothing in that case.
  175.         }
  176.         $field->setFormattedValue($this->formatAsString($field->getValue(), $targetEntityDto));
  177.     }
  178.     private function configureToManyAssociation(FieldDto $field): void
  179.     {
  180.         $field->setCustomOption(AssociationField::OPTION_DOCTRINE_ASSOCIATION_TYPE'toMany');
  181.         $field->setFormTypeOptionIfNotSet('multiple'true);
  182.         /* @var PersistentCollection $collection */
  183.         $field->setFormTypeOptionIfNotSet('class'$field->getDoctrineMetadata()->get('targetEntity'));
  184.         if (null === $field->getTextAlign()) {
  185.             $field->setTextAlign(TextAlign::RIGHT);
  186.         }
  187.         $field->setFormattedValue($this->countNumElements($field->getValue()));
  188.     }
  189.     private function formatAsString($entityInstanceEntityDto $entityDto): ?string
  190.     {
  191.         if (null === $entityInstance) {
  192.             return null;
  193.         }
  194.         if (method_exists($entityInstance'__toString')) {
  195.             return (string) $entityInstance;
  196.         }
  197.         if (null !== $primaryKeyValue $entityDto->getPrimaryKeyValue()) {
  198.             return sprintf('%s #%s'$entityDto->getName(), $primaryKeyValue);
  199.         }
  200.         return $entityDto->getName();
  201.     }
  202.     private function generateLinkToAssociatedEntity(?string $crudControllerEntityDto $entityDto): ?string
  203.     {
  204.         if (null === $crudController) {
  205.             return null;
  206.         }
  207.         $primaryKeyValue $entityDto->getPrimaryKeyValue();
  208.         // when processing fields for an entity in the index page, the primary key of the
  209.         // associated entity is null (e.g. admin_post_index and Post <-> User)
  210.         $crudAction null === $primaryKeyValue Action::INDEX Action::DETAIL;
  211.         // TODO: check if user has permission to see the related entity
  212.         return $this->adminUrlGenerator
  213.             ->setController($crudController)
  214.             ->setAction($crudAction)
  215.             ->setEntityId($primaryKeyValue)
  216.             ->unset(EA::FILTERS)
  217.             ->unset(EA::PAGE)
  218.             ->unset(EA::QUERY)
  219.             ->unset(EA::SORT)
  220.             ->generateUrl();
  221.     }
  222.     private function countNumElements($collection): int
  223.     {
  224.         if (null === $collection) {
  225.             return 0;
  226.         }
  227.         if (is_countable($collection)) {
  228.             return \count($collection);
  229.         }
  230.         if ($collection instanceof \Traversable) {
  231.             return iterator_count($collection);
  232.         }
  233.         return 0;
  234.     }
  235.     private function configureCrudForm(FieldDto $fieldEntityDto $entityDtostring $propertyNamestring $targetEntityFqcnstring $targetCrudControllerFqcn): void
  236.     {
  237.         $field->setFormType(CrudFormType::class);
  238.         $propertyAccessor = new PropertyAccessor();
  239.         if (null === $entityDto->getInstance()) {
  240.             $associatedEntity null;
  241.         } else {
  242.             $associatedEntity $propertyAccessor->isReadable($entityDto->getInstance(), $propertyName)
  243.                 ? $propertyAccessor->getValue($entityDto->getInstance(), $propertyName)
  244.                 : null;
  245.         }
  246.         if (null === $associatedEntity) {
  247.             $targetCrudControllerAction Action::NEW;
  248.             $targetCrudControllerPageName $field->getCustomOption(AssociationField::OPTION_EMBEDDED_CRUD_FORM_NEW_PAGE_NAME) ?? Crud::PAGE_NEW;
  249.         } else {
  250.             $targetCrudControllerAction Action::EDIT;
  251.             $targetCrudControllerPageName $field->getCustomOption(AssociationField::OPTION_EMBEDDED_CRUD_FORM_EDIT_PAGE_NAME) ?? Crud::PAGE_EDIT;
  252.         }
  253.         $field->setFormTypeOption(
  254.             'entityDto',
  255.             $this->createEntityDto($targetEntityFqcn$targetCrudControllerFqcn$targetCrudControllerAction$targetCrudControllerPageName),
  256.         );
  257.     }
  258.     private function createEntityDto(string $entityFqcnstring $crudControllerFqcnstring $crudControllerActionstring $crudControllerPageName): EntityDto
  259.     {
  260.         $entityDto $this->entityFactory->create($entityFqcn);
  261.         $crudController $this->controllerFactory->getCrudControllerInstance(
  262.             $crudControllerFqcn,
  263.             $crudControllerAction,
  264.             $this->requestStack->getMainRequest()
  265.         );
  266.         $fields $crudController->configureFields($crudControllerPageName);
  267.         $this->entityFactory->processFields($entityDtoFieldCollection::new($fields));
  268.         return $entityDto;
  269.     }
  270. }