Skip to main content
ActionText:

@mentions for Users with ActionText in Rails 6

24

Episode 288 · March 11, 2019

Mentioning Users with an @mention is a super common feature. Trix and ActionText are now part of Rails 6 which means we can very easily add support for tagging users with @mentions and other custom attachments in your applications.

ActionText Javascript ActiveRecord Rails 6


Resources

Notes

ActionText strips out the HTML from attachments on save in order to always render the current data of each attachment. If it just saved the HTML, the content could go stale if the related records changed. ActionText uses a GlobalID to reference the model for the attachment, allowing you to look up the record for each attachment and then render the partial for the attachment's HTML representation.

ActionText requires custom attachments to have a signed Global ID sgid attribute on them. We can get the sgid by including the ActionText::Attachable module on our ActiveModel objects.

class User < ApplicationRecord
  include ActionText::Attachable
end

You will have to send the sgid to the client using an AJAX request or ActionCable.

Then in our Javascript, we can attach the User with a Trix.Attachment.

let attachment = new Trix.Attachment({
  sgid: mention.sgid,
  content: mention.content
})

ActionText will automatically render the model's partial using to_partial_path when it renders the attachment. It will do the same when rendering the attachment again in the Trix form field. You'll want to render this template as well for the content attribute of the Trix.Attachment so the attachment looks the same in all 3 cases.

Transcripts

What's up guys, In this episode we are going to be implementing user @mentions and actiontext and trix which is gonna be pretty awesome. So this is a feature That's been highly requested, almost every website these days has this feature somewhere and so that's what we're gonna be implementing today.

Now what's cool about this is that we are going to be using Zurb's tributejs library. So that when you type it @ symbol in trix editor and some characters after that it will autocomplete and allow us to select a user. So this is gonna work really nicely with trix. So we're going to have zurb actually insert a trix attachment for the user. Now, normally when you hear the word attachment, you think of pdfs or images or other uploads that you might want to do but this is different, trix attachments are just objects that live inside of trix as a unit and so what we're gonna end up doing is implementing user mentions in trix with a custom attachment. Then action text is going to pull that out and then minify it and strip it out and use a global id to reference the user record in our database that way when it renders it again, it can pull the user out of the database and use their current avatar in case it changed or their name If that had happened to have changed as well. So this is gonna work really nicely and be cool feature to implement especially with action text because there's not any documentation on this stuff just yet. There's even a bug that we're going to talk about in this episode that I submitted a PR for which is cool.

So what we need first off is to have action text installed. That's all you really need. you're going to need this like so where you have some sort of model and you have an actiontext rich text field somewhere. So in the last video, if you want to see how to set this up, we created these posts and configured action text to allow us to render rich text and do file uploads here. So take a look at that last episode which will be in the notes below.If you haven't set this up before.

So let's dive in to adding user mention. first things first we're going to run

$ rails webpacker:install:stimulus 

because we want to make sure that we have stimulus support because we're going to use, stimulus to implement the javascript coordination of tribute and trix together and that's going to be as simple as that is. Next upwe're gonna run

$ yarn add tributejs

This is the zurb tribute javascript library that we're going to use to power the drop down for our mentions. Then we're gonna go to the routes in our application (app/config/routes.rb) and we're gonna add

resources :mentions, only: [:index]

and we're just going to want the index for this. So this is going to be the url that we hit to do the autocomplete for our users. So they're gonna load dynamically And this is the json endpoint we're gonna hit.

So let's create that controller app/controllers/mentions_controller.rb which will be MentionsController and it inherits from ApplicationController and we'll have an index for this and we just need to query the users out of the database.

app/controllers/mentions_controller.rb

class MentionsController < ApplicationController
  def index
    @users = User.all
    respond_to do |format|
      format.json
    end
  end
end

So we want to render a json response for this and really only accept a json request. One other thing you could do here is we're gonna pass in a param called query which will be the first charracters of the name that was typed and so you might want to pass this along to a search on your database or elasticsearch or something like that to filter out the results for autocomplete. we're just gonna leave it as User.all in this example, but keep in mind you're gonna want to replace this with a search. So you don't return every single user If you have 10,000 users, you want to just return like the first 10 or something like that. So keep that in mind. Then when we've got this we're, gonna need to make a directory called app/views/mentions, then we can create a file in there called app/views/mentions/index.json.jbuilder. This file is going to create an array for our users and we're gonna return a partial: users/user, as: :userand this is just going to go through each one of those users, render that partial and set the local variable as user in order to do that and I want to make sure that this is users/user because we want to render out an individual user and so that should be singular for the template name.

app/views/mentions/index.json.jbuilder

json.array! @users, partial: "users/user", as: :user

Then we can create the directory for app/views/users and then create a partial app/views/users/_user.json.jbuilder and here's going to be similar we're going to extract from the user, their id and their name and then we need to implement the two keys that we want for actiontext. First is the sgid and second is the content.

app/views/users/_user.json.jbuilder

json.extract! user, :id, :name

json.sgid
json.content

Now we want to call some method on the user to get the sgid and to do that we're going to include the actiontext attachable module onto our user.

app/models/user.rb

class User < ApplicationRecord
  include ActionText::Attachable

  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable and :omniauthable
  devise :masqueradable, :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable, :omniauthable

  has_person_name

  has_many :notifications, foreign_key: :recipient_id
  has_many :services
end

This is going to add a few methods that will make it compatible with actiontext, namely an attachable sgid method. This is going to give us a signed global id or sgid and this will allow us to then remove basically all the html When we save our mention to the database and then actiontext is going to look When we render it again, it will look for the sgid and say, oh, this is for a user with the id of number one. So let's go lookup that record in the database and render that out. And so it's going to automatically render the users/user partial, whatever your two partial path is on your user or whatever attachable model that you were using. So then we can say attachable sgid and actiontext. Wil know to render out the correct partial for us when it renders it out on the show view.

So that's gonna work for us and we want to actually just render out that exact same partial right now by doing users/user where locals we wanna pass in the user that we're operating on and we want to use the format html only. so actiontext when it displays your attachment will automatically render this partial by default. So we're going to render this out so that when we're editing in the form, we can render the partial and then insert it. So it looks exactly the same when we're editing is when we show it. So that's what we wantto do now.

app/views/users/_user.json.jbuilder

json.extract! user, :id, :name

json.sgid user.attachable_sgid
json.content render(partial: "users/user", locals: { user: user }, formats: [:html])

So let's create app/views/users/_user.html.erb. So in here, you can have any html content you want. We might say, let's make it a span with the mention class. So we can target and style this with css or something and we want to just print out the user's name But it might be nice to have something like the user's avatar which you grabbed from the nav bar and here we could say, let's just display our gravatar for the user and we'll change the user.email to match accordingly to the right variable. So this is what our mentions will look like. they'll just be an image next to some text wrapped in a span.

app/views/users/_user.html.erb

<span class="mention">
  <%= image_tag gravatar_image_url(user.email, size: 40), height: 20, width: 20, class: "rounded" %>
  <%= user.name %>
</span>

So now let's openup our browser and go to localhost:3000/mentions.json and we see here that we see all our users in our database and their sgid and their content. So all of this is great and gives us everything we need to go build out our front-end.

So first things first, Let's dive into the post form where we have our rich text area(app/views/posts/_form.html.erb) and let's add a controller to this for stimulus. So we'll call our controller creatively, we'll call it mentions and we're gonna add a target here called mentions.field And this is just gonna allow us to reference this field easily. In our code and in case you decided to move this somewhere else or whatever, you could put your controller up above even around your html and you could put your mentions inside whatever it is. That's just going to give us access to the field really easily.

app/views/posts/_form.html.erb

<%= form_with(model: post, local: true) do |form| %>
  <% if post.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h2>

      <ul>
      <% post.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <div class="form-group">
    <%= form.label :title %>
    <%= form.text_field :title, class: 'form-control' %>
  </div>

  <div class="form-group">
    <%= form.label :body %>
    <%= form.rich_text_area :body, class: 'form-control', data: { controller: "mentions", target: "mentions.field" } %>
  </div>

  <div class="form-group">
    <% if post.persisted? %>
      <div class="float-right">
        <%= link_to 'Destroy', post, method: :delete, class: "text-danger", data: { confirm: 'Are you sure?' } %>
      </div>
    <% end %>

    <%= form.submit class: 'btn btn-primary' %>

    <% if post.persisted? %>
      <%= link_to "Cancel", post, class: "btn btn-link" %>
    <% else %>
      <%= link_to "Cancel", posts_path, class: "btn btn-link" %>
    <% end %>
  </div>
