📜 ⬆️ ⬇️

Several useful tricks for developing on Yii 2

I collected several classes and snippets from the “tips & tricks” series that could be useful to someone.
Content:
- Several attributes in one column grid
- Navigation fix for active menu items
- Mapping tables to other names
- Why TimestampBehavior updates the updated_at property if nothing is changed
- Bootstrap DateTimePicker - 2 different formats for displaying in the interface and for sending the value to the server
- Accounting user time zone for fields with DateTimePicker


First, create a simple CRUD application with a single Product model.



')

Several attributes in one column grid.


Suppose we want to combine the “Created At” and “Updated At” columns into one, to save space, but at the same time we want to save the column configuration and sorting by them. To do this, create a separate small class for the combo box, inherited from the usual “DataColumn” and specify it in the configuration.

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'], ], ]); 




Let's make the default sorting be by id DESC.

 // frontend/models/ProductSearch.php public function search($params) { ... if (empty($dataProvider->sort->getAttributeOrders())) { $dataProvider->query->orderBy(['id' => SORT_DESC]); } ... } 

If you do through $dataProvider->sort->defaultOrder , then in the grid in the column name is added the sort icon.



Navigation fix for active menu items


Add user management. We put the module “dektrium / yii2-user”, apply the migration, add the admin user (with the password 123456, as without it), fix the links in the menu in “layouts / main.php”. Go to the page "/ user / login". The link "Login" in the menu is inactive.

 // frontend/views/layouts/main.php if (Yii::$app->user->isGuest) { $menuItems[] = ['label' => 'Login', 'url' => ['/user/login']]; } 



This is because the module adds its own routing rules. In order for such links to be active, you need to indicate in them the resulting URL, which is obtained after applying these rules (in this case "/ user / security / login"). It does not suit us, because why do we need routes for beautiful URLs?

Let's make the common\components\bootstrap\Nav class inherited from yii\bootstrap\Nav , and override the isItemActive() method, in which we add a couple of match checks to Yii::$app->request->getUrl() . In the “layouts / main.php” section of the “use” section, we indicate our class

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; } } 







Add TimestampBehavior to the Product model

 // common/models/Product.php public function behaviors() { return [ 'TimestampBehavior' => [ 'class' => \yii\behaviors\TimestampBehavior::className(), 'value' => function () { return date('Ymd H:i:s'); }, ], ]; } 


So far, everything is working fine. We will come back to this.



Mapping tables to other names


Let's make the application a little harder. Add session storage in the database and RBAC for managing access rights.
We also add the columns “user_id” and “category_id” to the “product” table.

teams
 php yii migrate --migrationPath=@vendor/yiisoft/yii2/web/migrations php yii migrate --migrationPath=@yii/rbac/migrations php yii migrate 


migration
 // 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'); 



Let's look at our database.



Something has become a lot of our tables, so right away you won’t understand where it came from, which ones belong to the application, and which belong to the secondary modules. What if they rename them? And it is desirable to change nothing in the code.



To do this, you need to make your own classes for working with the database connection and with the schema inherited from the standard ones, in which you override the quoteSql() and getRawTableName() methods. In the connection class there will be a new property $tableMap , in which you can set the correspondence between the internal name of the table, which is used in the application, and the real one, which is used in the database.

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', ], ], ... ], 

the name of the table "__user__user" looks a bit strange, you can not rename it, here just for clarity

If the configuration is set in the file “config / main.php”, then the line 'class' => 'yii\db\Connection' should be removed from “config / main-local.php”, since it will be connected later, and this parameter will be redefined. Or set the entire config in "config / main-local.php". Perhaps this is even better, with the development will be clear names, but in the production of normal.

Renaming tables do not need to be migrated. If you clone the project with these settings and start the migration, the tables will be created with new names. It is also quite difficult to rename the migration table itself in the migration. You can dance with a tambourine around copying the table and checking for its presence with a new name, but this is hardly justified.



Why TimestampBehavior updates the updated_at property if nothing is changed


Let's go into editing any product and set the user and category. Note that the property “Updated At” has been updated. Now we’ll go back to editing and, without changing anything, click Save. The property “Updated At” has been updated again. So it should not be.

This happened because we added “user_id” and “category_id”.
The chain is as follows. We send the form with a POST request. The data in it, of course, in string form. On the server, $model->load(Yii::$app->request->post()) called. It sets, for example, the property user_id = "1" (string) .
Next, $model->save() is called and the TimestampBehavior (which extends AttributeBehavior ) is triggered.

AttributeBehavior.php
 public function evaluateAttributes($event) { ... && empty($this->owner->dirtyAttributes) ... } 

BaseActiveRecord.php
 public function getDirtyAttributes($names = null) { ... || $value !== $this->_oldAttributes[$name]) ... } 


The value of $this->_oldAttributes[$name] loaded from the database, and then $this->_oldAttributes['user_id'] = 1 (int) . A strict comparison returns false, and the property is considered changed.


To fix this, you need to add value filtering to the rules () method.

For properties that are not null, it's pretty simple, we cast them to int. For properties that can be null, write a callback. In our application, the second option.

 // 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 different formats for displaying in the interface and sending the value to the server


Add the created_from, created_to, updated_from, updated_to filters. For date / time, I usually use kartik widgets for Bootstrap Datepicker / Datetimepicker.



But there is one problem, they cannot specify different formats for display and for storing value. As a result, something like “14 junio 2016, mar.” Can be sent to the server. You can fix this by adding a hidden field with a new format to the rendering. In the Datetimepicker, you can set the linkField and linkFormat options, and in the Datepicker, you must catch the changeDate event and format it manually. You also need to handle pressing the clear value button.

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:Ym-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:Ymd 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()) ?> 


You can also stylize the cleaning button to make it look better.

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) ?> 





By the way, I'm a little surprised by the fact that many datepickers have localization support, but there is no way to show and send the value in different formats. In my opinion, datepicker is a direct analogue of the select tag. In select, we show the text, we send the option value, in the datepicker we show the date in a beautiful and understandable format, we send it in a technical one.

The same kartik has a module yii2-datecontrol, in which you can specify a different saving format. But I didn’t like it, because by default it sends the displayed text to the server, there it parses, formats it in the specified format for saving and sends it back. You can set the setting for formatting on the client, but in general it is kind of cumbersome, and there is no reason to put it just to format the date in YYYY-mm-dd.



Accounting user time zone for fields with DateTimePicker


So, we have date filters. Now imagine that we have users from different time zones. Our server and base are in UTC. Formatting output is set by the formatter settings, but what to do with the input? The user in the filter sets the time that he expects to see in the grid data. The solution is simple; after loading the form, you need to convert field values ​​with time from the user's timezone to the server's timezone. Thus, within the application time will always be in 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:Ymd 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:md-Y', 'datetimeFormat' => 'php:mdY 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); } } } 






Widget for javascript code


Sometimes there is a need to write a javascript code in the view file. Of course, it is better to write it in js-files, but the cases are different. Often they write it in a string and register through registerJs () to display it at the end of the document along with the rest of the scripts. But not all editors have lights in the line, and there may be problems with quotes, and without a line it will be displayed in the middle. You can make a widget that will take the content between begin() and end() calls, remove tags and call registerJs() (by default, \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(); ?> 


Note


I will also leave a link to the documentation on how to place the advanced application on the same domain (for example, on a hosting). Google on demand "yii2 advanced single domain" issues examples with Apache configs, but in fact everything is much simpler. And for the correct link, you need to guess to enter "yii2 advanced shared hosting". In short, you need to move the “backend / web” folder to the “frontend / web / admin” folder and edit the paths in the “index.php”.

All examples can be viewed on github in separate commits.

Source: https://habr.com/ru/post/303406/


All Articles