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

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

Для начала создадим простое CRUD-приложение с одной моделью Product.

3115b452a211470482656bf938e08183.png

Несколько атрибутов в одной колонке грида


Допустим, мы хотим объединить колонки «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('
', $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('
', $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'],
    ],
]);

e62ad24144b3473d8ffccee0203ffa2b.png9d577ff5f54141b681d0be67cb0025f8.png

Сделаем, чтобы сортировка по умолчанию была по 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']];
}


97ac4fadebc443d5bb06560d303cade8.png

Это происходит потому, что модуль добавляет свои правила роутинга. Чтобы такие ссылки были активными, надо указывать в них результирующий 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;
    }
}


3fe365443f3947adb82f3239a9cbc043.png

Добавим 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');


Заглянем в нашу базу данных

db7cae63560f44f393349bdb84fc3ca5.png

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

9e5e0bf2ce7140ea99eec97fd1df034d.png

Для этого нужно сделать свои классы для работы с соединением БД и со схемой, унаследованные от стандартных, в которых переопределить методы 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.

c285c68faf7b4f54ae7965ebff1e1fd7.png

Но есть одна проблема, в них нельзя задать разные форматы для отображения и для хранения значения. В результате на сервер может отправляться что-то типа »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
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
 '×',
    'removeButtonSelector' => '.kv-date-remove-custom',
    'pluginEvents' => [
        'changeDate' => "function(e) {
            var isEmpty = ($(this).find('input').val() == '');
            $(this).find('.kv-date-remove-custom').toggle(!isEmpty);
        }",
    ],
];
?>
field($model, 'created_from')->widget(DateTimePicker::classname(), $dateTimePickerOptions) ?>



d0990d90b10e4bfeabf55ac2fe8f1caf.png

Кстати, я немного удивлен тем фактом, что во многих 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);
        }
    }
}

8d044c1f1753483d9d1122b9c975a78c.png

Виджет для javascript-кода


Иногда есть необходимость написать javascript-код во view-файле. Конечно, писать его лучше в js-файлах, но случаи бывают разные. Часто пишут его в строке и регистрируют через registerJs (), чтобы вывести в конце документа вместе с остальными скриптами. Но в строке не во всех редакторах есть подсветка, да и с кавычками могут быть проблемы, а без строки он выведется в середине. Можно сделать виджет, который будет брать содержимое между вызовами begin() и end(), убирать теги и вызывать registerJs().

Script
// common/widgets/Script.php

namespace common\widgets;

use Yii;
use yii\web\View;

/**
 * Allows to write javascript in view inside '' tags and render it at the end of body together with other scripts
 * '' 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*\s*$|ui', '', $script);
        $this->getView()->registerJs($script, $this->position);
    }
}









Примечание


Также оставлю ссылку на документацию о том, как разместить advanced-приложение на одном домене (например, на хостинге). Гугл по запросу «yii2 advanced single domain» выдает примеры с конфигами для Apache, а на самом деле все гораздо проще. А для правильной ссылки надо догадаться ввести «yii2 advanced shared hosting». Если коротко, то надо переместить папку «backend/web» в папку «frontend/web/admin» и отредактировать пути в «index.php».

Все примеры можно посмотреть на github в отдельных коммитах.

© Habrahabr.ru