Несколько полезных приемов для разработки на Yii 2

    Собрал несколько классов и сниппетов из серии «tips & tricks», которые могут оказаться кому-нибудь полезными.
    Содержание:
    Несколько атрибутов в одной колонке грида
    Исправление навигации для активных пунктов меню
    Маппинг таблиц на другие названия
    Почему 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 в отдельных коммитах.

    • +11
    • 39,2k
    • 6
    Поделиться публикацией

    Похожие публикации

    Комментарии 6
      0
      Поправьте заголовок статьи и теги.
      Лично я до сих пор пользуюсь 1.1 веткой и не сразу понял, что статья про yii 2.
      Спасибо.
        0
        Содержание есть, а заголовков в статье, соответствующих содержанию — нет.
          0
          Действительно, что-то не подумал. Исправил, спасибо.
          +1
          Виджет для javascript-кода

          Но в строке не во всех редакторах есть подсветка, да и с кавычками могут быть проблемы, а без строки он выведется в середине.


          В PhpStorm можно использовать следующий приём в view-файле:
          <?php
          $js = <<<js
          // here be dragons
          js;
          $this->registerJs($js);


          PhpStorm подсветит JavaScript как надо.

          Также стоит отметить, что в вашем примере отсутствует смысл в дополнительном оборачивании js-кода в $(document).ready, поскольку registerJs по-умолчанию работает с \yii\web\View::POS_READY (документация).
            0
            Согласен. Пожалуй, тоже немного поправлю, чтобы пример выглядел более логичным.
            0
            DatePicker неверно работает с русскими датами, видимо в коде ошибка — в первой строке метода registerScript() Вы получаете текущий язык приложения, но это значение нигде не используется.

            В методе registerScript() класса DateTimePicker во второй строке создается переменная $hiddenInputID, которая так же нигде не используется.

            Но за виджеты и статью спасибо, очень нужная вещь.

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

            Самое читаемое