Собрал несколько классов и сниппетов из серии «tips & tricks», которые могут оказаться кому-нибудь полезными.
Содержание:
— Несколько атрибутов в одной колонке грида
— Исправление навигации для активных пунктов меню
— Маппинг таблиц на другие названия
— Почему TimestampBehavior обновляет свойство updated_at, если ничего не изменено
— Bootstrap DateTimePicker — 2 разных формата для показа в интерфейсе и для отправки значения на сервер
— Учет временной зоны пользователя для полей с DateTimePicker
Для начала создадим простое CRUD-приложение с одной моделью Product.

Допустим, мы хотим объединить колонки «Created At» и «Updated At» в одну, для экономии места, но при этом хотим сохранить конфигурацию колонок и сортировку по ним. Для этого надо создать отдельный небольшой класс для комбинированной колонки, унаследованный от обычного «DataColumn» и указать его в конфигурации.


Сделаем, чтобы сортировка по умолчанию была по id DESC.
Если делать через
Добавим управление пользователями. Поставим модуль «dektrium/yii2-user», применим миграции, добавим пользователя admin (с паролем 123456, как же без него), поправим ссылки в меню в «layouts/main.php». Зайдем на страницу "/user/login". Ссылка «Login» в меню неактивна.

Это происходит потому, что модуль добавляет свои правила роутинга. Чтобы такие ссылки были активными, надо указывать в них результирующий URL, который получится после применения этих правил (в данном случае "/user/security/login"). Нам это не подходит, потому что зачем нам тогда роуты для красивых URL.
Сделаем класс

Добавим TimestampBehavior в модель Product
Пока что все работает нормально. Мы к этому еще вернемся.
Сделаем приложение чуть сложнее. Добавим хранение сессий в базе данных и RBAC для управления правами доступа.
Также добавим в таблицу «product» колонки «user_id» и «category_id».
Заглянем в нашу базу данных

Что-то много у нас таблиц стало, так сразу и не поймешь что откуда взялось, какие к приложению относятся, а какие к второстепенным модулям. Что если их переименовать? И желательно при этом ничего не менять в коде.

Для этого нужно сделать свои классы для работы с соединением БД и со схемой, унаследованные от стандартных, в которых переопределить методы
название таблицы "__user__user" выглядит немного странно, можно ее не переименовывать, здесь просто для наглядности
Если конфигурация задается в файле «config/main.php», то надо убрать из «config/main-local.php» строку
Переименование таблиц не нужно делать миграцией. Если склонировать проект с такими настройками и запустить миграции, то таблицы будут созданы уже с новыми именами. Также довольно сложно переименовать в миграции саму таблицу «migration». Можно потанцевать с бубном вокруг копирования таблицы и проверки наличия ее с новым именем, но вряд ли это оправдано.
Зайдем в редактирование какого-нибудь продукта и установим пользователя и категорию. Заметим, что свойство «Updated At» обновилось. Теперь снова зайдем в редактирование и, ничего не меняя, нажмем «Сохранить». Свойство «Updated At» снова обновилось. Так быть не должно.
Это произошло, потому что мы добавили «user_id» и «category_id».
Цепочка следующая. Мы отправляем форму POST-запросом. Данные в ней, естественно, в строковом виде. На сервере вызывается
Далее вызывается
AttributeBehavior.php
BaseActiveRecord.php
Значение
Чтобы это исправить, надо добавить фильтрацию значений в метод rules().
Для свойств, которые not null, все довольно просто, приводим их к int. Для свойств, которые могут быть null, надо написать callback. В нашем приложении второй вариант.
Добавим фильтры

Но есть одна проблема, в них нельзя задать разные форматы для отображения и для хранения значения. В результате на сервер может отправляться что-то типа «14 junio 2016, mar.». Исправить это можно, добавив в рендеринг hidden-поле с новым форматом. В Datetimepicker можно задать опции linkField и linkFormat, а в Datepicker надо ловить событие changeDate и форматировать вручную. Также надо обрабатывать нажатие на кнопку очистки значения.
Можно еще стилизовать кнопку очистки, чтобы выглядела получше.

