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

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