Skip to content

Instantly share code, notes, and snippets.

@wizhippo
Last active March 8, 2024 13:12
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save wizhippo/9043a6676ce2920676b730ba2f507655 to your computer and use it in GitHub Desktop.
Save wizhippo/9043a6676ce2920676b730ba2f507655 to your computer and use it in GitHub Desktop.
Start of adding autocomplete support to EntityFilter
<?php
namespace App\EasyAdmin\Filter;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\Mapping\MappingException;
use Doctrine\ORM\Query\Expr\Orx;
use Doctrine\ORM\QueryBuilder;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Filter\FilterInterface;
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto;
use EasyCorp\Bundle\EasyAdminBundle\Dto\FilterDataDto;
use EasyCorp\Bundle\EasyAdminBundle\Filter\FilterTrait;
use EasyCorp\Bundle\EasyAdminBundle\Form\Filter\Type\EntityFilterType;
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\ComparisonType;
use Symfony\Component\Uid\Ulid;
use Symfony\Component\Uid\Uuid;
final class AutocompleteEntityFilter implements FilterInterface
{
use FilterTrait;
public static function new(string $propertyName, $label = null): self
{
return (new self())
->setFilterFqcn(__CLASS__)
->setProperty($propertyName)
->setLabel($label)
->setFormType(EntityFilterType::class)
->setFormTypeOption('translation_domain', 'EasyAdminBundle')
;
}
public function apply(
QueryBuilder $queryBuilder,
FilterDataDto $filterDataDto,
?FieldDto $fieldDto,
EntityDto $entityDto
): void {
$alias = $filterDataDto->getEntityAlias();
$property = $filterDataDto->getProperty();
$comparison = $filterDataDto->getComparison();
$parameterName = $filterDataDto->getParameterName();
$value = $filterDataDto->getValue();
$isMultiple = $filterDataDto->getFormTypeOption('value_type_options.multiple');
if ($entityDto->isToManyAssociation($property)) {
// the 'ea_' prefix is needed to avoid errors when using reserved words as assocAlias ('order', 'group', etc.)
// see https://github.com/EasyCorp/EasyAdminBundle/pull/4344
$assocAlias = 'ea_'.$filterDataDto->getParameterName();
$queryBuilder->leftJoin(sprintf('%s.%s', $alias, $property), $assocAlias);
if (0 === \count($value)) {
$queryBuilder->andWhere(sprintf('%s %s', $assocAlias, $comparison));
} else {
$orX = new Orx();
$orX->add(sprintf('%s %s (:%s)', $assocAlias, $comparison, $parameterName));
if ('NOT IN' === $comparison) {
$orX->add(sprintf('%s IS NULL', $assocAlias));
}
$queryBuilder->andWhere($orX)
->setParameter($parameterName, $this->processParameterValue($queryBuilder, $value))
;
}
} elseif (null === $value || ($isMultiple && 0 === \count($value))) {
$queryBuilder->andWhere(sprintf('%s.%s %s', $alias, $property, $comparison));
} else {
$orX = new Orx();
$orX->add(sprintf('%s.%s %s (:%s)', $alias, $property, $comparison, $parameterName));
if (ComparisonType::NEQ === $comparison) {
$orX->add(sprintf('%s.%s IS NULL', $alias, $property));
}
$queryBuilder->andWhere($orX)
->setParameter($parameterName, $this->processParameterValue($queryBuilder, $value))
;
}
}
private function processParameterValue(QueryBuilder $queryBuilder, $parameterValue)
{
if (!$parameterValue instanceof ArrayCollection) {
return $this->processSingleParameterValue($queryBuilder, $parameterValue);
}
return $parameterValue->map(fn ($element) => $this->processSingleParameterValue($queryBuilder, $element));
}
private function processSingleParameterValue(QueryBuilder $queryBuilder, $parameterValue)
{
$entityManager = $queryBuilder->getEntityManager();
try {
$classMetadata = $entityManager->getClassMetadata(\get_class($parameterValue));
} catch (\Throwable $e) {
// only reached if $parameterValue does not contain an object of a managed
// entity, return as we only need to process bound entities
return $parameterValue;
}
try {
$identifierType = $classMetadata->getTypeOfField($classMetadata->getSingleIdentifierFieldName());
} catch (MappingException $e) {
throw new \RuntimeException(
sprintf(
'The EntityFilter does not support entities with a composite primary key or entities without an identifier. Please check your entity "%s".',
\get_class($parameterValue)
)
);
}
$identifierValue = $entityManager->getUnitOfWork()->getSingleIdentifierValue($parameterValue);
if (('uuid' === $identifierType && $identifierValue instanceof Uuid)
|| ('ulid' === $identifierType && $identifierValue instanceof Ulid)) {
try {
return Type::getType($identifierType)->convertToDatabaseValue(
$identifierValue,
$entityManager->getConnection()->getDatabasePlatform()
);
} catch (\Throwable $e) {
// if the conversion fails we cannot process the uid parameter value
return $parameterValue;
}
}
return $parameterValue;
}
}
<?php
namespace App\EasyAdmin\Filter\Configurator;
use App\EasyAdmin\Filter\AutocompleteEntityFilter;
use App\Form\Filter\Type\AutocompleteEntityFilterType;
use EasyCorp\Bundle\EasyAdminBundle\Config\Option\EA;
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Filter\FilterConfiguratorInterface;
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto;
use EasyCorp\Bundle\EasyAdminBundle\Dto\FilterDto;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
final class AutocompleteEntityFilterConfigurator implements FilterConfiguratorInterface
{
private AdminUrlGenerator $adminUrlGenerator;
public function __construct(AdminUrlGenerator $adminUrlGenerator)
{
$this->adminUrlGenerator = $adminUrlGenerator;
}
public function supports(
FilterDto $filterDto,
?FieldDto $fieldDto,
EntityDto $entityDto,
AdminContext $context
): bool {
return AutocompleteEntityFilter::class === $filterDto->getFqcn();
}
public function configure(
FilterDto $filterDto,
?FieldDto $fieldDto,
EntityDto $entityDto,
AdminContext $context
): void {
$propertyName = $filterDto->getProperty();
if (!$entityDto->isAssociation($propertyName)) {
return;
}
$doctrineMetadata = $entityDto->getPropertyMetadata($propertyName);
// TODO: add the 'em' form type option too?
$filterDto->setFormTypeOptionIfNotSet('value_type_options.class', $doctrineMetadata->get('targetEntity'));
$filterDto->setFormTypeOptionIfNotSet(
'value_type_options.multiple',
$entityDto->isToManyAssociation($propertyName)
);
$filterDto->setFormTypeOptionIfNotSet('value_type_options.attr.data-ea-widget', 'ea-autocomplete');
if ($entityDto->isToOneAssociation($propertyName)) {
// don't show the 'empty value' placeholder when all join columns are required,
// because an empty filter value would always return no result
$numberOfRequiredJoinColumns = \count(
array_filter(
$doctrineMetadata->get('joinColumns'),
static fn (array $joinColumn): bool => false === ($joinColumn['nullable'] ?? false)
)
);
$someJoinColumnsAreNullable = \count(
$doctrineMetadata->get('joinColumns')
) !== $numberOfRequiredJoinColumns;
if ($someJoinColumnsAreNullable) {
$filterDto->setFormTypeOptionIfNotSet('value_type_options.placeholder', 'label.form.empty_value');
}
}
$targetEntityFqcn = $doctrineMetadata->get('targetEntity');
$targetCrudControllerFqcn = $context->getCrudControllers()->findCrudFqcnByEntityFqcn($targetEntityFqcn);
if ($targetCrudControllerFqcn) {
$filterDto->setFormTypeOptionIfNotSet('value_type', AutocompleteEntityFilterType::class);
$filterDto->setFormTypeOptionIfNotSet('value_type_options.class', $doctrineMetadata->get('targetEntity'));
$filterDto->setFormTypeOptionIfNotSet(
'value_type_options.multiple',
$entityDto->isToManyAssociation($propertyName)
);
$filterDto->setFormTypeOptionIfNotSet('value_type_options.attr.data-widget', 'select2');
$autocompleteEndpointUrl = $this->adminUrlGenerator
->set('page', 1)
->setController($targetCrudControllerFqcn)
->setAction('autocomplete')
->setEntityId(null)
->unset(EA::SORT)
->set('autocompleteContext', [
EA::CRUD_CONTROLLER_FQCN => $context->getRequest()->query->get(EA::CRUD_CONTROLLER_FQCN),
'propertyName' => $propertyName,
'originatingPage' => $context->getCrud()->getCurrentAction(),
])
->generateUrl()
;
$filterDto->setFormTypeOption(
'value_type_options.attr.data-ea-autocomplete-endpoint-url',
$autocompleteEndpointUrl
);
}
}
}
{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
{% extends '@!EasyAdmin/crud/form_theme.html.twig' %}
{% block ea_autocomplete_widget %}
{{ form_widget(form.autocomplete, { attr: attr|merge({ required: required }) }) }}
<script>
document.dispatchEvent(new Event('ea.collection.item-added'));
</script>
{% endblock ea_autocomplete_widget %}
@BorisB
Copy link

BorisB commented Jul 22, 2022

Can't get it work.
An error has occurred resolving the options of the form "Symfony\Bridge\Doctrine\Form\Type\EntityType": The required option "class" is missing.

@wizhippo
Copy link
Author

Can't get it work. An error has occurred resolving the options of the form "Symfony\Bridge\Doctrine\Form\Type\EntityType": The required option "class" is missing.

Do you have the complete trace? At first glance it looks like '$doctrineMetadata->get('targetEntity')' may not be working for you and setting the correct class.

@kiler129
Copy link

Hi @wizhippo!

I found this gist through google search. Is this something you're planning to upstream?

@wizhippo
Copy link
Author

Issue EasyCorp/EasyAdminBundle#4244, I asked for input and got not. Not sure if they are interested.

@tonyellow
Copy link

@wizhippo The autocomplete filter works fine but it would be very nice if its support nested associations.

@kiler129
Copy link

@hidabe
Copy link

hidabe commented Mar 22, 2023

It crash for me, "The file "../../src/EasyAdmin/Filter/Configurator" does not exist"

@tonyellow
Copy link

tonyellow commented Jul 5, 2023

Anyone knows how to get this autocomplete feature to work with nested relations? I am aware of EasyCorp/EasyAdminBundle#4882 but the problem is that it does not support autocompletion like this gist does, which is really important for relations with many results.

@bastien70
Copy link

It doesn't work for me. When I submit the form with an item, I get an error telling me that the choice is not part of the list

@wizhippo
Copy link
Author

wizhippo commented Mar 7, 2024

It doesn't work for me. When I submit the form with an item, I get an error telling me that the choice is not part of the list

I just updated it to code I am currently using that is known to work. Not sure it it will fix your issue.

@bastien70
Copy link

bastien70 commented Mar 8, 2024

It doesn't work for me. When I submit the form with an item, I get an error telling me that the choice is not part of the list

I just updated it to code I am currently using that is known to work. Not sure it it will fix your issue.

In your last update, you used the "AutocompleteEntityFilterType", but this class doesn't exists. Is it a form type you created ?

EDIT : OK I used your old gist : https://gist.github.com/wizhippo/597bf56cd69dfd3682d8a0eee67ac5c0
But yes, I've still the same error, it's weird

I'm using this filter for a collection, like this :

    public function configureFilters(Filters $filters): Filters
    {
        return parent::configureFilters($filters)
            ->add(AutocompleteEntityFilter::new('users', 'Utilisateurs'))
            ;
    }

My entities use ULIDs as primary key, maybe it's the problem ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment