Javascript

Single Page Application Autoresponder with angular and rails: Follow ups implementation (Part 5)


Welcome to the fifth part of “How to create your own Single Page Application Autoresponder with Rails 4 and Angular.js”.

This post we will be focused on the follow ups implementation, also known as email autoresponders.

But before starting with the code, let’s see why autoresponder emails are so useful and important.
Let’s say you’re a teacher that have an own blog, and is about improving your english skills powerfully, and then you create a list that automatically sends tips to your readers about the basic skills; then, once you capture the suscribers’ attention with this free content, you offer them a premium paid course with advanced techniques. Before you know it, your blog has just become your new business.

In case you’re wondering how looks what we’ll build in this part, I’ll attach some screenshots:
part1

list of follow ups

list of follow ups

simple follow up

simple follow up

send window preferences and using a dynamic fields in follow

send window preferences and using a dynamic fields in follow

creating a new follow up

creating a new follow up

So, let’s get right to the follow ups implementation:

Let’s start by adding all the gems that we’ll be using in this post

Gemfile:

gem 'delayed_job_active_record', '~> 4.1.0'
gem 'delayed_job_web', '~> 1.2.10'
gem 'mandrill_mailer', '~> 1.1.0'
gem 'aasm', '~> 4.3.0'
gem 'liquid', '~> 2.6.1'
source 'https://rails-assets.org' do
gem 'rails-assets-angular-ui-sortable', '= 0.13.4'
gem 'rails-assets-jquery-ui'
end

A couple of things to talk about the code above:

  • Delayed jobs: We are using this gem to create background jobs and as it works with activerecord, we don’t need to install any additional extra pieces of software, which means that we can use delayed jobs for free in any platforms, such as heroku.com; we are also including the delayed_job_web in order to manage the jobs by visiting a special url in our application, but as you’ll notice we are using active_job, which means that if you are using sidekiq or a different background worker, you can switch and just make some modifications to your job classes depending on your needs.

  • Aasm: we’d want to control the states of the scheduled emails sent to the subscribers. For that reason we’ll use the aasm gem.

  • Liquid: We’ll implement a basic version to include dynamic variables content in our follow up templates, so we can parse the information based on the subscribers’ information.

  • About the ui sortable and ui plugins, we’ll use them because we want to control the order in follow ups and we want to implement the option for sorting this messages by using a simple dragging interaction.

  • mandrill_mailer: an easy way to send emails by using a mandrill api.

backend code time:

Let’s start by adding all the logic from the backend side:

Here’re all the migrations that we’ll use:

rails generate model FollowUp title:text days_to_be_sent_after_previous:integer time_to_be_sent:datetime content:text email_list_id:integer
rails generate model FollowUpSchedule state follow_up_id:integer subscriber_id:integer run_at:datetime
rails generate modal SendWindow monday:boolean tuesday:boolean wednesday:boolean thursday:boolean friday:boolean saturday:boolean sunday:boolean hour:time
rails generate delayed_job:active_record

Let’s run the migration that we just added.

bundle exec rake db:migrate

Then, we are going to edit the follow_up model and add the method for reordering the follow ups position; as you see, we are using a different and optimized query for either postgress and mysql:

#app/models/follow_up.rb
class FollowUp > ActiveRecord::Base
  # Associations
  #
  belongs_to :email_list

  validates_presence_of :email_list_id,
                        :title,
                        :days_to_be_sent_after_previous

  has_many :send_windows, dependent: :destroy
  accepts_nested_attributes_for :send_windows, allow_destroy: true

  ### Callbacks #######
  after_create :set_position_number

  # Thanks to http://thepugautomatic.com/2008/11/rails-jquery-sortables/
  # query for mysql
  def self.mysql_reorder_position(ids)
    update_all(
      ['position = FIND_IN_SET(id, ?)', ids.join(',')],
      { id: ids }
    )
  end

  def self.reorder_position(ids)
    db_adapter = ENV['DB_ADAPTER'] || 'postgress'
    return self.postgress_reorder_position(ids) if db_adapter == 'postgress'
    return self.mysql_reorder_position(ids) if db_adapter == 'mysql'
  end

  # Query for postgresSql
  def self.postgress_reorder_position(ids)
    self.where('id in (?)', ids)
        .update_all(["position = (STRPOS(?, ','||lpad(cast(id as text), 20, '0')||',') - 1)/21 + 1", ", #{ids.map{|x| "%020d" % x }.join(',')},"])
  end

  def is_first_in_email_list?
    FollowUp.where(email_list_id: self.email_list_id).order('position ASC').first.try(:id) == self.id
  end

  private

    def set_position_number
      last_question = FollowUp.where(email_list_id: self.email_list_id)
                              .where("id  #{self.id}")
                              .order('position DESC').first
      position_number = (last_question.try(:position)).to_i + 1
      update_column(:position, position_number) unless position
    end
end

