AngularJS Meet Open Atrium

Mike Potter, Software Architect
#Atrium | Posted

atrium-logo (1)

The recent 2.23 version of Open Atrium contains a cool new interactive site builder and navigator application (see it in action). This application was written using the AngularJS framework. The combination of Drupal, jQuery, and AngularJS proved to be powerful, but wasn’t without some pitfalls.

Using AngularJS in Drupal

The basics of using Angular within Drupal is pretty straight-forward. Simply reference the external AngularJS scripts using the drupal_add_js() function, then add your custom javascript app code, then use a tpl template to generate the markup including the normal Angular tags. For example, here is the Drupal module code, javascript and template for a simple Angular app:

  1. Drupal myapp.module code
  2. PHP
  3. // Implements hook_menu()
  4. function myapp_menu() {
  5. $items['myapp'] = array(
  6. 'page callback' => 'myapp_menu_callback',
  7. 'access callback' => TRUE,
  8. );
  9. return $items;
  10. }
  11.  
  12. // The menu callback to display the page
  13. function myapp_menu_callback() {
  14. drupal_add_js('<a href="https://ajax.googleapis.com/ajax/libs/angularjs/1.3.0/angular.min.js">https://ajax.googleapis.com/ajax/libs/angularjs/1.3.0/angular.min.js</a>');
  15.  
  16. $base = drupal_get_path('module', 'myapp');
  17.  
  18. // data you want to pass to the app
  19. drupal_add_js(array(
  20. 'myapp' => array(
  21. 'title' => t('Hello World'),
  22. ),
  23. ), 'setting');
  24.  
  25. drupal_add_js($base . '/myapp.js');
  26. drupal_add_css($base . '/myapp.css');
  27.  
  28. return theme('myapp', array());
  29. }
  30.  
  31. // Implements hook_theme().
  32. function myapp_theme() {
  33. return array(
  34. 'myapp' => array(
  35. 'template' => 'myapp',
  36. ),
  37. );
  38. }
  39.  
  40.  
  41. // Implements hook_menu()
  42. function myapp_menu() {
  43. $items['myapp'] = array(
  44. 'page callback' => 'myapp_menu_callback',
  45. 'access callback' => TRUE,
  46. );
  47. return $items;
  48. }
  49.  
  50. // The menu callback to display the page
  51. function myapp_menu_callback() {
  52. drupal_add_js('https://ajax.googleapis.com/ajax/libs/angularjs/1.3.0/angular.min.js');
  53.  
  54. $base = drupal_get_path('module', 'myapp');
  55.  
  56. // data you want to pass to the app
  57. drupal_add_js(array(
  58. 'myapp' => array(
  59. 'title' => t('Hello World'),
  60. ),
  61. ), 'setting');
  62.  
  63. drupal_add_js($base . '/myapp.js');
  64. drupal_add_css($base . '/myapp.css');
  65.  
  66. return theme('myapp', array());
  67. }
  68.  
  69. // Implements hook_theme().
  70. function myapp_theme() {
  71. return array(
  72. 'myapp' => array(
  73. 'template' => 'myapp',
  74. ),
  75. );
  76. }
  77. myapp.js script
  78. JavaScript
  79. (function ($) {
  80. var app = angular.module("myApp", []);
  81. app.controller("myAppController", function($scope) {
  82. $scope.settings = Drupal.settings.myapp;
  83. $scope.count = 1;
  84.  
  85. $scope.updateCount = function(value) {
  86. $scope.count = $scope.count + value;
  87. }
  88.  
  89. $scope.myClass = function() {
  90. return "myclass-" + $scope.count;
  91. }
  92. })
  93. }(jQuery));
  94.  
  95.  
  96. (function ($) {
  97. var app = angular.module("myApp", []);
  98. app.controller("myAppController", function($scope) {
  99. $scope.settings = Drupal.settings.myapp;
  100. $scope.count = 1;
  101.  
  102. $scope.updateCount = function(value) {
  103. $scope.count = $scope.count + value;
  104. }
  105.  
  106. $scope.myClass = function() {
  107. return "myclass-" + $scope.count;
  108. }
  109. })
  110. }(jQuery));
  111. myapp.tpl.php template
  112. <div class="myapp" ng-app="myApp" ng-controller="myAppController">
  113. <h3 ng-class="myClass()">{{settings.title}}</h3>
  114. <p>
  115. <a class="btn btn-default" ng-click="updateCount(1)">Click</a>
  116. to increment {{count}}
  117. </p>
  118. </div>
  119.  
  120.  
  121. div class="myapp" ng-app="myApp" ng-controller="myAppController">
  122. h3 ng-class="myClass()">{{settings.title}}/h3>
  123. p>
  124. a class="btn btn-default" ng-click="updateCount(1)">Click/a>
  125. to increment {{count}}
  126. /p>
  127. /div>