<% end %>

So then we can create a controller for this. Now, I'm just gonna cheat a little bit and grab the hello_controller.js. we will grab that as our template create app/javascript/controllers/mentions_controller.js and will paste that in and grab the static targets and connect here and we want to change output as the target to field. So just define that and basically we want to create a reference to our editor or trix editor from this.fieldTarget.editor. So that's going to give us access to that to use later on to insert our attachments and do other things like deleting text and so on.

app/javascript/controllers/mentions_controller.js

import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ "field" ]
  connect() {
    this.editor = this.fieldTarget.editor
  }
}  

Next thing is we want to set up tribute. So we're gonna have a this.initializeTribute() method that we'll call and we'll just define that right here in initialize tribute and this method is going to need to have access to tributes. So we're going to import Tribute from "tributejs" at the top here and we'll also import Trix from "trix". So that will give us our imports That we'll use later on and we're going to use the tribute one right Now. We'll say a new Tribute() and we'll pass in a couple options. One is allowSpaces. So we want to allow spaces in ournames and we want lookup to look on the name keys. So when we're looking at our mentions, we want this name attribute to the be the one that we're autocompleting on.
app/javascript/controller/mentions_controller.js

import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ "field" ]
  connect() {
    this.editor = this.fieldTarget.editor
    this.initializeTribute()
  }
initializeTribute() {
    this.tribute = new Tribute({
      allowSpaces: true,
      lookup: 'name',
      values: this.fetchUsers,
    })
    this.tribute.attach(this.fieldTarget)
    this.tribute.range.pasteHtml = this._pasteHtml.bind(this)
    this.fieldTarget.addEventListener("tribute-replaced", this.replaced)
  }
}

Next up, We want values and this is going to call a method called fetchUsers and we will define fetchUsers and that will take some text and a callback and this method is where we will make a request to our rails json endpoint to grab those. So we'll say, /mentions.js and we can pass in the query text here with text(/mentions.json?query=${text}) and then once we get some response back, we can convert the response to json and then once we have some json, we can call the callback with that json. So that will be our array of users and in this case, we could even call this users to be a bit more descriptive. Then we also want to have a catch. If there was an error, we want to call a callback and just pass in an empty array. Just so that it has some data to work with even if it is just an empty array. So, this is really setting up tribute and doing everything for us. We want to probably also disconnect and say this.tribute.detach(this.fieldTarget) whenever we navigate away and the stimulus controller gets torn down because this.tribute.attach() will be the next thing that we do. So this will basically go ahead and set tribute to run on our rich text field. So that will connect it to trix and then handle the @ keyboard presses and stuff So that this will intercept any of those @mentions that we might want to do and we should be able to see that this works in our browser.
app/javascript/controllers/mentions_controller.js

import { Controller } from "stimulus"
import Tribute from "tributejs"
import Trix from "trix"

export default class extends Controller {
  static targets = [ "field" ]

  connect() {
    this.editor = this.fieldTarget.editor
    this.initializeTribute()
  }

  disconnect() {
    this.tribute.detach(this.fieldTarget)
  }

  initializeTribute() {
    this.tribute = new Tribute({
      allowSpaces: true,
      lookup: 'name',
      values: this.fetchUsers,
    })
    this.tribute.attach(this.fieldTarget)
  }

  fetchUsers(text, callback) {
    fetch(`/mentions.json?query=${text}`)
      .then(response => response.json())
      .then(users => callback(users))
      .catch(error => callback([]))
  }
}

Now if you refresh. Now open up the javascript console to make sure that I don't have any javascript errors because if you do you might have made a typo or something like that but now once we have no errors, we should be able to type the @ symbol, see that drop down and show up. Even though it has no styles yet and then you can type and fill those out. If you hit enter, it doesn't do what you would expected to it Just says undefined and the reason for that is there was no value to actually insert in there that match their defaults. So we are going to actually get rid of all the stuff that they do and implement our own version that interacts with trix instead.

