Что такое AdminYard?
AdminYard — это библиотека для создания админок на PHP, которую я недавно написал с нуля. Зачем, спросите вы, если вокруг и так полно админок? Я искал библиотеку, которая бы встроилась в существующий легаси-проект и не притащила с собой кучу новых тяжелых зависимостей вроде фреймворков, шаблонизаторов и ORM. Ничего подходящего не нашел: мне попадались либо библиотеки из экосистемы фреймворков, либо мутные платные скрипты.
Я много работал с бандлом для Symfony EasyAdmin, еще начиная с первой версии. Из него позаимствовал общий подход и идею описания конфигурации. Также реализовал в своей библиотеке те фичи, которых мне не хватало в EasyAdmin и для которых приходилось придумывать костыли.
В этой статье я расскажу об основных возможностях AdminYard и приведу упрощенные примеры конфигурации. Если не охота читать, можете сразу попробовать демо-сайт или посмотреть исходный код на гитхабе.
Фичи AdminYard
CRUD-операции. Для начала работы в конфиге AdminYard описываются те таблицы и поля из базы данных, с которыми будет вестись работа в админке. После этого для каждой описанной сущности появляются 4 экрана:
-
list — cписок всех сущностей,
-
show — просмотр одной сущности,
-
new — форма создания,
-
edit — форма редактирования.
Отображение каждого поля на этих экранах настраивается независимо. Для определенных сущностей ненужные экраны можно отключить. Ячейки в таблице списка сущностей можно сделать редактируемыми прямо в списке сущностей, без перехода к форме редактирования (in-place edit).
Формы создания и редактирования сущностей создаются автоматически на основе конфига. К полям на формах создания и редактирования можно указать правила валидации.
Связи между сущностями и виртуальные поля. Если в вашем проекте связь многие-к-одному сделана стандартным образом, через хранение parent_id
в дочерней сущности, то такая связь указывается в конфиге AdminYard и работает из коробки. В колонке таблицы со списком сущностей вместо значения parent_id
отображается ссылка на саму родительскую сущность, а при создании или редактировании дочерней сущности родительская сущность выбирается из списка.
Поддержки связи многие-ко-многим нет, но есть точки расширения для её реализации в вашем коде. Я сделал так, потому что в отличие от стандартного поля parent_id
для связи многие-к-одному, связь многие-ко-многим хранится в отдельной таблице. Для изменения записей в ней нет стандартных интерфейсных подходов, интерфейс часто определяется бизнес-логикой. Рассмотрим пример связи многие-ко-многим: посты и теги в блоге. В интерфейсе удобно редактировать эту связь как дополнительное поле со списком тегов на форме редактирования поста. Такие поля описываются в конфиге как виртуальные поля. Чтобы они заработали, требуется написать обработчики событий, которые и будут сохранять содержимое виртуальных полей в другие таблицы.
Фильтры для списков сущностей. Это то, чего мне не хватало в EasyAdmin. Между полями фильтра и полями сущностей не всегда есть прямое соответствие. Например, в фильтре может присутствовать общий поиск по нескольким текстовым колонкам или, наоборот, два поля для выбора интервала времени при фильтрации по одной колонке. Чтобы задавать такое соответствие, потребуется написать фрагмент SQL-запроса.
Выбранный фильтр сохраняется в отдельном хранилище, например, в сессии. Это удобно, чтобы на какой-то странице админки не приходилось каждый раз выбирать одни и те же условия.
Контроль доступа на уровне строк. AdminYard позволяет указать условия в SQL-запросах для ограничения доступа к некоторым строкам на чтение и на запись. Вот как выглядит искусственный пример, в котором к записям с id от 31 до 35 ограничен доступ на запись, а к записям с id от 40 до 41 ограничен доступ на чтение.
Обычно управление доступом делается в зависимости от роли текущего пользователя и от состояния сущности. В примере с блогом рядовой модератор может одобрять или отклонять только новые комментарии, а администратор может менять статусы любых комментариев. Это можно сделать с помощью контроля доступа на уровне строк.
Шаблоны и их переопределение. Шаблоны в AdminYard — это обычные php-файлы. В комплекте есть шаблоны по умолчанию, и их можно переопределить независимо для каждой сущности. Например, в блоге форму редактирования поста надо существенно доработать: сверстать по красивому макету, добавить автосохранение в localStorage, подключить продвинутый редактор вместо стандартной textarea. А форма редактирования пользователей может остаться стандартной, так как её используют редко. Всё это делается с помощью переопределения встроенных шаблонов.
Защита от CSRF во всех формах. Мелочь, а приятно.
Чего нет в AdminYard
То, чем проект не является и что он не делает, описывает его не хуже, а может даже и лучше, чем список фич.
Нет авторизации. Она должна быть внешняя: пользователь и права должны проверяться до того, как управление будет передано в AdminYard. Если требуются разные привилегии пользователей в зависимости от ролей, они должны программироваться через конфиг. Он динамический и может содержать разные действия, поля и даже сущности в зависимости от ролей пользователя.
Нет внешних шаблонизаторов. В обычных шаблонах в php-файлах важно не забывать экранировать вывод, чтобы не допустить XSS-уязвимости.
Нет современного фронтенда. В библиотеке используется только серверный рендеринг (веб 1.0). Как показывает практика, этого более чем достаточно в непубличных интерфейсах админок.
Нет управления ассетами. Переопределяйте шаблон layout.php и указывайте в нем правильные пути к существующим ассетам.
Нет совместного редактирования. Такие продвинутые фичи уже за скоупом проекта. Если два человека отправят одну и ту же форму, сохранится только последняя версия.
Нет ORM. Вам самим нужно заниматься миграциями, создавать индексы, чтобы не тормозила фильтрация и сортировка, и в некоторых случаях писать фрагменты SQL.
Пример конфигурации
Это раздел для тех, кому интересно посмотреть на программный интерфейс библиотеки.
В простейшем случае в index.php
надо поместить следующий код:
<?php use S2\AdminYard\DefaultAdminFactory; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Session\Session; // Динамический конфиг, рассмотрим чуть ниже $adminConfig = require 'admin_config.php'; // Типовой код инициализации сервисов AdminYard // Вместо фабрики можно определить сервисы в каком-нибудь DI контейнере $pdo = new PDO('mysql:host=localhost;dbname=adminyard', 'username', 'passwd'); $adminPanel = DefaultAdminFactory::createAdminPanel($adminConfig, $pdo, require 'translations/en.php', 'en'); // Нужен компонент Symfony HTTP Foundation $request = Request::createFromGlobals(); $request->setSession(new Session()); $response = $adminPanel->handleRequest($request); $response->send();
Конфиг представляет собой php-код в объектном стиле. Вот базовый пример:
<?php use S2\AdminYard\Config\AdminConfig; use S2\AdminYard\Config\DbColumnFieldType; use S2\AdminYard\Config\EntityConfig; use S2\AdminYard\Config\FieldConfig; use S2\AdminYard\Config\Filter; use S2\AdminYard\Config\FilterLinkTo; use S2\AdminYard\Config\LinkTo; use S2\AdminYard\Database\PdoDataProvider; use S2\AdminYard\Event\AfterSaveEvent; use S2\AdminYard\Event\BeforeDeleteEvent; use S2\AdminYard\Event\BeforeSaveEvent; use S2\AdminYard\Validator\NotBlank; use S2\AdminYard\Validator\Length; $adminConfig = new AdminConfig(); $commentConfig = new EntityConfig( 'Comment', // Название сущности в интерфейсе и УРЛах 'comments' // Название таблицы в БД ); $postEntity = (new EntityConfig('Post', 'posts')) ->addField(new FieldConfig( name: 'id', // Название колонки в таблице БД type: new DbColumnFieldType(FieldConfig::DATA_TYPE_INT, true), // Тип - числовой первичный ключ // Колонка включена только на экранах list и show. // На формах создания и редактирования, очевидно, нельзя редактировать ID сущности. useOnActions: [FieldConfig::ACTION_LIST, FieldConfig::ACTION_SHOW] )) ->addField(new FieldConfig( name: 'title', // Тип колонки - строка. Закомментировано, так как это значение по умолчанию, его можно опустить // type: new DbColumnFieldType(FieldConfig::DATA_TYPE_STRING), // Если поле появляется на экранах new или edit, ему надо указать контрол на форме control: 'input', // Обычный инпут validators: [new Length(max: 80)], // Валидировать максимальную длину поля sortable: true, // Разрешить сортировку по этому полю на экране list actionOnClick: 'edit' // Сделать ячейку на экране list кликабельной и ведущей на экран edit )) ->addField(new FieldConfig( name: 'text', control: 'textarea', // Textarea для текста поста // Все экраны за исключением list, так как текст будет распирать таблицу useOnActions: [FieldConfig::ACTION_SHOW, FieldConfig::ACTION_EDIT, FieldConfig::ACTION_NEW] )) ->addField(new FieldConfig( name: 'created_at', type: new DbColumnFieldType(FieldConfig::DATA_TYPE_TIMESTAMP), // Тип колонки в БД - timestamp control: 'datetime', // HTML5-контрол выбора даты и времени sortable: true )) ->addField(new FieldConfig( name: 'comments', // Специальный конфиг для связи один-ко-многим. Описывает "виртуальную" колонку, которой нет в БД. // Она появится на экранах list и show в виде ссылки на список комментариев с примененным фильтром // с подставленным текущим постом. type: new LinkedByFieldType( $commentConfig, 'CASE WHEN COUNT(*) > 0 THEN COUNT(*) ELSE NULL END', // Текст для ссылки 'post_id' ), sortable: true )) // Здесь определяется фильтр на экране list ->addFilter(new Filter( 'search', // Фильтр содержит поле поиска 'Fulltext Search', // Название поля поиска 'search_input', // Контрол <input type="search"> 'title LIKE %1$s OR text LIKE %1$s', // шаблон условия для WHERE // Функция для преобразования входного значения в параметр для PDO // В этом случае пустая строка в поле поиска не приведет к фильтрации, // а непустая строка будет искаться как подстрока (LIKE '%...%') fn(string $value) => $value !== '' ? '%' . $value . '%' : null )) ; // Fields and filters configuration for "Comment" $commentConfig ->addField(new FieldConfig( name: 'id', type: new DbColumnFieldType(FieldConfig::DATA_TYPE_INT, true), // Primary key useOnActions: [] // Скрыть ID со всехе экранов )) ->addField($postIdField = new FieldConfig( name: 'post_id', type: new DbColumnFieldType(FieldConfig::DATA_TYPE_INT), control: 'autocomplete', // Контрол - автодополнение validators: [new NotBlank()], // Валидатор для запрета пустых значений поля sortable: true, // Специальный конфиг для связи многие-к-одному. В колонке будет отображаться ссылка на пост // на экранах list и show. Результат "CONCAT('#', id, ' ', title)" будет использован как текст ссылки. linkToEntity: new LinkTo($postEntity, "CONCAT('#', id, ' ', title)"), // Отключить на экране edit, при редактировании комментария его нельзя перенести к другому посту useOnActions: [FieldConfig::ACTION_LIST, FieldConfig::ACTION_SHOW, FieldConfig::ACTION_NEW] )) ->addField(new FieldConfig( name: 'name', control: 'input', validators: [new NotBlank(), new Length(max: 50)], inlineEdit: true, // Разрешить "инлайн-редактирование" поля прямо на экране list )) ->addField(new FieldConfig( name: 'email', control: 'email_input', // Контрол <input type="email"> validators: [new Length(max: 80)], inlineEdit: true, )) // ... ->addField(new FieldConfig( name: 'status_code', // defaultValue используется при добавлении в БД, если поле отсутствует на экране new type: new DbColumnFieldType(FieldConfig::DATA_TYPE_STRING, defaultValue: 'new'), control: 'radio', // Radio-кнопки для выбора статуса options: ['new' => 'Pending', 'approved' => 'Approved', 'rejected' => 'Rejected'], inlineEdit: true, // Отключить на экране new useOnActions: [FieldConfig::ACTION_LIST, FieldConfig::ACTION_SHOW, FieldConfig::ACTION_EDIT] )) // ... ->addFilter(new FilterLinkTo( $postIdField, // Специальный фильтр по полю, содержащему связь многие-к-одному 'Post', )) ->addFilter(new Filter( 'created_from', // name контрола в форме фильтров 'Created after', 'date', 'created_at >= %1$s', // Показать комментарии, созданные после указанной даты, created_at - поле коммента )) ->addFilter(new Filter( 'created_to', // name контрола в форме фильтров 'Created before', 'date', 'created_at < %1$s', // Показать комментарии, созданные после указанной даты, created_at - поле коммента )) ->addFilter(new Filter( 'statuses', 'Status', 'checkbox_array', // Несколько чекбоксов можно отметить одновременно для отображения сразу нескольких статусов 'status_code IN (%1$s)', // Filter comments by status options: ['new' => 'Pending', 'approved' => 'Approved', 'rejected' => 'Rejected'] // Коды и названия чекбоксов )); // Искусственный пример управления доступом $postEntity->setReadAccessControl( new LogicalExpression('read_access_control', 40, 'id != %1$s AND id != 1 + %1$s') ); $postEntity->setWriteAccessControl( new LogicalExpression('write_access_control', [31, 32, 33, 34, 35], 'id NOT IN (%s)'), ); // Более полезный пример управления доступом $postEntity-> setReadAccessControl( isGranted('ROLE_ADMIN') ? null // админам нет ограничений // а не-админы могут просматривать только свои посты или опубликованные чужие : new LogicalExpression('expression1', getUserId(), 'published = 1 OR user_id = %s') ) ->setWriteAccessControl( isGranted('ROLE_ADMIN') ? null // админам нет ограничений // а не-админы могут редактировать только свои посты : new LogicalExpression('expression2', getUserId()) ) return $adminConfig ->addEntity($postEntity) ->addEntity($commentConfig) ;
Более продвинутые возможности, например, редактирование виртуальных полей через обработчики событий, описаны в README на гитхабе.
Системные требования
AdminYard работает в PHP версии 8.2 и выше. Для работы нужна реляционная база данных: MySQL, PostgreSQL или SQLite.
Кому подойдет?
В первую очередь тем, кому надо добавить или переделать админку в существующем легаси-проекте. AdminYard позволяет не тратить время на создание и обработку типовых форм, а сразу сфокусироваться на бизнес-логике приложения.
Новые проекты, особенно объемом больше одного человеко-месяца, я бы не стал делать на AdminYard с нуля. Вам всё равно понадобится какой-нибудь фреймворк и библиотеки, лучше взять готовую админку, интегрирующуюся с ними.
Впечатления от разработки
Я взялся за разработку AdminYard, во многом надеясь на помощь нейросетей. Конечно, я бы и сам мог написать весь код с нуля, но у меня бы ушло слишком много времени на рутину и не осталось бы энтузиазма для размышлений над действительно интересными и творческими моментами. Да и самого времени, как всегда, не хватает.
Сначала я попросил ChatGPT предложить пример описания конфигурации админки на PHP в объектном стиле. Отредактировал и доработал этот пример. Затем попросил создать классы для описания конфигурации, сервисы, фикстуры демо-приложения. Первый результат, который хоть как-то выводил настоящее содержимое базы данных, появился достаточно быстро. Это придало мне уверенности в том, что я смогу за разумное время довести проект до состояния, в котором его можно применять в существующих проектах.
По ходу разработки меняется характер работы: программист переходит от крупных мазков и постоянного создания новых классов к работе над деталями, доработкам и рефакторингу. Здесь уже ChatGPT не помогает, так как сформулировать задачу для него слишком сложно из-за отсутствия контекста. Мне на помощь пришла нейросеть Codeium. Я установил ее как плагин к PhpStorm и использовал подсказки с автодополнением кода. Конечно, они не снимают необходимость понимать, что происходит в коде, но определенную помощь тоже оказывают.
Планы на будущее
Особых планов нет. Но если вы сделаете пулл-реквест с какой-нибудь полезной фичей, я при необходимости причешу код и смержу.
Ссылки
ссылка на оригинал статьи https://habr.com/ru/articles/866512/
Добавить комментарий