Building a polls plugin in Craft

Recently a client requested some basic poll functionality for their site that we were planning to build in Craft. I was a bit surprised to see that the Craft plugin directory didn't list anything for "poll" or "survey". Of course there are a lot of third-party apps you could use for this kind of thing, but having just watched Ben Croker's Craft Plugin Development course on Mijingo, it occurred to me that it could be built out in a pretty similar fashion to the Entry Count plugin in that tutorial. I hadn't built my own Craft plugin before, and I thought this would be a relatively simple one to start with.

In this article, we're going to walk through planning and building this plugin from the ground up. I'm approaching this as a primarily front-end developer, so some of the concepts involved are a bit outside my usual wheelhouse. If you're an experienced back-end or plugin developer, this may be a little basic for you.

The Plan

For this plugin, we’re going to add a Control Panel (CP) section where admins can create polls. Each poll will have a question, a list of answers, and an active status toggle. Admins will also be able to see a breakdown of a poll’s responses.

We’re also going to create a new field type called “Poll” that can be added to our entries. When adding a poll field to an entry, we can select which poll we want to add to the entry from the ones we've created in our control panel section. A poll can be added to multiple entries.

On the front-end, we’ll add some basic templates and styling for a poll, as well as a way to display responses after the user has answered. If the user has already responded to a poll, or the poll is inactive, they shouldn’t be able to respond and should instead just see the responses.

Plugin Structure

File structure of our plugin.

The first thing to do is build out the scaffolding for the plugin. For this we’re going to use PluginFactory.io. If you're new to Craft plugin development, like I am, I highly recommend you use this service, as it comes with super helpful documentation in each file about what should go there (I've stripped it out of the code samples below to keep things short).

I'm going to call my plugin GT Poll, because I'm super unoriginal.