Кстати, я немного удивлен тем фактом, что во многих datepicker-ах есть подержка локализации, но нет возможности показывать и отправлять значение в разных форматах. По-моему, datepicker — прямой аналог тега select. В select показываем текст, отправляем option value, в datepicker показываем дату в красивом и понятном формате, отправляем в техническом.
У того же kartik есть модуль yii2-datecontrol, в котором можно задать другой формат сохранения. Но мне он не понравился, потому что он по умолчанию отправляет показываемый текст на сервер, там его парсит, форматирует в заданном формате для сохранения и отправляет обратно. Можно задать настройку для форматирования на клиенте, но в целом он какой-то громоздкий, и нет причин его ставить, чтобы просто отформатировать дату в YYYY-mm-dd.
Итак, фильтры по датам у нас есть. Теперь представим, что у нас пользователи из разных временных зон. Сервер и база у нас в UTC. Форматирование вывода задается настройками форматтера, а что делать с вводом? Пользователь в фильтре задает то время, которое ожидает увидеть в данных грида. Решение простое, нужно после загрузки формы конвертировать значения полей с временем из таймзоны пользователя в таймзону сервера. Таким образом, внутри приложения время всегда будет в UTC.

Иногда есть необходимость написать javascript-код во view-файле. Конечно, писать его лучше в js-файлах, но случаи бывают разные. Часто пишут его в строке и регистрируют через registerJs(), чтобы вывести в конце документа вместе с остальными скриптами. Но в строке не во всех редакторах есть подсветка, да и с кавычками могут быть проблемы, а без строки он выведется в середине. Можно сделать виджет, который будет брать содержимое между вызовами
Также оставлю ссылку на документацию о том, как разместить advanced-приложение на одном домене (например, на хостинге). Гугл по запросу «yii2 advanced single domain» выдает примеры с конфигами для Apache, а на самом деле все гораздо проще. А для правильной ссылки надо догадаться ввести «yii2 advanced shared hosting». Если коротко, то надо переместить папку «backend/web» в папку «frontend/web/admin» и отредактировать пути в «index.php».
Все примеры можно посмотреть на github в отдельных коммитах.
Содержание:
— Несколько атрибутов в одной колонке грида
— Исправление навигации для активных пунктов меню
— Маппинг таблиц на другие названия
— Почему TimestampBehavior обновляет свойство updated_at, если ничего не изменено
— Bootstrap DateTimePicker — 2 разных формата для показа в интерфейсе и для отправки значения на сервер
— Учет временной зоны пользователя для полей с DateTimePicker
Для начала создадим простое CRUD-приложение с одной моделью Product.