We’ll use the FollowUpSchedule model to control the time when the follow ups notifications are sent and, as you will notice, we are using the aasm gem to control the right states transitions to avoid sending multiple times the same notification.

# app/models/follow_up_schedule.rb
require 'aasm'

class FollowUpSchedule < ActiveRecord::Base
  include AASM

  belongs_to :subscriber
  belongs_to :follow_up

  aasm column: 'state' do
    state :created, initial: true
    state :started
    state :failed
    state :completed

    event :process do
      transitions from: [:created, :failed], to: :started
    end

    event :error do
      transitions from: :started, to: :failed
    end

    event :finish do
      transitions from: :started, to: :completed
    end
  end
end

Let’s add the association bellow:

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

Send Windows

The follow ups(autoresponders) are sent based on the number of days configured individually. When the users only select the number of days, the autoresponders will be sent any time between 0 – 24 hours during that day; However, when the users specify the time and day by using the Send Window feature that gives to the user more control of autoresponder scheduling, they can choose which days of the week and at what time of those days the autoresponder will be sent.

Let’s start by declaring the send window model which is the responsible to control these preferences.

# app/models/send_window.rb
class SendWindow < ActiveRecord::Base
  belongs_to :follow_up

  validates_presence_of :hour

  after_save :set_hour_in_minutes

  private

    def set_hour_in_minutes
      hour_string = self.hour
      splitted_hour = hour_string.split(':')
      convert_hour_minutes = splitted_hour.first.to_i * 60 + splitted_hour.second.to_i

      # only update the column since we don't want to trigger the callbacks again
      self.update_column(:hour_in_minutes, convert_hour_minutes)
    end
end

As you see, we’ll save in ‘minutes’ the time that the user is selecting for each preference; later on, we’ll see more about this logic but we’re saving the data this way because it is easier to calculate when scheduling notifications.

Right after the user is added to an email list, we’ll start sending the follow ups notifications if there are any in the email list.

# app/models/subscriber.rb
  after_create :process_follow_ups

  private

    # let schedule the follow ups according to the email list
    def process_follow_ups
      manager = Autoresponder::Jobs::FollowUpManagerJob.new(self.email_list.id)
      manager.send_first_email_notification(self.id)
    end

Creating and managing follow ups

In this controller, we are sorting the follow ups and, in that method, we are using an optimized sql query which is available either for mysql and postgress. We’ll also use nested attributes so we can update/delete/add send window elements included in the follow up when we perform any operation to it.

# app/controllers/api/v1/follow_ups_controller.rb
class Api::V1::FollowUpsController < Api::BaseController

  before_action :find_email_list
  before_action :find_follow_up, except: [:index, :create, :sort]

  def index
    follow_ups = @email_list.follow_ups.order('position ASC')
    render_serialized(follow_ups, FollowUpSerializer, root: false)
  end

  def create
    follow_up = @email_list.follow_ups.create(follow_up_params)

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

  def show
    render_serialized(@follow_up, FollowUpSerializer, root: false)
  end

  def update
    if @follow_up.update(follow_up_params)
      render_serialized(@follow_up, FollowUpSerializer, root: false)
    else
      render_error_object(@follow_up.errors.messages)
    end
  end

  def destroy
    if @follow_up.destroy
      render_serialized(@follow_up, FollowUpSerializer, root: false)
    else
      render_with_error('An error occured while deleting the follow up message')
    end
  end

  def destroy_send_window
    if @follow_up && @follow_up.send_windows.find(params[:send_window_id]).destroy
      render_json_message('The send window was deleted successfully')
    else
      render_with_error('An error occured while deleting the follow up message')
    end
  end

  def sort
    element_ids = params[:element_ids]
    if element_ids && FollowUp.reorder_position(element_ids)
      render_json_message('The elements were updated successfullly')
    else
      render_with_error('The element was not found')
    end
  end

  private

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

    def find_follow_up
      id = params['id']
      @follow_up = @email_list.follow_ups.find(id)
    end

    def follow_up_params
      params.require(:follow_up)
            .permit(
              :title,
              :content,
              :days_to_be_sent_after_previous,
              :time_to_be_sent,
              :email,
              :send_windows_attributes => [
                :id,
                :sunday,
                :monday,
                :tuesday,
                :wednesday,
                :thursday,
                :friday,
                :saturday,
                :_destroy,
                :hour
              ]
            )
    end
end

Mailers using mandrill

The follow up message mailer is the responsible to send the follow up notification and we are using a template created within mandrill panel, and we are also using Liquid::Template to replace some variables in the form of: {{email}} or {{name}} are the only available at this moment.