Here’s the basic class structure for the plugin:

  • GtPollPlugin (our main plugin class - here we define the plugin name, author, settings, etc.)
  • Controllers
    • GtPoll_PollController (this controller is going to handle saving polls and resetting response counts in the CP, and incrementing polls on the public site)
  • Field Types
    • GtPoll_PollFieldType (this field type is going to allow our content editors to add polls to an entry)
  • Models
    • GtPoll_PollModel (polls are going to have question text and a status of either active or inactive)
    • GtPoll_AnswerModel (answers are going to have answer text, a poll that they belong to, and a position in the answer list)
  • Records
    • GtPoll_PollRecord (our record specifies how our polls are stored in the database - here we define our table name, columns, and relationships - in this case, polls have many answers)
    • GtPoll_AnswerRecord (answers belong to a poll)
  • Services
    • GtPollService (all of our business logic goes here - this is the part of our plugin that does the real work)
  • Variables
    • GtPollVariable (here we're going to create some methods for our templates to use, things like getting lists of our polls and their answers)

In addition to the classes above (which actually represent the majority of the file structure of the plugin), we're also going to have folders for Resources (a JavaScript file) and Templates (for our CP pages).

By itself, the plugin won't really do anything except show some screens in the CP. So we're also going to create some sample template code, JavaScript, and CSS that a developer could use when implementing polls on their website.

Main Plugin Class

The main plugin class holds the basic details about our plugin - name, description, author, etc. I should note here that I've lifted this plugin name override option straight from FeedMe, which is another plugin I looked to as an example during development. This allows a developer to rename the plugin as it appears to editors in the CP (which I am always very appreciative of).

In this class, we're also going to tell Craft what settings our plugin has (just the plugin name override), what HTML to render for that settings page (in this case, a very simple Twig template found at /gtpoll/templates/settings/index.twig), and whether or not our plugin has it's own CP section. We're also going to register a dynamic CP route so that if an editor navigates to /gtpoll/edit/15, they'll be able to edit the poll with an id of 15.

namespace Craft;

class GtPollPlugin extends BasePlugin
{

    public function getName()
    {
        $pluginName = Craft::t('GT Poll');
        $pluginNameOverride = $this->getSettings()->pluginNameOverride;

        return ($pluginNameOverride) ? $pluginNameOverride : $pluginName;
    }

    public function getDescription()
    {
        return Craft::t('Create polls and get your users\' opinions.');
    }

    public function getDocumentationUrl()
    {
        return 'https://github.com/gregorterrill/gtpoll/blob/master/README.md';
    }

    public function getReleaseFeedUrl()
    {
        return 'https://raw.githubusercontent.com/gregorterrill/gtpoll/master/releases.json';
    }

    public function getVersion()
    {
        return '1.0.0';
    }

    public function getSchemaVersion()
    {
        return '1.0.0';
    }

    public function getDeveloper()
    {
        return 'Gregor Terrill';
    }

    public function getDeveloperUrl()
    {
        return 'http://gregorterrill.com';
    }

    public function getSettingsHtml()
    {
        return craft()->templates->render('gtpoll/settings', array(
            'settings' => $this->getSettings()
        ));
    }

    protected function defineSettings()
    {
        return array(
            'pluginNameOverride'    => AttributeType::String,
        );
    }

    public function hasCpSection()
    {
        return true;
    }

    public function registerCpRoutes()
    {
        return array(
            'gtpoll/edit/(?P<pollId>\d+)' => 'gtpoll/edit',
       );
    }
}

Records & Models

After finishing our main plugin class, we need to define our data. We're going to build our records, which tell Craft how our data should be stored in the database, and our models, which we'll use to pass around data within our plugin. We have two types of data – polls and answers. This is the structure we’re going to use:

  • Polls have a questionText (the string to display to the user) and active (a boolean for the status of the poll). Craft will automatically add a primary key of id, as well as dateCreated and dateUpdated fields, so we don’t need to worry about adding those to our records.
  • Answers will have a pollId as a foreign key (so we know which poll the answer applies to), answerText (the string to display to the user), responses (the number of times users have responded to the poll with this answer selected), and position (the order in which it appears in the list of answers). Again, Craft will add its default fields to the table.

Here's our poll record class:

namespace Craft;

class GtPoll_PollRecord extends BaseRecord
{
    public function getTableName()
    {
        return 'gtpoll_poll';
    }

    protected function defineAttributes()
    {
        return array(
            'questionText' => array(AttributeType::String, 'default' => ''),
            'active'   => array(AttributeType::Bool)
        );
    }

    public function defineRelations()
    {
        return array(
            'answer' => array(static::HAS_MANY, 'GtPoll_AnswerRecord', 'pollId')
        );
    }
}

Our answer record is going to be structured similarly, but notice that in the defineRelations() method, we're telling Craft that the answer belongs to a poll. This will automatically create a column in the database called pollId which will be a foreign key. We're also defining an index on the pollId column, because we'll usually be searching for all the answers that belong to a specific poll.

namespace Craft;

class GtPoll_AnswerRecord extends BaseRecord
{
    public function getTableName()
    {
        return 'gtpoll_answer';
    }

   protected function defineAttributes()
    {
        return array(
            'answerText' => array(AttributeType::String, 'default' => ''),
            'responses'  => array(AttributeType::Number, 'default' => 0),
            'position'   => array(AttributeType::Number, 'default' => 0),
        );
    }

    public function defineRelations()
    {
        return array(
            'poll' => array(static::BELONGS_TO, 'GtPoll_PollRecord', 'required' => true, 'onDelete' => static::CASCADE)
        );
    }

    public function defineIndexes()
    {
        return array(
            array('columns' => array('pollId'))
        );
    }
}

Our models are containers we'll use to pass around this data, and their classes essentially reiterate the same information as the records, although we're also including the default fields I mentioned. These models will be separate files, but I'll combine them here since they're relatively short:

namespace Craft;

class GtPoll_PollModel extends BaseModel
{
    public function __toString()
    {
        return (string)$this->questionText;
    }

    protected function defineAttributes()
    {
        return array(
            'id' => AttributeType::Number,
            'questionText' => array(AttributeType::String, 'default' => ''),
            'active' => AttributeType::Bool,
            'dateCreated' => AttributeType::DateTime,
            'dateUpdated' => AttributeType::DateTime,
        );
    }
}

class GtPoll_AnswerModel extends BaseModel
{
    public function __toString()
    {
        return (string)$this->answerText . ' (' . (string)$this->responses . ' responses)';
    }

    protected function defineAttributes()
    {
        return array(
            'id' => AttributeType::Number,
            'pollId' => AttributeType::Number,
            'answerText' => array(AttributeType::String, 'default' => ''),
            'responses' => AttributeType::Number,
            'position' => AttributeType::Number,
            'dateCreated' => AttributeType::DateTime,
            'dateUpdated' => AttributeType::DateTime,
        );
    }

}

Templates

After creating the models and records, we're going to move directly into the CP templates. I find that laying out the page helps me visualize the functionality I need to create. For our CP section, we’re going to have an area with 3 tabs - a list of polls, a place to edit polls, and a link off to our settings page. The main list is going to use a template called index.twig. We’re going to extend the control panel layout included in Craft, add the tab list, and set our content to display a table showing all of our polls. There’s some documentation on the Craft site that helps explain this. However, when it comes to figuring out your layout (for example, building the table and deciding what classes to use), the official documentation is pretty light. Your best bet is to poke around Craft’s core templates (craft/app/templates/), another plugin’s templates, or check out the Inspector on another CP page.

Here's what we're building:

Our list of polls in action.
{% extends "_layouts/cp" %}

{% set title =  craft.gtPoll.getPluginName|t %}

{% set tabs = {
    active: { label: "List Polls"|t, url: url('gtpoll') },
    manage: { label: "Manage Polls"|t, url: url('gtpoll/edit') },
    settings: { label: "Settings"|t, url: url('settings/plugins/gtpoll') }
} %}

{% set selectedTab = 'active' %}

{% set polls = //TODO %}

{% set content %}
    
    {% if polls|length %}
        <table class="data fullwidth">
            <thead>
                <tr>
                    <th scope="col">{{ "Poll Question"|t }}</th>
                    <th scope="col">{{ "Answers"|t }}</th>
                    <th scope="col">{{ "Total Responses"|t }}</th>
                    <th scope="col"></th>
                </tr>
            </thead>
            <tbody>
            {% for poll in polls %}
                {% set answers = //TODO %}
                {% set responses = //TODO %}
                {% set status = 'disabled' %}
                {% if poll.active %}
                    {% set status = 'live' %}
                {% endif %}
                <tr>
                    <td data-title="Question Text">
                        <div class="element small hasstatus" data-id="{{ poll.id }}" data-status="{{ status }}">
                            <span class="status {{ status }}"></span>
                            <a href="{{ url('gtpoll/edit/' ~ poll.id )}}">{{ poll.questionText }}</a>
                        </div>
                    </td>
                    <td>
                        {% for answer in answers %}
                          {{ answer.answerText }}{% if responses > 0 %} ({{ answer.responses }} responses - {{ ((answer.responses / responses) * 100)|round(2) }}%){% endif %}<br />
                        {% endfor %}
                    </td>
                    <td>
                        {{ responses }}
                    </td>
                    <td>
                        <a href="{{ actionUrl('gtPoll/poll/reset', { pollId: poll.id }) }}" class="delete icon gtPoll__reset"></a>
                    </td>
                </tr>
            {% endfor %}
            </tbody>
        </table>
    {% else %}
        {{ "No polls have been created yet!"|t }}
    {% endif %}

{% endset %}

We can see that we’ve got loops to output polls and their answers, but we need to first set those three variables - polls, answers, and responses.

Variables

To do this, we’re going to open our GtPollVariable class and create some methods to return data from our services to our templates.

namespace Craft;

class GtPollVariable
{
    /**
     * Get the name of the plugin
     */
    public function getPluginName()
    {
        return craft()->plugins->getPlugin('gtPoll')->getName();
    }

    /**
     * Return all active polls
     */
    public function getActivePolls()
    {
        return craft()->gtPoll->getPolls(true);
    }

    /**
     * Return all polls, including inactive ones
     */
    public function getPolls()
    {
        return craft()->gtPoll->getPolls(false);
    }

    /**
     * Return a poll
     */
    public function getPoll($pollId)
    {
        return craft()->gtPoll->getPoll($pollId);
    }

    /**
     * Return list of answers for a poll
     */
    public function getAnswers($pollId)
    {
        return craft()->gtPoll->getAnswers($pollId);
    }

    /**
     * Return total responses for a poll
     */
    public function getResponses($pollId)
    {
        return craft()->gtPoll->getResponses($pollId);
    }
}

You can see that these are just simple get methods that are taking a pollId as an input and returning some data from a service. Returning to our template, we can now set our variables we'd previously marked as TODO. Note that Craft is smart enough to figure out the get part of the method name, so we’re able to shorten our code a little bit. We're going to update those set tags to the following:

{% set polls = craft.gtPoll.polls %}

{% set answers = craft.gtPoll.answers(poll.id) %}

{% set responses = craft.gtPoll.responses(poll.id) %}

These are going to call our getPolls(), getAnswers(), and getResponses() methods in our GtPollVariable class, respectively. Of course this won’t actually do anything yet, because we haven’t built the methods in our service which the variable methods are calling. So let’s do that next!

Services

In our GtPollService class, we're going to create a method called getPolls(), which returns an array of GtPoll_PollModels, optionally only if they are active. Our main list will show all polls, but later on when we create our field type, we'll only want to get a list of active polls. We’re also going to create a method called getPoll() that takes a pollId and returns a single GtPoll_PollModel, populated with the data from the record matching that pollId.

/**
 * Returns all polls
 * @param bool $activeOnly
 * @return array
 */
public function getPolls($activeOnly)
{
    // get records from DB
    if ($activeOnly) {
        $pollRecords = GtPoll_PollRecord::model()->findAllByAttributes(array('active' => 1));
    } else {
        $pollRecords = GtPoll_PollRecord::model()->findAll();
    }

     // create an array we'll use to store the poll models
    $polls = array();

    // if we have records...
    if ($pollRecords) {
        // ...create a model for each, populate it, and add it to the array
        foreach ($pollRecords as $pollRecord) {
            $pollModel = new GtPoll_PollModel();
            $pollModel = GtPoll_PollModel::populateModel($pollRecord);
            $polls[] = $pollModel;            
        }
    }

    // return the array of populated poll models
    return $polls;
}

/**
 * Returns single poll
 * @param int $pollId
 * @return GtPoll_PollModel
 */
public function getPoll($pollId)
{
    // create new model
    $pollModel = new GtPoll_PollModel();

    // get record from DB
    $pollRecord = GtPoll_PollRecord::model()->findById($pollId);
    
    // if the record exists, populate model from record
    if ($pollRecord) {            
        $pollModel = GtPoll_PollModel::populateModel($pollRecord);
    }

    // return the populated poll model
    return $pollModel;
}

While setting this up I was reading through the Yii ActiveRecord docs and was a little confused until I realized that Craft has an alias for findByPk() called findById(), which I wasn't finding there but is more commonly used in Craft. The more you know!

Next we're going to build out similar service methods for getAnswers() and getResponses().

/**
 * Returns all answers for a poll
 * @param int $pollId
 * @return array
 */
public function getAnswers($pollId)
{
    // get records from DB
    $answerRecords = GtPoll_AnswerRecord::model()->findAllByAttributes(
        array('pollId' => $pollId), 
        array('order' => 'position asc')
    );

    // create an array we'll use to store the answer models
    $answers = array();

    // if we have records...
    if ($answerRecords) {
        // ...create a model for each, populate it, and add it to the array
        foreach ($answerRecords as $answerRecord) {
            $answerModel = new GtPoll_AnswerModel();
            $answerModel = GtPoll_AnswerModel::populateModel($answerRecord);
            $answers[] = $answerModel;            
        }
    }

    // return the array of populated answer models
    return $answers;
}

/**
 * Returns response total for a poll
 * @param int $pollId
 * @return int
 */
public function getResponses($pollId)
{
    $responses = 0;

    // get all answer records from DB that match this poll
    $answerRecords = GtPoll_AnswerRecord::model()->findAllByAttributes(array('pollId' => $pollId));

    // if we have records...
    if ($answerRecords) {
        //...loop through each and sum their response counts
        foreach ($answerRecords as $answerRecord) {
            $responses += $answerRecord->responses;
        }
    }

    // return the sum of all responses
    return $responses;
}

That should do it for showing polls and answers, but now we need to be able to create and update them.

Edit Screen

Next we’re going to create a new CP template for the edit screen. This page is going to be a form where we can either create or edit a poll. There’s going to be a text field for the question text, a lightswitch field for the active status, and then an editable table for the list of answers, which will also allow us to reposition the answers.

To create a form in the CP, we import _includes/forms at the top of our template.

Here I want to pause for a moment and discuss my biggest source of confusion when building out my first plugin: It wasn't immediately obvious to me that including these forms was also going to include the JavaScript to make them interactive (for example, the Add Row and drag-to-reorder functionality of an editable table). From looking at some other plugins I assumed I was going to have to write this myself, and was totally lost as to where to start. You might have read that Craft internally uses a jQuery extension called Garnish for things like sorting tables and enabling Ctrl-S to save your entry in the browser, and there's virtually no documentation for it. You'll see a lot of plugins using it for things, but the good news is, if you're working with built-in Craft form fields, you probably don't have to.

If you look in /craft/app/templates/_includes/forms you’ll see templates for each field type. Looking at these, you can figure out what data the field type expects, and most of the rest will be automatically taken care of for you.

Before we look at the code, remember that dynamic route we added to the main plugin class? If we get to this template using that route, we're going to have a pollId defined, which is how our template will know if we're editing an existing poll or creating a new one.

{% extends "_layouts/cp" %}

{% import "_includes/forms" as forms %}

{% set title = craft.gtPoll.getPluginName|t %}

{% set tabs = {
    active: { label: "List Polls"|t, url: url('gtpoll') },
    manage: { label: "Manage Polls"|t, url: url('gtpoll/edit') },
    settings: { label: "Settings"|t, url: url('settings/plugins/gtpoll') }
} %}

{% set selectedTab = 'manage' %}

{% set content %}

<form method="post" accept-charset="UTF-8" action="">
    <input type="hidden" name="action" value="gtPoll/poll/savePoll">
    {{ getCsrfInput() }}
    
    {% if pollId is defined %}

        {% set poll = craft.gtPoll.getPoll(pollId) %}
        {% set answers = craft.gtPoll.getAnswers(pollId) %}

        <input type="hidden" name="pollId" value="{{ pollId }}">

        <h2>Edit Poll #{{ pollId }}</h2>

    {% else %}

        {% set pollId = 'new' %}

        <h2>New Poll</h2>

    {% endif %}

    {{ forms.textField({
        label: "Question Text"|t,
        id: 'question-' ~ pollId,
        name: 'questionText',
        value: poll.questionText|default('')
    }) }}

    {{ forms.lightswitchField({
        label: "Active"|t,
        id: 'active-' ~ pollId,
        name: 'active',
        on: poll.active|default(true)
    }) }}

    <h3>Answers</h3>

    {% set rows = [] %}

    {% if answers is defined and answers|length %}        

        {% for answer in answers %}

            {# row keys need to be a string or PHP will reset them from 0 #}
            {% set rows = rows|merge({
                ('answer_' ~ answer.id) : [answer.answerText]
            }) %}

        {% endfor %}

    {% endif %}
            
    {{ forms.editableTable({
        label: "Answers"|t,
        id: 'answers-' ~ pollId,
        name: 'answers',
        cols: [{
            heading: 'Answer Text',
            type: 'singleline',
        }],
        rows: rows
    }) }}    

    <br /><input type="submit" class="btn submit" value="{{ 'Save Poll'|t }}">

</form>

{% endset %}

While the text and lightswitch fields are fairly straightforward, the editable table one is a bit tricky. The cols and rows arguments are expecting arrays. This is the part I had the most trouble with:

{% set rows = [] %}

{% for answer in answers %}

    {# row keys need to be a string or PHP will reset them from 0 #}
    {% set rows = rows|merge({
        ('answer_' ~ answer.id) : [answer.answerText]
    }) %}

{% endfor %}

To add a new value to an array in Twig, we need to use the merge filter, which actually runs PHP’s array_merge function. I haven’t often needed to use that particular function in PHP, so I didn’t initially realize that PHP will reset numeric keys from 0, which is why I’ve prefixed mine with ‘answer_’. Without this prefix, this would cause us to overwrite the same first few answerIds in our database over and over (even if those answers were for other polls!).

It's also worth pointing out that in this case we’re passing an array containing one value (the answer text) but if our table had more than one column, we’d have multiple values in that array.

For a form submission in the CP, instead of putting an action attribute on the form, we add a hidden input with the name action that has our action URL as a value. There are some details on why this is the case here. Initially I had tried to use the actionURL() function to output the value here, but:

value="actionUrl('gtPoll/poll/savePoll')" doesn't work.

value="gtPoll/poll/savePoll" works.

I’m honestly not really sure why the first one doesn’t work, if you can clear up this mystery for me, send me a note!

Controller

Submitting the form with the action above is going to look for a method called actionSavePoll() in our GtPoll_PollController. This method is going to grab the info from our POST request, create a new poll model and an array of answer models and send it all to our service method (which we’ll create in a moment). The controller method will then set a session message telling the user if the poll has saved successfully, and redirect them back to the plugin index page.

While we're in the controller, we're also going to create two other methods we'll need later. The first will increment an answer's response count (which will be triggered from the front-end template by a user responding to the poll) and the second will reset all of the response counts for a poll's answers (which is triggered by clicking the (-) button in our list view in the CP).

For the public-facing action, we need to specify that users who aren't logged in can trigger this action, so we're going to do that by setting an array with that function name to $allowAnonymous, We also want to make sure that this method ends by either redirecting somewhere else or, in this case, returning JSON, otherwise we're going to get 404s when calling our controller via AJAX because Craft will try to continue routing the request (this one was a bit of a gotcha for me).

namespace Craft;

class GtPoll_PollController extends BaseController
{
    /**
     * Only increment answer is available to front-end users
     */
    protected $allowAnonymous = array('actionIncrementAnswer');

    /**
     * Increment an answer's response count
     * @return bool
     */
    public function actionIncrementAnswer()
    {
        $this->requirePostRequest();
        $this->requireAjaxRequest();

        // get POST data
        $pollId = craft()->request->getPost('poll');
        $answerId = craft()->request->getPost('poll_' . $pollId);

        // increment the answer's response count
        if (craft()->gtPoll->incrementAnswer($answerId)) {
            $response = array('response' => 'Answer incremented');
        } else {
            $response = array('response' => 'Answer failed to increment');
        }
        $this->returnJson($response);
    }

    /**
     * Save a poll and it's answers
     */
    public function actionSavePoll()
    {
        $this->requirePostRequest();

        // create a new poll model and populate it from our POST data
        $poll = new GtPoll_PollModel();
        $poll->id = craft()->request->getPost('pollId');
        $poll->questionText = craft()->request->getPost('questionText');
        $poll->active = craft()->request->getPost('active');

        // initialize our array to hold our answer models and zero-based position
        $answers = array();
        $position = 0;

        // for each answer in the POST data...
        foreach (craft()->request->getPost('answers') as $answerId => $answerText) {

            // ...create a new answer model and populate it
            $answer = new GtPoll_AnswerModel();
            if ($answerId) {
                // we have to store this key as a string in the template, here we convert it back
                $answer->id = (int)str_replace('answer_', '', $answerId);
            }
            $answer->answerText = $answerText[0];
            $answer->position = $position;

            // add the answer model to our array and increment position
            $answers[] = $answer;
            $position++;
        }

        // save the poll with our answers and show the user feedback
        if (craft()->gtPoll->savePoll($poll, $answers)) {
            craft()->userSession->setNotice(Craft::t('Poll saved.'));
            $this->redirect('gtpoll');
        } else {
            craft()->userSession->setError(Craft::t('Couldn’t save poll.'));
        }
    }

    /**
     * Reset all of a poll's answers' response counts
     */
    public function actionReset()
    {
        $pollId = craft()->request->getRequiredParam('pollId');
        craft()->gtPoll->resetAnswers($pollId);
        craft()->userSession->setNotice(Craft::t('Poll responses reset.'));
        $this->redirect('gtpoll');
    }
}

Back in our service, we’re going to create the savePoll() method which is actually going to insert or update the poll and answers. The logic here is fairly straightfoward. We’re going to validate the poll model we’ve been given by the controller, either grab the existing record or create a new one, set it’s values from the model, and then save. Once we’ve saved the poll, we have a pollId to relate the answers to, so we’ll loop through the answer models and save them in the same fashion.

/**
 * Save a poll
 * @param GtPoll_PollModel $poll
 * @param array $answers
 * @return bool
 */
public function savePoll(GtPoll_PollModel $poll, array $answers)
{
    $newPoll = true;

    // make sure the model is valid
    if ($poll->validate()) {

        // if the poll id already exists, we're updating an existing record...
        if ($poll->id) {
            $pollRecord = GtPoll_PollRecord::model()->findById($poll->id);
            $newPoll = false;

            if (!$pollRecord) {
                throw new Exception(Craft::t('No poll exists with the ID “{id}”', array('id' => $poll->id)));
            }
        // ...otherwise we'll create a new one
        } else {
            $pollRecord = new GtPoll_PollRecord();
        }

        // set the text and status of the poll
        $pollRecord->questionText = $poll->questionText;
        $pollRecord->active = $poll->active;

        // save the record, so we can save our answers with the relationship
        if ($pollRecord->save()) {

            // for each answer...
            foreach ($answers as $answer) {

                // make sure the model is valid
                if ($answer->validate()) {
                
                    // ...if the answer already exists, and this is an existing poll, we'll update it
                    if ($answer->id && !$newPoll) {
                        $answerRecord = GtPoll_AnswerRecord::model()->findById($answer->id);
                    // ...otherwise we'll create a new answer
                    } else {
                        $answerRecord = new GtPoll_AnswerRecord();
                    }

                    // set the answer text, position, and poll, then save it
                    $answerRecord->position = $answer->position;
                    $answerRecord->answerText = $answer->answerText;
                    $answerRecord->pollId = $pollRecord->id;

                    $answerRecord->save();
                }
            }
            return true;
        }
    }
    return false;
}

Field Type

The last thing we need to do on the admin side is create a field type so that our editors can add polls to entries. First we’re going to create the Twig template for the field type, which is fairly simple. We’re going to create an array of poll options in a similar fashion to our answers on the edit screen, prefixing their numeric keys. Then we’re going to output a dropdown with the poll question texts as the options:

{% import "_includes/forms" as forms %}

{% set polls = craft.gtPoll.polls %}

{% set options = [] %}

{% for poll in polls %}

  {% set options = options|merge({
    ('poll_' ~ poll.id) : poll.questionText
  }) %}

{% endfor %}

{{ forms.selectField({
    id: id,
    name: name,
    options: options,
    value: 'poll_' ~ values
}) }}

Once that’s done we’re going to create the field type class. The getInputHtml() method is going to render our Twig template with some values, including the current field value from the database. The prepValueFromPost() method is going to strip our prefix from the field value before saving it to the database.

namespace Craft;

class GtPoll_PollFieldType extends BaseFieldType
{
    public function getName()
    {
        return Craft::t('Poll');
    }

    public function defineContentAttribute()
    {
        return AttributeType::Mixed;
    }

    public function getInputHtml($name, $value)
    {
        if (!$value) {
            $value = new GtPoll_PollModel();
        }

        $id = craft()->templates->formatInputId($name);
        $namespacedId = craft()->templates->namespaceInputId($id);

        //variables to pass to the template
        $variables = array(
            'id' => $id,
            'name' => $name,
            'namespaceId' => $namespacedId,
            'values' => $value
        );

        return craft()->templates->render('gtpoll/fields/GtPoll_PollFieldType.twig', $variables);
    }

    public function prepValueFromPost($value)
    {
        $value = (int)str_replace('poll_', '', $value);
        return $value;
    }

}

Final CP Touches

The last thing we’re going to do on the CP side is add some JavaScript to show an alert to users when they click the Reset button that the action cannot be undone.

We're going to add this line to our index.twig file:

{% includeJsResource "gtpoll/js/gtPoll.js" %}

Note that the file path is actually gtpoll/resources/js/gtPoll.js but Craft will assume the resources part when we include it with that function.

The JavaScript file contains a single function that interrupts the click:

(function($){

  $('.gtPoll__reset').on('click', function(e) {
    e.preventDefault();

    if (confirm('About to reset all response counts for this poll. This cannot be undone. Continue?')) {
      window.location.href = $(this).attr('href');
    } 
  });

})(jQuery);

Front-End

Now that we’ve finished the back end for our plugin, we need to create the Twig template, CSS, and JavaScript that’s going to make all this come together on the front-end of the site. This isn’t part of the plugin per se, but it’s helpful to provide some documentation for developers using the plugin as to how they can implement it.

We're going to build a front-end module which displays the poll as a form, does an AJAX submission to increment the chosen response, and then does a card flip animation to show the results (including the response we've just put in). We're also going to set a cookie so that users who have already completed the poll won't be able to do so again. If users with this cookie go to an entry with this poll, or the poll has been set to inactive, we'll just see the results immediately.

This module consists of three parts: a CSS file, a JavaScript file, and some Twig code. Let's look at the template first. A developer using our plugin would want to put this anywhere a Poll field is used (in my case, I've got a Poll block in my main matrix field, which includes this code).

{% set pollId = block.poll %}
{% set poll = craft.gtPoll.getPoll(pollId) %}       
<div class="gtPoll{% if poll.active %} gtPoll--active{% else %} gtPoll--inactive{% endif %}">
  <div class="gtPoll__card">
    <div class="gtPoll__question">
      <form class="gtPoll__form" method="post" accept-charset="UTF-8" action="">
        <input type="hidden" name="action" value="{{ actionUrl('/gtPoll/poll/incrementAnswer') }}">     
        <input type="hidden" name="poll" value="{{ pollId }}">

        <h2 class="gtPoll__heading">Poll</h2>

        <p class="gtPoll__question-text">{{ poll.questionText }}</p>

        {% for answer in craft.gtPoll.getAnswers(pollId) %}

          <label class="gtPoll__response-label" for="poll_{{ pollId }}[{{ answer.id }}]">
            <input class="gtPoll__response-input" name="poll_{{ pollId }}" id="poll_{{ pollId }}[{{ answer.id }}]" type="radio" value="{{ answer.id }}"> {{ answer.answerText }}
          </label>

        {% endfor %}

        <input class="btn" type="submit" value="Submit" />

      </form>
    </div>        
    <div class="gtPoll__results">

      {% set responses = craft.gtPoll.responses(pollId) %}

      <h2 class="gtPoll__heading">Poll Results</h2>

      <p class="gtPoll__question-text">{{ poll.questionText }}</p>

      <ul class="gtPoll__answer-list">

      {% for answer in craft.gtPoll.getAnswers(pollId) %}

        {% set percentage = 0 %}
        {% if responses > 0 %}
          {% set percentage = ((answer.responses / responses) * 100)|round(2) %}
        {% endif %}

        <li class="gtPoll__answer-item">
          <p class="gtPoll__result-info">{{ answer.answerText }} <strong>(<span class="gtPoll__result-responses">{{ answer.responses }}</span> responses - <span class="gtPoll__result-percentage">{{ percentage }}</span>%)</strong></p>
          <div class="gtPoll__result-bar" data-answer="{{ answer.id }}" data-votes="{{ answer.responses }}" data-total="{{ responses }}" data-percentage="{{ percentage }}"></div>
        </li>

      {% endfor %}
      </ul>
    </div>
  </div>
</div>

So we've essentially got the typical card-flipper pattern there. The front side of the card has our poll question and submission form, and the back side has our responses. We're going to use the class .gtPoll--inactive on the main container to indicate our "flipped" status. You'll notice in our CSS we're going to omit heights for the cards. We'll add those in with JavaScript afterwards to ensure the card is always the height of the taller of our two card faces.

.gtPoll {
  margin: 2.375rem 0;
  display: inline-block;
  perspective: 1000px;
  position: relative;
  width:100%;
}

.gtPoll__card {
  transition: 0.5s;
  transform-style: preserve-3d;
  position:absolute;
  width:100%;
}

.gtPoll--inactive .gtPoll__card {
  transform: rotateY(180deg);
}

.gtPoll__question,
.gtPoll__results {
  display:block;
  background-color:#EDEDED; 
  padding:1.5rem;
  backface-visibility: hidden;
  position: absolute;
  top: 0;
  left: 0;
  width:100%;
}

.gtPoll__question {
  z-index: 2;
  transform: rotateY(0deg);
}

.gtPoll__results {
  transform: rotateY(180deg);
}

.gtPoll__heading {
  margin-top:0;
}

.gtPoll__question-text {
  font-size: 1.25rem;
  display:block;
  margin:1rem 0;
}

.gtPoll__response-label,
.gtPoll__result-info {
  font-size:1rem;
  position:static;
  display:block;
  line-height: 1.75;
}

.gtPoll__response-input {
  display:inline;
  width:auto;
}

.gtPoll__answer-list {
  list-style-type: none;
  margin:0;
  padding:0;
}

.gtPoll__answer-item {
  margin-bottom: 1rem;
}

.gtPoll__answer-list::last-of-type {
  margin-bottom:0;
}

.gtPoll__result-bar {
  height:1rem;
  min-width: 0.25rem;
  width:0%;
  background-color:red; 
  transition:0.5s width ease-in-out;
}

.gtPoll__result-bar[data-percentage="0"] {
  background-color:#CCC;
}

Finally, our JavaScript will take care of the rest. We'll need:

  • A function to animate the poll bars from zero to their new total, including the option the user just voted for.
  • A submit event for the form, that will submit the form data to our controller via AJAX, then flip our card, call the above animate function (you'll notice I'm using a setTimeout with a delay that matches our transition, but the more correct way to do this would be to listen for a transition end event on the flip), and then set our cookie.
  • When the page loads:
    • If the user has a cookie indicating they've completed the form, we need to make it inactive.
    • If the page contains any inactive polls, we want to animate their response bars right away.
    • We need to set the height of the card sides to the taller of the two sides (it might also be a good idea to re-trigger this function when the window is resized, but we're not going to worry about that).
;(function($) {

	//animate the bars into their percentage (and if there's an answer id, increment it first)
	var animatePollBars = function($poll, answerId) {
		var $bars = $poll.find('.gtPoll__result-bar');

		$bars.each(function() {

			var $bar = $(this),
				$text = $bar.prev('.gtPoll__result-info');

			//if THIS is the answer we're incrementing, its votes go up
			if ($bar.attr('data-answer') == answerId) {
				$bar.attr('data-votes', parseInt($bar.attr('data-votes')) + 1 );
				$text.find('.gtPoll__result-responses').html($bar.attr('data-votes'));
			}

			//if we're incrementing ANY answer, the total goes up and we recalc percentage
			if (answerId) {
				$bar.attr('data-total', parseInt($bar.attr('data-total')) + 1 );
				$bar.attr('data-percentage', ((parseInt($bar.attr('data-votes')) / parseInt($bar.attr('data-total'))) * 100).toFixed(2) );
				$text.find('.gtPoll__result-percentage').html($bar.attr('data-percentage'));
			}

			$bar.css('width', $bar.attr('data-percentage') + '%');
		});
	};

	//when the user submits the poll, create a cookie to indicate we've already completed the form
	$('.gtPoll__form').on('submit', function(e) {
		e.preventDefault();

		var $form = $(this),
			$poll = $form.parents('.gtPoll'),
			pollId = $form.find('input[name="poll"]').val(),
			answerId = $form.find('input[type="radio"]:checked').val(),
			actionURL = $form.find('input[name="action"]').val(),
			cookieName = 'poll' + pollId + 'complete',
			data = $form.serialize();

		//submit via an AJAX call
		$.ajax({
			url: actionURL,
			type: 'POST',
			data: data,
			complete: function(response) {
			//flip the card
			$poll.removeClass('gtPoll--active').addClass('gtPoll--inactive');
			//animate the poll bars after the flip (should use onTransEnd for this)
			setTimeout(function() {
			  animatePollBars($poll, answerId);
			}, 500);
			//set the cookie to indicate the poll has been completed
			document.cookie = cookieName + '=true';
			}
		});
	});

	// for each poll on the page, check if the user has already completed it
	$('.gtPoll__form').each(function(e) {

		var pollId = $(this).find('input[name="poll"]').val(),
			cookieName = 'poll' + pollId + 'complete',
			re = new RegExp(cookieName + "=([^;]+)"),
			cookieValue = re.exec(document.cookie);

		if (cookieValue) {
			cookieValue = unescape(cookieValue[1]);
		}

		//if the cookie is set, make the poll inactive
		if (cookieValue === 'true') {
			$(this).parents('.gtPoll').removeClass('gtPoll--active').addClass('gtPoll--inactive');
		}
	});

	// if poll is inactive on load, animate in the result bars
	if ($('.gtPoll--inactive').length) {
		setTimeout(function() {
			$('.gtPoll--inactive').each(function() {
				animatePollBars($(this));
			});
		}, 500);
	}

	//initial height setter
	var frontHeight = $('.gtPoll__question').outerHeight();
	var backHeight = $('.gtPoll__results').outerHeight();

	if (frontHeight > backHeight) {
		$('.gtPoll, .gtPoll__card, .gtPoll__question, .gtPoll__results').css('height', frontHeight);
	} else {
		$('.gtPoll, .gtPoll__card, .gtPoll__question, .gtPoll__results').css('height', backHeight);
	}

})(jQuery);

Wrapping Up

And that's it! Here's our front-end poll module:

Poll

What did you think of this post?

Poll Results

What did you think of this post?

  • It was great! (41 responses - 65.08%)

  • It was good, but it went into too much detail (7 responses - 11.11%)

  • It was good, but it didn't go into enough detail (7 responses - 11.11%)

  • It was too basic for me (4 responses - 6.35%)

  • It was too complicated for me (4 responses - 6.35%)

Full code for the plugin is available on GitHub.

There are some ways I've already thought of to improve the plugin. Most importantly, instead of representing our polls in a simple table, we could create them as ElementTypes, which would also allow us to use Craft's built-in translations. It would also be helpful to have the option to delete old polls, which wouldn't be difficult to implement.

Writing this first plugin for Craft was a big learning experience for me, and I'm sure the above article missed something or got some conventions wrong. If you have any comments or feedback, please drop me a line via email or Twitter, or create issues on the repo.

‹ Back