Всем доброго времени суток!

Нынче многие бросают свой взгляд в сторону EasyAdminBundle, но я до сих пор использую и предпочитаю одну из лучших панелей администратора для Symfony.

SonataAdminBundle

Система очень гибкая и многими недооценённая, мол Sonata ограничивает администратора в действиях (представляет малый функционал панели администратора). Если Вам нужно что-то иное, всегда можно дополнить или модернизировать уже существующие методы.

Недавно мне нужно было произвести пакетное действие для продуктов и после выполненной работы захотелось рассказать об этом Вам. Вдобавок прикреплю Select2, ибо у меня возникали некие трудности, офф.документация ограничена в информации.

Итак, начнем сразу с чистого проекта. На момент создания хаба актуальные версии пакетов:

При установке никаких проблем особо возникать не должно, в ином случае в интернете полно информации об установке SonataAdminBundle.

Для демонстративной работы пакетного действия нам понадобится 2 сущности, со связью M2M.

Создаём сущность Product:

#src/Entity/Product.php
<?php

namespace App\Entity;

use App\Repository\ProductRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: ProductRepository::class)]
class Product
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $name = null;

    #[ORM\Column(nullable: true)]
    private ?int $price = null;

    #[ORM\ManyToMany(targetEntity: Category::class, mappedBy: 'products')]
    private Collection $categories;
    
    public function __construct()
    {
        $this->products = new ArrayCollection();
    }
    
    public function __toString(): string
    {
        return $this->name;
    }
    
    //getters and setters
}

Также создаём Category:

#src/Entity/Category.php
<?php

namespace App\Entity;

use App\Repository\CategoryRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: CategoryRepository::class)]
class Category
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $name = null;

    #[ORM\ManyToMany(targetEntity: Product::class, inversedBy: 'categories')]
    private Collection $products;
    
    public function __construct()
    {
        $this->products = new ArrayCollection();
    }
    
    public function __toString(): string
    {
        return $this->name;
    }
    
    //getters and setters
}

В обе сущности требуется добавить метод возврата строки __toString
Обновим таблицы в БД:

bin/console doctrine:schema:update --force

Теперь создадим файлы для панели администратора:

#src/Admin/ProductAdmin.php
<?php

namespace App\Admin;

use Sonata\AdminBundle\Admin\AbstractAdmin;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Form\FormMapper;
use Sonata\AdminBundle\Show\ShowMapper;

class ProductAdmin extends AbstractAdmin
{
    protected function configureFormFields(FormMapper $form): void
    {
        if ($this->getSubject()->getId()) {
            $form
                ->add('price')
                ->add('categories')
            ;
        }else {
            $form
                ->add('name')
                ->add('price')
                ->add('categories')
            ;
        }
    }

    protected function configureDatagridFilters(DatagridMapper $filter): void
    {
        $filter
            ->add('name')
            ->add('price')
            ->add('categories')
        ;
    }

    protected function configureListFields(ListMapper $list): void
    {
        $list
            ->addIdentifier('name')
            ->add('price')
            ->add('categories')
            ->add('_action', 'actions',[
                'actions' => [
                    'edit' => [],
                    'delete' => [],
                ]
            ])
        ;
    }

    protected function configureShowFields(ShowMapper $show): void
    {
        $show
            ->with('Product')
                ->add('name')
                ->add('price')
            ->end()
            ->with('Categories')
                ->add('categories')
            ->end()
        ;
    }
}
#src/Admin/CategoryAdmin.php
<?php

namespace App\Admin;

use Sonata\AdminBundle\Admin\AbstractAdmin;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Form\FormMapper;
use Sonata\AdminBundle\Show\ShowMapper;

class CategoryAdmin extends AbstractAdmin
{
    protected function configureFormFields(FormMapper $form): void
    {
        $form
            ->add('name')
            ->add('products')
        ;
    }

    protected function configureDatagridFilters(DatagridMapper $filter): void
    {
        $filter
            ->add('name')
            ->add('products')
        ;
    }

    protected function configureListFields(ListMapper $list): void
    {
        $list
            ->addIdentifier('name')
            ->add('products')
        ;
    }

    protected function configureShowFields(ShowMapper $show): void
    {
        $show
            ->with('Category')
                ->add('name')
            ->end()
            ->with('Products')
                ->add('products')
            ->end()
        ;
    }
}

Нужно их зарегистрировать:

#config/services.yaml
services:
    App\Admin\ProductAdmin:
        arguments: [ ~, App\Entity\Product, ~ ]
        tags:
            - { name: sonata.admin, manager_type: orm, group: Content, label: Product }

    App\Admin\CategoryAdmin:
        arguments: [ ~, App\Entity\Category, ~ ]
        tags:
            - { name: sonata.admin, manager_type: orm, group: Content, label: Category }

Добавим кастомное поле, где будет отображаться скидка и цена со скидкой.
Саму скидку запишем в параметры контейнера:

#config/services.yaml
parameters:
    discount: 15