Несколько атрибутов в одной колонке грида
Допустим, мы хотим объединить колонки «Created At» и «Updated At» в одну, для экономии места, но при этом хотим сохранить конфигурацию колонок и сортировку по ним. Для этого надо создать отдельный небольшой класс для комбинированной колонки, унаследованный от обычного «DataColumn» и указать его в конфигурации.
CombinedDataColumn
// common/components/grid/CombinedDataColumn.php namespace common\components\grid; use yii\grid\DataColumn; /** * Renders several attributes in one grid column */ class CombinedDataColumn extends DataColumn { /* @var $labelTemplate string */ public $labelTemplate = null; /* @var $valueTemplate string */ public $valueTemplate = null; /* @var $attributes string[] | null */ public $attributes = null; /* @var $formats string[] | null */ public $formats = null; /* @var $values string[] | null */ public $values = null; /* @var $labels string[] | null */ public $labels = null; /* @var $sortLinksOptions string[] | null */ public $sortLinksOptions = null; /** * Sets parent object parameters for current attribute * @param $key string Key of current attribute * @param $attribute string Current attribute */ protected function setParameters($key, $attribute) { list($attribute, $format) = array_pad(explode(':', $attribute), 2, null); $this->attribute = $attribute; if (isset($format)) { $this->format = $format; } else if (isset($this->formats[$key])) { $this->format = $this->formats[$key]; } else { $this->format = null; } if (isset($this->labels[$key])) { $this->label = $this->labels[$key]; } else { $this->label = null; } if (isset($this->sortLinksOptions[$key])) { $this->sortLinkOptions = $this->sortLinksOptions[$key]; } else { $this->sortLinkOptions = []; } if (isset($this->values[$key])) { $this->value = $this->values[$key]; } else { $this->value = null; } } /** * Sets parent object parameters and calls parent method for each attribute, then renders combined cell content * @inheritdoc */ protected function renderHeaderCellContent() { if (!is_array($this->attributes)) { return parent::renderHeaderCellContent(); } $labels = []; foreach ($this->attributes as $i => $attribute) { $this->setParameters($i, $attribute); $labels['{'.$i.'}'] = parent::renderHeaderCellContent(); } if ($this->labelTemplate === null) { return implode('<br>', $labels); } else { return strtr($this->labelTemplate, $labels); } } /** * Sets parent object parameters and calls parent method for each attribute, then renders combined cell content * @inheritdoc */ protected function renderDataCellContent($model, $key, $index) { if (!is_array($this->attributes)) { return parent::renderDataCellContent($model, $key, $index); } $values = []; foreach ($this->attributes as $i => $attribute) { $this->setParameters($i, $attribute); $values['{'.$i.'}'] = parent::renderDataCellContent($model, $key, $index); } if ($this->valueTemplate === null) { return implode('<br>', $values); } else { return strtr($this->valueTemplate, $values); } } }
// frontend/views/product/index.php GridView::widget([ 'dataProvider' => $dataProvider, 'columns' => [ ['class' => 'yii\grid\SerialColumn'], 'id', 'name', [ 'class' => 'common\components\grid\CombinedDataColumn', 'labelTemplate' => '{0} / {1}', 'valueTemplate' => '{0} / {1}', 'labels' => [ 'Created At', '[ Updated At ]', ], 'attributes' => [ 'created_at:datetime', 'updated_at:html', ], 'values' => [ null, function ($model, $_key, $_index, $_column) { return '[ ' . Yii::$app->formatter->asDatetime($model->updated_at) . ' ]'; }, ], 'sortLinksOptions' => [ ['class' => 'text-nowrap'], null, ], ], ['class' => 'yii\grid\ActionColumn'], ], ]);


Сделаем, чтобы сортировка по умолчанию была по id DESC.
// frontend/models/ProductSearch.php public function search($params) { ... if (empty($dataProvider->sort->getAttributeOrders())) { $dataProvider->query->orderBy(['id' => SORT_DESC]); } ... }
Если делать через
$dataProvider->sort->defaultOrder, то в гриде в названии колонки добавляется иконка сортировки.Исправление навигации для активных пунктов меню
Добавим управление пользователями. Поставим модуль «dektrium/yii2-user», применим миграции, добавим пользователя admin (с паролем 123456, как же без него), поправим ссылки в меню в «layouts/main.php». Зайдем на страницу "/user/login". Ссылка «Login» в меню неактивна.
// frontend/views/layouts/main.php if (Yii::$app->user->isGuest) { $menuItems[] = ['label' => 'Login', 'url' => ['/user/login']]; }

Это происходит потому, что модуль добавляет свои правила роутинга. Чтобы такие ссылки были активными, надо указывать в них результирующий URL, который получится после применения этих правил (в данном случае "/user/security/login"). Нам это не подходит, потому что зачем нам тогда роуты для красивых URL.
Сделаем класс
common\components\bootstrap\Nav, унаследованный от yii\bootstrap\Nav, и переопределим в нем метод isItemActive(), в котором добавим пару проверок на совпадение с Yii::$app->request->getUrl(). В «layouts/main.php» в секции «use» укажем наш класс.Nav
// common/components/bootstrap/Nav.php namespace common\components\bootstrap; use Yii; use yii\bootstrap\Nav as YiiBootstrapNav; /** * @inheritdoc */ class Nav extends YiiBootstrapNav { /** * Adds additional check - directly compare item URL and request URL. * Used to make an item active when item URL is handled by module routing * * @inheritdoc */ protected function isItemActive($item) { if (parent::isItemActive($item)) { return true; } if (!isset($item['url'])) { return false; } $route = null; $itemUrl = $item['url']; if (is_array($itemUrl) && isset($itemUrl[0])) { $route = $itemUrl[0]; if ($route[0] !== '/' && Yii::$app->controller) { $route = Yii::$app->controller->module->getUniqueId() . '/' . $route; } } else { $route = $itemUrl; } $requestUrl = Yii::$app->request->getUrl(); $isActive = ($route === $requestUrl || (Yii::$app->homeUrl . $route) === '/' . $requestUrl); return $isActive; } }