Now, obviously we aren’t using the full Angular framework here. We aren’t using any directives, nor are we really using Angular as a MVC framework. But it gives you the idea of how easy it is to get started playing with basic Angular functionality.

Angular plus jQuery

Developing javascript applications in Angular requires a different mindset from normal Drupal and jQuery development. In jQuery you are often manipulating the DOM directly, whereas Angular is a full framework that allows data to be bound and manipulated on page elements. Trying to combine both is often a source of frustration unless you understand more about how Angular works behind the scenes. Specifically, Angular has it’s own execution loop causing a mix of Angular and jQuery code to not seem to execute in a straightforward order. For example, in the above code, we set the class of the H3 based on the current “count” variable. What if we modified the updateCount function to try and set a css property for this class:

  1. JavaScript
  2. $scope.updateCount = function(value) {
  3. $scope.count = $scope.count + value;
  4. $('.' + $scope.myClass()).css('color', 'red');
  5. }
  6.  
  7. $scope.updateCount = function(value) {
  8. $scope.count = $scope.count + value;
  9. $('.' + $scope.myClass()).css('color', 'red');
  10. }

If you click the button you’ll notice that the css color does NOT change to red! The problem is that Angular is executing the query function call BEFORE it actually updates the page. You need to delay the jQuery so it executes after the current Angular event loop is finished. If you change the code to:

  1. JavaScript
  2. $scope.updateCount = function(value) {
  3. $scope.count = $scope.count + value;
  4. setTimeout( function() {
  5. $('.' + $scope.myClass()).css('color', 'red');
  6. }, 1);
  7. }
  8.  
  9. $scope.updateCount = function(value) {
  10. $scope.count = $scope.count + value;
  11. setTimeout( function() {
  12. $('.' + $scope.myClass()).css('color', 'red');
  13. }, 1);
  14. }

then it will work. The timeout value can be anything greater than zero. It just needs to be something to take the jQuery execution outside the Angular loop. Now, that was a horrid example! You would never actually manipulate the css and class properties like this in a real application. But it was a simple way to demonstrate some of the possible pitfalls waiting to trap you when mixing jQuery with Angular.

Drupal Behaviors

When doing javascript the “Drupal way”, you typically create a behavior “attach” handler. Drupal executes all of the behaviors when the page is updated, passing the context of what part of the page has changed. For example, in an Ajax update, the DOM that was updated by Ajax is passed as the context to all attached behavior functions. Angular doesn’t know anything about these behaviors. When Angular updates something on the page, the behaviors are never called. If you need something updated from a Drupal behavior, you need to call Drupal.attachBehaviors() directly.

Angular with CTools modals

In the Open Atrium site map, we have buttons for adding a New Space or New Section. These are links to the Open Atrium Wizard module which wraps the normal Drupal node/add form into a CTools modal popup and groups the fields into “steps” that can be shown within vertical tabs. This is used to provide a simpler content creation wizard for new users who don’t need to see the full node/all form, and yet still allows all modules that hook into this form via form_alters to work as expected. The tricky part of this is that as you navigate through the sitemap, Angular is updating the URLs of these “New” links. But CTools creates a Drupal Ajax object for each link with the “ctools-use-modal” class in it’s Drupal behavior javascript. This causes the URL of the first link to be cached. When Angular updates the page and changes the link URLs, this Ajax object cache is not updated. To solve this within the Open Atrium Sitemap app, an event is called when the page is updated, and we update the cached Ajax object directly via the Drupal.ajax array. This was a rather kludgy way to handle it. Ultimately it would be better to create a true Angular “Directive” that encapsulates the CTools modal requirements in a way that is more reusable.

Summary

Angular can be a very useful framework for building highly interactive front-ends. Using Drupal as the backend is relatively straight-forward. Angular allowed us to create a very cool and intuitive interface for navigating and creating content quickly within Open Atrium far easier than it would have been in jQuery alone. In fact, we began the interactive site map tool in jQuery and the code quickly became unmanageable. Adding functionality such as drag/drop for rearranging your spaces and sections would have been a mess in jQuery. In Angular it was very straight-forward. Once you understand how Angular works, you’ll be able to blend the best of Drupal + jQuery + Angular into very rich interfaces. Programming in Angular is very different. L

Mike Potter

Mike Potter

Software Architect