Challenge #3 – The First Draft

19 November 2018 Solved Php Intermediate

A real-world use case for send­ing email noti­fic­a­tions when a new entry draft is cre­ated using a Craft mod­ule and an event hand­ler. It requires set­ting up a mod­ule and writ­ing some PHP code, so pull up your sleeves and dust off your IDE.

Chal­lenge

The chal­lenge is to cre­ate a mod­ule that listens for the EntryRevisions::EVENT_AFTER_SAVE_DRAFT event. Whenev­er the event is fired, provided that the draft is new, the mod­ule should send a noti­fic­a­tion email to the sys­tem email address with the sub­ject First Draft” and the fol­low­ing message:

A new entry draft “{title}” has been cre­ated by “{user­name}”: {cpUrl}

Where {title} is replaced by the title of the entry draft, {username} is replaced by the user­name of the user that cre­ated the draft and {cpUrl} is the URL to edit the entry in the con­trol panel.

For bonus points, if the envir­on­ment vari­able FIRST_DRAFT_EMAIL_TEMPLATE is set then the mod­ule should use the rendered tem­plate (as defined by the the envir­on­ment vari­able) as the email’s HTML body, provid­ing the 3 vari­ables above as tem­plate variables.

Rules

The mod­ule must be a single, self-con­tained file that sends an email noti­fic­a­tion as described above whenev­er a draft is cre­ated, if and only if the draft is new (not a revi­sion of an exist­ing draft). It should not rely on any plu­gins and the code will be eval­u­ated based on the fol­low­ing cri­ter­ia in order of priority:

  1. Use of Craft components 
  2. Read­ab­il­ity
  3. Brev­ity

There­fore the code should use nat­ive Craft com­pon­ents wherever pos­sible. It should be read­able and easy to under­stand, con­cise and non-repetative.

Tips

Every install­a­tion of Craft cre­ated using the Craft pro­ject as doc­u­mented in the install­a­tion instruc­tions comes with a modules/Module.php file. You can use this file as a start­ing point or read the guide on how to build a mod­ule and cre­ate your own.

For an in-depth explan­a­tion of mod­ules you can read the art­icle Enhan­cing a Craft CMS 3 Web­site with a Cus­tom Mod­ule.

Solution

Set­ting up the basic file struc­ture, class auto­load­ing and applic­a­tion con­fig for a mod­ule is covered in the docs. We begin with the basic out­line of a mod­ule class.

<?php
namespace modules;

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

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

The first step is to cre­ate an event listen­er that will be called whenev­er the EntryRevisions::EVENT_AFTER_SAVE_DRAFT event is triggered. We do this in the init() meth­od of the mod­ule (so that it will be attached to every request to the CMS) using Event::on and pass in 3 para­met­ers: the class name which con­tains the event; the name of the event; and a hand­ler that will handle the logic of what we want the event to do. The hand­ler can be any­thing that is callable and we will use an anonym­ous func­tion (or clos­ure) to handle the logic.

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

Look­ing at the source code of the event, we can see that it passes in a new object of type DraftEvent with 2 para­met­ers: the entry draft and a boolean rep­res­ent­ing wheth­er 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 hand­ler func­tion to accept a para­met­er 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 attrib­ute and exit with a return state­ment if it is not. Then we fetch the event’s draft attrib­ute and pass it to a meth­od (that we will cre­ate in the same class) that will handle send­ing the email noti­fic­a­tion 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 cre­ate the new meth­od to handle the email noti­fic­a­tion. The meth­od accepts a para­met­er of type EntryDraft, as that is what we sent it from our event hand­ler above, and pro­ceeds to do the following:

  • Cre­ate a mail­er com­pon­ent which provides APIs for send­ing email in Craft. 
  • Fetch the fromEmail value from the email sys­tem settings.
  • Get the cur­rent user by call­ing getIdentity() on the user com­pon­ent.
  • Cre­ate the para­met­ers for the noti­fic­a­tion mes­sage from the draft’s title and CP edit URL, and from the user’s username.
  • Cre­ate the noti­fic­a­tion mes­sage using the t meth­od (short­cut for trans­late) from the provided parameters.
  • Com­pose the noti­fic­a­tion email, assign who to send it to, a sub­ject 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();
}

Sim­il­ar solu­tions: Spenser Han­non.


For the bonus points men­tioned in the chal­lenge, we first check if the envir­on­ment vari­able FIRST_DRAFT_EMAIL_TEMPLATE exists. If it does then we to render it, giv­en the para­met­ers, to pro­duce and over­write the email noti­fic­a­tion mes­sage. To render the front-end tem­plate, we need to ensure that the view’s tem­plate mode is set to site first. We do this by stor­ing the cur­rent tem­plate mode as $oldTemplateMode using the getTemplateMode() meth­od, then set­ting the tem­plate mode to site as defined by the View::TEMPLATE_MODE_SITE con­stant using the setTemplateMode() meth­od. After ren­der­ing the tem­plate, we then restore the tem­plate mode to its ori­gin­al 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);
}

Put­ting this all togeth­er gives us the final solu­tion. Note that the class names have been shortened with the use oper­at­or to make the code more readable.

<?php
namespace modules;

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