Универсальный инструмент для быстрого приготовления табличных списков в административной панели

в 13:27, , рубрики: admin panel, php, symfony, symfony2

Речь пойдет о бандле для Symfony2, первую версию которого я написал более двух лет назад. Всё это время я и мои коллеги активно его использовали, бандл периодически улучшался. Решил поделиться им с сообществом.

Практически в любом приложении требуется выводить табличный список сущностей, обязательно должна быть пагинация, неплохо также иметь возможность сортировки по всем полям и гибкую фильтрацию. Именно эти задачи и решает предоставленный на ваш суд AdminPanelBundle. Конечно, это не что-то новое — та же SonataAdminBundle предоставляет подобный функционал, но Соната — это монстр (в хорошем смысле этого слова), с кучей настроек и зависимостей, а моей целью было реализация быстрой и гибкой навигации по большим табличным массивам.

Что может бандл:

  • На входе может быть array, DoctrineORMQuery, DoctrineORMQueryBuilder, DoctrineCommonCollectionArrayCollection
  • Выводятся только определённые поля (свойства)
  • Для любого поля (свойства) можно определить неограниченное кол-во фильтров (AND, OR) с выбором оператора (=, >, <, LIKE, etc...)
  • Для любого поля можно включить/отключить сортировку
  • При применении фильтра параметры фильтрации запоминаются в сессии, и при повторном посещении страницы применяются
  • Есть возможность выводить автосумму по любому числовому столбцу

Демонстрацию можно посмотреть здесь, исходный код здесь.

Установка и базовая конфигурация

Как обычно — запускаем

composer require "zk2/admin-panel-bundle:dev-master"

Бандл использует knplabs/knp-paginator-bundle и braincrafted/bootstrap-bundle, если они отсутствуют в вашем приложении, то будут установлены

Настройка KnpPaginatorBundle

В app/AppKernel.php инициализируем бандл

// app/AppKernel.php
public function registerBundles()
{
    return array(
        // ...
        new KnpBundlePaginatorBundleKnpPaginatorBundle(),
        // ...
    );
}

Настройка BraincraftedBootstrapBundle
В app/AppKernel.php инициализируем бандл

// app/AppKernel.php
public function registerBundles()
{
    return array(
        // ...
        new BraincraftedBundleBootstrapBundleBraincraftedBootstrapBundle(),
        // ...
    );
}

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

# app/config/config.yml
.......
# Assetic Configuration
assetic:
    debug:          "%kernel.debug%"
    use_controller: false
    bundles:        [ ]
    filters: # с использованием node
        less:
            node: /usr/bin/node # путь узнать можно выполнив $ whereis node
            node_paths: [/usr/lib/node_modules] # $ whereis node_modules
            apply_to: ".less$"
        cssrewrite: ~
braincrafted_bootstrap:
    less_filter: less
    jquery_path: %kernel.root_dir%/../web/js/jquery-1.11.1.js # путь к jQuery

Далее выполняем:


php app/console braincrafted:bootstrap:install
php app/console assetic:dump

В app/AppKernel.php инициализируем бандл, в app/config/config.yml дописываем необходимые настройки:

// app/AppKernel.php
public function registerBundles()
{
    return array(
        // ...
        new Zk2BundleAdminPanelBundleZk2AdminPanelBundle(),
        // ...
    );
}

# app/config/config.yml
......
twig:
    ......
    form:
        resources:
            - "Zk2AdminPanelBundle:AdminPanel:bootstrap_form_div_layout.html.twig"

# настройки бандла по умолчанию
zk2_admin_panel:
    check_flag_super_admin:  false # -- если true, то сущность пользователя должна иметь метод "flagSuperAdmin()", возвращающий булево значение
    pagination_template:     Zk2AdminPanelBundle:AdminPanel:pagination.html.twig # - шаблон блока пагинации
    sortable_template:       Zk2AdminPanelBundle:AdminPanel:sortable.html.twig # - шаблон ссылки для сортировки в колонках таблицы

И подгружаем стили, иконки и пр.


php app/console asset:install web --symlink

Использование

Продемонстрирую на примере небольшого приложения «Автомобили».
Структура классическая — Страна -> Бренд -> Модель
Не судите строго за заполненные данные — всё «от фонаря».

