Challenge #3 – The First Draft

19 November 2018 Solved Php Intermediate

A real-world use case for sending email notifications when a new entry draft is created using a Craft module and an event handler. It requires setting up a module and writing some PHP code, so pull up your sleeves and dust off your IDE.

Challenge

The challenge is to create a module that listens for the EntryRevisions::EVENT_AFTER_SAVE_DRAFT event. Whenever the event is fired, provided that the draft is new, the module should send a notification email to the system email address with the subject "First Draft" and the following message:

A new entry draft "{title}" has been created by "{username}": {cpUrl}

Where {title} is replaced by the title of the entry draft, {username} is replaced by the username of the user that created the draft and {cpUrl} is the URL to edit the entry in the control panel.

For bonus points, if the environment variable FIRST_DRAFT_EMAIL_TEMPLATE is set then the module should use the rendered template (as defined by the the environment variable) as the email's HTML body, providing the 3 variables above as template variables.

Rules

The module must be a single, self-contained file that sends an email notification as described above whenever a draft is created, if and only if the draft is new (not a revision of an existing draft). It should not rely on any plugins and the code will be evaluated based on the following criteria in order of priority:

  1. Use of Craft components
  2. Readability
  3. Brevity

Therefore the code should use native Craft components wherever possible. It should be readable and easy to understand, concise and non-repetative.

Tips

Every installation of Craft created using the Craft project as documented in the installation instructions comes with a modules/Module.php file. You can use this file as a starting point or read the guide on how to build a module and create your own.

For an in-depth explanation of modules you can read the article Enhancing a Craft CMS 3 Website with a Custom Module.

Solution

Setting up the basic file structure, class autoloading and application config for a module is covered in the docs. We begin with the basic outline of a module class with a module ID of firstdraft.

<?php
namespace firstdraft;

class Module extends \yii\base\Module
{
    public function init()
    {
        parent::init();

        // Custom initialization code goes here...
    }
}

The first step is to create an event listener that will be called whenever the EntryRevisions::EVENT_AFTER_SAVE_DRAFT event is triggered. We do this in the init() method of the module (so that it will be attached to every request to the CMS) using Event::on and pass in 3 parameters: the class name which contains the event; the name of the event; and a handler that will handle the logic of what we want the event to do. The handler can be anything that is callable and we will use an anonymous function (or closure) to handle the logic.

Event::on(EntryRevisions::class, EntryRevisions::EVENT_AFTER_SAVE_DRAFT, function() {});

Looking at the source code of the event, we can see that it passes in a new object of type DraftEvent with 2 parameters: the entry draft and a boolean representing whether the draft is new.

// Fire an 'afterSaveDraft' event
if ($this->hasEventHandlers(self::EVENT_AFTER_SAVE_DRAFT)) {
    $this->trigger(self::EVENT_AFTER_SAVE_DRAFT, new DraftEvent([
        'draft' => $draft,
        'isNew' => $isNewDraft,
    ]));
}

So we can update our event handler function to accept a parameter of type DraftEvent which we will call $event.

Event::on(EntryRevisions::class, EntryRevisions::EVENT_AFTER_SAVE_DRAFT, function(DraftEvent $event) {});

We can now begin adding the logic. We first ensure that this is a new draft using the event's isNew attribute and exit with a return statement if it is not. Then we fetch the event's draft attribute and pass it to a method (that we will create in the same class) that will handle sending the email notification for us.

Event::on(EntryRevisions::class, EntryRevisions::EVENT_AFTER_SAVE_DRAFT, function(DraftEvent $event) {
    // Return if this is not a new draft
    if ($event->isNew === false) {
        return;
    }

    // Get the draft from the event
    $draft = $event->draft;

    // Send a notification email
    $this->sendNotificationEmail($draft);
});

Next we create the new method to handle the email notification. The method accepts a parameter of type EntryDraft, as that is what we sent it from our event handler above, and proceeds to do the following:

  • Create a mailer component which provides APIs for sending email in Craft.
  • Fetch the fromEmail value from the email system settings.
  • Get the current user by calling getIdentity() on the user component.
  • Create the parameters for the notification message from the draft's title and CP edit URL, and from the user's username.
  • Create the notification message using the t method (shortcut for translate) from the provided parameters.
  • Compose the notification email, assign who to send it to, a subject and HTML body, and send it.
