How to create your own Single Page Application Autoresponder with Rails 4 and Angular.js (Part 4)

Reading Time: 15 minutes

Welcome back to the part #4 of "How to create your own Single Page Application Autoresponder with Rails 4 and Angular.js".

What are we going to do in this part?
Now, as we were working on adding subscribers to the email list by using an embedded code, we are going to continue working on subscribers and specifically we are going to implement some views and logic in order to manage subscribers.

It's worth mentioning some of the subjects that we are covering in this part:

  • We are using a paper_trail gem that gives us the advantage of tracking changes to our models for auditing or versioning. This is also used to see how a model looks at any stage in it's lifecycle, reverting it to any version, or restoring it after it has been destroyed.
  • By using Paper trail versioning, we will implement the undo destroy action when deleting subscribers.
  • We also are going to use the Angular event propagation when implementing the undo destroy action. In fact, we are going to create a new directive to handle that dom manipulation.
  • We will add the destroy action to the email list. While we work on that, we are also implementing ngDialog in order to inform the users about the operation they are about to perform. This will ask the user to confirm by typing a special text before being able to delete that resource.
  • We are implementing infinite scroll to list subscribers. This feature involves adding kaminari to control the pagination.

So let's get started managing all the subscribers email lists.

I would like to start by adding all the code required from the backend side and then focus on the frontend side:

As you read in the introduction, we are using Kaminari for pagination and paper_trail to control the versions which will be able to undo actions. Let us add them to the Gemfile:

# Gemfile
gem 'kaminari'
gem 'paper_trail', '~> 4.0.0'

Install dependencies

bundle install

We'll install paper_trail running to the command bellow:

bundle exec rails generate paper_trail:install

In this case, we'd want to save only 4 versions for each record to setup, we can create an initializer and set that up for paper_trail:

# config/initializers/paper_trail.rb
PaperTrail.config.version_limit = 3

We are using metadata when saving data in paper_trail so we can save custom fields for specific models. The information that we would want to save is the email_list_id. We can add this column when modifying the existent migration generated by paper_trail:install or to create a new migration. I would recommend choosing the second option:

rails g migration add_email_list_id_to_versions email_list_id:integer

Now, we run the new migrations that we just added:

bundle exec rake db:migrate

Here are the new routes that we'll use:

# config/routes.rb
# add this routes inside the :v1 namespace do
resources :subscribers do
  collection do
    post 'validate_email', to: 'subscribers#validate_email'
    post 'validate_email_uniqueness', to: 'subscribers#validate_email_uniqueness'
  end
end

resource :versions, only: :none do
  collection do
    post 'undo', to: 'versions#undo'
  end
end

Let's add uniqueness validation for email subscribers:

# app/models/subscriber.rb
validates :email, uniqueness: { scope: :email_list_id }

# code to start versioning all the operations
# For tracking versions by using meta data, we can make some queries like
# PaperTrail::Version.where(:email_list_id => email_list_id)
# For example, to restablish all the subscribers for an email list
# when using meta data, do not forget to create a new migration for it
has_paper_trail meta: { email_list_id: :email_list_id }

In this case, when we want to destroy the email list, we would destroy all the subscribers that belong to that email list. We are modifying the association between email_list and subscriber below:

# app/models/email_list.rb
has_many :subscribers, dependent: :destroy

Now the subscribers controller might look like this:

# app/controllers/api/v1/subscribers_controller.rb
class Api::V1::SubscribersController < Api::BaseController

  before_action :find_email_list
  before_action :find_subscriber, except: [:index, :create, :validate_email_uniqueness]

  PER_PAGE_RECORDS = 100

  def index
    page_count = params[:page_count].try(:to_i) || 1
    subscribers = @email_list.subscribers
                             .page(page_count)
                             .per(PER_PAGE_RECORDS)
                             .order('created_at DESC')

    serialized_data = serialize_data(subscribers,
                                    SubscriberSerializer,
                                    root: false)

    render_json_dump(subscribers: serialized_data,
                     page_count: page_count,
                     tot_subscribers: subscribers.total_count,
                     tot_pages: subscribers.total_pages)
  end

  def create
    subscriber = @email_list.subscribers.create(subscriber_params)

    if subscriber.valid?
      render_serialized(subscriber, SubscriberSerializer, root: false)
    else
      render_error_object(subscriber.errors.messages)
    end
  end

  def show
    render_serialized(@subscriber, SubscriberSerializer, root: false)
  end

  def update
    if @subscriber.update(subscriber_params)
      render_serialized(@subscriber, SubscriberSerializer, root: false)
    else
      render_error_object(@subscriber.errors.messages)
    end
  end

  def destroy
    if @subscriber.destroy
      version = @subscriber.versions.last
      render_serialized(version, VersionSerializer, root: false)
    else
      render_with_error('An error occured while deleting the subscriber')
    end
  end

  def validate_email_uniqueness
    already_exists = @email_list.subscribers
                                .where(email: params[:email])
                                .exists?
    if already_exists
      render_with_error('That email is already added in the list')
    else
      render_json_message('Email list was deleted successfully')
    end
  end

  def validate_email
    already_exists = @email_list.subscribers
                                .where(email: params[:email])
                                .where("id  #{@subscriber.id}")
                                .exists?
    if already_exists
      render_with_error('That email is already added in the list')
    else
      render_json_message('Email list was deleted successfully')
    end
  end

  private

    def find_email_list
      uuid = params['email_uuid']
      @email_list = EmailList.find_by(secure_key: uuid)
    end

    def find_subscriber
      id = params['id']
      @subscriber = @email_list.subscribers.find(id)
    end

    def subscriber_params
      params.require(:subscriber)
            .permit(
              :name,
              :email
            )
    end
