diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5b9f2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +/vendor/* \ No newline at end of file diff --git a/Controller/TreeCrudController.php b/Controller/TreeCrudController.php index 2287056..f793cb1 100644 --- a/Controller/TreeCrudController.php +++ b/Controller/TreeCrudController.php @@ -2,8 +2,11 @@ namespace Umanit\EasyAdminTreeBundle\Controller; +use Doctrine\ORM\Query; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection; +use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Assets; @@ -11,13 +14,19 @@ use EasyCorp\Bundle\EasyAdminBundle\Config\KeyValueStore; use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; +use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; +use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto; use EasyCorp\Bundle\EasyAdminBundle\Event\AfterCrudActionEvent; use EasyCorp\Bundle\EasyAdminBundle\Event\BeforeCrudActionEvent; use EasyCorp\Bundle\EasyAdminBundle\Exception\ForbiddenActionException; +use EasyCorp\Bundle\EasyAdminBundle\Factory\ActionFactory; use EasyCorp\Bundle\EasyAdminBundle\Factory\EntityFactory; +use EasyCorp\Bundle\EasyAdminBundle\Factory\FieldFactory; use EasyCorp\Bundle\EasyAdminBundle\Factory\FilterFactory; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; use EasyCorp\Bundle\EasyAdminBundle\Security\Permission; +use Gedmo\Tree\Entity\Repository\NestedTreeRepository; +use LogicException; abstract class TreeCrudController extends AbstractCrudController { @@ -29,7 +38,20 @@ public function __construct(ManagerRegistry $doctrine) $this->doctrine = $doctrine; } - public function index(AdminContext $context) + public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, + FilterCollection $filters): QueryBuilder { + /** @var NestedTreeRepository $repository */ + $repository = $this->doctrine->getRepository($entityDto->getFqcn()); + + $queryBuilder = $repository + ->createQueryBuilder('entity') + ->orderBy('entity.root, entity.lft', 'ASC'); + + return $queryBuilder; + } + + + public function index(AdminContext $context) { $event = new BeforeCrudActionEvent($context); $this->container->get('event_dispatcher')->dispatch($event); @@ -46,19 +68,17 @@ public function index(AdminContext $context) $context->getCrud()->setFieldAssets($this->getFieldAssets($fields)); $filters = $this->container->get(FilterFactory::class)->create($context->getCrud()->getFiltersConfig(), $fields, $context->getEntity()); - $repository = $this->doctrine->getRepository($context->getEntity()->getFqcn()); - - $queryBuilder = $repository - ->createQueryBuilder('entity') - ->orderBy('entity.root, entity.lft', 'ASC') - ; + $queryBuilder = $this->createIndexQueryBuilder($context->getSearch(), $context->getEntity(), $fields, $filters); $this->doctrine->getManager()->getConfiguration()->addCustomHydrationMode('tree', 'Gedmo\Tree\Hydrator\ORM\TreeObjectHydrator'); - $entities = $queryBuilder->getQuery()->getResult(); + $query = $queryBuilder->getQuery()->setHint(Query::HINT_INCLUDE_META_COLUMNS, true); + $query->getResult("tree"); + $entities = $query->getResult(); $entities = $this->container->get(EntityFactory::class)->createCollection($context->getEntity(), $entities); - $this->container->get(EntityFactory::class)->processFieldsForAll($entities, $fields); - $actions = $this->container->get(EntityFactory::class)->processActionsForAll($entities, $context->getCrud()->getActionsConfig()); + + $this->container->get(FieldFactory::class)->processFieldsForAll($entities, $fields); + $actions = $this->container->get(ActionFactory::class)->processGlobalActionsAndEntityActionsForAll($entities, $context->getCrud()->getActionsConfig()); $responseParameters = $this->configureResponseParameters(KeyValueStore::new([ 'pageName' => Crud::PAGE_INDEX, @@ -100,7 +120,7 @@ public function configureCrud(Crud $crud): Crud public static function getEntityFqcn(): string { - throw new \LogicException('Override this method in child class'); + throw new LogicException('Override this method in child class'); } public function configureAssets(Assets $assets): Assets diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 7d744cd..303fb67 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -15,8 +15,7 @@ class Configuration implements ConfigurationInterface /** * {@inheritDoc} */ - public function getConfigTreeBuilder() - { + public function getConfigTreeBuilder(): TreeBuilder { $treeBuilder = new TreeBuilder('umanit_easy_admin_tree'); $rootNode = $treeBuilder->getRootNode(); diff --git a/Entity/AbstractTreeItem.php b/Entity/AbstractTreeItem.php index 42869e5..ed257f9 100644 --- a/Entity/AbstractTreeItem.php +++ b/Entity/AbstractTreeItem.php @@ -10,15 +10,6 @@ #[ORM\MappedSuperclass] abstract class AbstractTreeItem { - - #[ORM\Id] - #[ORM\GeneratedValue] - #[ORM\Column] - protected int $id; - - #[ORM\Column(length: 255)] - protected string $name; - #[Gedmo\TreeLeft] #[ORM\Column(name: 'lft', type: Types::INTEGER)] protected $lft; @@ -42,29 +33,22 @@ abstract class AbstractTreeItem #[ORM\OrderBy(['lft' => 'ASC'])] protected $children; - public function getId(): int - { - return $this->id; - } + abstract public function getId(); - public function getName(): string - { - return $this->name; - } + abstract public function getName(): string; - public function setName(string $name): void - { - $this->name = $name; - } + abstract public function setName(string $name): static; - public function getRoot(): ?self + public function getRoot(): ?static { return $this->root; } - public function setParent(self $parent = null): void + public function setParent(self $parent = null): static { $this->parent = $parent; + + return $this; } public function getParent(): ?self @@ -77,9 +61,11 @@ public function getChildren() return $this->children; } - public function setChildren($children): void + public function setChildren($children): static { $this->children = $children; + + return $this; } public function getLevel() diff --git a/Field/Configurator/TreeConfigurator.php b/Field/Configurator/TreeConfigurator.php index 79ec446..b4c8903 100644 --- a/Field/Configurator/TreeConfigurator.php +++ b/Field/Configurator/TreeConfigurator.php @@ -2,6 +2,7 @@ namespace Umanit\EasyAdminTreeBundle\Field\Configurator; +use Doctrine\ORM\EntityRepository; use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldConfiguratorInterface; use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; @@ -19,5 +20,16 @@ public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $c { $targetEntityFqcn = $field->getDoctrineMetadata()->get('targetEntity'); $field->setFormTypeOptionIfNotSet('class', $targetEntityFqcn); + $field->setFormTypeOptionIfNotSet('query_builder', static function (EntityRepository $repository) use ($field) { + // TODO: should this use `createIndexQueryBuilder` instead, so we get the default ordering etc.? + // it would then be identical to the one used in autocomplete action, but it is a bit complex getting it in here + if (null !== $queryBuilderCallable = $field->getCustomOption(TreeField::OPTION_QUERY_BUILDER_CALLABLE)) { + $queryBuilder = $queryBuilderCallable($repository); + } else { + $queryBuilder = $repository->createQueryBuilder('entity'); + } + + return $queryBuilder; + }); } } diff --git a/Field/TreeField.php b/Field/TreeField.php index 9cef7bb..ebe7c8c 100644 --- a/Field/TreeField.php +++ b/Field/TreeField.php @@ -2,6 +2,7 @@ namespace Umanit\EasyAdminTreeBundle\Field; +use Closure; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface; use EasyCorp\Bundle\EasyAdminBundle\Field\FieldTrait; use Umanit\EasyAdminTreeBundle\Form\Type\TreeFieldType; @@ -11,6 +12,7 @@ class TreeField implements FieldInterface use FieldTrait; public const OPTION_CLASS = 'class'; + public const OPTION_QUERY_BUILDER_CALLABLE = 'queryBuilderCallable'; public static function new(string $propertyName, ?string $label = null) { @@ -20,6 +22,13 @@ public static function new(string $propertyName, ?string $label = null) ->addFormTheme('@UmanitEasyAdminTreeBundle/form/themes/tree.html.twig') ->setFormType(TreeFieldType::class) ->addCssFiles('bundles/umaniteasyadmintree/css/tree-field.css') + ->setCustomOption(self::OPTION_QUERY_BUILDER_CALLABLE, null) ; } + + public function setQueryBuilder(Closure $queryBuilderCallable): self { + $this->setCustomOption(self::OPTION_QUERY_BUILDER_CALLABLE, $queryBuilderCallable); + + return $this; + } } diff --git a/Form/Type/TreeFieldType.php b/Form/Type/TreeFieldType.php index cc771eb..adad43b 100644 --- a/Form/Type/TreeFieldType.php +++ b/Form/Type/TreeFieldType.php @@ -2,32 +2,95 @@ namespace Umanit\EasyAdminTreeBundle\Form\Type; -use Doctrine\ORM\EntityRepository; +use App\Repository\Client\CategoryRepository; +use Doctrine\ORM\Query; +use Doctrine\ORM\Query\Parameter; +use Doctrine\ORM\QueryBuilder; +use Doctrine\Persistence\ManagerRegistry; +use Gedmo\Tree\Hydrator\ORM\TreeObjectHydrator; +use Symfony\Bridge\Doctrine\Form\ChoiceList\DoctrineChoiceLoader; +use Symfony\Bridge\Doctrine\Form\ChoiceList\ORMQueryBuilderLoader; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\ChoiceList\ChoiceList; +use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; +use TypeError; class TreeFieldType extends AbstractType { - public function configureOptions(OptionsResolver $resolver): void + + public function __construct(private readonly ManagerRegistry $registry) {} + + public function configureOptions(OptionsResolver $resolver): void { + + $choiceLoader = function (Options $options) { + // Unless the choices are given explicitly, load them on demand + if (null === $options['choices']) { + // If there is no QueryBuilder we can safely cache + $vary = [$options['em'], $options['class']]; + + // also if concrete Type can return important QueryBuilder parts to generate + // hash key we go for it as well, otherwise fallback on the instance + if ($options['query_builder']) { + $vary[] = $this->getQueryBuilderPartsForCachingHash($options['query_builder'])??$options['query_builder']; + } + + return ChoiceList::loader($this, new DoctrineChoiceLoader( + $options['em'], + $options['class'], + $options['id_reader'], + new ORMQueryBuilderLoader( + $options['query_builder']??$options['em']->getRepository($options['class'])->createQueryBuilder('e') + ) + ), $vary); + } + + return null; + }; $resolver->setDefaults([ 'expanded' => true, 'block_name' => 'umanit_easyadmin_tree', - 'query_builder' => function (EntityRepository $er) { + 'query_builder' => function (CategoryRepository $er) { return $er ->createQueryBuilder('entity') ->orderBy('entity.root, entity.lft', 'ASC') ; }, + "choice_loader" => $choiceLoader, 'choice_attr' => function ($choice, $key, $value) { return ['data-level' => $choice->getLevel(), 'data-has-child' => !$choice->getChildren()->isEmpty()]; }, - 'placeholder' => 'umanit.easyadmin.tree.form-field.placeholder', +// 'placeholder' => 'umanit.easyadmin.tree.form-field.placeholder', ]); + } - public function getParent(): string + public function getQueryBuilderPartsForCachingHash(object $queryBuilder): ?array { + if (!$queryBuilder instanceof QueryBuilder) { + throw new TypeError(sprintf('Expected an instance of "%s", but got "%s".', QueryBuilder::class, get_debug_type($queryBuilder))); + } + + $this->registry->getManager()->getConfiguration()->addCustomHydrationMode('tree', TreeObjectHydrator::class); + $query = $queryBuilder->getQuery()->setHint(Query::HINT_INCLUDE_META_COLUMNS, true); + $query->getResult("tree"); + + return [ + $query->getSQL(), + array_map($this->parameterToArray(...), $queryBuilder->getParameters()->toArray()), + ]; + } + + /** + * Converts a query parameter to an array. + */ + private function parameterToArray(Parameter $parameter): array { + return [$parameter->getName(), $parameter->getType(), $parameter->getValue()]; + } + + + public function getParent(): string { return EntityType::class; } diff --git a/README.md b/README.md index 13ea4af..b0182bd 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ doctrine: default: mappings: gedmo_tree: - type: annotation + type: attribute prefix: Gedmo\Tree\Entity dir: "%kernel.project_dir%/vendor/gedmo/doctrine-extensions/src/Tree/Entity" alias: GedmoTree # (optional) it will default to the name set for the mapping diff --git a/Resources/config/services.yaml b/Resources/config/services.yaml index d6bbc09..7e50ffe 100644 --- a/Resources/config/services.yaml +++ b/Resources/config/services.yaml @@ -3,9 +3,11 @@ services: autowire: true tags: - 'ea.field_configurator' - Umanit\EasyAdminTreeBundle\Form\Type\TreeFieldType: autowire: true + autoconfigure: true + arguments: + $registry: '@doctrine' Umanit\EasyAdminTreeBundle\Twig\TreeExtension: autowire: true diff --git a/Resources/views/form/themes/tree.html.twig b/Resources/views/form/themes/tree.html.twig index 4da9d8f..0f14a87 100644 --- a/Resources/views/form/themes/tree.html.twig +++ b/Resources/views/form/themes/tree.html.twig @@ -14,7 +14,7 @@