Собрал несколько классов и сниппетов из серии «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 в отдельных коммитах.