end

As you may have noticed, we are using the SubscriberSerializer - so let's declare it:

# app/serializers/subscriber_serializer.rb
class SubscriberSerializer < ActiveModel::Serializer
  attributes :name,
             :created_at,
             :id,
             :email

  def created_at
    I18n.localize object.created_at, format: :subscriber_since
  end
end

For the created_at field, we are using a translation in order to format the created_at date, so let's add the format in our locales yaml file:

# config/locales/en.yml
en:
  time:
    am: am
    formats:
      default: "%a, %d %b %Y %H:%M:%S %z"
      long: "%B %d, %Y %H:%M"
      short: "%d %b %H:%M"
      subscriber_since: ! '%d %b %H:%M, %H:%M hrs'

Implementation of undo actions

# app/controllers/api/v1/versions_controller.rb
class Api::V1::VersionsController < Api::BaseController

  before_action :find_element_version

  def undo
    if is_user_creator?
      @current_version.reify.save!
      render_json_message('Undo was applied successfully')
    else
      activerecord_not_found
    end
  end

  private

    def is_user_creator?
      @current_version.whodunnit.to_i == current_user.id
    end

    def find_element_version
      @current_version = PaperTrail::Version.find(params[:id])
    end
end

The code above uses the VersionSerializer, and it looks like this:

# app/serializers/version_serializer.rb
class VersionSerializer  "{ 'active': $state.includes('email_list.details.form') }"}
      %a{:href => "#", 'ui-sref' => 'email_list.details.form({ emailListId: email_list.id })'}
        Email list details

    %li{'ng-class' => "{ 'active': $state.includes('email_list.details.signup_forms') }"}
      %a{:href => "#", 'ui-sref' => 'email_list.details.signup_forms.settings({ emailListId: email_list.id })'}
        Sign up forms

    %li{'ng-class' => "{ 'active': $state.includes('email_list.details.subscribers') }"}
      %a{:href => "#", 'ui-sref' => 'email_list.details.subscribers.list({ emailListId: email_list.id })'}
        Manage subscribers

    %li{'ng-class' => "{ 'active': $state.includes('email_list.details.follow_ups') }"}
      %a{:href => "#", 'ui-sref' => 'email_list.details.follow_ups({ emailListId: email_list.id })'}
        Follow ups

    %li{'ng-class' => "{ 'active': $state.includes('email_list.details.stats') }"}
      %a{:href => "#", 'ui-sref' => 'email_list.details.stats({ emailListId: email_list.id })'}
        Stats

    %li{'ng-class' => "{ 'active': $state.includes('email_list.details.subscribers.add') }"}
      %a{:href => "#", 'ui-sref' => 'email_list.details.subscribers.add({ emailListId: email_list.id })'}
        Add subscribers

%ui-view

Now, we'll update the email list template with the new updated states:

Additionally, we are changing the markup and adding a new link that is the responsible of destroying the email list. We'll implement that feature later on this tutorial.

// app/assets/javascripts/autoresponder/components/email_lists/index.html.haml
#email-lists
  .row
    %button{class: 'btn btn-success pull-right', 'ui-sref' => 'email_list.add'}
      Create List
  .row{class: 'span12', "ng-repeat" => "list in email_list"}
    .col-md-5
      %h4
        {{list.name}}
      %p {{list.created_at}}

    .col-md-4
      .data.inline-block{"aria-hidden" => "true"}
        %h4 0
        %p.dim-el Subscribers
      .data.inline-block{"aria-hidden" => "true"}
        %h4 0.0%
        %p.dim-el Opens
      .data.inline-block{"aria-hidden" => "true"}
        %h4 0.0%
        %p.dim-el Clicks

    .col-md-3.actions
      .btn-group
        %button.btn.btn-default{:type => "button",
                                'ui-sref' => 'email_list.details.subscribers.list({ emailListId: list.id })'} Manage subscribers
        %button.btn.btn-default.dropdown-toggle{"aria-expanded" => "false", "aria-haspopup" => "true", "data-toggle" => "dropdown", :type => "button"}
          %span.caret
          %span.sr-only Toggle Dropdown
        %ul.dropdown-menu
          %li
            %a{:href => "#", 'ui-sref' => 'email_list.details.signup_forms.settings({ emailListId: list.id })'} Signup Form
          %li
            %a{:href => "#", 'ui-sref' => 'email_list.details.follow_ups({ emailListId: list.id })'} Follow ups
          %li
            %a{:href => "#", 'ui-sref' => 'email_list.details.stats({ emailListId: list.id })'} Stats
          %li
            %a{:href => "#", 'ui-sref' => 'email_list.details.add_subscriber({ emailListId: list.id })'} Add Subscribers
          %li.divider{:role => "separator"}
          %li
            %a{:href => "", 'ng-click' => 'openModal(list)'} Destroy