public function sendNotificationEmail(EntryDraft $draft)
{
    // Create a mailer
    $mailer = Craft::$app->getMailer();

    // Get system email address
    $systemEmailAddress = Craft::$app->getSystemSettings()->getEmailSettings()->fromEmail;

    // Get the current user
    $user = Craft::$app->getUser()->getIdentity();

    // Create the parameters for the notification message
    $params = [
        'title' => $draft->title,
        'username' => $user->username,
        'cpUrl' => $draft->getCpEditUrl(),
    ];

    // Create the notification message
    $message = Craft::t('app', 'A new entry draft "{title}" has been created by "{username}": {cpUrl}', $params);

    // Compose and send a notification email
    $mailer->compose()
        ->setTo($systemEmailAddress)
        ->setSubject('First Draft')
        ->setHtmlBody($message)
        ->send();
}

Similar solutions: Spenser Hannon.


For the bonus points mentioned in the challenge, we first check if the environment variable FIRST_DRAFT_EMAIL_TEMPLATE exists. If it does then we to render it, given the parameters, to produce and overwrite the email notification message. To render the front-end template, we need to ensure that the view's template mode is set to site first. We do this by storing the current template mode as $oldTemplateMode using the getTemplateMode() method, then setting the template mode to site as defined by the View::TEMPLATE_MODE_SITE constant using the setTemplateMode() method. After rendering the template, we then restore the template mode to its original value.

// Overwrite the message with the rendered template as defined by the environment variable if it exists
$template = getenv('FIRST_DRAFT_EMAIL_TEMPLATE');

if ($template !== false) {
    // Set Craft to the front-end site template mode
    $view = Craft::$app->getView();
    $oldTemplateMode = $view->getTemplateMode();
    $view->setTemplateMode(View::TEMPLATE_MODE_SITE);

    $message = $view->renderTemplate($template, $params);

    // Restore the original template mode
    $view->setTemplateMode($oldTemplateMode);
}

Putting this all together gives us the final solution. Note that the class names have been shortened with the use operator to make the code more readable.

<?php
namespace firstdraft;

use Craft;
use craft\events\DraftEvent;
use craft\models\EntryDraft;
use craft\services\EntryRevisions;
use craft\web\View;
use yii\base\Event;

class Module extends \yii\base\Module
{
    public function init()
    {
        parent::init();

        Event::on(EntryRevisions::class, EntryRevisions::EVENT_AFTER_SAVE_DRAFT, function(DraftEvent $event) {
            // Return if this is not a new draft
            if ($event->isNew === false) {
                return;
            }

            // Get the draft from the event
            $draft = $event->draft;

            // Send a notification email
            $this->sendNotificationEmail($draft);
        });
    }

    public function sendNotificationEmail(EntryDraft $draft)
    {
        // Create a mailer
        $mailer = Craft::$app->getMailer();

        // Get system email address
        $systemEmailAddress = Craft::$app->getSystemSettings()->getEmailSettings()->fromEmail;

        // Get the current user
        $user = Craft::$app->getUser()->getIdentity();

        // Creat the parameters for the notification message
        $params = [
            'title' => $draft->title,
            'username' => $user->username,
            'cpUrl' => $draft->getCpEditUrl(),
        ];

        // Create the notification message
        $message = Craft::t('app', 'A new entry draft "{title}" has been created by "{username}": {cpUrl}', $params);

        // Overwrite the message with the rendered template as defined by the the environment variable if it exists
        $template = getenv('FIRST_DRAFT_EMAIL_TEMPLATE');

        if ($template !== false) {
            // Set Craft to the front-end site template mode
            $view = Craft::$app->getView();
            $oldTemplateMode = $view->getTemplateMode();
            $view->setTemplateMode(View::TEMPLATE_MODE_SITE);

            $message = $view->renderTemplate($template, $params);

            // Restore the original template mode
            $view->setTemplateMode($oldTemplateMode);
        }

        // Compose and send a notification email
        $mailer->compose()
            ->setTo($systemEmailAddress)
            ->setSubject('First Draft')
            ->setHtmlBody($message)
            ->send();
    }
}

Submitted Solutions

  • Spenser Hannon