# app/mailers/follow_up_message_mailer.rb
class FollowUpMessageMailer < MandrillMailer::TemplateMailer
  default from: 'heriberto.perez@magmalabs.io'

  def send_email(follow_up, subscriber)
    recipients = [
      {
        name: subscriber.name,
        email: subscriber.email
      }
    ]

    mandrill_mail(
      template: 'follow_ups',
      subject: replace_placeholder_values(follow_up.title, subscriber),
      to: recipients,
      vars: {
        'CONTENT' => replace_placeholder_values(follow_up.content, subscriber),
      },
      important: true,
      inline_css: true,
     )
  end

  def replace_placeholder_values(content, subscriber)
    template = Liquid::Template.parse(content)
    hash_values = find_placeholder_subscriber(subscriber)
    template.render(hash_values)
  end

  def find_placeholder_subscriber(subscriber)
    @placeholder_values ||= {
      'name' => subscriber.name,
      'email' => subscriber.email
    }
  end
end

Let’s modify the EmailListSerializer

We are basically just adding the follow_ups attribute

# app/serializers/email_list_serializer.rb
class EmailListSerializer < ActiveModel::Serializer
  attributes :name,
             :default_from,
             :default_from_name,
             :remind_people_message,
             :company_organization,
             :city,
             :country,
             :state_province,
             :phone,
             :created_at,
             :address,
             :secure_key,
             :thank_you_page_url,
             :already_subscribed_url,
             :subscribers_count,
             :follow_ups_count,
             :id

  def follow_ups_count
    object.follow_ups.count
  end

  def subscribers_count
    object.subscribers.count
  end
end

The followUpSerializer looks pretty straighforward.

# app/serializers/follow_up_serializer.rb
class FollowUpSerializer < ActiveModel::Serializer
  attributes :title,
             :content,
             :days_to_be_sent_after_previous,
             :position,
             :first_in_email_list,
             :id

  has_many :send_windows,
           serializer: SendWindowSerializer,
           root: :send_windows_attributes

  def send_windows
    object.send_windows.order('created_at DESC')
  end

  def first_in_email_list
    object.is_first_in_email_list?
  end
end

Here’s the SendWindowSerializer:

# app/serializers/send_window_serializer.rb
class SendWindowSerializer < ActiveModel::Serializer
  attributes :id,
             :sunday,
             :monday,
             :tuesday,
             :wednesday,
             :thursday,
             :friday,
             :saturday,
             :hour

end

Here’s what the configuration for delayed job web looks like:

# config.ru
if Rails.env.production?
  DelayedJobWeb.use Rack::Auth::Basic do |username, password|
    username == ENV['USER_DELAYEDJOB'] && password == ENV['PASSWORD_DELAYEDJOB']
  end
end

Let’s setup delayed jobs as the default backgroud worker:

# config/application.rb
config.active_job.queue_adapter = :delayed_job

# We are placing some classes inside the lib folder
config.autoload_paths  587,
  :user_name => ENV['MANDRILL_USERNAME'],
  :password  => ENV['MANDRILL_API_KEY'],
  :domain    => 'heroku.com'
}

ActionMailer::Base.delivery_method = :smtp

MandrillMailer.configure do |config|
  config.api_key = ENV['MANDRILL_API_KEY']
end

Create the new route for delayed_jobs_web panel and the follow ups resources:

In fact, this is how it looks the whole config/routes.rb file.

# config/routes.rb
Rails.application.routes.draw do
  devise_for :users
  root to: 'users#index'

  match '/delayed_secret_job' => DelayedJobWeb, :anchor => false, via: [:get, :post]

  constraints do
    namespace :api, path: '/api' do
      namespace :v1 do
        resources :email_lists do
          collection do
            post 'subscribe/:email_uuid', to: 'email_lists#add_public_subscriber'
          end
        end

        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

        resources :follow_ups do
          collection do
            post '/sort',                      to: 'follow_ups#sort'
            delete '/destroy_send_window/:id', to: 'follow_ups#destroy_send_window'
          end
        end
      end
    end
  end
end

Scheduling follow ups by using a manager class:

