Applicant comments and user mentions

In most team-based SaaS products, communicating with each other and leaving internal notes on records are core functions. Applicant tracking systems are no different — a basic function of an ATS is the ability for users to leave internal notes on applicants, for themselves or for their team members.

Users may want to record their thoughts after an interview, remind themselves of something an applicant mentioned, or ask a teammate to review an applicant. In our application, we will address this need by building a reusable Comment model.

We will allow users to leave comments on an applicant, review existing comments, and send notifications to other users by mentioning them in a comment. We will also add Sidekiq to the application and move notification creation into background jobs.

When we finish with the work in this chapter, the commenting system will work like this:

A screen recording of a user typing a comment into a comment box, mentioning another user with an @ symbol. When the user submits the comment form, the form resets and the comment is added to a list of comments.

Building the comment resource

In this book we will only add comments to applicants. In a commercial application, it is likely that over time users would want to add comments to other types of resources, like job postings.

For this reason, we will create a generic Comment model that can be reused with other resources in the future, rather than tying comments directly to applicants.

To get started, create a new Comment model from your terminal:

rails g model Comment user:references commentable:references{polymorphic}
rails db:migrate

Instead of a direct applicant reference on the Comment model, we instead create a polymorphic commentable relationship that can be used with applicants, jobs, or anything else we like.

Head to app/models/user.rb to setup the user relationship with comments:

has_many :comments, dependent: :destroy

And then add the commentable association to the Applicant model:

has_many :comments, as: :commentable, dependent: :destroy, counter_cache: :commentable_count

Note here the addition of the counter_cache option to the relationship. We will use the counter cache to easily and efficiently display the number of comments an applicant has on the applicants index page.

While the Rails guides indicate that you do not need to specify the counter_cache relationship on the has_many side of a relationship, that is not true in the case of polymorphic relationships.

Before the counter cache will work, we need to add the commentable_count column to the applicants table. Generate a new migration from your terminal:

rails g migration AddCommmentableCountToApplicants commentable_count:integer
rails db:migrate

In the Comment model, update it to set a counter_cache value and to add a comment rich text field:

class Comment < ApplicationRecord
  belongs_to :user
  belongs_to :commentable, polymorphic: true, counter_cache: :commentable_count

  has_rich_text :comment
end

With the Comment model in place, we need a controller and views to handle creating and viewing comments. From your terminal:

rails g controller Comments
touch app/views/comments/_comments.html.erb app/views/comments/_form.html.erb app/views/comments/_comment.html.erb

Update the CommentsController:

class CommentsController < ApplicationController
  before_action :set_commentable

  def index
    comments = @commentable.comments.includes(:user).with_rich_text_comment.order(created_at: :desc)
    html = render_to_string(partial: 'comments', locals: { comments: comments, commentable: @commentable })
    render operations: cable_car
      .inner_html('#slideover-content', html: html)
      .text_content('#slideover-header', text: "Comments on #{@commentable.name}")
  end

  def create; end

  private

  def comment_params
    params.require(:comment).permit(:comment)
  end

  def set_commentable
    commentable_param = params.keys.detect{ |key| key.include?('_id') }

    @commentable = commentable_param.remove('_id').classify.constantize.find(params[commentable_param])
  end
end

Comments will be viewed and created in our trusty slideover drawer. The index action inserts existing comments for the @commentable resource into the slideover as usual.

The set_commentable method is translating a param like applicant_id from the url into a matching class. This extra bit of work is not necessary while our application only supports commenting on applicants because we know that @commentable will always be an applicant.

We implemented the more complicated version of set_commentable to demonstrate that this controller can be reused for other resources in the future. @commentable could be a Job and set_commentable would still find the correct record.

Head to config/routes.rb to define the new comment routes:

concern :commentable do
  resources :comments, only: %i[index create]
end

resources :applicants, concerns: :commentable do
  # Snip
end

We added a commentable concern to the route definitions and then used that concern on the applicants resource in service of our more complicated but reusable Comment implementation.

This change results in two new nested routes for the applicant resource:

rails routes =>
applicant_comments GET /applicants/:applicant_id/comments(.:format) comments#index
POST /applicants/:applicant_id/comments(.:format) comments#create

Now we will update the partials we need to render comments in the slideover. In app/views/comments/_comments.html.erb:

Note the polymorphic_path call here, which will result in a link to the show page of the commentable resource passed to the method. For us, this means the applicant show page. Again, slightly more complicated so we can see how to reuse the comment resource across the application.

And app/views/comments/_comment.html.erb:

And app/views/comments/_form.html.erb where we use polymorphic_path again:

We will also update Trix styles for the comment form to remove the Trix toolbar entirely and to adjust the height of the Trix editor to a more reasonable size for a comment form. In app/assets/stylesheets/actiontext.css:

Users cannot leave comments on applicants if there is no way to open the comment slideover. Let’s fix that by updating the applicant card partial to add a link to the comments for each applicant.

In the applicants card partial:

The comment link is relying on a new svg icon that we have not created yet. Add that icon next, from your terminal:

touch app/assets/images/chat-bubbles.svg

And fill in the new svg with:

With that change in place, head to the applicants index page and click on the comment icon for any applicant. If all has gone well so far, a slideover should open with the comment form populated.

A screen recording of a user clicking on a comment icon. Clicking the icon opens a drawer from the right edge of the screen with a list of comments and a comment form visible in the drawer.

Submitting the comment form will not work yet because the create action in the CommentsController has not been filled in yet. Head to the CommentsController and update the create action like this:

def create
  @comment = Comment.new(comment_params)
  @comment.commentable = @commentable
  @comment.user = current_user

  if @comment.save
    html = render_to_string(partial: 'comment', locals: { comment: @comment })
    form_html = render_to_string(partial: 'form', locals: { comment: Comment.new, commentable: @commentable })
    render operations: cable_car
      .prepend('#comments', html: html)
      .replace('#comment-form', html: form_html)
  else
    html = render_to_string(partial: 'form', locals: { comment: @comment, commentable: @commentable })
    render operations: cable_car
      .inner_html('#comment-form', html: html), status: :unprocessable_entity
  end
end

In this iteration of using the slideover drawer, instead of closing the drawer on a successful form submission, we instead reset the comment form with a new, empty version of the form and add the newly created comment to the list of comments displayed in the drawer.

A screen recording of a user typing in a comment in a comment form. When the user submits the form, the comment is added to an existing list of comments and the comment form is reset.

Update comment count on new comment creation

You might notice that the number of comments listed on the applicant’s card does not update when a new comment is created. Users have to refresh the page to see the correct comment count.

Thanks to CableReady magic, we can implement this new behavior with almost no extra effort.

First, in app/models/account.rb:

- has_many :applicants, through: :jobs, enable_updates: { on: :create }
+ has_many :applicants, through: :jobs, enable_updates: true

Now CableReady's updates_for will broadcast on all applicant changes, instead of only when new applicants are created. Halfway there.

And then, head to the Comment model:

- belongs_to :commentable, polymorphic: true, counter_cache: :commentable_count
+ belongs_to :commentable, polymorphic: true, counter_cache: :commentable_count, touch: true

That's all of the changes we need to make to get comment counts updated. Really!

Head back to the applicants index page and leave a comment on an applicant. You should see the number of comments update on that applicant’s card immediately after the comment is created.

Mentioning other users

Comments are much more useful if you can bring that comment to another user’s attention by mentioning them in the text of the comment. This pattern of mentioning other users to send them a notification is incredibly common. Without it, comments would be of limited use — why leave a comment if no one will ever know the comment is there without going to look?

We will use tributejs and StimulusReflex to add mentions to our commenting system. Mentioning a user will automatically create a new notification for that user with a link for the mentioned user to view the comment.

The inspiration for this section comes from an excellent GoRails episode demonstrating an integration with tributejs and ActionText. Chris Oliver’s work is foundational to this particular section, and GoRails is a wonderful resource for Rails developers.

Let’s begin by adding tributejs to the application and creating a new reflex. From your terminal:

yarn add tributejs
rails g stimulus_reflex mentions
rails stimulus:manifest:update

Recall that generating a new reflex creates both a server-side reflex and a client-side Stimulus controller.

Head to the new Stimulus controller first:

This is a hefty controller. Let’s break it down a bit.

In the connect method we setup the controller, initializing a new Tribute instance and calling a not-yet-defined reflex with this.stimulate:

connect() {
  super.connect()
  this.editor = this.element.editor
  this.initializeTribute()
  this.stimulate("Mentions#user_list")
}

In initializeTribute, we initialize a new Tribute instance and attach that instance to the controller’s DOM element.

this.tribute = new Tribute({
  allowSpaces: true,
  lookup: 'name',
  values: [],
  noMatchTemplate: function () { return 'No matches!'; },
})
this.tribute.attach(this.element)

The most important option passed in to Tribute is the empty values array. We populate that array with this.stimulate("Mentions#user_list") in the connect lifecycle method and with userListValueChanged.

userListValueChanged() {
  if (this.userListValue.length > 0) {
    this.tribute.append(0, this.userListValue)
  }
}

