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.

Revisions

  1. Anubarak created this gist Apr 8, 2019.
    75 changes: 75 additions & 0 deletions ContentMigration.php
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,75 @@
    <?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);
    }
    }
    101 changes: 101 additions & 0 deletions Module.php
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,101 @@
    <?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');
    }
    }
    206 changes: 206 additions & 0 deletions RatingBehavior.php
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,206 @@
    <?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;
    }
    }
    143 changes: 143 additions & 0 deletions RatingQueryBehavior.php
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,143 @@
    <?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));
    }
    }
    }
    }
    41 changes: 41 additions & 0 deletions Variable.php
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,41 @@
    <?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;
    }
    }
    }
    }
    46 changes: 46 additions & 0 deletions form.twig
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,46 @@
    {# 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 %}