Добавим TimestampBehavior в модель Product
// common/models/Product.php public function behaviors() { return [ 'TimestampBehavior' => [ 'class' => \yii\behaviors\TimestampBehavior::className(), 'value' => function () { return date('Y-m-d H:i:s'); }, ], ]; }
Пока что все работает нормально. Мы к этому еще вернемся.
Маппинг таблиц на другие названия
Сделаем приложение чуть сложнее. Добавим хранение сессий в базе данных и RBAC для управления правами доступа.
Также добавим в таблицу «product» колонки «user_id» и «category_id».
команды
php yii migrate --migrationPath=@vendor/yiisoft/yii2/web/migrations php yii migrate --migrationPath=@yii/rbac/migrations php yii migrate
миграции
// product_user $this->addColumn('{{%product}}', 'user_id', $this->integer()->after('id') ); $this->addForeignKey('fk_product_user', '{{%product}}', 'user_id', '{{%user}}', 'id'); // product_category $this->createTable('{{%category}}', [ 'id' => $this->primaryKey(), 'name' => $this->string(100), ]); $this->addColumn('{{%product}}', 'category_id', $this->integer()->after('user_id') ); $this->addForeignKey('fk_product_category', '{{%product}}', 'category_id', '{{%category}}', 'id');
Заглянем в нашу базу данных

Что-то много у нас таблиц стало, так сразу и не поймешь что откуда взялось, какие к приложению относятся, а какие к второстепенным модулям. Что если их переименовать? И желательно при этом ничего не менять в коде.