# lib/autoresponder/follow_up_scheduler_manager.rb
module Autoresponder
  class FollowUpSchedulerManager

    MINUTES_AFTER_MIDNIGHT = 1

    def initialize(email_list_id)
      find_email_list(email_list_id)
    end

    def find_email_list(list_id)
      @email_list ||= EmailList.find_by(id: list_id)
    end

    def process_follow_up(follow_up_id, subscriber_id, scheduled_id)
      follow_up = FollowUp.find_by(id: follow_up_id)
      scheduled = FollowUpSchedule.find_by(id: scheduled_id)
      subscriber = Subscriber.find_by(id: subscriber_id)

      # in case the subscriber was removed we are
      # not sending the follow ups anymore
      if follow_up && subscriber
        begin
          scheduled.process!

          FollowUpMessageMailer.send_email(follow_up, subscriber).deliver

          # By default we'd want to schedule the second notification if exists
          schedule_next(follow_up.id, subscriber_id) if find_next_scheduled(follow_up.id)
          scheduled.finish!
        rescue => e
          Rails.logger.error "================ Error while sending process follow ups #{e.inspect}============"
          scheduled.error!
        end
      end
    end

    def process_first_follow_up(subscriber_id)
      follow_ups = @email_list.follow_ups.order('position ASC')
      subscriber = Subscriber.find_by(id: subscriber_id)
      first_follow = follow_ups.first

      # in case the subscriber was removed we are
      # not sending the follow ups anymore
      if first_follow && subscriber
        FollowUpMessageMailer.send_email(first_follow, subscriber).deliver

        # By default we'd want to schedule the second notification if exists
        schedule_next(first_follow.id, subscriber_id) if find_next_scheduled(first_follow.id)
      end
    end

    def find_next_scheduled(follow_up_id)
      @next_followup ||= @email_list.follow_ups
                                    .order('position ASC')
                                    .where('id > ?', follow_up_id)
                                    .first
    end

    def schedule_next(follow_up_id, subscriber_id)
      next_follow_up = find_next_scheduled(follow_up_id)

      if next_follow_up

        follow_up_attributes = {
          follow_up_id: next_follow_up.id,
          subscriber_id: subscriber_id,
          run_at: calculate_run_at(next_follow_up)
        }

        FollowUpSchedule.create(follow_up_attributes)
      end
    end

    def calculate_run_at(follow_up)
      find_next_calculated_week_dates(follow_up).each do |day|
        @selected_day = day[:day_date]
        day_name = Date::DAYNAMES[@selected_day.wday].downcase
        @next_send_windows = follow_up.send_windows.where("#{day_name} = ?", true)
        break if @next_send_windows.exists?
      end

      if @next_send_windows.count > 1
        minutes_quantity = @next_send_windows.order('hour_in_minutes ASC')
                                            .first
                                            .hour_in_minutes
      else
        minutes_quantity = MINUTES_AFTER_MIDNIGHT
      end

      @selected_day + minutes_quantity.minutes
    end

    # NOTE: Add some rspec tests after setting up the suite
    # Response example:
    #   follow_up.days_to_be_sent_after_previous => 1
    #   Date.today      => Thu, 15 Oct 2015
    #
    #   After running the method you should have the next
    #   days starting from tomorrow
    #   next_week_days => [{:day_date=>Fri, 16 Oct 2015},
    #                      {:day_date=>Sat, 17 Oct 2015},
    #                      {:day_date=>Sun, 18 Oct 2015},
    #                      {:day_date=>Mon, 19 Oct 2015},
    #                      {:day_date=>Tue, 20 Oct 2015},
    #                      {:day_date=>Wed, 21 Oct 2015},
    #                      {:day_date=>Thu, 22 Oct 2015}]
    def find_next_calculated_week_dates(follow_up)
      days_after = follow_up.days_to_be_sent_after_previous || 1
      date_starts = Date.today + days_after.days
      next_week_days = []

      7.times do
        next_week_days  :environment do
    timezone_now = Time.zone.now
    starting_with = timezone_now - 8.hours
    finishing_with = timezone_now - 20.minutes

    oldest_followups = FollowUpSchedule.where(run_at: starting_with..finishing_with)
                                       .where(state: 'created')
                                       .includes(:follow_up)

    oldest_followups.find_each do |scheduled|
      manager = Autoresponder::Jobs::FollowUpManagerJob.new(scheduled.follow_up.email_list.id)
      manager.send_email_notification(scheduled.follow_up_id, scheduled.subscriber_id, scheduled.id)
    end
  end
end

What Next?

Now, we are going to create all the frontend code.

Let’s start by including all the dependencies for application.js and application.css that will be used in the second part of this tutorial.

// app/assets/javascripts/application.js

//= require angular-ui-sortable
//= require jquery-ui

then we need to include the dependencies on our main app file:

