Недавно была статья про Yii, где в комментариях обсуждали специфичные для Yii компоненты, в частности GridView и ActiveForm, и фреймворк Laravel. Я подумал, а почему бы и нет.
Что из этого получилось, читайте под катом. Потребовалось написать пару небольших обвязок и сконфигурировать определенным образом, но в целом все работает — и грид, и формы. Также есть небольшой обзор существующих аналогов для Laravel.
https://github.com/view-components/grids
https://github.com/assurrussa/grid-view-table
https://github.com/dwightwatson/bootstrap-form
https://github.com/core-system/bootstrap-form
https://github.com/adamwathan/bootforms
https://github.com/zofe/rapyd-laravel
Основные требования:
— верстка Bootstrap
— автоматическая обработка сортировки, пагинации, ошибок валидации формы
— минимум кода, написанного вручную
— кастомизируемость
https://github.com/view-components/grids
Хороший грид, не зависит от фреймворков. Для фреймворков есть коннекторы. Довольно громоздкая конфигурация. К тому же, похоже, выводимые данные не экранируются.
https://github.com/assurrussa/grid-view-table
Много бойлерплейта, добавляет свою глобальную функцию, какой-то странный способ рендеринга.
https://github.com/dwightwatson/bootstrap-form
Форма сама выбирает роуты для action, ошибки берутся из сессии. Но в целом близко к тому, что нужно.
Мне не нравится подход с передачей ошибок и введенных значений через сессию. Через F5 форму повторно не отправить, если обновить случайно, то все ошибки и значения стираются.
https://github.com/core-system/bootstrap-form
Какой смысл в билдере, если открывать/закрывать группу тегов надо вручную.
https://github.com/core-system/bootstrap-form
Хороший форм-билдер, практически полный аналог ActiveForm. Можно задать хранилище ошибок и введенных значений.
https://github.com/zofe/rapyd-laravel
Этот вариант кажется наиболее подходящим. Есть и грид, и формы. Грид вполне неплохой, но с формами проблема.
— Действия view/create/edit висят на одном роуте, различаются через get-параметр. Соответственно и в гриде по умолчанию URL для действий такие же.
— Это одна форма, просто различается режимом отображения. Это создает проблемы, если надо created_at/updated_at показывать только для view. И свой класс для поля надо описывать для всех 3 режимов.
— Не очень хороший код в проекте
Буду делать по шагам, окончательный код можно найти в конце статьи. Так как таблицы и полное редактирование часто встречаются в административном разделе, пример будет в виде админки для некоторого сайта.
Для справки, папка laravel занимает 2.6 Мб, папка symfony 4.6 Мб, папка yiisoft 3.9 Мб, зависимости Yii 5.6 Мб.
Рассмотрим простое приложение с заказами и товарами.
Создадим Eloquent модели и OrderController для раздела заказов. Создадим группу роутов для админки.
Создадим Bootstrap-шаблон со ссылками на CDN.
Делаем middleware с инициализацией и подключаем к роутам админки. Инициализация выглядит так.
Компонент запроса выглядит так:
Токеном управляет Laravel, поэтому регенерацию обрабатывать не надо.
Теперь можно попробовать запустить. Добавим код для списка заказов.
Нужно установить
Метки колонок пока оставим автогенерируемые.
Также надо задать некоторые стили для иконок сортировки.
Грид выводится, но так как нет фронтенд-скриптов, то кнопка Delete не работает.
Надо вывести скрипты, которые находятся в компоненте
В Yii регистрация ассетов происходит в функции
Вызов
Нужно сделать базовую модель, унаследованную от
Можно унаследоваться и определить специализированную модель, и поместить все туда.
Есть небольшая проблема, что если задана filterModel, но нет ни одного поля для фильтра, то все равно отображается строка с пустыми ячейками. В этом случае можно метки вручную проставить. Хотя лучше было бы, если бы в самом компоненте была такая проверка.
Тут делаем аналогично, настройки колонок можно скопировать из грида. Товары в заказе сделаем отдельным гридом на странице просмотра. Метки тоже пока оставим автогенерируемые.
Сначала нужно сделать модель формы, враппер для Eloquent моделей, унаследованный от
Теперь можно сделать редактирование.
Правила валидации задаются в стиле Yii. Если нужно, можно переопределить метод
Blade не разрешает объявлять переменные. А
Как и в случае с фильтром, можно унаследоваться от
Теперь можно использовать
Тут все просто.
Можно подключить Gii. В чистом виде он не нужен, но можно брать из генератора моделей правила валидации формы и метки для полей, чтобы не генерировать их вручную. Или можно свой генератор написать.
С
Gii будет работать, несмотря на то, что мы отключили jQuery, так как у него свой шаблон отображения, и поэтому он сбрасывает настройки ассетов приложения.
Можно вынести конфигурацию
Можно сделать обертку для ActiveForm, куда поместить вызов виждета, и передавать модель в конструктор. Это позволит убрать прямые теги
Исходный код можно найти здесь. Все шаги сделаны отдельными коммитами. Есть миграции и тестовые данные.
Обертки находятся в папке
Обязательные:
Без остальных можно обойтись, но с ними удобнее:
Также, думаю, это неплохой пример для сравнения разных реализаций. Если есть время и желание, приводите в комментариях свою реализацию этой админки на другом стеке технологий.
composer create-project laravel/laravel
...
composer require yiisoft/yii2
Что из этого получилось, читайте под катом. Потребовалось написать пару небольших обвязок и сконфигурировать определенным образом, но в целом все работает — и грид, и формы. Также есть небольшой обзор существующих аналогов для Laravel.
Какие есть варианты
https://github.com/view-components/grids
https://github.com/assurrussa/grid-view-table
https://github.com/dwightwatson/bootstrap-form
https://github.com/core-system/bootstrap-form
https://github.com/adamwathan/bootforms
https://github.com/zofe/rapyd-laravel
Основные требования:
— верстка Bootstrap
— автоматическая обработка сортировки, пагинации, ошибок валидации формы
— минимум кода, написанного вручную
— кастомизируемость
https://github.com/view-components/grids
Хороший грид, не зависит от фреймворков. Для фреймворков есть коннекторы. Довольно громоздкая конфигурация. К тому же, похоже, выводимые данные не экранируются.
https://github.com/assurrussa/grid-view-table
Много бойлерплейта, добавляет свою глобальную функцию, какой-то странный способ рендеринга.
https://github.com/dwightwatson/bootstrap-form
Форма сама выбирает роуты для action, ошибки берутся из сессии. Но в целом близко к тому, что нужно.
Мне не нравится подход с передачей ошибок и введенных значений через сессию. Через F5 форму повторно не отправить, если обновить случайно, то все ошибки и значения стираются.
https://github.com/core-system/bootstrap-form
Какой смысл в билдере, если открывать/закрывать группу тегов надо вручную.
https://github.com/core-system/bootstrap-form
Хороший форм-билдер, практически полный аналог ActiveForm. Можно задать хранилище ошибок и введенных значений.
https://github.com/zofe/rapyd-laravel
Этот вариант кажется наиболее подходящим. Есть и грид, и формы. Грид вполне неплохой, но с формами проблема.
— Действия view/create/edit висят на одном роуте, различаются через get-параметр. Соответственно и в гриде по умолчанию URL для действий такие же.
— Это одна форма, просто различается режимом отображения. Это создает проблемы, если надо created_at/updated_at показывать только для view. И свой класс для поля надо описывать для всех 3 режимов.
— Не очень хороший код в проекте
Интеграция
Буду делать по шагам, окончательный код можно найти в конце статьи. Так как таблицы и полное редактирование часто встречаются в административном разделе, пример будет в виде админки для некоторого сайта.
Для справки, папка laravel занимает 2.6 Мб, папка symfony 4.6 Мб, папка yiisoft 3.9 Мб, зависимости Yii 5.6 Мб.
Рассмотрим простое приложение с заказами и товарами.
SQL
CREATE TABLE IF NOT EXISTS `users` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`email` varchar(100) NOT NULL,
`password` varchar(255) NOT NULL,
`remember_token` varchar(100) DEFAULT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `users_email_unique` (`email`)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `products` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `orders` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(10) unsigned NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `orders-users` (`user_id`),
CONSTRAINT `orders-users` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `order_items` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`order_id` int(10) unsigned NOT NULL,
`product_id` int(10) unsigned NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `order_items-orders` (`order_id`),
KEY `order_items-products` (`product_id`),
CONSTRAINT `order_items-orders` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `order_items-products` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB;
Создадим Eloquent модели и OrderController для раздела заказов. Создадим группу роутов для админки.
routes/web.php
Route::group(['prefix' => 'admin', 'as' => 'admin.', 'namespace' => 'Admin'], function () {
Route::get('/order', 'OrderController@index')->name('order.index');
Route::get('/order/view/{id}', 'OrderController@view')->name('order.view');
Route::get('/order/create', 'OrderController@create')->name('order.create');
Route::get('/order/update/{id}', 'OrderController@update')->name('order.update');
Route::post('/order/create', 'OrderController@create');
Route::post('/order/update/{id}', 'OrderController@update');
Route::post('/order/delete/{id}', 'OrderController@delete')->name('order.delete');
});
Создадим Bootstrap-шаблон со ссылками на CDN.
resources/views/layouts/main.blade.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="/favicon.ico">
<title>@yield('title')</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
<!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
<![endif]-->
<style>body { padding-top: 60px; }</style>
</head>
<body>
@include('layouts.nav')
<div class="container">
@yield('content')
</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
</body>
</html>
Делаем middleware с инициализацией и подключаем к роутам админки. Инициализация выглядит так.
routes/web.php
$initYii2Middleware = function ($request, $next)
{
define('YII_DEBUG', env('APP_DEBUG'));
include '../vendor/yiisoft/yii2/Yii.php';
spl_autoload_unregister(['Yii', 'autoload']);
$config = [
'id' => 'yii2-laravel',
'basePath' => '../',
'timezone' => 'UTC',
'components' => [
'assetManager' => [
'basePath' => '@webroot/yii-assets',
'baseUrl' => '@web/yii-assets',
'bundles' => [
'yii\web\JqueryAsset' => [
'sourcePath' => null,
'basePath' => null,
'baseUrl' => null,
'js' => [],
],
],
],
'request' => [
'class' => \App\Yii\Web\Request::class,
'csrfParam' => '_token',
],
'urlManager' => [
'enablePrettyUrl' => true,
'showScriptName' => false,
],
'formatter' => [
'dateFormat' => 'php:m/d/Y',
'datetimeFormat' => 'php:m/d/Y H:i:s',
'timeFormat' => 'php:H:i:s',
'defaultTimeZone' => 'UTC',
],
],
];
(new \yii\web\Application($config)); // initialization is in constructor
Yii::setAlias('@bower', Yii::getAlias('@vendor') . DIRECTORY_SEPARATOR . 'bower-asset');
return $next($request);
};
Route::group(['prefix' => 'admin', 'as' => 'admin.', 'namespace' => 'Admin', 'middleware' => $initYii2Middleware], function () {
...
});
spl_autoload_unregister(['Yii', 'autoload']);
— лучше отключить, чтобы не мешался, достаточно автозагрузчиков Laravel. Он ищет файлы через getAlias('@'...)
и конечно не находит.basePath
— корневая директория приложения, при неправильной установке могут быть ошибки в путях. В этой же директории создается папка runtime
.assetManager.basePath, assetManager.baseUrl
— путь и URL для публикации ассетов, название папки произвольное.assetManager.bundles
— отключаем публикацию jQuery, так как она подключается в главном шаблоне отдельно.request
— переопределяем компонент запроса, в котором заменяем работу с CSRF-токеном, название поля такое же как в настройках Laravel.urlManager.enablePrettyUrl
— надо включить, если нужны дополнительные модули типа Gii.(new \yii\web\Application($config))
— в конструкторе происходит присвоение Yii::$app = $this;
Компонент запроса выглядит так:
app/Yii/Web/Request.php
namespace App\Yii\Web;
class Request extends \yii\web\Request
{
public function getCsrfToken($regenerate = false)
{
return \Session::token();
}
}
Токеном управляет Laravel, поэтому регенерацию обрабатывать не надо.
Грид
Теперь можно попробовать запустить. Добавим код для списка заказов.
app/Http/Controllers/Admin/OrderController.php
public function index(Request $request)
{
$allModels = Order::query()->get()->all();
$gridViewConfig = [
'dataProvider' => new \yii\data\ArrayDataProvider([
'allModels' => $allModels,
'pagination' => ['route' => $request->route()->uri(), 'defaultPageSize' => 10],
'sort' => ['route' => $request->route()->uri(), 'attributes' => ['id']],
]),
'columns' => [
'id',
'user.name',
['label' => 'Items', 'format' => 'raw', 'value' => function ($model) {
$html = '';
foreach ($model->items as $item) {
$html .= '<div>' . htmlspecialchars($item->product->name) . '</div>';
}
return $html;
}],
'created_at:datetime',
'updated_at:datetime',
[
'class' => \yii\grid\ActionColumn::class,
'urlCreator' => function ($action, $model, $key) use ($request) {
$baseRoute = $request->route()->getName();
$baseRouteParts = explode('.', $baseRoute);
$baseRouteParts[count($baseRouteParts) - 1] = $action;
$route = implode('.', $baseRouteParts);
$params = is_array($key) ? $key : ['id' => (string) $key];
return route($route, $params, false);
}
],
],
];
return view('admin.order.index', ['gridViewConfig' => $gridViewConfig]);
}
resources/views/admin/order/index.blade.php
@extends('layouts.main')
@section('title', 'Index')
@section('content')
<h1>Orders</h1>
<div class="text-right">
<a href="{{ route('admin.order.create') }}" class="btn btn-success">Create</a>
</div>
{!! \yii\grid\GridView::widget($gridViewConfig) !!}
@endsection
Нужно установить
dataProvider.pagination.route
и dataProvider.sort.route
, иначе произойдет обращение к Yii::$app->controller->getRoute()
, а контроллер у нас null
. Аналогично с ActionColumn
, только там будет проверка и InvalidParamException
. URL генерируется через \yii\web\UrlManager
, но результат получается такой же, как с роутингом Laravel. Можно задать менеджер через dataProvider.pagination.urlManager
, если нужно.Метки колонок пока оставим автогенерируемые.
Также надо задать некоторые стили для иконок сортировки.
Грид выводится, но так как нет фронтенд-скриптов, то кнопка Delete не работает.
Надо вывести скрипты, которые находятся в компоненте
\yii\web\View
. Методы renderHeadHtml(), renderBodyBeginHtml(), renderBodyEndHtml()
защищены (непонятно от кого, особенно учитывая, что все переменные public
). Как ни странно, есть повод применить антипаттерн «public morozov». Или можно просто скопипастить их в главный шаблон.app/Yii/Web/View.php
namespace App\Yii\Web;
class View extends \yii\web\View
{
public function getHeadHtml()
{
return parent::renderHeadHtml();
}
public function getBodyBeginHtml()
{
return parent::renderBodyBeginHtml();
}
public function getBodyEndHtml($ajaxMode = false)
{
return parent::renderBodyEndHtml($ajaxMode);
}
public function initAssets()
{
\yii\web\YiiAsset::register($this);
ob_start();
$this->beginBody();
$this->endBody();
ob_get_clean();
}
}
В Yii регистрация ассетов происходит в функции
endBody()
, а также весь рендеринг оборачивается в буфер, в котором потом производится замена магических констант CDATA
на реальные ассеты. Эмуляция этого поведения находится в функции initAssets()
. Заменять мы ничего не будем, нам нужно просто чтобы были заполнены свойства $this->js, $this->css
и другие.routes/web.php
'components' => [
...
'view' => [
'class' => \App\Yii\Web\View::class,
],
],
resources/views/admin/order/index.blade.php
<!DOCTYPE html>
<html lang="en">
<head>
...
<?php $view = \Yii::$app->getView(); $view->initAssets(); ?>
{!! \yii\helpers\Html::csrfMetaTags() !!}
{!! $view->getHeadHtml() !!}
</head>
<body>
{!! $view->getBodyBeginHtml() !!}
...
{!! $view->getBodyEndHtml() !!}
</body>
</html>
Вызов
Html::csrfMetaTags()
нужен, так как скрипт yii.js берет csrf-токен из HTML страницы.ArrayDataProvider
работает, но надо сделать аналог ActiveDataProvider
, чтобы получать из базы только то что нужно.app/Yii/Data/EloquentDataProvider.php
class EloquentDataProvider extends \yii\data\BaseDataProvider
{
public $query;
public $key;
protected function prepareModels()
{
$query = clone $this->query;
if (($pagination = $this->getPagination()) !== false) {
$pagination->totalCount = $this->getTotalCount();
if ($pagination->totalCount === 0) {
return [];
}
$query->limit($pagination->getLimit())->offset($pagination->getOffset());
}
if (($sort = $this->getSort()) !== false) {
$this->addOrderBy($query, $sort->getOrders());
}
return $query->get()->all();
}
protected function prepareKeys($models)
{
$keys = [];
if ($this->key !== null) {
foreach ($models as $model) {
$keys[] = $model[$this->key];
}
return $keys;
} else {
$pks = $this->query->getModel()->getKeyName();
if (is_string($pks)) {
$pk = $pks;
foreach ($models as $model) {
$keys[] = $model[$pk];
}
} else {
foreach ($models as $model) {
$kk = [];
foreach ($pks as $pk) {
$kk[$pk] = $model[$pk];
}
$keys[] = $kk;
}
}
return $keys;
}
}
protected function prepareTotalCount()
{
$query = clone $this->query;
$query->orders = null;
$query->offset = null;
return (int) $query->limit(-1)->count('*');
}
protected function addOrderBy($query, $orders)
{
foreach ($orders as $attribute => $order) {
if ($order === SORT_ASC) {
$query->orderBy($attribute, 'asc');
} else {
$query->orderBy($attribute, 'desc');
}
}
}
}
app/Http/Controllers/Admin/OrderController.php
'dataProvider' => new \App\Yii\Data\EloquentDataProvider([
'query' => Order::query(),
'pagination' => ['route' => $request->route()->uri(), 'defaultPageSize' => 10],
'sort' => ['route' => $request->route()->uri(), 'attributes' => ['id']],
]),
Метки и фильтры
Нужно сделать базовую модель, унаследованную от
\yii\base\Model
, которая будет возвращать гриду метки для колонок и правила полей для фильтрации. Для этого есть параметр filterModel
. Сделаем ее конфигурируемой через конструктор.app/Yii/Data/FilterModel.php
namespace App\Yii\Data;
use App\Yii\Data\EloquentDataProvider;
use Route;
class FilterModel extends \yii\base\Model
{
protected $labels;
protected $rules;
protected $attributes;
public function __construct($labels = [], $rules = [])
{
parent::__construct();
$this->labels = $labels;
$this->rules = $rules;
$safeAttributes = $this->safeAttributes();
$this->attributes = array_combine($safeAttributes, array_fill(0, count($safeAttributes), null));
}
public function __get($name)
{
if (array_key_exists($name, $this->attributes)) {
return $this->attributes[$name];
} else {
return parent::__get($name);
}
}
public function __set($name, $value)
{
if (array_key_exists($name, $this->attributes)) {
$this->attributes[$name] = $value;
} else {
parent::__set($name, $value);
}
}
public function rules()
{
return $this->rules;
}
public function attributeLabels()
{
return $this->labels;
}
public function initDataProvider($query, $sortAttirbutes = [], $route = null)
{
if ($route === null) { $route = Route::getCurrentRoute()->uri(); }
$dataProvider = new EloquentDataProvider([
'query' => $query,
'pagination' => ['route' => $route],
'sort' => ['route' => $route, 'attributes' => $sortAttirbutes],
]);
return $dataProvider;
}
public function applyFilter($params)
{
$query = null;
$dataProvider = $this->initDataProvider($query);
return $dataProvider;
}
}
Можно унаследоваться и определить специализированную модель, и поместить все туда.
namespace App\Forms\Admin;
use App\Yii\Data\FilterModel;
class OrderFilter extends FilterModel
{
public function rules()
{
return [
['id', 'safe'],
['user.name', 'safe'],
];
}
public function attributeLabels()
{
return [
'id' => 'ID',
'created_at' => 'Created At',
'updated_at' => 'Updated At',
'user.name' => 'User',
];
}
public function applyFilter($params)
{
$this->load($params);
$query = \App\Models\Order::query();
$query->join('users', 'users.id', '=', 'orders.user_id')->select('orders.*');
if ($this->id) $query->where('orders.id', '=', $this->id);
if ($this->{'user.name'}) $query->where('users.name', 'like', '%'.$this->{'user.name'}.'%');
$sortAttributes = [
'id',
'user.name' => ['asc' => ['users.name' => SORT_ASC], 'desc' => ['users.name' => SORT_DESC]],
];
$dataProvider = $this->initDataProvider($query, $sortAttributes);
$dataProvider->pagination->defaultPageSize = 10;
if (empty($dataProvider->sort->getAttributeOrders())) {
$dataProvider->query->orderBy('orders.id', 'asc');
}
return $dataProvider;
}
}
app/Http/Controllers/Admin/OrderController.php
public function index(Request $request)
{
$filterModel = new \App\Forms\Admin\OrderFilter();
$dataProvider = $filterModel->applyFilter($request);
$gridViewConfig = [
'dataProvider' => $dataProvider,
'filterModel' => $filterModel,
...
];
...
}
Есть небольшая проблема, что если задана filterModel, но нет ни одного поля для фильтра, то все равно отображается строка с пустыми ячейками. В этом случае можно метки вручную проставить. Хотя лучше было бы, если бы в самом компоненте была такая проверка.
Просмотр
Тут делаем аналогично, настройки колонок можно скопировать из грида. Товары в заказе сделаем отдельным гридом на странице просмотра. Метки тоже пока оставим автогенерируемые.
app/Http/Controllers/Admin/OrderController.php
public function view($id)
{
$model = Order::findOrFail($id);
$detailViewConfig = [
'model' => $model,
'attributes' => [
'id',
'user.name',
'created_at:datetime',
'updated_at:datetime',
],
];
$gridViewConfig = [
'dataProvider' => new \App\Yii\Data\EloquentDataProvider([
'query' => $model->items(),
'pagination' => false,
'sort' => false,
]),
'layout' => '{items}{summary}',
'columns' => [
'id',
'product.name',
'created_at:datetime',
'updated_at:datetime',
],
];
return view('admin.order.view', ['model' => $model, 'detailViewConfig' => $detailViewConfig, 'gridViewConfig' => $gridViewConfig]);
}
resources/views/admin/order/view.blade.php
@extends('layouts.main')
@section('title', 'Index')
@section('content')
<h1>Order: {{ $model->id }}</h1>
<p class="text-right">
<a href="{{ route('admin.order.update', ['id' => $model->id]) }}" class="btn btn-primary">Update</a>
<a href="{{ route('admin.order.delete', ['id' => $model->id]) }}" class="btn btn-danger" data-confirm="Are you sure?" data-method="post">Delete</a>
</p>
{!! \yii\widgets\DetailView::widget($detailViewConfig) !!}
<h2>Order Items</h2>
{!! \yii\grid\GridView::widget($gridViewConfig) !!}
@endsection
Создание / Обновление
Сначала нужно сделать модель формы, враппер для Eloquent моделей, унаследованный от
\yii\base\Model
, чтобы компонент ActiveForm
мог вызывать нужные методы.app/Yii/Data/FormModel.php
namespace App\Yii\Data;
use Illuminate\Database\Eloquent\Model as EloquentModel;
class FormModel extends \yii\base\Model
{
protected $model;
protected $labels;
protected $rules;
protected $attributes;
public function __construct(EloquentModel $model, $labels = [], $rules = [])
{
parent::__construct();
$this->model = $model;
$this->labels = $labels;
$this->rules = $rules;
$fillable = $model->getFillable();
$attributes = [];
foreach ($fillable as $field) {
$attributes[$field] = $model->$field;
}
$this->attributes = $attributes;
}
public function getModel()
{
return $model;
}
public function __get($name)
{
if (array_key_exists($name, $this->attributes)) {
return $this->attributes[$name];
} else {
return $this->model->{$name};
}
}
public function __set($name, $value)
{
if (array_key_exists($name, $this->attributes)) {
$this->attributes[$name] = $value;
} else {
$this->model->{$name} = $value;
}
}
public function rules()
{
return $this->rules;
}
public function attributeLabels()
{
return $this->labels;
}
public function save()
{
if (!$this->validate()) {
return false;
}
$this->model->fill($this->attributes);
return $this->model->save();
}
}
Теперь можно сделать редактирование.
app/Http/Controllers/Admin/OrderController.php
public function create(Request $request)
{
$model = new Order();
$formModel = new \App\Yii\Data\FormModel(
$model,
['user_id' => 'User'],
[['user_id', 'safe']]
);
if ($request->isMethod('post')) {
if ($formModel->load($request->input()) && $formModel->save()) {
return redirect()->route('admin.order.view', ['id' => $model->id]);
}
}
return view('admin.order.create', ['formModel' => $formModel]);
}
public function update($id, Request $request)
{
$model = Order::findOrFail($id);
$formModel = new \App\Yii\Data\FormModel(
$model,
['user_id' => 'User'],
[['user_id', 'safe']]
);
if ($request->isMethod('post')) {
if ($formModel->load($request->input()) && $formModel->save()) {
return redirect()->route('admin.order.view', ['id' => $model->id]);
}
}
return view('admin.order.update', ['formModel' => $formModel]);
}
resources/views/admin/order/_form.blade.php
<?php $form = \yii\widgets\ActiveForm::begin() ?>
{!! $form->field($formModel, 'user_id')->dropDownList(\App\User::pluck('name', 'id'), ['prompt' => '']) !!}
<button type="submit" class="btn btn-primary">Submit</button>
<?php \yii\widgets\ActiveForm::end() ?>
Правила валидации задаются в стиле Yii. Если нужно, можно переопределить метод
validate()
и вызывать там валидатор Laravel. В данном примере мы этого делать не будем.Blade не разрешает объявлять переменные. А
ActiveForm::begin()
и выводит теги и возвращает значение. Можно явно написать тег <?php ?>
, можно сделать новый тег через Blade::extend()
, как советуют здесь, можно сделать обертку для ActiveForm
. Пока оставим <?php ?>
.Как и в случае с фильтром, можно унаследоваться от
FormModel
и поместить все объявления туда.app/Forms/Admin/OrderForm.php
namespace App\Forms\Admin;
class OrderForm extends FormModel
{
public function rules()
{
return [
['user_id', 'safe'],
];
}
public function attributeLabels()
{
return [
'id' => 'ID',
'user_id' => 'User',
'created_at' => 'Created At',
'updated_at' => 'Updated At',
'user.name' => 'User',
];
}
}
Метки на странице просмотра
Теперь можно использовать
OrderForm
, чтобы задать метки в методе app/Http/Controllers/Admin/OrderController.php
.$formModel = new \App\Forms\Admin\OrderForm($model);
$detailViewConfig = [
'model' => $formModel,
...
];
Удаление
Тут все просто.
app/Http/Controllers/Admin/OrderController.php
public function delete($id)
{
$model = Order::findOrFail($id);
$model->delete();
return redirect()->route('admin.order.index');
}
Дополнения
Можно подключить Gii. В чистом виде он не нужен, но можно брать из генератора моделей правила валидации формы и метки для полей, чтобы не генерировать их вручную. Или можно свой генератор написать.
composer require yiisoft/yii2-gii --dev
routes/web.php
$config = [
'components' => [
...
'db' => [
'class' => \yii\db\Connection::class,
'dsn' => 'mysql:host='.env('DB_HOST', 'localhost')
.';port='.env('DB_PORT', '3306')
.';dbname='.env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
],
...
],
];
if (YII_DEBUG) {
$config['modules']['gii'] = ['class' => \yii\gii\Module::class];
$config['bootstrap'][] = 'gii';
}
(new \yii\web\Application($config)); // initialization is in constructor
Yii::setAlias('@bower', Yii::getAlias('@vendor') . DIRECTORY_SEPARATOR . 'bower-asset');
Yii::setAlias('@App', Yii::getAlias('@app') . DIRECTORY_SEPARATOR . 'App');
...
Route::any('gii{params?}', function () {
$request = \Yii::$app->getRequest();
$request->setBaseUrl('/admin');
\Yii::$app->run();
return null;
})->where('params', '(.*)');
Yii::setAlias('@App')
— путь к файлам определяется через Yii::getAlias('@'...)
, поэтому для класса App\Models\Order
будет проверяться путь '@App/Models/Order.php'
.setBaseUrl('/admin')
— нужно, чтобы роутинг Yii обрабатывал только часть после '/admin'.С
Yii::setAlias('@App')
и ['Yii', 'autoload']
есть такая проблема. Если не отключить автозагрузчик, то при неправильном названиии класса или неймспейса в существующем файле происходит ошибка, которая неправильно обрабатывается. Происходит это так. Он подключает файл, но потом не находит класс и бросает исключение UnknownClassException
. Вызывается автозагрузчик Laravel, который проверяет фасады и алиасы и тоже ничего не находит. Потом вызывается автозагрузчик Composer, который снова подключает файл, и возникает уже другая ошибка 'Cannot declare class '...', because the name is already in use'. Приложение падает с ошибкой 500 без записи в лог.Gii будет работать, несмотря на то, что мы отключили jQuery, так как у него свой шаблон отображения, и поэтому он сбрасывает настройки ассетов приложения.
vendor\yiisoft\yii2-gii\Module.php
protected function resetGlobalSettings()
{
if (Yii::$app instanceof \yii\web\Application) {
Yii::$app->assetManager->bundles = [];
}
}
Можно вынести конфигурацию
ActionColumn
в отдельный класс, чтобы не копировать в разные гриды.app/Yii/Widgets/ActionColumn.php
namespace App\Yii\Widgets;
use URL;
use Route;
class ActionColumn extends \yii\grid\ActionColumn
{
public $keyAttribute = 'id';
public $baseRoute = null;
public $separator = '.';
/**
* Overrides URL generation to use Laravel routing system
*
* @inheritdoc
*/
public function createUrl($action, $model, $key, $index)
{
if (is_callable($this->urlCreator)) {
return call_user_func($this->urlCreator, $action, $model, $key, $index, $this);
} else {
if ($this->baseRoute === null) {
$this->baseRoute = Route::getCurrentRoute()->getName();
}
$baseRouteParts = explode($this->separator, $this->baseRoute);
$baseRouteParts[count($baseRouteParts) - 1] = $action;
$route = implode($this->separator, $baseRouteParts);
$params = is_array($key) ? $key : [$this->keyAttribute => (string) $key];
return URL::route($route, $params, false);
}
}
}
Можно сделать обертку для ActiveForm, куда поместить вызов виждета, и передавать модель в конструктор. Это позволит убрать прямые теги
<?php ?>
и передачу модели в каждое поле. Также туда можно добавлять дополнительные методы для инициализации сторонних виджетов полей типа Select2. Такой билдер можно использовать и в проектах на Yii.app/Yii/Widgets/FormBuilder.php
namespace App\Yii\Widgets;
use yii\widgets\ActiveForm;
use yii\helpers\Html;
class FormBuilder extends \yii\base\Component
{
protected $model;
protected $form;
public function __construct($model)
{
$this->model = $model;
}
public function getModel()
{
return $this->model;
}
public function setModel($model)
{
$this->model = $model;
}
public function getForm()
{
return $this->form;
}
public function open($params = ['successCssClass' => ''])
{
$this->form = ActiveForm::begin($params);
}
public function close()
{
ActiveForm::end();
}
public function field($attribute, $options = [])
{
return $this->form->field($this->model, $attribute, $options);
}
public function submitButton($content, $options = ['class' => 'btn btn-primary'])
{
return Html::submitButton($content, $options);
}
}
resources/views/admin/order/_form.blade.php
{!! $form->open() !!}
{!! $form->field('user_id')->dropDownList(
\App\User::pluck('name', 'id'),
['prompt' => ''])
!!}
{!! $form->submitButton('Submit'); !!}
{!! $form->close() !!}
Исходный код
Исходный код можно найти здесь. Все шаги сделаны отдельными коммитами. Есть миграции и тестовые данные.
php artisan migrate:refresh --seed
Обертки находятся в папке
app/Yii
.Обязательные:
App\Yii\Web\Request
App\Yii\Data\EloquentDataProvider
App\Yii\Data\FormModel
Без остальных можно обойтись, но с ними удобнее:
App\Yii\Data\FilterModel
App\Yii\Web\View
App\Yii\Widgets\ActionColumn
App\Yii\Widgets\FormBuilder
Также, думаю, это неплохой пример для сравнения разных реализаций. Если есть время и желание, приводите в комментариях свою реализацию этой админки на другом стеке технологий.