Контроллер должен наследоваться от Zk2BundleAdminPanelBundleAdminPanelAdminPanelController
Родительский конструктор принимает:

  • Основную сущность
  • Алиас для этой сущности
  • Необязательный параметр «название entity_manager» — по умолчанию «default»

namespace AppBundleController;

use SensioBundleFrameworkExtraBundleConfigurationRoute;
use Zk2BundleAdminPanelBundleAdminPanelAdminPanelController;
use SymfonyComponentHttpFoundationRequest;
use SymfonyComponentSecurityCoreExceptionAccessDeniedException;
use SymfonyComponentHttpKernelExceptionNotFoundHttpException;

class DefaultController extends AdminPanelController
{
    /**
     * Constructor
     */
    public function __construct()
    {
        parent::__construct('AppBundleEntityModel','m');
    }

listAction — основной метод

    /**
     * listAction
     *
     * @Route("/", name="model_list")
     *
     * @return renderView
     */
    public function listAction( Request $request )
    {
        // Если есть разграничение прав доступа
        // Метод isZk2Granded приинимает роль или массов ролей
        // если в app/config.yml параметр zk2_admin_panel.check_flag_super_admin == true,
        // метод проверяет наличие "полного доступа"
        /*
        if ( false === $this->isZk2Granded(array('ROLE_LIST')) )
        {
            throw new AccessDeniedException();
        }*/
        
        // при сбросе всех фильтров
        if( $this->isReset() )
        {
            return $this->redirect( $this->generateUrl( $this->get('request')->get('_route') ) );
        }
        
        // построение колонок таблицы
        $this->buildListFields();
        
        // структура колонок таблицы для передачи в шаблон
        $items = $this->getListFields();
        
        // инициализируем запрос
        $this->getEm()->buildQuery();
        
        // сам запрос может содержать как обращение к объектам -- "m,b,c" ,
        // так и к конкретным свойствам объектов -- "b.id AS brand_id,b.name AS brand_name,m.name,m.color"
        // разница в том, что в первом случае запрос возвращает коллекцию объектов, а это может быть накладно
        // а во втором случае возвращается обычный массив массивов
        $this->getQuery()
            ->select(
                'b.id AS brand_id,b.name AS brand_name,c.name AS country_name,b.logo,m.id AS id,m.name,'
                .'m.color,m.airbag,m.sales,m.speed,m.price,m.dateView')
            ->leftJoin('m.brand','b')
            ->leftJoin('b.country','c')
        ;
        
        // сортировка по умолчанию
        if( !$this->get('request')->query->has('sort') )
        {
            $this->getQuery()->orderBy('m.id','DESC');
        }
        
        // строим фильтры
        $this->buildFilterFields();
        // применяем фильтры
        $this->checkFilters();
        // инициализируем KnpPaginator
        $pagination = $this->getPaginator(30);
        // форма фильтров для передачи в шаблон
        $filter_form = $this->getViewFiltersForm();
        // если необходима автосумма каких-то колонок
        $this->initAutosum();
        $autosum = $this->getSumColumns();
        
        return $this->render('AppBundle:Model:list.html.twig', array(
            'results'     => $pagination,
            'items'       => $items,
            'filter_form' => $filter_form,
            // будет ли кнопка для создания новой сущности
            'is_new'      => false, //$this->isZk2Granded(array('ROLE_NEW_ITEM')),
            'autosum'     => $autosum,
            // формат чисел по умолчанию (PHP::number_format), можно переопределять для каждой колонки
            'zkNumberFormat' => array('0','.',' '), 
        ));
    }

Построение колонок таблицы:

Метод addInList принимает массив:

  • свойство сущности
  • заголовок колонки (метод trans — аналог стандартной функции Symfony. Принимает значение, домен, массив параметров)
  • алиас сущности
  • массив опций

Дефолтные значения массива опций:

  • 'sort' => true, — сортировка столбца
  • 'func' => null, — функции (dateTimeFormat)
  • 'filter' => null, — фильтры (yes_no)
  • 'method' => null, — название свойства или метода
  • 'autosum' => null, — уникальный алиас для автосуммы

