Friday, July 11, 2014

Angular JS in ActiveAdmin

We use an angular app to display exams at BoardVitals.com. We found out that the Explanations being displayed for answers on the exams were differently displayed than how they are displayed in our ActiveAdmin edit pages.

This makes sense - AA uses simple textareas, whereas we render the field as HTML on the front end. What we wanted was a Live Preview feature, where admin users could see EXACTLY how explanations were being displayed to users, within active admin. I thought, why not inject Angular and render the exact same directive we use?

Let's get into it.

To be specific, here is the template for the Angular tag I used for explanations:

<p>{{response.question.combined_letter_choices}}</p>
<p ng-bind-html="visibleExplanation"></p>
<p id="reference" ng-bind-html="reference"></p>

Here is the directive for that tag:

angular.module("exam.directives")
.directive('explanationText', ['$sce', function($sce) {
  return {
    restrict: "E",
    templateUrl: 'exams/explanation_text.html',
    link: function($scope) {
      $scope.sanitizeAndSet = function(text) {
        $scope.visibleExplanation = $sce.trustAsHtml(text);
        // Used in ActiveAdmin to set explanation with jquery from outside.
      }
    }
  }
}])


I wanted to render this tag inside ActiveAdmin, beneath the explanation name field. To inject it, first I required my angular manifest inside active_admin.js.coffee, the manifest used by AA:

//= require active_admin/base
//= require angular_dash.js
//= require lodash

(I used coffeescript before I switched to angular...forgive me, haven't swapped it out yet.)

Angular_dash.js is pretty simple:

//= require angular
//= require angular-route
//= require angular-rails-templates
//= require_tree ../templates
//= require lodash
//= require dashboard/exam/exam.js
//= require_tree ./dashboard/exam

This would load the angular library in active admin. Next I had to place it in the page. But how to do that? AA uses a DSL from the FormTastic library, it won't let you edit HTML directly. You could inject it with a $.append call after the fact, but then you have to compile the Angular app and bootstrap it yourself. That sucks.

The solution is a custom input field. FormTastic lets you create these custom input classes with custom to_html methods and then you can use them in the forms. Create a folder called app/inputs, and add this file to it, called angular_input.rb:

For ActiveAdmin before Rails 4.2:

class AngularInput
  include Formtastic::Inputs::Base
  include Formtastic::Inputs::Base::Placeholder

  def to_html
    "<label class='label'><b>Live Preview</b></label>" +
    "<div ng-app='exam' class='admin_explanation' id='explanation'>" +
    "<explanation-text></explanation-text></div>"
  end
end

For ActiveAdmin Rails 4.2+:

class AngularInput
  include Formtastic::Inputs::Base
  def to_html
   html =  "<label class='label'><b>Live Preview</b></label>" +
    "<div ng-app='exam' class='admin_explanation' id='explanation'>" +
    "<explanation-text></explanation-text></div>"
    input_wrapping do
      builder.template.content_tag(:preview, html, { :id => "live_preview" }, false)
    end
  end
end


Then you can add this angular input to your form by doing:

    f.inputs :name => "Explanation", :for => [:explanation, f.object.explanation || Explanation.new] do |explanation_form|
      explanation_form.input :name
      explanation_form.input :preview, :as => :angular
....

As you can see, I just render it like any other input, and FormTastic injects my HTML and my angular app for me. It gets bootstrapped normally. 

Finally, I want to tie the explanation name field to the angular rendered tag. I do this in active_admin.js.coffee:

    # Automatically sets the field inside the explanation element in angular with the changes from the field.
    # Uses a clone of the gsub logic on production-side to mimic the changes that happen there.
    if($("#question_explanation_attributes_name"))
      adjustAngular = (e) ->
        visibleExplanation = $("#question_explanation_attributes_name").val().replace(/[\r\n]/g,"<br/>\n")
        angular.element('#explanation explanation-text').scope().$apply((scope) ->
           scope.sanitizeAndSet(visibleExplanation)
        )
      $("#question_explanation_attributes_name").on('keypress', adjustAngular);
      adjustAngular();

Notice my use of the sanitizeAndSet method I defined in the directive. This is because you can't access the $sce service from $apply, but you can from the linking function.

The admin_explanation class I use in the video is this:

ol div.admin_explanation {
  width: 76%;
  margin-left: 20.5%;
  margin-bottom: 20px;
  background-color: white;
  box-shadow: 0 1px 1px;
  border-color:#dddddd;
  border: 1px solid transparent;
  border-radius: 4px;
}

if you're interested.

I made a screencast view of the result:

http://screencast.com/t/YPUenkiFjf (You'll have to copy paste this link)