As we don't need some files anymore, we can delete them:

rm app/assets/javascripts/autoresponder/components/email_lists/details/add_subscriber.html.haml app/assets/javascripts/autoresponder/components/email_lists/details/subscribers.html.haml

Before moving on, let's create the new subscribers folder location:

mkdir app/assets/javascripts/autoresponder/components/email_lists/details/subscribers

Now, we can start listing the subscribers:

Here is how the subscribers list template looks like:

// app/assets/javascripts/autoresponder/components/email_lists/details/subscribers/list.html.haml
%div{"infinite-scroll" => "loadMoreSubscribers()",
     'infinite-scroll-disabled' => 'inifiteScrollStatus',
     "infinite-scroll-distance" => "0"}
  .row.subscribers-list
    .col-md-4{"ng-repeat" => "subscriber in subscribers"}
      .subscriber-element{'ng-click' => 'goToSubscriberDetails(subscriber.id)'}
        %p.email
          {{ subscriber.email }}
        %p.name
          {{ subscriber.name }}

Here's the subscribers controller:

# app/assets/javascripts/autoresponder/components/email_lists/details/subscribers/subscribersCtrl.js
AutoresponderApp
  .controller('subscribersCtrl', [
    '$scope',
    'findCurrentEmailListInParent',
    '$location',
    'SubscriberService',
    function (
      $scope,
      findCurrentEmailListInParent,
      $location,
      SubscriberService
    ) {

      var initialSetup = function(){
        $scope.page_number = $scope.$stateParams.pageNumber || 1;
        $scope.subscribers = [];
        $scope.loading_subscribers = false;

        // we are initializing this value as false
        $scope.inifiteScrollStatus = true;
      }

      initialSetup();

      var loadEmailListSubscribers = function() {
        if($scope.loading_subscribers) { return };
        $scope.loading_subscribers = true;

        var _data = {
          email_uuid: findCurrentEmailListInParent.secure_key,
          page_count: $scope.page_number
        }

        return SubscriberService.findAll(_data).then(function(successResponse) {
          $scope.loading_subscribers = false
          $scope.subscribers = $scope.subscribers.concat(successResponse.subscribers);
          $scope.subscribers_count = successResponse.tot_subscribers;
          $scope.page_number = successResponse.page_count + 1;
          $scope.tot_pages = successResponse.tot_pages;
          $scope.inifiteScrollStatus = false;
        });
      };

      $scope.loadMoreSubscribers = function() {
        if(showLoadMore()) {
          loadEmailListSubscribers();
        }
      };

      var showLoadMore = function() {
        return ($scope.page_number  "SubscriberAddForm",
      "ng-submit" => "addSubscriber()",
      :novalidate => ""}
  .row
    .col-md-4

      .form-group
        %label{:for => "name"} Name
        %input.form-control{:type => "text",
                            'required' => 'required',
                            :placeholder => 'Full name',
                            'name' => 'name',
                            "ng-model" => 'subscriber.name'}/

      .form-group{'ng-class' => "{ 'has-error': SubscriberAddForm.email.$dirty && SubscriberAddForm.email.$invalid }" }
        %label{:for => "already-subscribed"} Email
        %input.form-control{:type => "email",
                            'required' => 'required',
                            'ensure-unique-email-by-list' => "",
                            :placeholder => 'email',
                            'name' => 'email',
                            'ng-model' => 'subscriber.email'}/
        .errors{"ng-show" => "SubscriberAddForm.email.$dirty && SubscriberAddForm.email.$invalid"}
          %span.help-block{"ng-show" => "SubscriberAddForm.email.$error.unique"} That email is already added in the list

  .row
    .col-md-6
      %button{ type:'submit',
               'ng-disabled' => "SubscriberAddForm.$invalid",
               class: 'btn btn-success'}
        Add Subscriber

Here's what the addSubscriberCtrl looks like:

# app/assets/javascripts/autoresponder/components/email_lists/details/subscribers/addSubscriberCtrl.js
AutoresponderApp
  .controller('addSubscriberCtrl', [
    '$scope',
    'findCurrentEmailListInParent',
    '$location',
    'Render',
    'SubscriberService',
    function (
      $scope,
      findCurrentEmailListInParent,
      $location,
      Render,
      SubscriberService
    ) {

      $scope.subscriber = {};

      $scope.addSubscriber = function() {
        var _data;
        _data = {};

        _data.subscriber = $scope.subscriber;
        _.extend(_data, { email_uuid: findCurrentEmailListInParent.secure_key });

        SubscriberService.create(_data).then(function(list) {
          Render.showGrowlNotification('success', 'The subscriber was created successfully');
          return $scope.$state.go('^.list');
        }, function(errorResponse) {
          return Render.showGrowlNotification('warning', 'An error occurred while creating the subscriber');
        });
      };
    }
  ])

When creating a new subscriber, we should validate that the email is not already taken in that email list. To do that, we validate the email uniqueness by using a custom directive called "ensureUniqueEmailByList":

// app/assets/javascripts/autoresponder/components/email_lists/details/subscribers/ensureUniqueEmailByListDirective.js
AutoresponderApp

  .directive(
    'ensureUniqueEmailByList',
    ['SubscriberService',
      function(SubscriberService) {

    return {
      require: 'ngModel',
      link: function($scope, ele, attrs, c) {
        function validateEmailUnique() {
          if(this.last) {
            var _data = {
              email_uuid: $scope.email_list.secure_key,
              subscriber_id: $scope.subscriber.id,
              email: $scope.subscriber.email
            }

            SubscriberService.validateEmailUnique(_data).then(function(list) {
              c.$setValidity('unique', true);
            }, function(errorResponse) {
              c.$setValidity('unique', false);
            });
          }
        }
        $scope.$watch(attrs.ngModel, validateEmailUnique);
      }
    }
  }])

So, if we try to add the same email in the email list we should see this error:

email directive remote validation

Now, it is time to implement the Subscriber detail page.

Inside the subscriber details page, we'll add a button to delete the current subscriber. However, we want to avoid the pop up confirmation by using undo notifications instead.

Preferring undos instead of prompting for confirmation actions.

Applications that uses undos respect the initial human intent by allowing the action. Prompting messages on the other hand suggests by default that the users do not know what they're doing. It questions them by saying "Are you sure to...". Most of the time those actions are intended and only a couple of them occur accidentally. The inefficiency of prompting messages is visible when the users are doing the same action multiple times, and then prompts will be shown numerously over and over.

That being said, do we want to use undos in our application? The answer is yes, but first, let us prepare the Angular application to support notifications and let's show them the center position of the main header:

Let's start by doing some changes to the application.html.haml layout. Here, we are modifying the header columns by adding a directive to handle notifications. This is how it looks after applying the new modifications:

// app/views/layouts/application.html.haml
!!!
/[if lt IE 7] 
/[if IE 7] 
/[if IE 8] 
/ [if gt IE 8]> "", 'ng-app' =>"AutoresponderApp", 'ng-controller' => "mainCtrl"}
  /  "utf-8"}/
    %meta{:content => "IE=edge,chrome=1", "http-equiv" => "X-UA-Compatible"}/
    %title
    %meta{:content => "", :name => "description"}/
    %meta{:content => "width=device-width, initial-scale=1", :name => "viewport"}/
    %link{:href => "apple-touch-icon.png", :rel => "apple-touch-icon"}/
    = stylesheet_link_tag    "application", media: "all"
    = javascript_include_tag "application", "data-turbolinks-track" => true
    = csrf_meta_tags
  %body
    /[if lt IE 8]
      <p class="browserupgrade">You are using an <strong>outdated</strong> browser. Please <a href="http://browsehappy.com/">upgrade your browser</a> to improve your experience.</p>
    .navbar.navbar-inverse.navbar-custom.navbar-fixed-top{:role =&gt; "navigation"}
      .container
        .row
          .col-md-4
            %button.navbar-toggle{"data-target" =&gt; ".navbar-collapse", "data-toggle" =&gt; "collapse", :type =&gt; "button"}
              %span.sr-only Toggle navigation
              %span.icon-bar
              %span.icon-bar
              %span.icon-bar
            %a.navbar-brand{:href =&gt; "#/"} MailAssemble
          .col-md-4
            .div#system-notifications{'ng-show' =&gt; 'undo_action', 'undo-notification' =&gt; ''}
              %p
                %b The {{ message_notification }} has been deleted
                %a{'href' =&gt; '', 'ng-click' =&gt; 'undoAction()'}
                  undo action
          .col-md-4
            #main-menu.collapse.navbar-collapse
              %ul.nav.navbar-nav.navbar-right
                %li
                  %a{:href =&gt; "/#email_list"} Manage lists
                %li.dropdown
                  %a.dropdown-toggle{"data-toggle" =&gt; "dropdown", :href =&gt; "#"}
                    Manage lists
                    %b.caret
                  %ul.dropdown-menu
                    %li
                      %a{:href =&gt; "/#email_list"} Manage lists
                    %li
                      %a{:href =&gt; "/#email_list/add"} Create a list
                -if user_signed_in?
                  %li
                    = link_to('Log Out', destroy_user_session_path, :method =&gt; :delete)

        / /.nav-collapse
    .container{:style =&gt; "padding-top: 60px"}
      %div{:growl =&gt; ""}
      %ui-view
      = yield

We are using the "mainCtrl" which is the highest controller (hierarchically), and the included code for now should look like:

// app/assets/javascripts/autoresponder/components/mainCtrl.js
AutoresponderApp
  .controller('mainCtrl', [
    '$scope',
    'Render',
    'versionControlService',
    function (
      $scope,
      Render,
      versionControlService
    ) {

      // hiden div notification by default
      $scope.undo_action = false;

      $scope.$on('version:destroyed', function(evt, next, current) {
        // listening to events, this one is called
        // within the subscribersCtrl in deleteSubscriber method
        // we save the value that we want to use
        // in the undoAction
        $scope.currentVersion = next;
        // we are listening to this event in the undoNotification directive
        // so we can show hide the elements
        $scope.$emit('version:send_notification');
      });

      $scope.undoAction = function() {
        versionControlService.undo($scope.currentVersion.id).then(function(version) {
          // After undoing the action let's hide the element
          $scope.undo_action = false;
          Render.showGrowlNotification('success', 'The undo action was perfomed successfully');
          var state = versionControlService.handleRedirectState($scope.currentVersion);
          $scope.$state.go(state, {}, { reload: true });
        }, function(errorResponse) {
          return Render.showGrowlNotification('warning', 'An error occurred while deleting the subscriber');
        });
      }
    }
  ])

As you can see above, we are using $emit. This is a good moment to talk a little bit about event propagation in Angular.

Let's start by defining what are the angular events.

Just like browsers respond to browser-level events, such as a mouse hover or mouse click, Angular can respond appropiately to Angular events. This feature gives us the advantage of being able to communicate between components, controllers, directives and so on. By design we can only listen for Angular events.

Since scopes are hierarchical, we can pass events up and down the scope. For instance, if we want to communicate the entire system from top - bottom, we would want to use a broadcast downwards. But, what if what we wanted is to communicate from child to parent scopes? We can use $emit to pass an event upwards.

Even though the events are powerful, is a good idea to limit the number of notifications sent on a global level.

How to use $emit()?

We'll use $emit() when we want to send an event from child scopes to parent scopes.

Listening for events

For listening, we can make use of the $on() method, which will register a listener for a particular event name.
For example, we can listen for the event that is fired when the route resolves successfully:
scope.$on('$stateChangeSuccess', function(evt, next, current) {
// the route has resolved successfully
});

At this point, we are using just $emit() but later on in this tutorial we are going to include some code in order to listen when the event "version:send_notification" changes, so for this moment we'll use $on.

Now, we are going to declare a new service for handling request for PaperTrail::Version:

// app/assets/javascripts/autoresponder/components/common/versionControlService.js
AutoresponderApp
  .factory('versionControlService', ['Restangular',
                            function(
                              Restangular
                            ) {
    var model;

    model = 'api/v1/versions';

    return {
      undo: function(versionId) {
        var _params = {
          id: versionId
        }
        return Restangular.all(model + '/undo').post(_params);
      },

      handleRedirectState: function(version) {
        var state;

        switch (version.item_type) {
          case "Subscriber":
            state = 'email_list.details.subscribers.list';
          break;
          case "EmailList":
            state = 'email_list.details.subscribers.list';
          break;
          default:
            state = ''
        }

        return state;
      }
    };
  }
]);

and we are removing the content from users/index.html.haml view:

// app/views/users/index.html.haml
//empty view
// for more info take a look at the application.html.haml

Showing details for subscribers:

Content for the main template that shows the different options for an individual subscriber:

// app/assets/javascripts/autoresponder/components/email_lists/details/subscribers/show.html.haml
.row
  .email-list-details.col-md-3.col-md-offset-9
    %ul.nav-menu.text-right
      %li{'ng-class' =&gt; "{ 'active': $state.includes('email_list.details.subscribers.list') }"}
        %a{:href =&gt; "#", 'ui-sref' =&gt; '^.list'}
          List all

      %li{'ng-class' =&gt; "{ 'active': $state.includes('email_list.details.subscribers.show.details') }"}
        %a{:href =&gt; "#", 'ui-sref' =&gt; '^.show.details'}
          Details

      %li{'ng-class' =&gt; "{ 'active': $state.includes('email_list.details.subscribers.show.activity') }"}
        %a{:href =&gt; "", 'ui-sref' =&gt; '^.show.activity'}
          Activity

.row
  %ui-view

Template that shows the subscriber details:

// app/assets/javascripts/autoresponder/components/email_lists/details/subscribers/show.details.html.haml
.row.subscriber-details
  .row
    .image
      = image_tag 'default-profile.png', :width =&gt; "84"
    .body
      %h4 {{ subscriber.email }}
      %h5 {{ subscriber.name }}
      %p Subscribed: {{subscriber.created_at}}
      %button{ type:'submit',
               "ng-click" =&gt; "deleteSubscriber()",
                class: 'btn btn-danger'}
        Delete Subscriber


  .row
    .col-md-12
      %h4.title
        Subscriber Details
      %button{ type:'submit',
               "ng-click" =&gt; "editableForm.$show()",
                class: 'btn btn-success'}
        Edit

  %form{"editable-form" =&gt; "", :name =&gt; "editableForm", :onaftersave =&gt; "updateSubscriber()"}

    .row
      .col-md-6
        %h4 Email Address
        %p.fields{'ng-show' =&gt; '!editableForm.$visible'}
          {{ subscriber.email }}
        %p.fields{'ng-show' =&gt; 'editableForm.$visible'}
          %span{"e-name" =&gt; "email",
                'onbeforesave' =&gt; "validateEmail($data)",
                "e-required" =&gt; "",
                "editable-text" =&gt; "subscriber.email"}

      .col-md-6
        %h4 Name
        %p.fields{'ng-show' =&gt; '!editableForm.$visible'}
          {{ subscriber.name }}
        %p.fields{'ng-show' =&gt; 'editableForm.$visible'}
          %span{"e-name" =&gt; "name", "editable-text" =&gt; "subscriber.name"}

    .row{'ng-show' =&gt; 'editableForm.$visible'}
      .col-md-12
        %div
          / button to show form
          %button.btn.btn-default{"ng-click" =&gt; "editableForm.$show()", "ng-show" =&gt; "!editableForm.$visible", :type =&gt; "button"}
            Edit
          / buttons to submit / cancel form
          %span{"ng-show" =&gt; "editableForm.$visible"}
            %button.btn.btn-primary{"ng-disabled" =&gt; "editableForm.$waiting", :type =&gt; "submit"}
              Save
            %button.btn.btn-default{"ng-click" =&gt; "editableForm.$cancel()", "ng-disabled" =&gt; "editableForm.$waiting", :type =&gt; "button"}
              Cancel

Now, if we click on a subscriber this is how it looks so far:
subscriber details page

What you can see here is that the margins seem like they are wrong. To fix that we are going to use the railstrap gem. When we installed it, the file app/assets/stylesheets/bootstrap_and_overrides.css.les was created, so we can override some boostrap styles. In this case it is the class .row.
First, we need to include that file to the application.js file at the bottom:

# app/assets/stylesheets/application.css
*= require bootstrap_and_overrides

and the content that we'd add looks like:

// app/assets/stylesheets/bootstrap_and_overrides.css.less
.row {
  margin-left: 0px;
  margin-right: 0px;
}

As you see, we are using an image in the template that is located in:

app/assets/images/default-profile.png

And for what is missing for now, I'll add it for you in the pull request in this tutorial so you can download it.

Continuing with the subscriber details section templates for now, let's just declare an empty template for subscriber activity:

// app/assets/javascripts/autoresponder/components/email_lists/details/subscribers/show.activity.html.haml
%h1
  Activity

Here's what the subscriberDetails controller looks like:

# app/assets/javascripts/autoresponder/components/email_lists/details/subscribers/subscriberDetailsCtrl.js
AutoresponderApp
  .controller('subscriberDetailsCtrl', [
    '$scope',
    'Render',
    '$q',
    '$http',
    'findCurrentEmailListInParent',
    '$location',
    'SubscriberService',
    function (
      $scope,
      Render,
      $q,
      $http,
      findCurrentEmailListInParent,
      $location,
      SubscriberService
    ) {

      var data, subscriberId;

      _data = {
        email_uuid: findCurrentEmailListInParent.secure_key
      };

      subscriberId = $scope.$stateParams.subscriberId;

      var loadSubscriber = function() {
        return SubscriberService.findOne(subscriberId, _data).then(function(subscriber) {
          $scope.subscriber = subscriber;
        });
      };

      loadSubscriber();

      $scope.updateSubscriber = function() {
        var _data;
        _data = {};

        _data.subscriber = $scope.subscriber.plain();

        _.extend(_data, {email_uuid: findCurrentEmailListInParent.secure_key});

        SubscriberService.update(_data, $scope.subscriber.id).then(function(subscriber) {
          Render.showGrowlNotification('success', 'The subscriber was updated successfully');
          return $scope.subscriber = subscriber;
        }, function(errorResponse) {
          return Render.showGrowlNotification('warning', 'An error occurred while updating the subscriber');
        });

      };

      $scope.deleteSubscriber = function() {
        var _data;
        _data = {};

        _data = {
          email_uuid: findCurrentEmailListInParent.secure_key,
          id: $scope.subscriber.id,
        }

        SubscriberService.destroy($scope.subscriber.id, _data).then(function(version) {
          // using emit to comunicate events
          // from children to parents
          // if you want to use from parents to children
          // use broadcast instead
          $scope.$emit('version:destroyed', version.plain());
          Render.showGrowlNotification('success', 'The subscriber was deleted sucessfully');
          $scope.$state.go('email_list.details.subscribers.list');
        }, function(errorResponse) {
          return Render.showGrowlNotification('warning', 'An error occurred while deleting the subscriber');
        });

      };

      $scope.validateEmail = function(data) {
        var _data = {};

        _data = {
          email_uuid: findCurrentEmailListInParent.secure_key,
          id: $scope.subscriber.id,
          email: data
        }

        var d = $q.defer();

        SubscriberService.validateEmailSubscriberUpdate(_data).then(function(successResponse) {
          d.resolve()
        }, function(errorResponse) {
          d.reject(errorResponse.data.error);
        });

        return d.promise;
      }
    }
  ])

Now, we are able to see the subscriber details and actually we can delete it.
subscriber details page and delete

But we are not able to edit the details for that subscriber, so we are going to implement the subscriber edition using edition in place with angular-xeditable directive.

Let's start adding the gem to the Gemfile inside the rails assets group:

gem 'rails-assets-angular-xeditable', '= 0.1.8'

Install dependencies:

bundle install

Include it in the application.js file:

// app/assets/javascripts/application.js
//= require angular-xeditable

Include the angular dependency:

// app/assets/javascripts/autoresponder/app.js
'xeditable',

And if you want to have control on the styles used in xeditable, you can copy the based css styles so you can modify them based on your needs:

/*app/assets/stylesheets/modules/forms.scss*/
/*!
angular-xeditable - 0.1.8
Edit-in-place for angular.js
Build date: 2014-01-10 
*/

.editable-wrap {
  display: block;
  display: inline-block;
  margin: 5px 0px 10px 0;
  width: 80%;
}

.editable-wrap .editable-controls,.editable-wrap .editable-error {
  margin-bottom: 0;
  color: red;
}

.editable-wrap .editable-controls>input,.editable-wrap .editable-controls>select,.editable-wrap .editable-controls>textarea {
  margin-bottom:0
}

.editable-wrap .editable-input {
  display:inline-block;
  width: 100%;
  height: 34px;
  padding: 6px 12px;
  font-size: 14px;
  line-height: 1.42857143;
  color: #555;
  background-color: #fff;
  background-image: none;
  border: 1px solid #ccc;
}

.editable-buttons { 
  display:inline-block;
  vertical-align:top
}

.editable-buttons button { 
  margin-left:5px
}

.editable-input.editable-has-buttons{
  width:auto
}

.editable-bstime .editable-input input[type=text]{
  width:46px
}

.editable-bstime .well-small{
  margin-bottom:0;
  padding:10px
}

.editable-range output{
  display:inline-block;
  min-width:30px;
  vertical-align:top;
  text-align:center
}

.editable-color input[type=color]{
  width:50px
}

.editable-checkbox label span,.editable-checklist label span,.editable-radiolist label span {
  margin-left:7px;
  margin-right:10px
}

.editable-hide{
  display:none!important
}

.editable-click,a.editable-click {
  text-decoration:none;
  color:#428bca;
  border-bottom:dashed 1px #428bca
}

.editable-click:hover,a.editable-click:hover {
  text-decoration:none;
  color:#2a6496;
  border-bottom-color:#2a6496
}

.editable-empty,.editable-empty:hover,.editable-empty:focus,a.editable-empty,a.editable-empty:hover,a.editable-empty:focus {
  font-style:italic;
  color:#D14;
  text-decoration:none
}

Let's include the forms styles in the manifest file:

/*app/assets/stylesheets/main.scss*/
@import 'modules/forms';

Now, if we edit a subscriber, it should look like:
subscriber details page edit in place

So, finally, it is time to start working with undo deletion messages. Let's start adding some styles for notifications mesages:

/*app/assets/stylesheets/main.scs*/

#system-notifications {
  background-color: #f9edbe;
  border: 1px solid #f0c36d;
  font-size: 13px;
  height: 28px;
  margin-top: 12px;
  padding: 3px 0px 0px 0px;
  text-align: center;

  a {
    text-decoration: underline;
  }
}

Let's implement the directive responsible for showing/hidding undo actions notifications messages:

// app/assets/javascripts/autoresponder/components/common/undoActionNotificationDirective.js
AutoresponderApp
  .directive('undoNotification', [ '$timeout', function($timeout) {
    var timeoutId;

    var decorateLabelType = function(type) {
      var object_title;

      switch (type) {
        case "Subscriber":
          object_title = 'subscriber';
        break;
        case "EmailList":
          object_title = "email list";
        break;
        default:
          object_title = "";
      }

      return object_title;
    }

    return {
      restrict: 'A',
      link: function( $scope, elem, attrs) {

        $scope.$on('version:send_notification', function(evt, next, current) {
          // let's show the element
          $scope.undo_action = true;
          $scope.message_notification = decorateLabelType($scope.currentVersion.item_type);

          // we are canceling the previous timer
          // if a new one is created
          // in order to avoid closing the notification
          // right after deleting an element
          $timeout.cancel(timeoutId);

          var _this = this;

          timeoutId = $timeout(function() {
            $scope.undo_action = false;
          }, 10000);
          });
      }
    };
  }])

Now if we delete a subscriber, we are able to undo that action (yaay!), so you should see something like this after deleting a subscriber:
subscriber delete undo action

What's next?,

In the next and last section, we will implement the destroy option for email lists, and should look like this:
destroying email lists 1

destroying email lists 2

destroying email lists 3

In order to implement that feature, we are using the ngDialog directive.

Let's start by adding that library to the project:

We need to add that dependency in our Gemfile:

source 'https://rails-assets.org' do
  gem 'rails-assets-ngDialog'
end
# dont't forget to run bundler: bundle install 

Let's open up and require ndDialoh in the manifest file:

// app/assets/javascripts/application.js
//= require ngDialog

We also need to include css styles:

# app/assets/stylesheets/application.css
*= require ngDialog

And the dependency to the main app.js file as well:

// app/assets/javascripts/autoresponder/app.js
var AutoresponderApp = angular.module('AutoresponderApp', [
  'ngDialog',
  .
  .
  .
]);

Let's open up the email list controller, and include the ngDialog dependency:

// app/assets/javascripts/autoresponder/components/email_lists/emailListCtrl.js
  .controller('emailListCtrl', [
    '$scope',
    'ngDialog',
    'Render',
    'EmailListService',
    function (
      $scope,
      ngDialog,
      Render,
      EmailListService
    ) {

      $scope.openModal = function(current_list) {
        $scope.current_list = current_list;
        ngDialog.open({
          template: 'autoresponder/components/email_lists/warningDestroy.html',
          className: 'ngdialog-theme-default',
          scope: $scope
        });
      };

      $scope.destroyEmailList = function(emailListId) {
        EmailListService.destroy(emailListId).then(function(removed_list) {
          Render.showGrowlNotification('success', 'The email list was deleted sucessfully');
          ngDialog.closeAll()
          return $scope.$state.go('email_list.list', {}, { reload: true });
        }, function(errorResponse) {
          return Render.showGrowlNotification('warning', 'An error occurred while deleting the email list');
        });
      }
    }
  ])

We'll include the destroy method inside the emailService:

// app/assets/javascripts/autoresponder/components/email_lists/emailListService.js
destroy: function(emailListId) {
  return Restangular.one(model, emailListId).remove();
},

The template that we want to add will show a warning message. Before deleting the email list, the user will be asked to confirm the operation by typing the word 'DELETE' in the input and then he'll be able to delete the email list:

// app/assets/javascripts/autoresponder/components/email_lists/warningDestroy.html.haml
.row

  .row
    .col-md-12
      %h2.title
        Are you sure you want to delete this list?

    .row
      .col-md-12
        %h4
          All list settings, subscribers, and associated data will be deleted as well
  .row
    .col-md-12
      %p
        %strong Subscribers:
        {{ current_list.subscribers_count }}
  .row
    .col-md-7
      %label{'for' => 'delete_confirmation'}
        Type DELETE to confirm
      %input.form-control{ :name => "default_from_name",
                           :id => 'delete_confirmation',
                           'required' => 'required',
                           :placeholder => 'Type DELETE to confirm.',
                           'ng-model' => 'input_confirmation',
                           :type => "text",
                           :value => ""}/

  %button.ngdialog-button.ngdialog-button-secondary{"ng-click" => "closeThisDialog(0)", :type => "button"} No
  %button.ngdialog-button.ngdialog-button-primary{ "ng-click" => "destroyEmailList(current_list.id)",
                                                   'ng-class' => "{ 'ngdialog-button-secondary': input_confirmation != 'DELETE' }",
                                                   "ng-disabled" => "input_confirmation != 'DELETE'",
                                                   :type => "button" } Yes

There we have it

Wrapping up:

While working on the implementation of subscribers management, we have learned about communication between controllers by using event propagation and we have also learned how to listen and send those events between controllers.

This guide was to show you how easy it is to implement the undo operations by using Angular and PaperTrail, as well as the benefits of choosing this approach over the common popup confirmation messages "Are you sure?".

As always, I hope you learned a lot from this post.

Keep in touch and wait for the part number 5 which is about the implemention of follow-ups: we will use background jobs in order to manage all the scheduled emails.

P.S: All the code that we added in this tutorial is included in this pull request:

https://github.com/heridev/MailAssemble/pull/4

H.

0 Shares:
You May Also Like