  • Так-же в массиве опций могут присутствовать:
  • 'link_id' => 'brand_edit' — имя роута
  • 'lid' => 'brand_id' — свойство или название метода для передачи ID в роут
  • 'style' => 'text-align:center' — любой css стиль (применится к ячейке таблицы)
  • 'icon_path' => '/img/' — обернётся в тэг img src="{icon_path}значение"
  • 'icon_width' => 24 — используется с icon_path (ширина картинки)
  • 'zkNumberFormat' => array(2,'.',' ') — PHP::number_format
  • 'dateTimeFormat' => 'Y-m-d' — используется для func::dateTimeFormat

Подробнее про опции и их использование можно посмотреть в исходном коде AdminPanelBundle/Resources/views/AdminPanel/adminList.html.twig

Можно передавать любые свои опции, но тогда нужно переопределить шаблон adminList.html.twig одним из способов переопределения в Symfony и обрабатывать их на своё усмотрение:

    /**
     * Построение колонок таблицы
     */
    public function buildListFields()
    {
        $this
        ->addInList(array(
            'name',                            // свойство сущности
            $this->trans('Brand','messages'),  // заголовок колонки
            'b',                               // алиас сущности 
            array(
                // если наш запрос возвращает простой массив, то здесь алиас ( b.name AS brand_name )
                // иначе здесь дложно быть название метода, который определён в базовой сущности
                //     ( в нашем случае Model::getBrandName() )
                'method'  => 'brand_name',
                
                // Название бренда будет ссылкой ( @Route("/brand/{id}/edit", name="brand_edit") )
                'link_id' => 'brand_edit',
                
                // если наш запрос возвращает простой массив, то здесь алиас ( b.id AS brand_id )
                // иначе здесь дложно быть название метода, который определён в базовой сущности
                //     ( в нашем случае Model::getBrandId() )
                // если link_id определён, а lid нет, то в роут подставится ID из базовой сущности
                'lid' => 'brand_id'
            ),
        ))
        ->addInList(array(
            'name',
            $this->trans('Country','messages'),
            'c',
            array(
                'method'  => 'country_name',
            ),
        ))
        ->addInList(array(
            'logo',
            $this->trans('Logo','messages'),
            'b',
            array(
                'sort'    => false,
                'style' => 'text-align:center',
                'icon_path' => '/img/'
            ),
        ))
        ->addInList(array(
            'name',
            $this->trans('Model','messages'),
            'm',
            array(
                'link_id' => 'model_edit',
            ),
        ))
        ->addInList(array(
            'color',
            $this->trans('Color','messages'),
            'm',
            array(
                'style' => 'text-align:center'
            ),
        ))
        ->addInList(array(
            'airbag',
            $this->trans('Airbag','messages'),
            'm',
            array(
                'filter'  => 'yes_no',  // Будет выводиться  "Да" или "Нет"
                'style' => 'text-align:center'
            ),
        ))
        ->addInList(array(
            'sales',
            $this->trans('Sales','messages'),
            'm',
            array(
                'autosum' => 'sales_sum', // Будет подсчитана сумма колонки
                'style' => 'text-align:center'
            ),
        ))
        ->addInList(array(
            'speed',
            $this->trans('Max speed','messages'),
            'm',
            array(
                'style' => 'text-align:center'
            ),
        ))
        ->addInList(array(
            'price',
            $this->trans('Price','messages'),
            'm',
            array(
                'style' => 'text-align:center',
                'zkNumberFormat' => array(2,'.',' ')
            ),
        ))
        ->addInList(array(
            'dateView',
            $this->trans('Date','messages'),
            'm',
            array(
                'func'    => 'dateTimeFormat',  // Для DateTime
                'dateTimeFormat' => 'Y-m-d',
                'style' => 'text-align:center'
            ),
        ))
        ;
    }

Построение фильтров:

Метод addInFilter принимает массив:

  • 'b_name' — алиас и название свойства через нижнее подчёркивание
  • 'zk2_admin_panel_XXXXX_filter' — тип фильтра
  • Название фильтра
  • количество фильтров для поля
  • набор доступных операторов (LIKE, =, >, <, etc...). Подробнее — AdminPanel/ConditionOperator.php
  • массив параметров

Типы фильтров:

  • 'zk2_admin_panel_boolean_filter' — булев фильтр (да/нет)
  • 'zk2_admin_panel_choice_filter' — выпадаючий список, определённый тут-же
  • 'zk2_admin_panel_date_filter' — фильтр по дате
  • 'zk2_admin_panel_entity_filter' — выпадаючий список, содержащий сущности (выполняется запрос к БД)
  • 'zk2_admin_panel_text_filter' — обычное текстовое поле

    /**
     * Построение фильтров
     */
    public function buildFilterFields()
    {
        $this
        ->addInFilter(array( // -- выпадаючий список, содержащий сущности
            'b_name',
            'zk2_admin_panel_entity_filter',
            $this->trans('Brand','messages'),
            5,
            'smal_int',
            array(
                'entity_type' => 'entity',
                'entity_class' => 'AppBundleEntityBrand',
                'property' => 'name',
                'sf_query_builder' => array( // Если необходимо ограничить запрос условием
                    'alias' => 'b',
                    'where' => 'b.id IS NOT NULL',
                    'order_field' => 'b.name',
                    'order_type' => 'ASC',
                )
        )))
        ->addInFilter(array(
            'm_name',
            'zk2_admin_panel_text_filter',
            $this->trans('Model','messages'),
            5,
            'light_text'
        ))
        ->addInFilter(array( // выпадаючий список, определённый тут-же
            'm_color',
            'zk2_admin_panel_choice_filter',
            $this->trans('Color','messages'),
            5,
            'smal_int',
            array('sf_choice' => array(
                'black' => 'black',
                'blue' => 'blue',
                'brown' => 'brown',
                'green' => 'green',
                'red' => 'red',
                'silver' => 'silver',
                'white' => 'white',
                'yellow' => 'yellow',
            )),
        ))
        ->addInFilter(array(
            'm_airbag',
            'zk2_admin_panel_boolean_filter',
            $this->trans('Airbag','messages'),
        ))
        ->addInFilter(array(
            'm_door',
            'zk2_admin_panel_text_filter',
            $this->trans('Number of doors','messages'),
            5,
            'medium_int'
        ))
        ->addInFilter(array(
            'm_speed',
            'zk2_admin_panel_text_filter',
            $this->trans('Max speed','messages'),
            5,
            'medium_int'
        ))
        ->addInFilter(array(
            'm_prise',
            'zk2_admin_panel_text_filter',
            $this->trans('Price','messages'),
            5,
            'medium_int'
        ))
        ->addInFilter(array( // фильтр по дате
            'm_dateView',
            'zk2_admin_panel_date_filter',
            $this->trans('Date','messages'),
            2
        ))
        ;
    }

Методы для форм

    /**
     * edit Brand Action
     *
     * @Route("/brand/{id}/edit", name="brand_edit")
     * 
     * @param Request $request
     * @param integer $id
     *
     * @return renderView
     */
    public function editBrandAction( Request $request, $id )
    {
        ............
    }
    
    /**
     * edit Action
     *
     * @Route("/model/{id}/edit", name="model_edit")
     * 
     * @param Request $request
     * @param integer $id
     *
     * @return renderView
     */
    public function editAction( Request $request, $id )
    {
        .....
    }
}

Ну и очень простой шаблон

# AppBundle:Model:list.html.twig

{% extends "Zk2AdminPanelBundle::base.html.twig" %}

{% block zk2_title %}Models list{% endblock %}

{% block zk2_h %}<h1>General list</h1>{% endblock %}

{% block zk2_body %}
   
{% if filter_form %}
{% include 'Zk2AdminPanelBundle:AdminPanel:adminFilter.html.twig' with {
    'filter_form': filter_form,
    'colspan': 2, {# кол-во колонок в таблице фильтра #}
    'this_path': path('model_list')
} %}
{% endif %}
    
{% include 'Zk2AdminPanelBundle:AdminPanel:adminList.html.twig' with {
    'items': items,
    'results': results,
    'Zk2NumberFormat': zkNumberFormat
} %}

{% if is_new %}
    Кнопка "Создать"
{% endif %}

{% endblock %}

Автор: zk-zeka

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js