Как скрестить ежа с ужом. Используем GridView из Yii 2 в проекте на Laravel

в 13:00, , рубрики: activeform, gridview, laravel, yii, yii2, ненормальное программирование

Недавно была статья про Yii, где в комментариях обсуждали специфичные для Yii компоненты, в частности GridView и ActiveForm, и фреймворк Laravel. Я подумал, а почему бы и нет.

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' => [
                    'yiiwebJqueryAsset' => [
                        'sourcePath' => null,
                        'basePath' => null,
                        'baseUrl' => null,
                        'js' => [],
                    ],
                ],
            ],
            'request' => [
                'class' => AppYiiWebRequest::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 yiiwebApplication($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 yiiwebApplication($config)) — в конструкторе происходит присвоение Yii::$app = $this;

Компонент запроса выглядит так:

app/Yii/Web/Request.php

namespace AppYiiWeb;

class Request extends yiiwebRequest
{
    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 yiidataArrayDataProvider([
            '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' => yiigridActionColumn::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>

    {!! yiigridGridView::widget($gridViewConfig) !!}

@endsection

Нужно установить dataProvider.pagination.route и dataProvider.sort.route, иначе произойдет обращение к Yii::$app->controller->getRoute(), а контроллер у нас null. Аналогично с ActionColumn, только там будет проверка и InvalidParamException. URL генерируется через yiiwebUrlManager, но результат получается такой же, как с роутингом Laravel. Можно задать менеджер через dataProvider.pagination.urlManager, если нужно.
Метки колонок пока оставим автогенерируемые.
Также надо задать некоторые стили для иконок сортировки.

Грид выводится, но так как нет фронтенд-скриптов, то кнопка Delete не работает.

Надо вывести скрипты, которые находятся в компоненте yiiwebView. Методы renderHeadHtml(), renderBodyBeginHtml(), renderBodyEndHtml() защищены (непонятно от кого, особенно учитывая, что все переменные public). Как ни странно, есть повод применить антипаттерн «public morozov». Или можно просто скопипастить их в главный шаблон.

app/Yii/Web/View.php

namespace AppYiiWeb;

class View extends yiiwebView
{
    public function getHeadHtml()
    {
        return parent::renderHeadHtml();
    }

    public function getBodyBeginHtml()
    {
        return parent::renderBodyBeginHtml();
    }

    public function getBodyEndHtml($ajaxMode = false)
    {
        return parent::renderBodyEndHtml($ajaxMode);
    }

    public function initAssets()
    {
        yiiwebYiiAsset::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' => AppYiiWebView::class,
    ],
],

resources/views/admin/order/index.blade.php

<!DOCTYPE html>
<html lang="en">
    <head>
        ...

        <?php $view = Yii::$app->getView(); $view->initAssets(); ?>
        {!! yiihelpersHtml::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 yiidataBaseDataProvider
{
    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 AppYiiDataEloquentDataProvider([
        'query' => Order::query(),
        'pagination' => ['route' => $request->route()->uri(), 'defaultPageSize' => 10],
        'sort' => ['route' => $request->route()->uri(), 'attributes' => ['id']],
    ]),

Метки и фильтры

Нужно сделать базовую модель, унаследованную от yiibaseModel, которая будет возвращать гриду метки для колонок и правила полей для фильтрации. Для этого есть параметр filterModel. Сделаем ее конфигурируемой через конструктор.

app/Yii/Data/FilterModel.php

namespace AppYiiData;

use AppYiiDataEloquentDataProvider;
use Route;

class FilterModel extends yiibaseModel
{
    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 AppFormsAdmin;

use AppYiiDataFilterModel;

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 = AppModelsOrder::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 AppFormsAdminOrderFilter();

    $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 AppYiiDataEloquentDataProvider([
            '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>

    {!! yiiwidgetsDetailView::widget($detailViewConfig) !!}

    <h2>Order Items</h2>

    {!! yiigridGridView::widget($gridViewConfig) !!}

@endsection

Создание / Обновление

Сначала нужно сделать модель формы, враппер для Eloquent моделей, унаследованный от yiibaseModel, чтобы компонент ActiveForm мог вызывать нужные методы.

app/Yii/Data/FormModel.php

namespace AppYiiData;

use IlluminateDatabaseEloquentModel as EloquentModel;

class FormModel extends yiibaseModel
{
    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 AppYiiDataFormModel(
            $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 AppYiiDataFormModel(
            $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 = yiiwidgetsActiveForm::begin() ?>

    {!! $form->field($formModel, 'user_id')->dropDownList(AppUser::pluck('name', 'id'), ['prompt' => '']) !!}

    <button type="submit" class="btn btn-primary">Submit</button>

<?php yiiwidgetsActiveForm::end() ?>

Правила валидации задаются в стиле Yii. Если нужно, можно переопределить метод validate() и вызывать там валидатор Laravel. В данном примере мы этого делать не будем.

Blade не разрешает объявлять переменные. А ActiveForm::begin() и выводит теги и возвращает значение. Можно явно написать тег <?php ?>, можно сделать новый тег через Blade::extend(), как советуют здесь, можно сделать обертку для ActiveForm. Пока оставим <?php ?>.

Как и в случае с фильтром, можно унаследоваться от FormModel и поместить все объявления туда.

app/Forms/Admin/OrderForm.php

namespace AppFormsAdmin;

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 AppFormsAdminOrderForm($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' => yiidbConnection::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' => yiigiiModule::class];
        $config['bootstrap'][] = 'gii';
    }

    (new yiiwebApplication($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('@'...), поэтому для класса AppModelsOrder будет проверяться путь '@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, так как у него свой шаблон отображения, и поэтому он сбрасывает настройки ассетов приложения.

vendoryiisoftyii2-giiModule.php

protected function resetGlobalSettings()
{
    if (Yii::$app instanceof yiiwebApplication) {
        Yii::$app->assetManager->bundles = [];
    }
}

Можно вынести конфигурацию ActionColumn в отдельный класс, чтобы не копировать в разные гриды.

app/Yii/Widgets/ActionColumn.php

namespace AppYiiWidgets;

use URL;
use Route;

class ActionColumn extends yiigridActionColumn
{
    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 AppYiiWidgets;

use yiiwidgetsActiveForm;
use yiihelpersHtml;

class FormBuilder extends yiibaseComponent
{
    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(
        AppUser::pluck('name', 'id'),
        ['prompt' => ''])
    !!}

    {!! $form->submitButton('Submit'); !!}

{!! $form->close() !!}

Исходный код

Исходный код можно найти здесь. Все шаги сделаны отдельными коммитами. Есть миграции и тестовые данные.

php artisan migrate:refresh --seed

Обертки находятся в папке app/Yii.

Обязательные:

AppYiiWebRequest
AppYiiDataEloquentDataProvider
AppYiiDataFormModel

Без остальных можно обойтись, но с ними удобнее:

AppYiiDataFilterModel
AppYiiWebView
AppYiiWidgetsActionColumn
AppYiiWidgetsFormBuilder

Также, думаю, это неплохой пример для сравнения разных реализаций. Если есть время и желание, приводите в комментариях свою реализацию этой админки на другом стеке технологий.

Автор: michael_vostrikov

Источник


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


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