// app/assets/javascripts/autoresponder/app.js
var AutoresponderApp = angular.module('AutoresponderApp', [
  'ui.sortable',
  .
  .
  .

Css styles modifications:

Let’s replace the id “#email-lists” for “.email-list” class, something like this:

# app/assets/stylesheets/modules/email_lists.scss
.articles {
  .row {
    border-bottom: 1px solid $corn_silk;
    padding: 17px 0;

    .data {
      padding-left: 30px;
    }

    .inline-block {
      display: inline-block;
    }

    .actions {
      text-align: right;
      font-size: 20px;
      padding: 16px;
    }

   }

  .row-hover {
    &:hover {
      background: #fafafa;
    }
  }

  .sortable-row {
    cursor: move;
  }
}

and we will include the following css code at the bottom of the same css file :

.follow-up-form {
  .interval-section {

    label.title {
      display: inline-block;
      margin-right: 5px;
    }

    #title {
      width: 100px;
      display: inline-block;
    }

  }
}

Time for declaring the new routes(states):

Notice that I’m pasting the routes that have been changed/modified:

# app/assets/javascripts/autoresponder/states.js
.state("home", {
           url: "/",
           templateUrl: 'autoresponder/components/dashboard_home.html',
         })

         .state("email_list.details.follow_ups", {
           url: "/follow_ups",
           abstract: true,
           controller: 'parentFollowUpsCtrl',
           templateUrl: 'autoresponder/components/email_lists/details/follow_ups/main.html',
         })

         .state("email_list.details.follow_ups.list", {
           url: "",
           controller: 'followUpsCtrl',
           templateUrl: 'autoresponder/components/email_lists/details/follow_ups/index.html',
         })

         .state("email_list.details.follow_ups.add", {
           url: "/add",
          controller: 'createFollowUpsCtrl',
          templateUrl: 'autoresponder/components/email_lists/details/follow_ups/add.html',
        })

         .state("email_list.details.follow_ups.edit", {
          url: "/edit/:followUpId",
           controller: 'followUpDetailsCtrl',
           templateUrl: 'autoresponder/components/email_lists/details/follow_ups/edit.html',
         })

We are using a parent controller to place the common methods used within follow ups section:

# app/assets/javascripts/autoresponder/components/email_lists/details/follow_ups/parentFollowUpsCtrl.js
AutoresponderApp
  .controller('parentFollowUpsCtrl', [
    '$scope',
    'Render',
    'Utils',
    'findCurrentEmailListInParent',
    'ngDialog',
    'FollowUpService',
    function (
      $scope,
      Render,
      Utils,
      findCurrentEmailListInParent,
      ngDialog,
      FollowUpService
    ) {

      $scope.validDaySelected = function(send_window) {
        var anySelectedDay = send_window.monday || send_window.tuesday || send_window.wednesday || send_window.thursday || send_window.friday || send_window.saturday || send_window.sunday
        return !anySelectedDay;
      }

      $scope.addSendWindowTo = function(follow_up) {
        follow_up.send_windows_attributes.push({
          sunday: false,
          monday: false,
          tuesday: false,
          wednesday: false,
          thursday: false,
          friday: false,
          saturday: false,
          isNotPersisted: true,
        })
      };

      $scope.removeSendWindowFrom = function(followUp, index, sendWindow) {
        followUp.send_windows_attributes.splice(index, 1);

        if(!sendWindow.isNotPersisted) {
          destroyFromDb(sendWindow);
        }
      };

      var destroyFromDb = function(sendWindow) {
        var data,
            followUpId,
            removeElements,
            sendWindowId;

        followUpId = $scope.$stateParams.followUpId;

        _data = {
          email_uuid: findCurrentEmailListInParent.secure_key,
          send_window_id: sendWindow.id
        };

        FollowUpService.destroySendWindow(followUpId, _data).then(function(successResponse) {
          return Render.showGrowlNotification('success', successResponse.status);
        }, function(errorResponse) {
          return Render.showGrowlNotification('warning', 'An error occurred while removing the send window');
        });
      };

      $scope.availableHours = [
        '00:00',
        '4:00',
        '8:00',
        '12:00',
        '16:00',
        '20:00',
        '24:00',
      ]
    }
  ])

The main followup template should look like this:

# app/assets/javascripts/autoresponder/components/email_lists/details/follow_ups/main.html.haml
.row
  %ui-view

The following directive is created in order to make the boostrap group button work:

// app/assets/javascripts/autoresponder/components/common/globalDirectives.js
AutoresponderApp
   .directive('checkboxWithChangeHandler', ['$timeout', function($timeout) {
     return {
       replace: false,
       require: 'ngModel',
       scope: false,
       link: function (scope, element, attr, ngModelCtrl) {
        $timeout( function(){
          if(element[0].checked) {
            $(element).parent().addClass('active')
          }
        }, 0);

         $(element).change(function () {
           scope.$apply(function () {
             ngModelCtrl.$setViewValue(element[0].checked);
           });
         });
       }
     };
   }]);

Here, we are adding a function that returns if a variable is not defined or nil.

// app/assets/javascripts/autoresponder/components/common/utils.js
AutoresponderApp.factory('Utils', function() {
  return {
    httpProtocolUrlFor: function(url_params) {
      .
      .
      .
    },

    isDefined: function(obj) {
      return angular.isDefined(obj) && obj !== null;
    },
  }
});

Let’s change the template message shown in the home state.

# app/assets/javascripts/autoresponder/components/dashboard_home.html.haml
%h1
  Dashboard Home

As we are changing some of the follow ups routes, we might want to remove this one:

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

Time to declare the new templates for follow ups:

# app/assets/javascripts/autoresponder/components/email_lists/details/follow_ups/add.html.haml
%form.follow-up-form{:name => "FollowUpForm",
                            "ng-submit" => "createFollowUp()",
                            :novalidate => ""}
  .row
    .col-md-7
      .form-group{'ng-class' => "{ 'has-error': FollowUpForm.title.$invalid }"}
        %label{:for => "title"} Title
        %input#title.form-control{:type => "text",
                                  'required' => 'required',
                                  'placeholder' => 'Hello {{name}}',
                                  'name' => 'title',
                                  "ng-model" => 'follow_up.title'}/

      .form-group{'ng-class' => "{ 'has-error': FollowUpForm.content.$invalid }"}
        %label{:for => "content"} Content
        %textarea#content.form-control{ :rows => '10',
                                        'required' => 'required',
                                        'placeholder' => 'Thank you for...',
                                        'name' => 'content',
                                        "ng-model" => "follow_up.content"}
    .col-md-5.interval-section
      %h4
        Interval

      %div{'ng-show' => 'email_list.follow_ups_count == 0'}
        Follow Up # 1 is the welcome message your subscribers will get immediately after signing up.

      %div{'ng-show' => 'email_list.follow_ups_count != 0'}
        %span.form-group{'ng-class' => "{ 'has-error': FollowUpForm.days_to_be_sent_after_previous.$invalid  }"}
          %label.title{:for => "title"} Follow Up # {{email_list.follow_ups_count + 1}} sent
          %input#title.form-control{:type => "number",
                                    'ng-required' => 'email_list.follow_ups_count != 0',
                                    'name' => 'days_to_be_sent_after_previous',
                                    'ng-class' => "{ 'has-error': FollowUpForm.days_to_be_sent_after_previous.$invalid }",
                                    "ng-model" => 'follow_up.days_to_be_sent_after_previous'}/
        day(s) after previous

      %hr

      %ng-include{'ng-show' => 'email_list.follow_ups_count != 0',
                  :src => "'autoresponder/components/email_lists/details/follow_ups/send_window_preferences.html'"}

  .row
    .col-md-6
      %button{ type:'submit',
               'ng-disabled' => "FollowUpForm.$invalid",
               class: 'btn btn-success'}
        Create Follow up

      %button{ type:'button',
               'ui-sref' => 'email_list.details.follow_ups.list',
               class: 'btn btn-warning'}
        Cancel

