A new version of Hotwiring Rails is in the works!
An updated version of Hotwiring Rails will be published in spring 2024. Sign up for updates: Hotwiring Rails 2024.
Creating and moving applicants
Companies use applicant tracking systems to attract candidates and manage their hiring process, not to create job postings in an administrative interface.
Right now our version of an ATS is a list of jobs, posted into the void, not very useful. We will make the application more valuable in this chapter by creating an Applicants
resource and then adding the ability for users to create and manage applicants manually in the admin interface.
When we finish this chapter, we will have a Kanban board styled Applicants
page. Applicants will be grouped by hiring stage and users will be able to drag-and-drop applicants between stages. We will use the same CableReady-powered slideover drawer to add new applicants, and we will explore two different methods for dragging applicants between stages: a Stimulus-only version and a StimulusReflex version.
At the end of this chapter, the applicants page will look like this:
Build applicant resource
To begin, generate a scaffold from your terminal and migrate the database:
rails g scaffold Applicant first_name:string last_name:string email:string:index phone:string stage:string:index status:string:index job:references
rails db:migrate
Update the applicant model at app/models/applicant.rb
with enum definitions, basic validations, and a name
helper method:
class Applicant < ApplicationRecord
belongs_to :job
enum stage: {
application: 'application',
interview: 'interview',
offer: 'offer',
hired: 'hire'
}
enum status: {
active: 'active',
inactive: 'inactive'
}
validates_presence_of :first_name, :last_name, :email
def name
[first_name, last_name].join(' ')
end
end
We are storing an applicant’s hiring stage directly on the applicants table in our application. If we were building an application intended for commercial use, a better approach would be moving hiring stages to a separate database table to allow each company to create their own hiring stages. The four static stages we are using will work just fine for the learning application that we are building.
Update the job model to add the applicants has_many
association:
has_many :applicants, dependent: :destroy
Add a link to the applicants index page to the authenticated navigation bar:
Fill in app/views/applicants/index.html.erb
to create a basic Kanban layout:
Here, we loop through groups of applicants based on their hiring stage and render each applicant.
For simplicity, we are querying for applicants by stage in the view which is not efficient or sustainable, but it works for now. We will improve this grouping related code in a future chapter of this book.
Take note of the applicants-#{key}
id assigned to each applicant group. We will use this id to insert newly created applicants into the correct location on the board.
Each applicant will be rendered as a card on the board, and we will render them using a card
partial.
Create that partial from your terminal:
touch app/views/applicants/_card.html.erb
Fill the new card partial in:
The card partial is rendering a calendar svg icon that we have not added yet. Create it from the terminal:
touch app/assets/images/calendar.svg
Fill the new icon file in:
With these changes in place, the Applicants index page now renders, but we cannot create new applicants through the UI so the page is not very useful yet. Next up we will use the slideover drawer technique we used in the last chapter to enable users to create applicants in the UI.
Create applicants
Update the new
action in the ApplicantsController
:
before_action :authenticate_user!
def new
html = render_to_string(partial: 'form', locals: { applicant: Applicant.new })
render operations: cable_car
.inner_html('#slideover-content', html: html)
.text_content('#slideover-header', text: 'Add an applicant')
end
Here, we added authenticate_user!
to require users to login before accessing these routes and then we updated the new
action to return CableReady JSON. This code is nearly identical to the new
action in the JobController
.
Now update the applicants form partial:
When we click the Add a new applicant button the applicant form will open in a slideover, but submitting the form will redirect to the applicant show page.
When a new applicant is created the slideover should close and the applicant should be prepended to the applicants list, mirroring the functionality on the jobs page.
Head back to the ApplicantsController
and update the create
action:
def create
@applicant = Applicant.new(applicant_params)
if @applicant.save
html = render_to_string(partial: 'card', locals: { applicant: @applicant })
render operations: cable_car
.prepend("#applicants-#{@applicant.stage}", html: html)
.dispatch_event(name: 'submit:success')
else
html = render_to_string(partial: 'form', locals: { applicant: @applicant })
render operations: cable_car
.inner_html('#applicant-form', html: html), status: :unprocessable_entity
end
end
As in the new
action, create
will look familiar. Like the JobsController
, on a successful submission the newly created resource is prepended to a target element in the DOM, and the slideover is closed via the submit:success
DOM event.
In all applicant tracking systems, applicants can upload a resume with their application, so we will add that feature next. To support resume uploading, we will install and configure ActiveStorage in our application.
ActiveStorage resumes
Our goal is to allow a resume file to be attached to an applicant when the applicant is created. To start, update the Applicant
model to tell ActiveStorage about the resume
attachment:
has_one_attached :resume
has_one_attached
defines a single resume
attribute that will store the attached file. If applicants were allowed to have multiple resumes, we could use has_many_attached instead.
With resume
added to the applicant model, we can update the applicants form partial to add a new file upload field:
Update applicant_params
in the ApplicantsController
to add the new resume
attribute:
def applicant_params
params.require(:applicant).permit(:first_name, :last_name, :email, :phone, :stage, :status, :job_id, :resume)
end
At this point refresh the page, open the applicant creation slideover, and see that the resume field is visible on the form. Attach a pdf and save the applicant and the pdf will be attached to the applicant.
Because we have not built the applicant show page yet, for now you can verify the file is attached in the Rails console. Start the Rails console with rails c
in your terminal and then:
Applicant.last.resume.attached?
=> true
File uploading can be slow, especially for users on mobile networks. To improve performance and prevent our application servers from being kept busy uploading files and then transmitting them to a third party storage service, we can use the direct upload feature of ActiveStorage
.
Direct uploads allow us to send a file directly from the browser to a cloud storage provider, completely bypassing our own application servers. Adding direct upload capability to a file field requires two modifications to our existing code.
The first step is to import and start ActiveStorage's
JavaScript in app/javascript/application.js
import * as ActiveStorage from "@rails/activestorage"
ActiveStorage.start()
Step two is updating the resume
file field to add the direct_upload
attribute:
Easy.
Note that in development direct uploads are just regular uploads. In production, you will interface with a cloud storage provide like Amazon S3 for direct uploads. See the full list of supported cloud storage providers in the Rails guides.
In the next section, we will add the ability to move applicants between hiring stages by dragging and dropping the applicant’s card.
Drag applicants between stages
Drag-and-drop is a very common feature in web applications. In this section we will take a look at two different methods for implementing drag-and-drop on the applicants page.
Users will drag-and-drop applicants between hiring stages in order to track the current hiring stage for each applicant. As an applicant progresses through the hiring process, users move them between the hiring stage columns on the Applicants page. In a commercial applicant tracking system, this Kanban board style layout can become very difficult to use as the number of applicants on the page increases. However, for small numbers of applicants this interface is intuitive and, despite the poor commercial viablity of this applicants page layout, it presents a good learning opportunity for us.
Each time an applicant is dragged to a new hiring stage, we will update the applicant’s hiring stage in the database without making any changes to the UI.
In the first solution, Stimulus will be used to send a PATCH request to a server endpoint to update applicant hiring stages after a drag event occurs. In the second solution, we will use StimulusReflex to accomplish the same thing without a manually constructed PATCH request.
In both methods, we will allow users to drag applicants between hiring stage columns in the UI using SortableJS.
To begin, we need the SortableJS JavaScript package installed, so we will start there. From your terminal:
yarn add sortablejs
Dragging applicants with Stimulus
The Stimulus-only approach is an opportunity to examine another common use case for Stimulus controllers, integrating third party JavaScript libraries into your user interface. Let's see what this looks like.
Start by generating a new Stimulus controller. From your terminal:
rails g stimulus drag
Fill the new drag Stimulus controller:
import { Controller } from 'stimulus'
import Sortable from 'sortablejs'
export default class extends Controller {
static targets = [ 'list' ]
listTargetConnected() {
this.listTargets.forEach(this.initializeSortable.bind(this))
}
initializeSortable(target) {
new Sortable(target, {
group: 'hiring-stage',
animation: 100,
sort: false
})
}
}
In the controller, we declare a list
target and then in listTargetConnected
we loop through each list
target in our controller and call initializeSortable
on the list
element. [name]TargetConnected
is a built-in Stimulus callback that runs each time the target element is added to the DOM.
initializeSortable
makes the target element a sortable list. The group option is how we enable dragging between different sortable lists on the same page. Note that the hiring-stage
group name can be anything we like.
There are two key concepts to take note of in this controller. The first is that an instance of a Stimulus controller can have any number elements with the same target
identifier. w=We can get all target elements at once with [targetName]Targets
, making it trivial to act on multiple child elements at once in a controller.
The second is that Stimulus callbacks (like listTargetConnected and controller lifecycle callbacks) make it easy for us to add behavior from third party JavaScript libraries to our code.
Instead of listening to page-level events on the document or window, Stimulus utilizes mutation observers that allow us to attach behavior when an element enters or exits the DOM. This pattern of adding behavior on connect
or initialize
is one you will come back to regularly when working with Stimulus.
Now we will connect the controller to the DOM. In app/views/applicants/index.html.erb
, update the applicant stages container div like this:
Here we added the drag
controller to the div
wrapping all of the applicant stage columns and added the data-drag-target="list"
attribute to each column.
When the applicants index page loads, the drag
controller connect
method is called and each stage column becomes an individual Sortable list. Because each list has the same group
attribute, we can drag applicants between lists.
We added sort: false
in Sortable's options. This option prevents dragging applicants to a new position within the same group, you can only drag them to a new group. This is done intentionally, because we will not be allowing users to persist the order of applicants within a stage in this book.
You will notice that dragging between stages at this point “works”, but only until the page is reloaded. We are not persisting stage changes in the database yet so applicant's move back to their original stage each time the page is reloaded. Let’s tackle persistence next.
First, we will update the drag
controller to send a PATCH request when the user finishes dragging an applicant, hooking into Sortable’s onEnd
option:
import { Controller } from 'stimulus'
import Sortable from 'sortablejs'
export default class extends Controller {
static targets = [ 'list' ]
static values = {
url: String,
attribute: String
}
connect() {
this.listTargets.forEach(this.initializeSortable.bind(this))
}
initializeSortable(target) {
new Sortable(target, {
group: 'shared',
animation: 100,
sort: false,
onEnd: this.end.bind(this)
})
}
end(event) {
const id = event.item.dataset.id
const url = this.urlValue.replace(":id", id)
const formData = new FormData()
formData.append(this.attributeValue, event.to.dataset.newValue)
window.mrujs.fetch(url, {
method: 'PATCH',
body: formData
}).then(() => {}).catch((error) => console.error(error))
}
}
Here, we are using values to define a url
and an attribute
. Using values enables us to define the URL and attribute that we want to update in the DOM, instead of hardcoding those values in the controller. By defining these values when we initialize the controller, we make the controller easier to reuse in other places in our application.
We also added a new end
function which is called when Sortable’s onEnd
event is triggered. end
grabs the id of the applicant that was moved and combines it with the url
and attribute
values to construct a PATCH request to the server.
We need to update the markup to define these values when we initialize the Stimulus controller before this controller will work as expected.
Back to the applicants index view, starting with the div with the drag
controller attached:
Here, we updated the controller element to define the url
and attribute
value, and we updated the list
target element to add the column's stage as a new-value
data attribute.
We need each applicant to set a data-id
attribute to use when we construct the final url for the PATCH request. Make that change in the card
partial:
The last step to persisting stage changes in the database is to add a route and controller action to handle the stage change PATCH request.
We will do this by adding a non-RESTful change_stage
route to the Applicants resource.
If we want to keep the entire application RESTful, we could define a new Applicants::StageChangesController
with an update
action. However, that would be a lot of extra ceremony for very little extra learning benefit, so we are going to break the rules a bit by using a non-RESTful action. Don't tell DHH about this, please.
Update the application's routes to add the new change_stage
route:
resources :applicants do
patch :change_stage, on: :member
end
Now define the change_stage
action in the ApplicantsController
:
before_action :set_applicant, only: %i[ show edit update destroy change_stage ]
def change_stage
@applicant.update(applicant_params)
head :ok
end
Refresh the applicants page after adding the new route and action. Move applicants between stages and then refresh the page to see that your changes are now saved to the database.
Now that we have seen a Stimulus-powered drag-and-drop interface, let’s reset and look at the same interface built with StimulusReflex. Like in the last chapter, we will implement two different solutions to the same problem, comparing and contrasting so you can get comfortable with the different options available to you as a Rails developer.
Sidebar: Drag-and-drop with StimulusReflex
The path forward with StimulusReflex-powered drag-and-drop is similar to the Stimulus powered approach. We will have a Stimulus controller that handles initializing Sortable on draggable elements. The Stimulus controller will hand things off to the backend when the Sortable end
event is triggered, and the backend will silently save the applicant’s new stage in the database.
To get started, generate a new reflex using the generator provided by StimulusReflex:
rails g stimulus_reflex draggable
rails stimulus:manifest:update
This generator creates a client-side Stimulus controller (draggable_controller.js
) and a server-side reflex (draggable_reflex.rb
) that work together to produce the desired end user experience.
Because new StimulusReflex controllers do not automatically update the Stimulus manifest file, whenever we generate a new Stimulus controller with StimulusReflex, we must follow that command with rails stimulus:manifest:update
. The manifest:update
task scans the app/javascript/controllers
directory and registers each controller in the controllers
directory in the Stimulus manifest file found at app/javascript/controllers/index.js
.
StimulusReflex-enabled Stimulus controllers are very similar to regular Stimulus controllers, with the added bonus of being able to trigger server-side Ruby code effortlessly.
To see this in action, fill in the draggable
Stimulus controller like this:
import ApplicationController from './application_controller'
import Sortable from 'sortablejs'
export default class extends ApplicationController {
static targets = [ 'list' ]
static values = {
attribute: String,
resource: String
}
connect () {
super.connect()
this.listTargets.forEach(this.initializeSortable.bind(this))
}
initializeSortable(target) {
new Sortable(target, {
group: 'shared',
animation: 100,
sort: false,
onEnd: this.end.bind(this)
})
}
end(event) {
const value = event.to.dataset.newValue
this.stimulate(
"Draggable#update_record",
event.item,
this.resourceValue,
this.attributeValue,
value
)
}
}
This code should look pretty familiar. Let’s walk through what is different.
First, instead of importing stimulus
, we import application_controller
and extend that controller to enable StimulusReflex functionality in the controller.
The super.connect()
in the connect
lifecycle method is also part of the basic creation of a StimulusReflex controller.
The end
function is where things get interesting. In the Stimulus-only version of this controller, end
is responsible for constructing form data from the DOM event’s data, building a URL, and sending a PATCH request to that URL.
In this version, the end
function calls this.stimulate
to trigger a reflex action on the server. In the stimulate call, we pass in data from the DOM event and values from the Stimulus controller. These arguments are used by the server-side reflex action to find and update the correct applicant in the database.
We will define the update_record
action in the reflex class next. Head to the DraggableReflex
and fill it in with:
class DraggableReflex < ApplicationReflex
def update_record(resource, field, value)
id = element.dataset.id
resource = resource.constantize.find(id)
resource.update("#{field}": value)
morph :nothing
end
end
This is mostly standard Rails code. update_record
finds a resource by id and then updates that resource with the value passed to update_record
. There are two important StimulusReflex concepts demonstrated here.
You will notice that the this.stimulate
call in the Stimulus controller had 4 arguments (plus the reflex action name) but update_record
only accepts 3 arguments.
This is because the second argument passed to stimulate
is the element
we are interested in. By default, element
is the DOM element the Stimulus controller is attached to. In our case, we need access to the id
attribute on the individual applicant being moved, so we override the element
in the stimulate call. This allows the reflex on the server-side to access that element's data attributes.
The second concept to review is the unfamiliar morph :nothing
call. Morphs are how we tell StimulusReflex what to update in the DOM.
By default, after a reflex action (like our update_record
action) runs, StimulusReflex updates the entire body of the page by reprocessing the current controller action, sending the new HTML body to the frontend, and then making updates with morphdom.
In many cases, these automatic, full page updates are exactly what you need. No more thinking about client-side state, just let StimulusReflex update the page as efficiently as possible after state has changed on the server as a result of a reflex action.
In our case, we do not need any page updates after this reflex runs. The DOM is already up to date because the user dragged the applicant where they wanted them to be. The reflex action’s only job is updating the database. Reprocessing a controller action and sending an HTML payload back to the frontend would be a waste of energy in this case.
This is when a nothing morph comes in handy. Nothing morphs tell StimulusReflex to do… nothing. Once the code in the reflex runs, StimulusReflex tells the client the reflex finished and then wraps up for the day, no reprocessing controller actions or rendering wasted HTML.
With that brief introduction to a few StimulusReflex concepts, let’s wrap up this sidebar by updating the DOM to connect the draggable
controller so that dragging and dropping applicants uses StimulusReflex.
Update the applicants index page:
Here we connect the controller using data-controller
, add the data-draggable-value
attributes, and then add the data-draggable-target="list"
to each of the stage columns. All of these changes are very close to the Stimulus-only version of this feature
The card partial in this version of the feature is identical to the Stimulus-only version:
After the markup is updated, restart your server and then refresh the page and then drag applicants between stages. If all is well, the applicant stage changes should persist in the database as before. If you check the Rails server logs, you will see the nice, neat output from StimulusReflex broadcasting after each reflex action:
[4b77bdd6] 1/1 DraggableReflex#update_record -> document via Nothing Morph (dispatch_event)
StimulusReflex::Channel transmitting {"cableReady"=>true, "operations"=>[{"name"=>"stimulus-reflex:morph-nothing", "selector"=>nil, "payload"=>{}, "stimulusReflex"=>{"attrs"=>{"class"=>"flex flex-col pb-2 overflow-auto", "data-id"=>"92c88316-8e05-476d-ab91-b7ff08ec8826"... (via streamed from StimulusReflex::Channel:)
Now that we have seen drag-and-drop implemented with Stimulus and with StimulusReflex, moving forward this book will assume you have followed the Stimulus-only path. If you are use the StimulusReflex-powered version instead, be careful when copy and pasting updates to the applicants index page.
I chose to use the Stimulus-only case as the base for the rest of the book because StimulusReflex is probably a bit more than you need for a feature like this. If your application’s UX never gets more complicated than this type of interaction, you will likely be best served by skipping the extra complexity that StimulusReflex introduces.
Later on in this book, we will take a look at more complex scenarios where StimulusReflex is the best tool for the job.
We have reached the end of chapter four, great work! In this chapter, we sharpened our CableReady skills with another slideover drawer implementation and then built a drag-and-drop interface with Stimulus. We also got our first taste of StimulusReflex by rebuilding the drag-and-drop interface with StimulusReflex as a sidebar. Before moving on to chapter five, pause and review any code that is not completely clear, commit your code if you are coding along with me, and then take a break if you need it.
When you are ready, move on to chapter five where we will add the ability to search and filter the applicants index page AND the jobs index page. It is going to get pretty wild.
To see the full set of changes in this chapter, review this pull request on Github.
Changelog
Change | Date | PR link |
---|---|---|
Updated DragController to use target connected callback instead of relying on the controller connect callback. This fixes a bug that introduced when filtering is added to the applicants page in Chapter 5. connect only runs once in our markup, while listTargetConnected runs each time the list is updated by the filtering feature.
|
March 1, 2022 | PR 17 on Github |