Для этого нужно сделать свои классы для работы с соединением БД и со схемой, унаследованные от стандартных, в которых переопределить методы
quoteSql() и getRawTableName(). В классе соединения будет новое свойство $tableMap, в котором можно задавать соответствие внутреннего имени таблицы, которое используется в приложении, и реального, которое используется в БД.Connection
// common/components/db/Connection.php namespace common\components\db; use Yii; use yii\db\Connection as BaseConnection; /** * Allows to add mapping between internal table name used in application and real table name * Can be used to set different prefixes for tables from different modules, just to group them in DB */ class Connection extends BaseConnection { /** * @var array Mapping between internal table name used in application and real table name * Can be used to add different prefixes for tables from different modules * Example: 'tableMap' => ['%session' => '%__web__session'] */ public $tableMap = []; /** * @inheritdoc */ public function quoteSql($sql) { return preg_replace_callback( '/(\\{\\{(%?[\w\-\. ]+%?)\\}\\}|\\[\\[([\w\-\. ]+)\\]\\])/', function ($matches) { if (isset($matches[3])) { return $this->quoteColumnName($matches[3]); } else { return $this->getRealTableName($matches[2]); } }, $sql ); } /** * Returns real table name which is used in database * @param $tableName string * @param $useMapping bool */ public function getRealTableName($tableName, $useMapping = true) { $tableName = ($useMapping && isset($this->tableMap[$tableName]) ? $this->tableMap[$tableName] : $tableName); $tableName = str_replace('%', $this->tablePrefix, $this->quoteTableName($tableName)); return $tableName; } }
mysql/Schema
// common/components/db/mysql/Schema.php namespace common\components\db\mysql; use yii\db\mysql\Schema as BaseSchema; /** * @inheritdoc */ class Schema extends BaseSchema { /** * @inheritdoc * Also gets real table name from database connection object before replacing table prefix */ public function getRawTableName($name) { if (strpos($name, '{{') !== false) { $name = preg_replace('/\\{\\{(.*?)\\}\\}/', '\1', $name); $name = $this->db->getRealTableName($name); return $name; } else { return $name; } } }
// common/config/main.php 'components' => [ ... 'db' => [ 'class' => 'common\components\db\Connection', 'schemaMap' => [ 'mysql' => 'common\components\db\mysql\Schema', ], 'tableMap' => [ '%migration' => '%__db__migration', '%session' => '%__web__session', '%auth_assignment' => '%__rbac__auth_assignment', '%auth_item' => '%__rbac__auth_item', '%auth_item_child' => '%__rbac__auth_item_child', '%auth_rule' => '%__rbac__auth_rule', '%user' => '%__user__user', '%profile' => '%__user__profile', '%token' => '%__user__token', '%social_account' => '%__user__social_account', ], ], ... ],
название таблицы "__user__user" выглядит немного странно, можно ее не переименовывать, здесь просто для наглядности
Если конфигурация задается в файле «config/main.php», то надо убрать из «config/main-local.php» строку
'class' => 'yii\db\Connection', так как он подключается позже, и этот параметр будет переопределен. Либо задавать весь конфиг в «config/main-local.php». Возможно, так даже лучше, при разработке будут понятные названия, а в продакшене нормальные.Переименование таблиц не нужно делать миграцией. Если склонировать проект с такими настройками и запустить миграции, то таблицы будут созданы уже с новыми именами. Также довольно сложно переименовать в миграции саму таблицу «migration». Можно потанцевать с бубном вокруг копирования таблицы и проверки наличия ее с новым именем, но вряд ли это оправдано.
Почему TimestampBehavior обновляет свойство updated_at, если ничего не изменено
Зайдем в редактирование какого-нибудь продукта и установим пользователя и категорию. Заметим, что свойство «Updated At» обновилось. Теперь снова зайдем в редактирование и, ничего не меняя, нажмем «Сохранить». Свойство «Updated At» снова обновилось. Так быть не должно.
Это произошло, потому что мы добавили «user_id» и «category_id».
Цепочка следующая. Мы отправляем форму POST-запросом. Данные в ней, естественно, в строковом виде. На сервере вызывается
$model->load(Yii::$app->request->post()). Он устанавливает, например, свойство user_id = "1" (string).Далее вызывается
$model->save() и срабатывает TimestampBehavior (который extends AttributeBehavior).AttributeBehavior.php
public function evaluateAttributes($event) { ... && empty($this->owner->dirtyAttributes) ... }
BaseActiveRecord.php
public function getDirtyAttributes($names = null) { ... || $value !== $this->_oldAttributes[$name]) ... }
Значение
$this->_oldAttributes[$name] загружено из базы, и значит $this->_oldAttributes['user_id'] = 1 (int). Строгое сравнение возвращает false, и свойство считается измененным.Чтобы это исправить, надо добавить фильтрацию значений в метод rules().
Для свойств, которые not null, все довольно просто, приводим их к int. Для свойств, которые могут быть null, надо написать callback. В нашем приложении второй вариант.
// not null [['user_id', 'category_id'], 'filter', 'filter' => 'intval'], // null [['user_id', 'category_id'], 'filter', 'filter' => function ($value) { return ($value === '' ? null : (int)$value); }],
Bootstrap DateTimePicker — 2 разных формата для показа в интерфейсе и для отправки значения на сервер
Добавим фильтры
created_from, created_to, updated_from, updated_to. Для даты/времени я обычно использую виджеты от kartik для Bootstrap Datepicker/Datetimepicker.
Но есть одна проблема, в них нельзя задать разные форматы для отображения и для хранения значения. В результате на сервер может отправляться что-то типа «14 junio 2016, mar.». Исправить это можно, добавив в рендеринг hidden-поле с новым форматом. В Datetimepicker можно задать опции linkField и linkFormat, а в Datepicker надо ловить событие changeDate и форматировать вручную. Также надо обрабатывать нажатие на кнопку очистки значения.
DatePicker
// common/widgets/DatePicker.php namespace common\widgets; use Yii; use yii\helpers\Html; use yii\helpers\FormatConverter; use yii\base\InvalidParamException; /** * Extended DatePicker, allows to set different formats for sending and displaying value */ class DatePicker extends \kartik\date\DatePicker { public $saveDateFormat = 'php:Y-m-d'; private $savedValueInputID = ''; private $attributeValue = null; public function __construct($config = []) { $defaultOptions = [ 'type' => static::TYPE_COMPONENT_APPEND, 'convertFormat' => true, 'pluginOptions' => [ 'autoclose' => true, 'format' => Yii::$app->formatter->dateFormat, ], ]; $config = array_replace_recursive($defaultOptions, $config); parent::__construct($config); } public function init() { if ($this->hasModel()) { $model = $this->model; $attribute = $this->attribute; $value = $model->$attribute; $this->model = null; $this->attribute = null; $this->name = Html::getInputName($model, $attribute); $this->attributeValue = $value; if ($value) { try { $this->value = Yii::$app->formatter->asDateTime($value, $this->pluginOptions['format']); } catch (InvalidParamException $e) { $this->value = null; } } } return parent::init(); } protected function parseMarkup($input) { $res = parent::parseMarkup($input); $res .= $this->renderSavedValueInput(); $this->registerScript(); return $res; } protected function renderSavedValueInput() { $value = $this->attributeValue; if ($value !== null && $value !== '') { // format value according to saveDateFormat try { $value = Yii::$app->formatter->asDate($value, $this->saveDateFormat); } catch(InvalidParamException $e) { // ignore exception and keep original value if it is not a valid date } } $this->savedValueInputID = $this->options['id'].'-saved-value'; $options = $this->options; $options['id'] = $this->savedValueInputID; $options['value'] = $value; // render hidden input if ($this->hasModel()) { $contents = Html::activeHiddenInput($this->model, $this->attribute, $options); } else { $contents = Html::hiddenInput($this->name, $value, $options); } return $contents; } protected function registerScript() { $language = $this->language ? $this->language : Yii::$app->language; $format = $this->saveDateFormat; $format = strncmp($format, 'php:', 4) === 0 ? substr($format, 4) : FormatConverter::convertDateIcuToPhp($format, $type); $saveDateFormatJs = static::convertDateFormat($format); $containerID = $this->options['data-datepicker-source']; $hiddenInputID = $this->savedValueInputID; $script = " $('#{$containerID}').on('changeDate', function(e) { var savedValue = e.format(0, '{$saveDateFormatJs}'); $('#{$hiddenInputID}').val(savedValue).trigger('change'); }).on('clearDate', function(e) { var savedValue = e.format(0, '{$saveDateFormatJs}'); $('#{$hiddenInputID}').val(savedValue).trigger('change'); }); $('#{$containerID}').data('datepicker').update(); $('#{$containerID}').data('datepicker')._trigger('changeDate'); "; $view = $this->getView(); $view->registerJs($script); } }
DateTimePicker
// common/widgets/DateTimePicker.php namespace common\widgets; use Yii; use yii\helpers\Html; use yii\helpers\FormatConverter; use yii\base\InvalidParamException; /** * Extended DateTimePicker, allows to set different formats for sending and displaying value */ class DateTimePicker extends \kartik\datetime\DateTimePicker { public $saveDateFormat = 'php:Y-m-d H:i'; public $removeButtonSelector = '.kv-date-remove'; private $savedValueInputID = ''; private $attributeValue = null; public function __construct($config = []) { $defaultOptions = [ 'type' => static::TYPE_COMPONENT_APPEND, 'convertFormat' => true, 'pluginOptions' => [ 'autoclose' => true, 'format' => Yii::$app->formatter->datetimeFormat, 'pickerPosition' => 'top-left', ], ]; $config = array_replace_recursive($defaultOptions, $config); parent::__construct($config); } public function init() { if ($this->hasModel()) { $model = $this->model; $attribute = $this->attribute; $value = $model->$attribute; $this->model = null; $this->attribute = null; $this->name = Html::getInputName($model, $attribute); $this->attributeValue = $value; if ($value) { try { $this->value = Yii::$app->formatter->asDateTime($value, $this->pluginOptions['format']); } catch (InvalidParamException $e) { $this->value = null; } } } return parent::init(); } public function registerAssets() { $format = $this->saveDateFormat; $format = strncmp($format, 'php:', 4) === 0 ? substr($format, 4) : FormatConverter::convertDateIcuToPhp($format, $type); $saveDateFormatJs = static::convertDateFormat($format); $this->savedValueInputID = $this->options['id'].'-saved-value'; $this->pluginOptions['linkField'] = $this->savedValueInputID; $this->pluginOptions['linkFormat'] = $saveDateFormatJs; return parent::registerAssets(); } protected function parseMarkup($input) { $res = parent::parseMarkup($input); $res .= $this->renderSavedValueInput(); $this->registerScript(); return $res; } protected function renderSavedValueInput() { $value = $this->attributeValue; if ($value !== null && $value !== '') { // format value according to saveDateFormat try { $value = Yii::$app->formatter->asDateTime($value, $this->saveDateFormat); } catch(InvalidParamException $e) { // ignore exception and keep original value if it is not a valid date } } $options = $this->options; $options['id'] = $this->savedValueInputID; $options['value'] = $value; // render hidden input if ($this->hasModel()) { $contents = Html::activeHiddenInput($this->model, $this->attribute, $options); } else { $contents = Html::hiddenInput($this->name, $value, $options); } return $contents; } protected function registerScript() { $containerID = $this->options['id'] . '-datetime'; $hiddenInputID = $this->savedValueInputID; if ($this->removeButtonSelector) { $script = " $('#{$containerID}').find('{$this->removeButtonSelector}').on('click', function(e) { $('#{$containerID}').find('input').val('').trigger('change'); $('#{$containerID}').data('datetimepicker').reset(); $('#{$containerID}').trigger('changeDate', { type: 'changeDate', date: null, }); }); $('#{$containerID}').trigger('changeDate', { type: 'changeDate', date: null, }); "; $view = $this->getView(); $view->registerJs($script); } } }
// frontend/views/product/_search.php <?= $form->field($model, 'created_from')->widget(\common\widgets\DateTimePicker::classname()) ?>
Можно еще стилизовать кнопку очистки, чтобы выглядела получше.
main.css
.input-group.date .kv-date-remove, .input-group.date .kv-date-calendar { color: #626262; } .input-group.date .kv-date-remove-custom { position: absolute; z-index: 3; color: #000; opacity: 0.4; font-size: 16px; font-weight: 700; line-height: 0.6; right: 50px; top: 14px; cursor: pointer; } .input-group.date .kv-date-remove-custom:hover { opacity: 0.6; } .input-group.date input { padding-right: 30px; } .input-group.date .input-group-addon.kv-date-calendar + .kv-date-remove-custom { left: 50px; right: auto; } .input-group.date .input-group-addon.kv-date-calendar + .kv-date-remove-custom + input { padding-left: 32px; }
_search.php
<?php // frontend/views/product/_search.php $dateTimePickerOptions = [ 'removeButton' => '<span class="kv-date-remove kv-date-remove-custom">×</span>', 'removeButtonSelector' => '.kv-date-remove-custom', 'pluginEvents' => [ 'changeDate' => "function(e) { var isEmpty = ($(this).find('input').val() == ''); $(this).find('.kv-date-remove-custom').toggle(!isEmpty); }", ], ]; ?> <?= $form->field($model, 'created_from')->widget(DateTimePicker::classname(), $dateTimePickerOptions) ?>

Кстати, я немного удивлен тем фактом, что во многих datepicker-ах есть подержка локализации, но нет возможности показывать и отправлять значение в разных форматах. По-моему, datepicker — прямой аналог тега select. В select показываем текст, отправляем option value, в datepicker показываем дату в красивом и понятном формате, отправляем в техническом.
У того же kartik есть модуль yii2-datecontrol, в котором можно задать другой формат сохранения. Но мне он не понравился, потому что он по умолчанию отправляет показываемый текст на сервер, там его парсит, форматирует в заданном формате для сохранения и отправляет обратно. Можно задать настройку для форматирования на клиенте, но в целом он какой-то громоздкий, и нет причин его ставить, чтобы просто отформатировать дату в YYYY-mm-dd.
Учет временной зоны пользователя для полей с DateTimePicker
Итак, фильтры по датам у нас есть. Теперь представим, что у нас пользователи из разных временных зон. Сервер и база у нас в UTC. Форматирование вывода задается настройками форматтера, а что делать с вводом? Пользователь в фильтре задает то время, которое ожидает увидеть в данных грида. Решение простое, нужно после загрузки формы конвертировать значения полей с временем из таймзоны пользователя в таймзону сервера. Таким образом, внутри приложения время всегда будет в UTC.
InputTimezoneConverter
// common/components/InputTimezoneConverter.php namespace common\components; use Yii; use yii\i18n\Formatter; /** * Allows to convert time values in user timezone (usually from input fields) * into appplication timezone which is used in models * Conversion from application timezone into user timezone * is usulally done by Yii::$app->formatter->asDatetime() */ class InputTimezoneConverter { /** @var Formatter */ private $formatter = null; public function __construct($formatter = null) { if ($formatter === null) { // we change formatter configuration so we need to clone it $formatter = clone(Yii::$app->formatter); } $this->formatter = $formatter; $this->formatter->datetimeFormat = 'php:Y-m-d H:i:s'; // swap timeZone and defaultTimeZone of default formatter configuration // to perform conversion back to default timezone $timeZone = $this->formatter->timeZone; $this->formatter->timeZone = $this->formatter->defaultTimeZone; $this->formatter->defaultTimeZone = $timeZone; } /** * @param $value string */ public function convertValue($value) { if ($value === null || $value === '') { return $value; } return $this->formatter->asDatetime($value); } }
// common/config/main.php return [ 'timeZone' => 'UTC', ... 'components' => [ ... 'formatter' => [ 'dateFormat' => 'php:m-d-Y', 'datetimeFormat' => 'php:m-d-Y H:i', 'timeZone' => 'Europe/Moscow', 'defaultTimeZone' => 'UTC', ], ... ], ... ];
// frontend/models/ProductSearch.php /** * @inheritdoc * Additionally converts attributes containing time from user timezone to application timezone */ public function load($data, $formName = NULL) { $loaded = parent::load($data, $formName); if ($loaded) { $timeAttributes = ['created_from', 'created_to', 'updated_from', 'updated_to']; $inputTimezoneConverter = new \common\components\InputTimezoneConverter(); foreach ($timeAttributes as $attribute) { $this->$attribute = $inputTimezoneConverter->convertValue($this->$attribute); } } }

Виджет для javascript-кода
Иногда есть необходимость написать javascript-код во view-файле. Конечно, писать его лучше в js-файлах, но случаи бывают разные. Часто пишут его в строке и регистрируют через registerJs(), чтобы вывести в конце документа вместе с остальными скриптами. Но в строке не во всех редакторах есть подсветка, да и с кавычками могут быть проблемы, а без строки он выведется в середине. Можно сделать виджет, который будет брать содержимое между вызовами
begin() и end(), убирать теги и вызывать registerJs() (по умолчанию \yii\web\View::POS_READY).Script
// common/widgets/Script.php namespace common\widgets; use Yii; use yii\web\View; /** * Allows to write javascript in view inside '<script></script>' tags and render it at the end of body together with other scripts * '<script></script>' tags are removed from result output */ class Script extends \yii\base\Widget { /** @var string Script position, used in registerJs() function */ public $position = View::POS_READY; /** * @inheritdoc */ public function init() { parent::init(); ob_start(); } /** * @inheritdoc */ public function run() { $script = ob_get_clean(); $script = preg_replace('|^\s*<script>|ui', '', $script); $script = preg_replace('|</script>\s*$|ui', '', $script); $this->getView()->registerJs($script, $this->position); } }
<?php // frontend/views/product/_form.php use common\widgets\Script; ?> <?php Script::begin(); ?> <script> console.log('Product form: $(document).ready()'); </script> <?php Script::end(); ?>
Примечание
Также оставлю ссылку на документацию о том, как разместить advanced-приложение на одном домене (например, на хостинге). Гугл по запросу «yii2 advanced single domain» выдает примеры с конфигами для Apache, а на самом деле все гораздо проще. А для правильной ссылки надо догадаться ввести «yii2 advanced shared hosting». Если коротко, то надо переместить папку «backend/web» в папку «frontend/web/admin» и отредактировать пути в «index.php».
Все примеры можно посмотреть на github в отдельных коммитах.
