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

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.         $field->setFormTypeOptionIfNotSet('multiple'true);
  165.         /* @var PersistentCollection $collection */
  166.         $field->setFormTypeOptionIfNotSet('class'$field->getDoctrineMetadata()->get('targetEntity'));
  167.         if (null === $field->getTextAlign()) {
  168.             $field->setTextAlign(TextAlign::RIGHT);
  169.         }
  170.         $field->setFormattedValue($this->countNumElements($field->getValue()));
  171.     }
  172.     private function formatAsString($entityInstanceEntityDto $entityDto): ?string
  173.     {
  174.         if (null === $entityInstance) {
  175.             return null;
  176.         }
  177.         if (method_exists($entityInstance'__toString')) {
  178.             return (string) $entityInstance;
  179.         }
  180.         if (null !== $primaryKeyValue $entityDto->getPrimaryKeyValue()) {
  181.             return sprintf('%s #%s'$entityDto->getName(), $primaryKeyValue);
  182.         }
  183.         return $entityDto->getName();
  184.     }
  185.     private function generateLinkToAssociatedEntity(?string $crudControllerEntityDto $entityDto): ?string
  186.     {
  187.         if (null === $crudController) {
  188.             return null;
  189.         }
  190.         // TODO: check if user has permission to see the related entity
  191.         return $this->adminUrlGenerator
  192.             ->setController($crudController)
  193.             ->setAction(Action::DETAIL)
  194.             ->setEntityId($entityDto->getPrimaryKeyValue())
  195.             ->unset(EA::MENU_INDEX)
  196.             ->unset(EA::SUBMENU_INDEX)
  197.             ->includeReferrer()
  198.             ->generateUrl();
  199.     }
  200.     private function countNumElements($collection): int
  201.     {
  202.         if (null === $collection) {
  203.             return 0;
  204.         }
  205.         if (is_countable($collection)) {
  206.             return \count($collection);
  207.         }
  208.         if ($collection instanceof \Traversable) {
  209.             return iterator_count($collection);
  210.         }
  211.         return 0;
  212.     }
  213.     private function configureCrudForm(FieldDto $fieldEntityDto $entityDtostring $propertyNamestring $targetEntityFqcnstring $targetCrudControllerFqcn): void
  214.     {
  215.         $field->setFormType(CrudFormType::class);
  216.         $propertyAccessor = new PropertyAccessor();
  217.         if (null === $entityDto->getInstance()) {
  218.             $associatedEntity null;
  219.         } else {
  220.             $associatedEntity $propertyAccessor->isReadable($entityDto->getInstance(), $propertyName)
  221.                 ? $propertyAccessor->getValue($entityDto->getInstance(), $propertyName)
  222.                 : null;
  223.         }
  224.         if (null === $associatedEntity) {
  225.             $targetCrudControllerAction Action::NEW;
  226.             $targetCrudControllerPageName $field->getCustomOption(AssociationField::OPTION_EMBEDDED_CRUD_FORM_NEW_PAGE_NAME) ?? Crud::PAGE_NEW;
  227.         } else {
  228.             $targetCrudControllerAction Action::EDIT;
  229.             $targetCrudControllerPageName $field->getCustomOption(AssociationField::OPTION_EMBEDDED_CRUD_FORM_EDIT_PAGE_NAME) ?? Crud::PAGE_EDIT;
  230.         }
  231.         $field->setFormTypeOption(
  232.             'entityDto',
  233.             $this->createEntityDto($targetEntityFqcn$targetCrudControllerFqcn$targetCrudControllerAction$targetCrudControllerPageName),
  234.         );
  235.     }
  236.     private function createEntityDto(string $entityFqcnstring $crudControllerFqcnstring $crudControllerActionstring $crudControllerPageName): EntityDto
  237.     {
  238.         $entityDto $this->entityFactory->create($entityFqcn);
  239.         $crudController $this->controllerFactory->getCrudControllerInstance(
  240.             $crudControllerFqcn,
  241.             $crudControllerAction,
  242.             $this->requestStack->getMainRequest()
  243.         );
  244.         $fields $crudController->configureFields($crudControllerPageName);
  245.         $this->entityFactory->processFields($entityDtoFieldCollection::new($fields));
  246.         return $entityDto;
  247.     }
  248. }