Теперь передадим этот параметр в качестве аргумента:

#config/services.yaml
services:
    App\Admin\ProductAdmin:
        arguments: [ ~, App\Entity\Product, ~, '%discount%' ]

Принимаем параметр и создаём само поле:

#src/Admin/ProductAdmin.php
<?php
    private ?int $discount;
    
    public function __construct(
        ?string $code = null, 
        ?string $class = null, 
        ?string $baseControllerName = null, 
        ?int $discount = null
    )
    {
        parent::__construct($code, $class, $baseControllerName);
        $this->discount = $discount;
    }


    protected function configureListFields(ListMapper $list): void
    {
        $list
            ->add('discountPrice', null,[
                'template' => 'SonataAdmin/price.html.twig',
                'discount' => $this->discount, 
            ])
        ;
    }

Чтобы не вызывало ошибку о несуществующем методе, создадим метод в сущности продукта. Не важно что он будет возвращать.

#src/Entity/Product.php
<?php
    public function getDiscountPrice(): int
    {
        return 1;
    }

Нам теперь нужно создать шаблон, который будет отображать поле

#templates/SonataAdmin/price.html.twig
{% extends '@SonataAdmin/CRUD/base_list_field.html.twig' %}

{% block field %}
    DiscountPrice: {{ (object.price/100*(100-field_description.options.discount))|round }}<br>
    Discount: {{ field_description.options.discount }}
{% endblock %}

Добавим тройку тестовых записей продуктов, заодно сразу тройку категорий.

Проверяем.

Теперь появилось поле, в котором отображается цена со скидкой и сама скидка
Теперь появилось поле, в котором отображается цена со скидкой и сама скидка

Можно создавать пакетное действие. Нужно создать метод configureBatchActions

#src/Admin/ProductAdmin.php
<?php
    protected function configureBatchActions(array $actions): array
    {
        $actions['add_category'] = [
            'ask_confirmation' => true, //можно убрать, если не нужно подтверждение действия
        ];
        return $actions;
    }

Проверим отображение

Как мы видим, наше действие уже отображается.
Как мы видим, наше действие уже отображается.

Попробуем добавить поле выбора категории. Для этого я решил выбрать Select2 с подгрузкой названий с помощью Ajax. Создадим новый шаблон. Метод пакетного удаления мне не нужен, поэтому я удалил возможность выбора и всегда будет использоваться только наш метод:

#templates/SonataAdmin/list.html.twig
{% extends '@SonataAdmin/CRUD/base_list.html.twig' %}

{% block batch_actions %}
    <label class="checkbox" for="{{ admin.uniqid }}_all_elements">
        <input type="checkbox" name="all_elements" id="{{ admin.uniqid }}_all_elements">
        {{ 'all_elements'|trans({}, 'SonataAdminBundle') }}
        ({{ admin.datagrid.pager.countResults() }})
    </label>
    <input id="action" name="action" value="addCategory" style="display: none">
    <select id="category" name="category" style="width: 150px"></select>
    <script>
        $(function (){
            $('#category').select2({
                ajax:{
                    url: '/find_category_ajax',
                    dataType: 'json',
                    processResults: function (data) {
                        return {
                            results: data
                        };
                    },
                },
                minimumInputLength: 3,
            })
        })
    </script>
{% endblock %}

Установим новый шаблон для нашего списка

#config/services.yaml
services:
    App\Admin\ProductAdmin:
        calls:
            - [ setTemplate, [ list, SonataAdmin/list.html.twig ] ]

Теперь у нас поле выбора категории, но ещё не настроен ответ Ajax.

Категории мы уже создали.

Нужно создать запрос query в CategoryRepository, который будет возвращать сущности с похожим набором символов в имени.

#src/Repository/CategoryRepository.php
<?php

namespace App\Repository;

use App\Entity\Category;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<Category>
 *
 * @method Category|null find($id, $lockMode = null, $lockVersion = null)
 * @method Category|null findOneBy(array $criteria, array $orderBy = null)
 * @method Category[]    findAll()
 * @method Category[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 */
class CategoryRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Category::class);
    }

    public function add(Category $entity, bool $flush = false): void
    {
        $this->getEntityManager()->persist($entity);

        if ($flush) {
            $this->getEntityManager()->flush();
        }
    }

    public function remove(Category $entity, bool $flush = false): void
    {
        $this->getEntityManager()->remove($entity);

        if ($flush) {
            $this->getEntityManager()->flush();
        }
    }

    public function findToName(string $value)
    {
        return $this->createQueryBuilder('c')
            ->andWhere('LOWER(c.name) LIKE :val')
            ->setParameter('val', '%'.$value.'%')
            ->setMaxResults(5)
            ->getQuery()
            ->getResult()
        ;
    }
}

Создадим контроллер, который будет принимать запрос от Ajax.

#src/Controller/Admin/CategoryAjaxController.php
<?php

namespace App\Controller\Admin;

use App\Entity\Category;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class CategoryAjaxController extends AbstractController
{
    public function findCategoryAjax(ManagerRegistry $doctrine, Request $request): Response
    {
        $string = $request->query->get('q');
        $categories = $doctrine->getRepository(Category::class)->findToName($string);
        $result = array();
        foreach ($categories as $category)
        {
            $result[] = [
                'id' => $category->getId(),
                'text' => $category->getName(),
            ];
        }
        return new Response(json_encode($result));
    }
}

Настроим маршрут для контроллера

#config/routes.yaml
find_category_ajax:
    path: /find_category_ajax
    controller: App\Controller\Admin\CategoryAjaxController::findCategoryAjax

Теперь можно проверить работу нашего Select2

Осталось только настроить контроллер, который будет принимать категорию и добавлять её к выделенным продуктам.

Нам нужен CRUD контроллер и метод с названием нашего пакетного действия:

#src/Controller/Admin/ProductAdminController.php
<?php

namespace App\Controller\Admin;

use App\Entity\Category;
use Sonata\AdminBundle\Controller\CRUDController;
use Sonata\AdminBundle\Admin\AdminInterface;
use Sonata\AdminBundle\Datagrid\ProxyQueryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Doctrine\Persistence\ManagerRegistry;

class ProductAdminController extends CRUDController
{
    private ManagerRegistry $doctrine;

    public function __construct(ManagerRegistry $doctrine)
    {
        $this->doctrine = $doctrine;
    }

    public function batchActionAddCategory(
        ProxyQueryInterface $query,
        AdminInterface $admin,
    ): RedirectResponse
    {
        $id = json_decode($_POST['data'])->category;
        $category = $this->doctrine->getRepository(Category::class)->find($id);

        $entity = $this->doctrine->getManager();
        $products = $query->execute();
        foreach ($products as $product)
        {
            $product->addCategory($category);
            $entity->persist($product);
        }
        $entity->persist($category);
        $entity->flush();

        $this->addFlash(
            'sonata_flash_success',
            'Successfully added to "'.$category->getName().'" category'
        );

        return new RedirectResponse(
            $admin->generateUrl('list',[
                'filter' => $admin->getFilterParameters()
            ])
        );
    }
}

Теперь передадим в наш сервис контроллер в качестве аргумента:

#config/services.yaml
services:
        App\Admin\ProductAdmin:
        arguments: [ ~, App\Entity\Product, App\Controller\Admin\ProductAdminController, '%discount%']

Попробуем добавить категорию к нескольким продуктам

Как мы видим, теперь всё работает и работает всё как нам нужно и к отмеченным продуктам прикрепилась выбранная категория.

Заключение

Я хотел вам показать, что SonataAdminBundle не ограничивает Вас в действиях. Вы можете сделать любое действие, которое Вам требуется, нужно лишь знать как и где это править.

Буду рад любым замечаниям и комментариям, также постараюсь ответить на вопросы, если у Вас они возникнут.

Комментарии (4)


  1. bombe
    12.09.2022 11:30
    +1

    Ничего не имею против SonataAdminBundle. Но в EasyAdmin3 вся статья поместится в одну строку в методе ProductCrudController::configureFields:

    yield AssociationField::new('category')->autocomplete();


  1. michael_v89
    12.09.2022 12:12
    +2

    # config/services.yaml
    App\Admin\ProductAdmin:
      calls:
        - [ setTemplate, [ list, SonataAdmin/list.html.twig ] ]

    Программирование через конфиг.


    tags:
      - { name: sonata.admin, manager_type: orm, group: Content, label: Product }

    Магические заклинания, которые можно узнать только в древних свитках в документации на 367 странице.


    Чтобы не вызывало ошибку о несуществующем методе, создадим метод в сущности продукта.

    Всякие костыли.


    configureFormFields
    configureDatagridFilters
    configureListFields
    configureShowFields

    God-object, который умеет всё.


    Верстка поехала, и чтобы исправить надо переопределять кучу кода. Способы переопределения снова надо долго искать в древних свитках.


    object.price/100*(100-field_description.options.discount)

    Бизнес-логика в представлениях.


    1. RNSNS Автор
      13.09.2022 11:18

      Если Вы сделали бы лучше, то это не значит ведь, что я сделал плохо.
      Не понимаю почему Вас смущают записи в конфиг.
      В документации сонаты написано про создание геттера в сущности, который может вернуть что угодно. Это не костыли. Я мог и там создать эту "Бизнес-логику", но 1 поле == 1 значение. Я лишь показал, что можно это кастомизировать как угодно.

      Но всё равно, спасибо за критику! Впредь буду думать шире.


      1. michael_v89
        13.09.2022 13:24

        Я говорю к тому, что все эти автоматизированные админки подходят только для учебных CRUD-ов, а когда надо сделать что-то серьезное, то приходится долго изучать, что и где надо переопределить, и писать много сложноподдерживаемого кода, проще и быстрее написать с нуля без таких админок.