userListValueChanged takes advantage of built-in Stimulus value change callbacks. At the top of the controller, we defined a userList value, which expects to be an array. When the userList value changes (which the server-side Mentions#user_list reflex will handle), Stimulus runs the userListValueChanged callback which calls Tribute.append to populate the list users that can be mentioned in a comment.

replaced and _pasteHtml handle inserting mentioned users into the Trix editor cleanly, creating a Trix attachment and embedded that attachment inline with the rest of the text in the comment.

The server-side reflex will be responsible for populating the list of mentionable users that Tribute relies on. Head to app/reflexes/mentions_reflex.rb and fill it in like this:

class MentionsReflex < ApplicationReflex
  def user_list
    users = current_user
            .account
            .users
            .where.not(id: current_user.id)
            .map { |user| { sgid: user.attachable_sgid, name: user.name } }
            .to_json

    cable_ready.set_dataset_property(
      name: 'mentionsUserListValue',
      selector: '#comment_comment',
      value: users
    )
    morph :nothing
  end
end

This reflex queries the database for mentionable users and then builds an array of hashes with name and sgid keys.

Then we use the set_dataset_property CableReady operation that we used in the last chapter to update the userList value. When this operation runs, the value of userList changes, Stimulus fires the userListValueChanged callback, and Tribute gets an updated list of mentionable users.

Neat.

Recall that in the last chapter, after the chart reflexes run, we relied on a StimulusReflex afterReflex callback to update the charts. Why did we not take the same approach here? So we could demonstrate Stimulus values callbacks.

We could accomplish exactly the same behavior with a StimulusReflex callback in the Stimulus controller. Remove the userListValueChanged callback and add this instead and everything would work just the same:

afterUserList() {
  this.tribute.append(0, this.userListValue)
}

My preference is using afterUserList instead of userListValueChanged because there is a bit less ceremony involved with binding this and checking userList's value.

It is also worth noting that we could technically build the list of mentionable users in the initial call to comments#index and render the form with the userList value already populated.

Retrieving the users after the fact is not strictly necessary but we took this route because it gives us more chances to learn.

Before mentioning will work, we have a few more steps to take.

First, we need to update the User model to make it possible to create Trix attachments tied to users with the attachable_sgid method that we used in the MentionsReflex.

We also need the User model to define a to_attachable_partial_path so that user mention attachments can be displayed properly when a saved comment is rendered.

Head to the User model:

include ActionText::Attachable

def to_attachable_partial_path
  'users/mention_attachment'
end

users/mention_attachment does not exist yet. Create it from your terminal:

touch app/views/users/_mention_attachment.html.erb

And fill that new partial in:

Next, we need to attach the mentions controller to the DOM. In the comments form partial:

Here, we attached the mentions controller to the Trix editor and added the data-turbo-false attribute to prevent Turbo from caching the Trix editor. Preventing Turbo from caching this element prevents JavaScript errors caused by a cached version of the input that can occur when navigating back to the applicants page after leaving a comment. Instead of disabling caching on this element, we could also use Turbo's before-cache event to teardown Tribute before caching the page. Either option works fine but because there is no value in caching this form element so disabling the cache entirely is reasonable.

One last step before we test out mentioning users in the comment form. We need to add some basic styles to the list of users that tribute displays.

Create a new stylesheet where we will add these styles. From your terminal:

touch app/assets/stylesheets/mentions.css

And then fill that new file in:

.tribute-container {
  @apply rounded-sm border border-gray-100 overflow-hidden shadow;
  z-index: 60;
}
.tribute-container ul { 
  @apply list-none m-0 p-0
}
.tribute-container li {
  @apply bg-white p-1 max-w-full text-gray-700;
  min-width: 15em;
}
.tribute-container .highlight {
  @apply bg-blue-500 text-white cursor-pointer;
}
.tribute-container .highlight span {
  @apply font-bold;
}

Most of these changes are cosmetic. The exception is the z-index value applied to the tribute-container class. Because the comment form is in a drawer (which has a higher z-index than normal page content), we need to bump the z-index of the tribute content above the slideover z-index or we will not be able to see the list of mentionable users when we test things out.

Last step — import the new stylesheet into app/assets/stylesheets/application.tailwind.css:

@import "mentions.css";

With these changes in place, make sure that you are logged in to an account with at least two users and head to the applicants index page. On the index page, open the comment drawer for any applicant and type an @ into the comment form input.

You should see the list of mentionable users pop-up. Choose a user to mention and see that the mention attachment adds seamlessly into the existing comment text.

Notify mentioned users

Mentioning a user in a comment is not very useful if the notified user is not notified of the comment. In this section, we will use the reusable Notifications base that we built earlier to send notifications when a user is mentioned in a comment.

Recall that notifications rely on single table inheritance, with each type of notification being defined in its own class. We will start by creating that new class. From your terminal:

touch app/notifications/applicant_comment_notification.rb

And fill the new class in like this:

class ApplicantCommentNotification < Notification
  def message
    "#{params[:user].name} mentioned you in a comment on #{params[:applicant].name}"
  end

  def url
    applicant_comments_path(params[:applicant])
  end
end

Nothing fancy here — each notification class defines message and url methods for rendering itself in the notifications list.

Then update the comment model to create a new notification each time a user is mentioned in a comment:

after_create_commit :notify_mentioned_users

def notify_mentioned_users
  mentioned_users.each do |mentioned_user|
    Notification.create(
      user: mentioned_user,
      type: "#{commentable.class}CommentNotification",
      params: {
        user: user,
        applicant: commentable
      }
    )
  end
end

def mentioned_users
  comment.body.attachments.select { |att| att.attachable.is_a?(User) }.map(&:attachable).uniq
end

There is a small wrinkle here. Technically, any number of users can be mentioned in the same comment. When a new comment is created, we run through all of the Trix attachments on the comment and create a notification for each user with an attachment.

With this change in place, login to two users on the same account. In one window, create a new comment that mentions the user logged in to the second window. You should see the new comment notification come through instantly for the mentioned user.

Great work so far in this chapter! To finish this chapter, we are going to move notification creation to a background job, setting up a basic Sidekiq installation in the process.

Background notifications

When a new comment is created, it is technically possible for dozens of user notifications to be created — imagine a very excited user mentioning every user on their account in the same comment.

Because notifications are created inline right now, the user who created the comment would need to wait for all of those dozens of notifications to be created before the request to create the comment would complete.

A better approach is moving non-essential updates like this to a background worker where they can be processed out-of-band, unblocking the user who created the comment and avoiding tying up resources unnecessarily.

To process background jobs in our application, we will use Sidekiq and ActiveJob.

Install Sidekiq from your terminal:

bundle add sidekiq
touch config/sidekiq.yml

And fill in the Sidekiq configuration file with a simple configuration to get us started (and lifted right from the Sidekiq documentation):

And then tell Rails that we want to use Sidekiq with ActiveJob. In config/application.rb:

config.active_job.queue_adapter = :sidekiq

Now that we have Sidekiq installed and ActiveJob configured to use Sidekiq, we need to create a new job to create user notifications. To do this, use the built-in Job generator. From your terminal:

rails g job NotifyUser

And then fill the job in at app/jobs/notify_user_job.rb:

class NotifyUserJob < ApplicationJob
  queue_as :default

  def perform(resource_id:, resource_type:, user_id:)
    resource = resource_type.constantize.find(resource_id)
    user = User.find(user_id)
    resource.create_notification(user)
  end
end

The NotifyUserJob will create multiple types of notifications (right now we can notify users of new mentions and new inbound emails), so we pass in the information that it needs to find the record and create the notification.

Now update the Comment model to create notifications in the background:

def notify_mentioned_users
  mentioned_users.each do |mentioned_user|
    NotifyUserJob.perform_later(
      resource_id: id,
      resource_type: 'Comment',
      user_id: mentioned_user.id
    )
  end
end

def mentioned_users
  comment.body.attachments.select { |att| att.attachable.is_a?(User) }.map(&:attachable).uniq
end

def create_notification(mentioned_user)
  Notification.create(
    user: mentioned_user,
    type: "#{commentable.class}CommentNotification",
    params: {
      user: user,
      applicant: commentable
    }
  )
end

notify_mentioned_users is still the method the after_create_commit callback runs. This method still loops through each mentioned user, but it sends each notification into the background with NotifyUserJob.perform_later.

After updating the Comment model, stop the Rails server and then update Procfile.dev to start sidekiq along with building assets and starting the Rails server:

worker: bundle exec sidekiq

Start the application back up with bin/dev, mention a user in a comment and see that notifications are still created as expected.

If you like, you can also use the new NotifyUser background job to create inbound email notifications. To do so, update the Email model like this:

after_create_commit :notify_recipient, if: :inbound?

def notify_recipient
  NotifyUserJob.perform_later(
    resource_id: id,
    resource_type: 'Email',
    user_id: user.id
  )
end

def create_notification(user)
  InboundEmailNotification.create(
    user: user,
    params: {
      applicant: applicant,
      email: self
    }
  )
end

Incredible work — with this last bit of code we have completed our Hotwired ATS application and you have reached the end of this book!

To see the full set of changes in this chapter, review this pull request on Github.

I appreciate you joining me on this journey, and I hope you have found this book to be information, valuable, and useful for you as a Ruby on Rails developer.

When you are ready, read on to Wrapping Up to recap what we built together and to find learning resources outside of this book to help you continue on your journey.