Всем доброго времени суток!
Нынче многие бросают свой взгляд в сторону EasyAdminBundle, но я до сих пор использую и предпочитаю одну из лучших панелей администратора для Symfony.
SonataAdminBundle
Система очень гибкая и многими недооценённая, мол Sonata ограничивает администратора в действиях (представляет малый функционал панели администратора). Если Вам нужно что-то иное, всегда можно дополнить или модернизировать уже существующие методы.
Недавно мне нужно было произвести пакетное действие для продуктов и после выполненной работы захотелось рассказать об этом Вам. Вдобавок прикреплю Select2, ибо у меня возникали некие трудности, офф.документация ограничена в информации.
Итак, начнем сразу с чистого проекта. На момент создания хаба актуальные версии пакетов:
symfony/skeleton 6.1
При установке никаких проблем особо возникать не должно, в ином случае в интернете полно информации об установке 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)
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)
Бизнес-логика в представлениях.
RNSNS Автор
13.09.2022 11:18Если Вы сделали бы лучше, то это не значит ведь, что я сделал плохо.
Не понимаю почему Вас смущают записи в конфиг.
В документации сонаты написано про создание геттера в сущности, который может вернуть что угодно. Это не костыли. Я мог и там создать эту "Бизнес-логику", но 1 поле == 1 значение. Я лишь показал, что можно это кастомизировать как угодно.
Но всё равно, спасибо за критику! Впредь буду думать шире.michael_v89
13.09.2022 13:24Я говорю к тому, что все эти автоматизированные админки подходят только для учебных CRUD-ов, а когда надо сделать что-то серьезное, то приходится долго изучать, что и где надо переопределить, и писать много сложноподдерживаемого кода, проще и быстрее написать с нуля без таких админок.
bombe
Ничего не имею против
SonataAdminBundle
. Но вEasyAdmin3
вся статья поместится в одну строку в методеProductCrudController::configureFields
: