Как стать автором
Обновить

Пакетное действие SonataAdminBundle + Select2

Время на прочтение8 мин
Количество просмотров2.3K

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

Нынче многие бросают свой взгляд в сторону 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 не ограничивает Вас в действиях. Вы можете сделать любое действие, которое Вам требуется, нужно лишь знать как и где это править.

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

Теги:
Хабы:
Всего голосов 7: ↑7 и ↓0+7
Комментарии6

Публикации

Истории

Работа

PHP программист
168 вакансий

Ближайшие события

19 августа – 20 октября
RuCode.Финал. Чемпионат по алгоритмическому программированию и ИИ
МоскваНижний НовгородЕкатеринбургСтавропольНовосибрискКалининградПермьВладивостокЧитаКраснорскТомскИжевскПетрозаводскКазаньКурскТюменьВолгоградУфаМурманскБишкекСочиУльяновскСаратовИркутскДолгопрудныйОнлайн
24 – 25 октября
One Day Offer для AQA Engineer и Developers
Онлайн
25 октября
Конференция по росту продуктов EGC’24
МоскваОнлайн
26 октября
ProIT Network Fest
Санкт-Петербург
7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань