Skip to content

Instantly share code, notes, and snippets.

@Anubarak
Created April 8, 2019 17:45
Show Gist options
  • Save Anubarak/b404fc7d115a6290164abacc7c1628ca to your computer and use it in GitHub Desktop.
Save Anubarak/b404fc7d115a6290164abacc7c1628ca to your computer and use it in GitHub Desktop.
Custom behavior to add additional attributes to all Elements
<?php
// just a simple migration file to store the information
namespace craft\contentmigrations;
use Craft;
use craft\db\Migration;
/**
* m190408_152357_ratings migration.
*/
class m190408_152357_ratings extends Migration
{
public $tableName = '{{%user_ratings}}';
/**
* @inheritdoc
*/
public function safeUp()
{
// Place migration code here...
$this->createTable(
$this->tableName,
[
'id' => $this->primaryKey(),
'elementId' => $this->integer()->notNull(), // the element id that is voted
'siteId' => $this->integer()->notNull(), // the site id, to make it complete
'userId' => $this->integer()->notNull(), // the ID of the user that voted, just as an example
'rating' => $this->integer(), // just a number from 0-X
'text' => $this->text(), // some comment
'dateUpdated' => $this->dateTime()->notNull(),
'dateCreated' => $this->dateTime()->notNull(),
'uid' => $this->uid(),
]
);
$this->createIndex(null, $this->tableName, 'userId');
$this->addForeignKey(
null,
$this->tableName,
['userId'],
'{{%elements}}',
['id'],
'CASCADE'
);
$this->addForeignKey(
null,
$this->tableName,
['siteId'],
'{{%sites}}',
['id'],
'CASCADE'
);
$this->addForeignKey(
null,
$this->tableName,
['elementId'],
'{{%elements}}',
['id'],
'CASCADE'
);
}
/**
* @inheritdoc
*/
public function safeDown()
{
$this->dropTable($this->tableName);
}
}
{# just a simple use case in Twig how to display the information and store content of an element #}
{# show some attributes #}
{{ entry.text }}
{{ entry.rating }}
{{ entry.user }}
{# change some attributes #}
<form method="post">
{{ csrfInput() }}
<input type="hidden" name="action" value="entries/save-entry">
{# of course we would usually hash the value and all this kind of things.. just not now #}
<input type="hidden" name="elementId" value="{{ entry.id }}">
{# same as well... we would usually grab that via PHP or hash it, but I'm lazy in this example #}
<input type="hidden" name="userId" value="{{ currentUser.id }}">
{# the id for Crafts internal Controller #}
<input type="hidden" name="entryId" value="{{ entry.id }}">
How much do you like it?
<input type="number" name="rating" value="{{ entry.rating }}">
Tell us your opinion
<textarea name="text" cols="30" rows="10">{{ entry.text }}</textarea>
<input type="submit">Submit it
</form>
{# display a list #}
{# see RatingQueryBehavior for information about the query, notice the custom rating() function #}
{% set entries = craft
.entries
.rating('>2')
.all()
%}
{# apply eager loading for users #}
{% do craft.foobar.eagerLoadUser(entries) %}
{% for entry in entries %}
----------------------------------
title {{ entry.title }}<br>
points: {{ entry.rating }}<br>
message: {{ entry.text }}<br>
user: {{ entry.user }}
-----------------------------------
{% endfor %}
<?php
// just the basic module stuff for events and such
class Module extends \yii\base\Module
{
/**
* @var \modules\Module $instance
*/
public static $instance;
public function __construct($id, $parent = null, array $config = [])
{
// ...
// some controller initialization and all the things
// ...
// define a behavior for all elements, let's call it rating
Event::on(
Element::class,
Element::EVENT_DEFINE_BEHAVIORS,
static function(DefineBehaviorsEvent $event) {
$event->behaviors['ratingBehavior'] = RatingBehavior::class;
}
);
// define a query behavior for all ElementQueries
Event::on(
ElementQuery::class,
ElementQuery::EVENT_DEFINE_BEHAVIORS,
static function(DefineBehaviorsEvent $event) {
$event->behaviors['ratingQueryBehavior'] = RatingQueryBehavior::class;
}
);
// should your attributes appear in the index list?
Event::on(
Element::class,
Element::EVENT_REGISTER_TABLE_ATTRIBUTES,
[$this, 'registerTableAttributes']
);
// set the HTML
Event::on(
Element::class,
Element::EVENT_SET_TABLE_ATTRIBUTE_HTML,
[$this, 'setTableAttribute']
);
// wanna make them sortable?
Event::on(
Element::class,
Element::EVENT_REGISTER_SORT_OPTIONS,
[$this, 'registerSortOption']
);
// just a way to apply eager loading.. you can as well create a Twig extension
Event::on(
CraftVariable::class,
CraftVariable::EVENT_INIT,
static function(Event $event) {
/** @var CraftVariable $variable */
$variable = $event->sender;
$variable->set('foobar', Variable::class);
}
);
// ....
}
/**
* registerTableAttributes
*
* @param \craft\events\RegisterElementTableAttributesEvent $event
*
* @author Robin Schambach
*/
public function registerTableAttributes(RegisterElementTableAttributesEvent $event)
{
$event->tableAttributes['rating'] = [
'label' => \Craft::t('app', 'Rating')
];
}
/**
* setTableAttribute
*
* @param \craft\events\SetElementTableAttributeHtmlEvent $event
*
* @author Robin Schambach
*/
public function setTableAttribute(SetElementTableAttributeHtmlEvent $event)
{
if ($event->attribute === 'rating') {
$event->html = $event->sender->rating;
}
}
public function registerSortOption(RegisterElementSortOptionsEvent $event)
{
$event->sortOptions['rating'] = \Craft::t('app', 'rating');
}
}
<?php
/**
* craft for Craft CMS 3.x
*
* Created with PhpStorm.
*
* @link https://github.com/Anubarak/
* @email [email protected]
* @copyright Copyright (c) 2019 Robin Schambach
*/
namespace modules\behaviors;
use craft\base\Element;
use craft\elements\User;
use craft\events\ModelEvent;
use yii\base\Behavior;
/**
* Class RatingBehavior
* @package modules\behaviors
* @since 08.04.2019
* @property array $attributes
* @property \craft\elements\User|null $user
* @property Element $owner
*/
class RatingBehavior extends Behavior
{
/**
* The ID of our user rating "record"
*
* @var int $id
*/
public $userRatingId;
/**
* Note: you might want to rename all these since it's highly likely they'll
* be already used as field handles.. I just didn't want to use variables like
* `userRatingUserId` for this example
*
* As you may have noticed this is just a simple use case
* every element can only be rated once.. not really realistic, it's just
* to show you the basic idea
*
* @var int $userId
*/
public $userId;
/**
* The user that created the comment
*
* @var User $_user
*/
private $_user;
/**
* The element that will be rated, basically the same as `$this->owner`
*
* @var \craft\base\Element $element
*/
public $element;
/**
* How many points should the element receive, rating from 0-10000000
*
* @var int $rating
*/
public $rating;
/**
* Some additional text or other information about users the opinion
*
* @var string $text
*/
public $text;
/**
* events
*
* @return array
*
* @author Robin Schambach
*/
public function events(): array
{
return [
Element::EVENT_AFTER_SAVE => 'afterSaveElement',
];
}
/**
* afterSaveElement
*
* @param \craft\events\ModelEvent $event
*
* @throws \yii\db\Exception
*
* @author Robin Schambach
*/
public function afterSaveElement(ModelEvent $event)
{
// everything is called by reference so luckily it doesn't care
// if we use $event->sender or this :)
$data = [
'elementId' => $this->owner->id,
'userId' => $this->userId,
'rating' => $this->rating,
'text' => $this->text,
'siteId' => $this->owner->siteId
];
// validate the data somehow... for this example I'll just do a crappy if statement
if($data['userId'] !== null && $data['siteId'] !== null && $data['elementId'] !== null){
// insert or update the data as you like :)
$isNew = $this->userRatingId === null;
if ($isNew === true) {
// insert a new value
\Craft::$app->getDb()->createCommand()->insert('{{%user_ratings}}', $data)->execute();
} else {
// update an existing one
\Craft::$app->getDb()->createCommand()
->update('{{%user_ratings}}', $data, ['id' => $this->userRatingId])->execute();
}
// include some error handling.. I'll leave it here as it is
}
}
// some setters and getters... mostly boring stuff
/**
* setRating
*
* @param int $rating
*
* @return $this
*
* @author Robin Schambach
*/
public function setRating(int $rating): self
{
$this->rating = $rating;
return $this;
}
/**
* setUser
*
* @param \craft\elements\User $user
* @return \craft\base\Element
*
* @author Robin Schambach
*/
public function setUser(User $user): Element
{
$this->userId = $user->id;
$this->_user = $user;
return $this->owner;
}
/**
* setUserId
*
* @param int $userId
* @return \craft\base\Element
*
* @author Robin Schambach
*/
public function setUserId(int $userId): Element
{
$this->userId = $userId;
return $this->owner;
}
/**
* setText
*
* @param string $text
* @return \craft\base\Element
*
* @author Robin Schambach
*/
public function setText(string $text): Element
{
$this->text = $text;
return $this->owner;
}
/**
* Get the actual User object
*
* @return \craft\elements\User|null
*
* @author Robin Schambach
*/
public function getUser()
{
if($this->_user === null){
$this->_user = \Craft::$app->getUsers()->getUserById($this->userId);
}
// you might wonder: uhh tooo many queries...
// but we'll implement eager loading later on^^
return $this->_user;
}
}
<?php
/**
* craft for Craft CMS 3.x
*
* Created with PhpStorm.
*
* @link https://github.com/Anubarak/
* @email [email protected]
* @copyright Copyright (c) 2019 Robin Schambach
*/
namespace modules\behaviors;
use craft\elements\db\ElementQuery;
use craft\events\PopulateElementEvent;
use craft\helpers\Db;
use yii\base\Behavior;
/**
* Class RatingQueryBehavior
* @package modules\behaviors
* @since 08.04.2019
* @property ElementQuery $owner
*/
class RatingQueryBehavior extends Behavior
{
/**
* Basically the same as Craft does
*
* @var integer $rating
*/
public $rating;
/**
* rating
* see Craft docs
*
* Entry::find()->rating('>4');
* Entry::find()->rating(['and', '>4', '<7'])
* everything is possible here :)
*
* @param int $rating
* @return \craft\elements\db\ElementQuery
*
* @author Robin Schambach
*/
public function rating($rating): ElementQuery
{
$this->rating = $rating;
return $this->owner;
}
/**
* events
*
* @return array
*
* @author Robin Schambach
* @since 08.04.2019
*/
public function events(): array
{
return [
ElementQuery::EVENT_AFTER_PREPARE => 'onAfterPrepare',
// this is a bit hacky and only for lazy people that don't want to trigger a custom Controller
// I never actually used it that way, so I didn't spend much time thinking about a better way
// Maybe we could ask Brad/Brandon for a custom event specific to populate custom values by post request
// you could as well use `beforeValidate` events....
ElementQuery::EVENT_AFTER_POPULATE_ELEMENT => 'afterPopulateElement'
];
}
/**
* onAfterPrepare
*
* @author Robin Schambach
* @since 08.04.2019
*/
public function onAfterPrepare()
{
// join it for both because we might want to filter later on..
$this->owner->subQuery->leftJoin('{{%user_ratings}} ratings', '[[ratings.elementId]] = [[elements.id]]');
$this->owner->query->leftJoin('{{%user_ratings}} ratings', '[[ratings.elementId]] = [[elements.id]]')
// select all the additional columns
->addSelect(
[
'ratings.userId',
'ratings.rating',
'ratings.text',
'ratings.id as userRatingId', // "id" will cause conflicts
]
)// search for the correct site, note this might change after Craft 3.2 when they query for multiple sites
->andWhere(
[
'or',
['ratings.siteId' => $this->owner->siteId],
// always include a null row for reasons... you won't be able to login otherwise because no user is found :P
['ratings.siteId' => null]
]
);
// include custom conditions
if($this->rating !== null){
// only query for ratings with the criteria
$this->owner->subQuery->andWhere(Db::parseParam('ratings.rating', $this->rating));
}
// include some other custom conditions....
}
/**
* AfterPopulateElement
*
* @param \craft\events\PopulateElementEvent $event
*
* @throws \craft\errors\SiteNotFoundException
*
* @author Robin Schambach
*/
public function afterPopulateElement(PopulateElementEvent $event)
{
/** @var \craft\base\Element $element */
$element = $event->element;
// at this point our element already has all the attributes from DB populated
// as said in the comment few lines above, this is actually a hacky way... and doesn't allow
// you to store your custom fields from the beginning but usually you want to handle such thing via custom
// controller action ¯\_(ツ)_/¯ or use some other event for this
// you can as well do that in beforeSave or where-ever you want
$request = \Craft::$app->getRequest();
if ($request->isConsoleRequest === false && $request->getIsPost() === true) {
$siteId = (int) $request->getBodyParam('siteId', \Craft::$app->getSites()->getCurrentSite()->id);
if ($element->id !== null && (int) $element->siteId === $siteId &&
(int) $element->id === (int) $request->getBodyParam('elementId')) {
// the element matches with the one of our post request, populate the new values
$element->setRating($request->getBodyParam('rating', $element->rating))
->setUserId($request->getBodyParam('userId', $element->userId))
->setText($request->getBodyParam('text', $element->text));
}
}
}
}
<?php
/**
* craft for Craft CMS 3.x
*
* Created with PhpStorm.
*
* @link https://github.com/Anubarak/
* @email [email protected]
* @copyright Copyright (c) 2019 Robin Schambach
*/
namespace modules;
use craft\elements\User;
use craft\helpers\ArrayHelper;
class Variable
{
/**
* Just a real crappy version of eager loading just to show the basic idea
* this should usually be in a component/service with a bit more stuff in it
* @param array $elements
*
* @author Robin Schambach
*/
public function eagerLoadUser(array $elements)
{
// fetch all unique user Ids
$userIds = array_unique(ArrayHelper::getColumn($elements, 'userId'));
// fetch all user indexed by ID
$users = ArrayHelper::index(User::find()->id($userIds)->all(), 'id');
foreach ($elements as $element){
// set the user to the element
$user = $users[$element->userId]?? null;
if($user !== null){
$element->user = $user;
}
}
}
}
@Anubarak
Copy link
Author

Anubarak commented Apr 8, 2019

Just a basic example how to use yii\base\Behavior to store additional information for elements without placing fields in their field layout. You can include additional events such as BeforeValidate to handle custom validations as well. There are a bunch of things I included for my personal usage but I didn't want to make it too complex, so all functions only show the basic concept and the idea how to use it.

You could include something like a custom "namespace" for your attributes to make sure they are in a separate container so instead of doing

$element->myValue; 
$element->anotherValue; 

You would call

$element->myValues->myValue;
$element->myValues->anotherValue;

That will reduce the chance someone might have the same field handle as your custom behavior attribute names.
Maybe we could ask Brad/Brandon for a proper way to include eager loading as well, currently there is no chance to include it really well since there are no events between using ElementQuery::all() and ElementQuery::createElements() that's why I had to use a "strange" way.

If there was an event, I would include something like this
QueryBehavior

public $withUser;
public function withUser(bool $withUser)
{
    $this->withUser = $withUser;
}

// .....

public function beforeAll()
{
    if($this->withUser === true){
        // get all users and apply the params, basically what Crafts eager loading map does..
    }
}

All this might look like a lot of effort but I promise you - once you get it, it won't be a problem anymore

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment