
When we implemented our action program at Climate for Change, we needed to create a system to support it on our website that was relatively easy to administrate and grow, and that allowed us insight into user behaviour.
Essentially what was needed was a bunch of signup pages, one per action that the user could take, but they would need some custom details to support all the dynamic generation of content and tagging of users.
NationBuilder doesn’t support custom fields for pages, but Angular, Tabletop and Google Sheets allowed me to quickly roll my own.
The example below is so simple you could well question the effort involved to build it, but of course, you probably don’t want something that simple, and once the foundation is in place, it really pays dividends as you expanded functionality. Creating menu systems, seasonal actions, guides and reminders were all possible by adding new sheets and some simple functions to our factory to serve that up to the page.
Administratively, it’s really simple for anyone to make changes since they just edit the Google Sheet.
Full Source Code
Full code for this example is available at GitHub, or you can preview and step through a demo.
Before we go any further, a couple of things you should be aware of:
Note: If you’re planning to use this in anything that needs to take a large number of requests, you should read this post on why you shouldn’t rely on a Google Spreadsheet for production and follow one of the workarounds suggested in the comments.
Note: Your spreadsheet has to be published publicly for the app to use it, so everything in your spreadsheet is potentially available to anyone who wants to find it, so don’t go putting anything that should be private or secret in there.
Configure Tabletop
Tabletop provides a really simple, intuitive access to configuration stored in Google Sheets. Each sheet is an array of rows in the sheet. Each row is an object where the headings in the top row are used as the keys for each row object.
Angular Tabletop wraps that up in a neat angular factory that ensures you only load the spreadsheet once no matter how many times it’s referenced in your code.
Configuring them is a breeze. Download the source files for both, put them in your theme folder, and add them to the bottom of your layout.html
While you’re there, let’s add some other things we’ll need: load our application javascripts, and a file for configuration settings.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.4.2/angular.min.js"></script> <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.4.2/angular-route.min.js"></script> <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.4.2/angular-animate.min.js"></script> <!-- Add in Tabletop and tabletopProvider --> <script src="tabletop.js"></script> <script src="TabletopProvider.js"></script> <!-- other scripts, eg --> <script src="app.js"></script> <script src="actions_controller.js"></script> <!-- Configuration settings --> {% include "config" %} </body> </html> |
And we’ll also add the ng-app
directive to the top of layout.html
1 2 3 4 |
<!DOCTYPE html> <html lang="en" ng-app="actionsDemo"> <head> ... |
Then configure the provider in your app.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
'use strict'; angular.module('actionsDemo', ['times.tabletop']) .config(function(TabletopProvider){ // Get the address of the spreadsheet var spreadsheetUrl = config.actions_spreadsheet; // Configure tabletop with the address TabletopProvider.setTabletopOptions({ key: spreadsheetUrl + '/pubhtml', simpleSheet: false }); }) /** Need to avoid interpolation clashes with Liquid **/ .config(function($interpolateProvider){ // Change the default angular escape code as this clashes with // NationBuilder's Liquid and causes all our inlines to disappear $interpolateProvider.startSymbol('{[{').endSymbol('}]}'); }); |
The URL for the spreadsheet is basically the URL you’d use to access it, plus /pubhtml
Rather than hardcode the spreadsheet URL, I’ll put it into a global variable config
which we’ll define in config.html
.
Before we can do that though, we’ll need to setup the spreadsheet, so go ahead and setup your own spreadsheet. For this demo, I’m going to give each action a name, description, slug for it’s signup page, and tags for when the user starts and finishes.
Once you’ve created the spreadsheet, you’ll need to publish it (File menu -> publish) so that the Javascript will be able to access it for any visitor to your site.
Once you publish it, you can take the URL of your spreadsheet and put it in your config.html
.
1 2 3 4 5 6 7 8 |
<script type="text/javascript"> (function() { window.config = window.config || {}; // Save the user tags as an array config.actions_spreadsheet = 'https://docs.google.com/spreadsheets/d/1doC8tt8A2ySJnf8VV1G-Zvgt0OPOzCfO3PL5BDiSohk'; })(); </script> |
Why not create a config.js instead? By including it as an HTML file using Liquid include, you get access to all the variables that Liquid offers. For example, in our system, we get the list of users tags, and use that to highlight the actions that the user hasn’t already done.
So now, you’ve got Angular setup and a provider that knows where to find the spreadsheet. The provider creates a promise to load the spreadsheet, that we can use to run our code once the spreadsheet has been loaded.
Let’s create a quick controller to load the spreadsheet and dump some fields to the console to check that everything is working as expected.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
'use strict'; (function() { angular.module('actionsDemo') .controller('ActionsController', ['$log', 'Tabletop', ActionsController]); /** * ActionsController */ function ActionsController($log, Tabletop) { Tabletop.then(function(TabletopSheets) { var allSheets = TabletopSheets[0]; var actionSheet = TabletopSheets[0]["Actions"].all(); // Iterate over the actions and log them for (var i=0; i<actionSheet.length; i++) { $log.debug('Action: ' + actionSheet[i]['Name'] + ', slug: ' + actionSheet[i]['Page Slug']); } }); } })(); |
Create a simple element somewhere on your page that loads the controller
1 |
<p ng-controller="ActionsController"></p> |
If all is going well, open up a page, and open up the Javascript console and you should see something like this:
1 2 3 |
Action: Sign Our Petition, slug: act_petition Action: Write to your MP, slug: act_write_mp Action: Talk to your friends, slug: act_talk_friends |
Creating an actions factory
Accessing tabletop directly in your controller is ok for simple access, but for anything complicated, you’re better off wrapping it in a factory with some helpers, it will make your code a lot more readable and quicker to build upon.
We’ll need to wrap it in our own promise that resolves once Tabletop has loaded the spreadsheet.
Here’s a simple factory that returns an object that exposes just one method: allActions
(If you’re wondering about the layout of the file, it’s based on the angular style guide)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
'user strict'; (function() { angular.module('actionsDemo') .factory('ActionService', ['$q', '$log', 'Tabletop', ActionService]); /** * Factory for the Actions spreadsheet * * *Methods* * * allActions - Returns an array of all available actions */ function ActionService($q, $log, Tabletop) { var svc = init(), actionSheet = null, allSheets = null; return svc; /** * Initialise tabletop and return a promise that resolves once the * spreadsheet has loaded */ function init() { // An object that holds all the methods the caller can access var service = { allActions: allActions } var deferred = $q.defer(); $log.debug('Actions are configured in: ' + config.actions_spreadsheet); // Load the spreadsheet Tabletop.then(function(TabletopSheets) { // Once the spreadsheet is loaded allSheets = TabletopSheets[0]; actionSheet = TabletopSheets[0]["Actions"].all(); deferred.resolve(service); }); return deferred.promise; } /** * Return all actions */ function allActions() { return actionSheet; } } })(); |
You’ll notice I like to log the URL of the Google Sheet, it makes debugging a little easier for those that are fresh to the code and wondering where the data’s coming from.
Add the factory to your layout.html
1 2 3 4 5 6 |
... <!-- other scripts, eg --> <script src="app.js"></script> <script src="actions_factory.js"></script> <script src="actions_controllers.js"></script> ... |
Let’s change our controller to make use of the factory – we’ll need to update the controller definition to use our new factory instead of Tabletop, and instead of putting the actions to debug logging, let’s put them on the $scope
where they can be more useful.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
(function() { angular.module('actionsDemo') .controller('ActionsController', ['$scope', '$log', 'ActionService', ActionsController]); /** * ActionsController * * *$scope* * * actions - Array of actions to display */ function ActionsController($scope, $log, ActionService) { $scope.actions = []; ActionService.then(function(actions) { $scope.actions = actions.allActions(); }); } })(); |
Now we can create a signup page that uses the actions. We’ll give it the slug actions
, and override it’s theme (not it’s intro) to put the actions in a table.
1 2 3 4 5 6 7 8 |
<h2>Choose an action</h2> <table ng-controller="ActionsController"> <tr ng-repeat="action in actions"> <th>{[{ action['Name'] }]}</th> <td>{[{ action['Description'] }]}</td> <td><action-button action="action"></action></td> </tr> </table> |
The last column of the table we’ll create a new directive to create the code for the sign up button.
Let’s use the techniques from the previous posts on dynamic tags and destinations to tag the user and send them to the desired page.
So we’ll add a directive definition and controller for our button to make all of those things happen in actions_controller.js
. The controller will handle updating the destination page_id, while the template will include a line for tagging the user.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
'use strict'; (function() { angular.module('actionsDemo') .controller('ActionsController', ['$scope', '$log', 'ActionService', ActionsController]) .directive('actionButton', function ActionButtonDirective() { return { restrict: 'E', templateUrl: 'action_button.html', scope: { action: '=' }, controller: ['$scope', '$element', '$timeout', ActionButtonController] } }); /** * ActionsController * * *$scope* * * actions - Array of actions to display */ function ActionsController($scope, $log, ActionService) { $scope.actions = []; ActionService.then(function(actions) { $scope.actions = actions.allActions(); }); } /** * ActionButtonController * * Adjusts the page_id on the signup form to send the user to the next page */ function ActionButtonController($scope, $element, $timeout) { setDestinationPage($scope.action); // Sets the destination page for this action. Only sets it if // action is present function setDestinationPage(action) { // Timeout to give the DOM a chance to render so the // input element can be found $timeout(function() { // Find the page id from the slug var pageID = config.page_map[action['Page Slug']]; // Find the hidden form element, update it’s value $element.find("input[name='page_id']").attr('value', pageID); }, 0); } } })(); |
Don’t forget to add jQuery to your layout, as the selector used is too complicated for Angular’s built in version of jQuery.
1 2 3 4 5 6 |
... {% yield %} <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.2/jquery.min.js"></script> <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.4.2/angular.min.js"></script> ... |
To map page ids to slugs, we’ll need to add some Liquid to config.html
to create that map.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
<script type="text/javascript"> (function() { window.config = window.config || {}; // Save the user tags as an array config.actions_spreadsheet = 'https://docs.google.com/spreadsheets/d/1doC8tt8A2ySJnf8VV1G-Zvgt0OPOzCfO3PL5BDiSohk' config.page_map = { {% for child in page.children %} '{{ child.slug }}': '{{ child.id}}', {% endfor %} }; })(); </script> |
And lastly a template for the button is needed. We’ll embed that in actions.html
as that’s the only place it’s needed.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
... <script type="text/ng-template" id="action_button.html"> {% form_for signup %} <!-- Tag them when they click the button --> <input type="hidden" name="signup[optional_tag]" value="{[{ action['Start Tag'] }]}" /> <!-- Include email for sorta_logged_in? users --> <input type="hidden" id="signup_email" name="signup[email]" value="{{ request.current_signup.email }}" /> {% submit_tag "Do this action", class:"pull-right submit-button btn btn-primary" %} {% endform_for %} </script> ... |
Note the second hidden input, signup_email. Normally on a signup page you would ask for people’s details. If the user is logged in, then the button will work just fine without this, but if the user is not logged in, then the submit will fail. We can work around this by forcing the use of the sorta_logged_in email with a hidden input. For users that are totally logged out, we created a custom button that would popup a sign in form, instead of submitting the signup.
If all is working well, you should see a table with the name, description and signup button for your actions.
Inspect the source code to check that your signup button has the correct page_id
assigned.
There you have it, dynamic signup actions based on a Google Spreadsheet.