Creating new follow ups

the Angular controller:

// app/assets/javascripts/autoresponder/components/email_lists/details/follow_ups/createFollowUpsCtrl.js
AutoresponderApp
  .controller('createFollowUpsCtrl', [
    '$scope',
    'Render',
    'findCurrentEmailListInParent',
    'FollowUpService',
    function (
      $scope,
      Render,
      findCurrentEmailListInParent,
      FollowUpService
    ) {

      $scope.follow_up = {
        send_windows_attributes: []
      }

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

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

        FollowUpService.create(_data).then(function(follow_up) {
          // We are using email_list_count when adding a new follow up
          // in order to show/hide interval
          $scope.email_list.follow_ups_count +=1;

          Render.showGrowlNotification('success', 'The follow up was created successfully');
          return $scope.$state.go('^.edit', { followUpId: follow_up.id });
        }, function(errorResponse) {
          return Render.showGrowlNotification('warning', 'An error occurred while creating the follow up');
        });
      }
    }
  ])

Send windows options included when creating and editing a follow up:

# app/assets/javascripts/autoresponder/components/email_lists/details/follow_ups/send_window_preferences.html.haml
%h4 Send window

%div{'ng-repeat' => 'send_window in follow_up.send_windows_attributes'}
  %div{'ng-show' => '!send_window._destroy'}
    %div{class: 'pull-right'}
      %a{:href => "", 'ng-click' => 'removeSendWindowFrom(follow_up, $index, send_window)'}
        %span.glyphicon.glyphicon-trash{"aria-hidden" => "true"}

    %h5 Time
    .form-group{'ng-class' => "{ 'has-error': FollowUpForm.hour.$invalid }"}
      %select.form-control{"ng-model" => "send_window.hour",
                           'required' => 'required',
                           'name' => 'hour',
                           "ng-options" => "hour as hour for hour in availableHours"}

    %h5 Day
    %br
    %div{ 'ng-show' => "validDaySelected(send_window)" }
      %h5.text-danger You must select at least one option
    .btn-group{"data-toggle" => "buttons", 'data-validation' => "required"}
      %label.btn.btn-primary
        %input{'ng-model' => "send_window.sunday",
               :type => "checkbox",
               'name' => 'send_window.sunday',
               'ng-required' => "validDaySelected(send_window)",
               'checkbox-with-change-handler' => ''}/
        Sun

      %label.btn.btn-primary
        %input{'ng-model' => "send_window.monday",
               :type => "checkbox",
               'name' => 'monday',
               'checkbox-with-change-handler' => ''}/
        Mon

      %label.btn.btn-primary
        %input{'ng-model' => "send_window.tuesday",
               :type => "checkbox",
               'name' => 'send_window.tuesday',
               'checkbox-with-change-handler' => ''}/
        Tue

      %label.btn.btn-primary
        %input{'ng-model' => "send_window.wednesday",
               :type => "checkbox",
               'name' => 'wednesday',
               'ng-required' => "validDaySelected(send_window)",
               'checkbox-with-change-handler' => ''}/
        Wed

      %label.btn.btn-primary
        %input{'ng-model' => "send_window.thursday",
               :type => "checkbox",
               'name' => 'thursday',
               'ng-required' => "validDaySelected(send_window)",
               'checkbox-with-change-handler' => ''}/
        Thu

      %label.btn.btn-primary
        %input{'ng-model' => "send_window.friday",
               :type => "checkbox",
               'ng-required' => "validDaySelected(send_window)",
               'name' => 'friday',
               'checkbox-with-change-handler' => ''}/
        Fri

      %label.btn.btn-primary
        %input{'ng-model' => "send_window.saturday",
               :type => "checkbox",
               'name' => 'saturday',
               'checkbox-with-change-handler' => ''}/
        Sat

    %hr