So, what we'll do is we'll say this.tribute.range.pasteHtml we're going to override that method with our own _pasteHtml method and we'll bind to the current scope and our version will be at _pasteHtml and it will take the html start position of where you started typing and your end position of that as well and we're going to actually convert this over to this.editor. getPosition() so that we know in trix where we're at And then we can say this.editor.setSelectedRange. We want the position minus the end position and we want to go to the position. So that sets the range that we're going to highlight and then we're just going to set this.editor.deleteInDirection("backward") And so that's going to delete those characters that were just added by the user. So that we delete those and we're not going to insert anything from tribute. We're actually going to do our own callback for that. So here we're going to have this.fieldTarget.addEventListener and we will say "tribute-replaced" and we'll call a method called this.replaced and that one we will define here replaced and it will take an event and we will have our event and we can look up the original user mention that we received back by going to e.detail.item.original that will give us the json item from here, that we actually matched against. So this object like So if we type test user, it grab us this json content and then we can go and create our trix attachment. So let's say let attachment = new Trix.Attachment and we're gonna pass in the sgid most importantly so mention.sgid we're gonna paste in our content for that. So we want mention.content. And that is all we really need to go passover.
These are two important keys that are allowed by trix and actiontext. So if you pass in other things, it might get stripped out. So keep that in mind as you go ahead and do this. sgid and content are the required ones that we're going to need to make this work. So we're then going to grab the trix editor again and insert.Attachment at the current position and then we will say this.editor.insertString(" ") of a single space because it's usually good to type a user's name and then have a space and continue typing without having to hit that space yourself.
app/javascript/controller/mentions_controller.js

import { Controller } from "stimulus"
import Tribute from "tributejs"
import Trix from "trix"

export default class extends Controller {
  static targets = [ "field" ]

  connect() {
    this.editor = this.fieldTarget.editor
    this.initializeTribute()
  }

  disconnect() {
    this.tribute.detach(this.fieldTarget)
  }

  initializeTribute() {
    this.tribute = new Tribute({
      allowSpaces: true,
      lookup: 'name',
      values: this.fetchUsers,
    })
    this.tribute.attach(this.fieldTarget)
    this.tribute.range.pasteHtml = this._pasteHtml.bind(this)
    this.fieldTarget.addEventListener("tribute-replaced", this.replaced)
  }

  fetchUsers(text, callback) {
    fetch(`/mentions.json?query=${text}`)
      .then(response => response.json())
      .then(users => callback(users))
      .catch(error => callback([]))
  }

  replaced(e) {
    let mention = e.detail.item.original
    let attachment = new Trix.Attachment({
      sgid: mention.sgid,
      content: mention.content
    })
    this.editor.insertAttachment(attachment)
    this.editor.insertString(" ")
  }

  _pasteHtml(html, startPos, endPos) {
    let position = this.editor.getPosition()
    this.editor.setSelectedRange([position - endPos, position])
    this.editor.deleteInDirection("backward")
  }
}

So now if we hop back to our browser, we can type a user select them and you'll see that we get their avatar and the user. So if you inspect this, the way that Trix works is that you have a figure and that is a wrapper around the html content. So, this figure kind of represents the attachment itself and it works Similarly when it gets rendered out, there's actually a wrapper html tag around your html content but you'll see inside of here, the span class of mentioned. That is the content that we created. So attachments have some content and they also can have an optional caption. Ours is empty So there's nothing there and what this will do is it will get serialized and basically removed and minified. So our html that we wrote here is just going to disappear when it's saved to the database, but it will keep a reference to this user. So it knows to actually render out the exact same partial when we render our rich text like we see here. Now there's a problem to this though if we hit edit then our attachment is gone and this is actually a bug as it seems in actiontext currently. I've made a PR to basically fix this and have a good default for it. So it's automatically assuming the same partial in both situations but for now you can go to your model(app/models/user.rb) and define a method called to_trix_content_attachment_partial_path and define that method just to call the to_partial_path method and delegate to that.

app/models/user.rb