%button{ type:'button',
         'ng-click' => 'addSendWindowTo(follow_up)',
         class: 'btn btn-sm btn-success'}
  Add new send window

The follow ups edition looks like:

# app/assets/javascripts/autoresponder/components/email_lists/details/follow_ups/edit.html.haml
%form.follow-up-form{:name => "FollowUpForm",
                     "ng-submit" => "updateFollowUp()",
                     :novalidate => ""}
  .row
    %h3
      Follow Up Details
    .col-md-7

      .form-group{'ng-class' => "{ 'has-error': FollowUpForm.title.$invalid }"}
        %label{:for => "title"} Title
        %input#title.form-control{:type => "text",
                                  'required' => 'required',
                                  'name' => 'title',
                                  "ng-model" => 'follow_up.title'}/

      .form-group{'ng-class' => "{ 'has-error': FollowUpForm.content.$invalid }"}
        %label{:for => "content"} Content
        %textarea#content.form-control{ :rows => '10',
                                        'required' => 'required',
                                        'name' => 'content',
                                        "ng-model" => "follow_up.content"}
    .col-md-5.interval-section
      %h4
        Interval

      %div{'ng-show' => 'follow_up.first_in_email_list'}
        Follow Up # 1 is the welcome message your subscribers will get immediately after signing up.

      %div{'ng-show' => '!follow_up.first_in_email_list'}
        %span.form-group{'ng-class' => "{ 'has-error': FollowUpForm.days_to_be_sent_after_previous.$invalid  }"}
          %label.title{:for => "title"} Follow Up # {{email_list.follow_ups_count}} sent
          %input#title.form-control{:type => "number",
                                    'required' => 'required',
                                    'name' => 'days_to_be_sent_after_previous',
                                    'ng-class' => "{ 'has-error': FollowUpForm.days_to_be_sent_after_previous.$invalid }",
                                    "ng-model" => 'follow_up.days_to_be_sent_after_previous'}/

        day(s) after previous

      %hr

      %ng-include{'ng-show' => '!follow_up.first_in_email_list',
                  :src => "'autoresponder/components/email_lists/details/follow_ups/send_window_preferences.html'"}

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

      %button{ type:'button',
               'ui-sref' => 'email_list.details.follow_ups.list',
               class: 'btn btn-warning'}
        Cancel

Follow up details

The responsible angular controller looks something like this:

# app/assets/javascripts/autoresponder/components/email_lists/details/follow_ups/followUpDetailsCtrl.js
AutoresponderApp
  .controller('followUpDetailsCtrl', [
    '$scope',
    'Render',
    'findCurrentEmailListInParent',
    'FollowUpService',
    function (
      $scope,
      Render,
      findCurrentEmailListInParent,
      FollowUpService
    ) {

      var loadEmailListFollowUp = function() {

        var data, followUpId;

        _data = {
          email_uuid: findCurrentEmailListInParent.secure_key
        };

        followUpId = $scope.$stateParams.followUpId;

        return FollowUpService.findOne(followUpId, _data).then(function(follow_up) {
          $scope.follow_up = follow_up.plain();
        });

      };

      loadEmailListFollowUp();

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

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

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

The follow up service responsible for making http requests against the server:

# app/assets/javascripts/autoresponder/components/email_lists/details/follow_ups/followUpService.js
AutoresponderApp
  .factory('FollowUpService', ['Restangular',
                                 function(
                                   Restangular
                                 ) {
    var model;

    model = 'api/v1/follow_ups';

    return {
      create: function(params) {
        return Restangular.all(model).post(params);
      },

      sort: function(params) {
        // this will send the params to the endpoint post api/v1/questions/sort
        return Restangular.all(model + '/sort').post(params);
      },

      findAll: function(params) {
        return Restangular.all(model).customGET('', params);
      },

      destroy: function(followUpId, data) {
        return Restangular.one(model, followUpId).remove(data);
      },

      destroySendWindow: function(followUpId, data) {
        return Restangular.one(model + '/destroy_send_window', followUpId).remove(data);
      },

      findOne: function(followUpId, extra_params) {
        return Restangular.one(model, followUpId).get(extra_params);
      },

      update: function(params, followUpId) {
        return Restangular.one(model, followUpId).customPUT(params);
      },
    };
  }
]);

listing follow ups:

The angular controller is going to look like this:

// app/assets/javascripts/autoresponder/components/email_lists/details/follow_ups/followUpsCtrl.js

AutoresponderApp
  .controller('followUpsCtrl', [
    '$scope',
    'Render',
    'Utils',
    'findCurrentEmailListInParent',
    'ngDialog',
    'FollowUpService',
    function (
      $scope,
      Render,
      Utils,
      findCurrentEmailListInParent,
      ngDialog,
      FollowUpService
    ) {

      var loadEmailListFollowUps = function() {

        var _data = {
          email_uuid: findCurrentEmailListInParent.secure_key,
        }

        return FollowUpService.findAll(_data).then(function(messages) {
          $scope.follow_up_messages = messages.plain();
        });
      };

      loadEmailListFollowUps();

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

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

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

        FollowUpService.destroy(followUpId, _data).then(function(removed_list) {
          Render.showGrowlNotification('success', 'The follow up was deleted sucessfully');
          ngDialog.closeAll()
          return $scope.$state.go('^.list', {}, { reload: true });
        }, function(errorResponse) {
          return Render.showGrowlNotification('warning', 'An error occurred while deleting the follow up');
        });
      }

      $scope.sortableRows = {
        // we are sending the new array with order
        stop: function(e, ui) {
          // we trigger the update only when the item has changed
          if(Utils.isDefined(ui.item.sortable.dropindex)){
            var newOrderElements,
            elementsIds;

            newOrderElements = ui.item.sortable.sourceModel;
            elementIds = _.pluck(newOrderElements, 'id');

            var _data = {};
            _data.element_ids = elementIds;
            _.extend(_data, { email_uuid: findCurrentEmailListInParent.secure_key });

            FollowUpService.sort(_data).then(function(rows) {
              Render.showGrowlNotification('success', 'The order was changed successfully');
            }, function(errorResponse) {
              Render.showGrowlNotification('warning', 'An error occurred while deleting the follow up');
            });
          }

        },
      };
    }
  ])

Template responsible for listing follow ups will look like this:

// app/assets/javascripts/autoresponder/components/email_lists/details/follow_ups/index.html.haml
.articles
  .row
    %button{class: 'btn btn-success pull-right', 'ui-sref' => 'email_list.details.follow_ups.add'}
      Create message

  .follow-ups{'ui-sortable' => "sortableRows", 'ng-model' => 'follow_up_messages' }
    .row{class: 'span12 sortable-row row-hover', "ng-repeat" => "message in follow_up_messages"}
      .col-md-5
        %h4
          {{message.title}}
        %p {{message.created_at}}

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

      .col-md-3.actions
        .btn-group
          %button.btn.btn-default{:type => "button",
                                  'ui-sref' => 'email_list.details.follow_ups.edit({ followUpId: message.id })'} Settings
          %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 => "", 'ng-click' => 'sendFollowUpTest(message.id)'} Send a test
            %li.divider{:role => "separator"}
            %li
              %a{:href => "", 'ng-click' => 'openModal(message)'} Destroy

Before destroying a follow up we’d want to show a ‘destroy’ warning.

Let’s start by adding the templates.

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

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

    .row
      .col-md-12
        %h4
          All the information related will be deleted as well
  .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" => "destroyFollowUp(current_follow_up.id)",
                                                   'ng-class' => "{ 'ngdialog-button-secondary': input_confirmation != 'DELETE' }",
                                                   "ng-disabled" => "input_confirmation != 'DELETE'",
                                                   :type => "button" } Yes

What’s next? Now’s time for updating some old references to follow ups

from email list details:

# app/assets/javascripts/autoresponder/components/email_lists/details/main.html.haml
-  %a{:href => "#", 'ui-sref' => 'email_list.details.follow_ups({ emailListId: email_list.id })'}
+  %a{:href => "#", 'ui-sref' => 'email_list.details.follow_ups.list({ emailListId: email_list.id })'}
# app/assets/javascripts/autoresponder/components/email_lists/index.html.haml
-#email-lists
+.articles

-            %a{:href => "#", 'ui-sref' => 'email_list.details.follow_ups({ emailListId: list.id })'} Follow ups
+            %a{:href => "#", 'ui-sref' => 'email_list.details.follow_ups.list({ emailListId: list.id })'} Follow ups

-            %a{:href => "#", 'ui-sref' => 'email_list.details.add_subscriber({ emailListId: list.id })'} Add Subscribers
+            %a{:href => "#", 'ui-sref' => 'email_list.details.subscribers.add({ emailListId: list.id })'} Add Subscribers

You can find the code in which we have been working on:

https://github.com/heridev/MailAssemble/tree/feature/implementation-part-5

Thanks for following my posts, I hope I’ll keep on writing some more, see you around.

H.

Events
RailsConf 2017 Adventure
Development
Angular 2 Overview
Rails
Active Job
  • Ryan

    Wow! This tutorial is great. Question, would there need to be major changes if I were using Angular 2?