class User < ApplicationRecord
  include ActionText::Attachable

  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable and :omniauthable
  devise :masqueradable, :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable, :omniauthable

  has_person_name

  has_many :notifications, foreign_key: :recipient_id
  has_many :services

  def to_trix_content_attachment_partial_path
    to_partial_path
  end
end

So that's going to basically tell it when you're rendering the same attachment in trix, you need a little bit different layout. So go ahead and render the same partial though. So now if we do that we're fixed and it's consistent in both cases. So that's really cool. What's awesome about this is that if we go to our app/views/users/_user.html.erb and we decided hey let's make this link to a user. Let's add resources :users in our app/config/routes.rb
and change this span to a link_to user and we'll say class: "mention" do.
we can make a change like so.
app/views/users/_user.html.erb

<%= link_to user, class: "mention" do %>
  <%= image_tag gravatar_image_url(user.email, size: 40), height: 20, width: 20, class: "rounded" %>
  <%= user.name %>
<% end %>

and if we refresh it will use that link now, which is pretty cool. So this is going to set it up. Even though we haven't implemented the users_controller or anything like that, we can go and modify this And so as you edit text and other things, these will always be up to date attachments. So if you're referring to you know on github, if you're referring to an issue or a pull request or user or repository and any of those names or content ends up changing. This is gonna allow you to actually reference the latest version of that all the time. It's a lot better than actually referencing the html that you originally generated and just saving that permanently. So this is really awesome and this is really where actiontext shines.

The last thing I want to point out here is how to add a little bit of styling to this your attachments and in trix, you can just inspect and you can take a look at those and figure out what you want to do with the figures in the content. Inside of them, we added a little bit of css in the last episode for our attachments and attachment previews. So they display inline-block but our attachment previews actually take up the full width and are centered. Then I've also added some css here for tribute so that you can have a nice little shadowed drop down and white background here like so, so it allows you to select those users and it also even highlights the text that it's matching on so we'll put a span around the text that it is match. So here you can see that War is bold and the rest of it is not. So you know what you're matching on so that's pretty cool and easy to use.
app/assets/stylesheets/application.scss

//= require actiontext
//
// $navbar-default-bg: #312312;
// $light-orange: #ff8c00;
// $navbar-default-color: $light-orange;

@import "font-awesome-sprockets";
@import "font-awesome";
@import "bootstrap";
@import "sticky-footer";
@import "announcements";

// Fixes bootstrap nav-brand container overlap
@include media-breakpoint-down(xs) {
  .container {
    margin-left: 0;
    margin-right: 0;
  }
}

// Masquerade alert shouldn't have a bottom margin
body > .alert {
  margin-bottom: 0;
}

// Trix attachment formatting
.attachment--preview {
  margin: 0.6em 0;
  text-align: center;
  width: 100%;
}
.attachment {
  display: inline-block;
  position: relative;
  max-width: 100%;
  margin: 0;
  padding: 0;
}

// Tribute styles
.tribute-container {
  border-radius: 4px;
  border: 1px solid rgba(0,0,0,0.1);
  box-shadow: 0 0 4px rgba(0,0,0,0.1), 0 5px 20px rgba(0,0,0,0.05);

  ul {
    list-style-type: none;
    margin: 0;
    padding: 0;
  }

  li {
    background: #fff;
    padding: 0.2em 1em;
    min-width: 15em;
    max-width: 100%;
  }

  .highlight {
    background: #1b6ac9;
    color: #fff;

    span {
      font-weight: bold;
    }
  }
}

So that is all there is to it. This is how you would set up @user mentions in trix with actiontext as a background but it also works for any kind of custom attachments you might want to add as well. you're going to need to include that actiontext attachable and then have content and sgid in the tricks attachment, that you insert into the editor and that is about it and we will probably continue on with some more of this stuff in the future but for now,that is it for this episode and I will talk to you in the next one peace

Transcript written by Abhinay Kumar

Loading...

Subscribe to the newsletter

Join 24,647+ developers who get early access to new screencasts, articles, guides, updates, and more.

    By clicking this button, you agree to the GoRails Terms of Service and Privacy Policy.

    More of a social being? We're also on Twitter and YouTube.