Job Postings

Job postings are a basic building block of an applicant tracking system. When a user needs to hire a new employee, they create a job in their applicant tracking system, advertise that job posting, and receive applications to that job.

Without job postings, an applicant tracking system would just be a database of applications with no way to collect or organize applications in a meaningful way. Since jobs are the key organizational unit of our system, we will build job postings first and then add the ability to apply to those jobs and manage job applicants in future chapters.

When we finish this chapter, logged-in users will have the ability to create new job postings, view those job postings in their account, and edit existing job postings.

To build these features, we will use plenty of standard Rails code with a bit of CableReady (plus a sidebar into Turbo Streams and Turbo Frames), and it will look like this:

A gif of a user filling out a job posting form in a slideover drawer. When they click the submit button on the form, the slideover slides off the right edge of the screen and the new job is added to the list of job postings already present on the page.

Scaffold jobs

To start, use the Rails scaffold generator to create the base model, controller, and views for jobs:

rails g scaffold Job title:string status:string:index job_type:string:index location:string account:references

Both job type and job status will be enums. Before migrating the database we will update the migration file to prevent null values and set default enum values for new records:

class CreateJobs < ActiveRecord::Migration[7.0]
  def change
    create_table :jobs, id: :uuid do |t|
      t.string :title
      t.string :location
      t.string :status, null: false, default: 'open'
      t.string :job_type, null: false, default: 'full_time'
      t.references :account, null: false, foreign_key: true, type: :uuid

      t.timestamps
    end
    add_index :jobs, :status
    add_index :jobs, :job_type
  end
end

Then migrate the database. From your terminal:

rails db:migrate

Next, update the job model with relationships, validations, and basic enum definitions:

class Job < ApplicationRecord
  belongs_to :account

  validates_presence_of :title, :status, :job_type, :location

  enum status: {
    draft: 'draft',
    open: 'open',
    closed: 'closed'
  }

  enum job_type: {
    full_time: 'full_time',
    part_time: 'part_time'
  }
end

Note that these enums are stored in normal text columns even though, as of Rails 7, Postgres Enum types are now supported natively in ActiveRecord.

We could store the enums in real enum columns without any extra effort. However, native enums in ActiveRecord have major limitations that make working with them challenging when you expect the enum values to change over time.

Adding new values to an existing enum definition is not yet supported by ActiveRecord, so you still have to drop to raw SQL to add new values. More importantly, you cannot remove a value from an already defined enum without jumping through hoops.

While Postgres Enums are an option, we will use text columns throughout this book.

Next up, define the has_many side of the jobs/accounts relationship. In app/models/account.rb:

class Account < ApplicationRecord
  validates_presence_of :name

  has_many :jobs, dependent: :destroy
  has_many :users, dependent: :destroy
end

Job postings are not very useful without a description of the job duties and requirements. You will notice that we did not add a description column to the job in the database. This is because we are going to use ActionText to store job descriptions, and users will add job descriptions edit in the UI with Trix.

Our use case only needs to support basic styling and links in job descriptions, so Trix, despite its limitations, is a reasonable choice.

Installing ActionText and Trix is straightforward thanks to built-in Rails tasks. From your terminal:

rails action_text:install
bundle install
rails db:migrate

With ActionText installed, adding a new ActionText-powered field to a model can be done in one line with has_rich_text. Update app/models/job.rb to add an ActionText-powered field:

has_rich_text :description

Next up, modify the scaffolded JobsController to set the job's account and ensure that only logged-in users can access the jobs controller:

before_action :authenticate_user!

def create
  @job = Job.new(job_params)
  @job.account = current_user.account

  if @job.save
    redirect_to @job, notice: "Job was successfully created."
  else
    render :new, status: :unprocessable_entity
  end
end

Here, we first added a before_action callback with the Devise provided authenticate_user! method.

Then we updated the create action to set job.account to the current user’s account.

At this point, thanks to the Rails scaffold generator, we have a fully functional job resource. We can visit http://localhost:3000/jobs and see all of the jobs in the database, create new jobs, and edit and delete existing jobs. But, we are not using any fun new tools yet — we have standard Rails controller with full page turns for every action. Let's fix that.

In the next section, we will build a nicer-looking jobs index page. Instead of using full page-turns to create job postings, we will use some new magic to create job postings in a drawer that slides out from the side of the screen. I'm excited too.

Viewing and creating job postings

To start, let’s update the authenticated nav to link to the jobs index page:

Then update the job index view:

Standard ERB-flavored HTML here that renders a header and a collection of @jobs, which are set in the index action in the JobsController.

Update app/views/jobs/_job.html.erb next:

More standard ERB here, displaying a bit of information about the job, along with links to edit and delete the job posting.

The delete button has a few important items to note. First, we are using the button_to helper instead of link_to. This is better for accessibility and is semantically the right approach. link_to would work fine too, but there is no reason to use a link_to for a delete action.

You will notice that we are passing in a confirm data attribute to the delete button. Because deleting a record is typically a permanent decision, making users confirm that they want to take this destructive action is a common pattern in web development. When this attribute is present, the text value of the attribute ("Are you sure?") is shown in a modal when the user clicks the link and they need to click the confirm button on the modal to proceed.

Rails provides a simple way to implement this functionality, but how this behavior works has changed in Rails 7 and it is worth taking a brief detour into this topic since the Rails internet is full of questions about these changes.

In Rails 6, data-confirm attributes were powered by @rails/ujs which shipped by default with Rails. @rails/ujs has been deprecated and is no longer included in new Rails 7 applications. Rails now supports data-confirm behavior by default with Turbo; however, the data attribute has changed to data-turbo-confirm.

But wait, if data-turbo-confirm is the new attribute name, why did we use data-confirm? Because we are using Mrujs. Mrujs is intended as a direct replacement for @rails/ujs and intentionally retained the same attribute names to make migration of existing Rails projects easier. While data-turbo-confirm works just fine and we could use it in this project, Mrujs works with data-confirm and that is what we will use throughout this project. If you prefer, you can use data-turbo-confirm instead.

That is all for the history lesson; back to building jobs.

Creating jobs in a slideover

Currently, clicking on the new job link takes the user to a new page to fill out the job posting form. After they submit the form they are redirected to the job posting. Our goal is to open the job form in a slideover drawer instead of navigating to an entirely new page. When the form is submitted, the newly created job should be added to the existing list of jobs and the slide over drawer should close.

This will be easier than it sounds.

To get started, we need a Stimulus controller to handle opening and closing the slideover. Create that controller from your terminal:

rails g stimulus slideover

Fill the new Stimulus controller in with:

This Stimulus controller is adapted from tailwind-stimulus-components, simplified to meet the needs of our application and so we can learn together. In this controller, we are introducing one new Stimulus concept: targets.

At the top of the controller, static targets = [ "slideover" ] defines one target element that our Stimulus controller will rely on. We can reference this target in the controller's methods with this.slideoverTarget.

Targets are DOM elements that we set when we connect a controller to the DOM. When working with Stimulus, targets are used to obtain a reference to a specific DOM element. In this controller, we use this.slideoverTarget to toggle classes on the target element. This is a very common use of Stimulus targets.

The event listener that we add when open is called is the other important piece of the SlideoverController. This event listener, submit:success, is how we close the drawer after a successful form submission.

We will see how this works when we update the JobsController later on in this section. Before that, we need to add the HTML for the slideover, which includes connecting the Stimulus controller to the DOM.

Create a new partial from your terminal:

touch app/views/shared/_slideover.html.erb

And fill the new partial in with:

Note the data-slideover-target on the container element. This data attribute is how we define the slideoverTarget in our Stimulus controller. We also have data-action="slideover-close" on the close button. Like with the alert in the last chapter, the close function in the slideover controller will be called when the button is clicked.

Notice the empty slideover-content div. By default, the slideover renders with no content! This is intentional. We are going to reuse this slideover throughout the application, and dynamically insert content each time we use it.

With the partial created, add it to the DOM and connect the Stimulus controller by updating app/views/layouts/application.html.erb:

Here, we added data-controller="slideover" to the body and inserted the partial just before the end.

Next up, we want to open the content of the new job posting page when a user clicks the Post a new job link on the jobs index page. To do that, we will update the jobs index page like this:

Now, our post link has a data-action attribute and a data-remote attribute. The data-action tells Stimulus to fire slideover#open when the link is clicked. The remote attribute indicates that the link should be handled by Mrujs’ CableCar plugin, as described in the plugin's documentation.

With our link updated to expect CableCar JSON, we need to update the JobsController to respond with the right JSON from the new action:

def new
  html = render_to_string(partial: 'form', locals: { job: Job.new })
  render operations: cable_car
    .inner_html('#slideover-content', html: html)
    .text_content('#slideover-header', text: 'Post a new job')
end

The new action in a standard Rails CRUD controller renders the new.html.erb view. We are short-circuiting that default rendering here. Instead, we render the jobs form partial to a string and then render cable_car operations. These operations are sent back to the browser as a JSON payload that Mrujs handles.

It is important to emphasize here that using CableReady through the CableCar plugin does not require WebSockets or ActionCable, which may be surprising if you are familiar with CableReady. Using CableCar and Mrujs keeps users in the normal request/response cycle, without involving WebSockets.

The user makes a request in their browser and Rails responds to that request with JSON payload containing CableReady operations. Mrujs receives that response in the browser. Then Mrujs calls cableReady.perform which applies the operations sent from the server, no WebSockets required.

This CableCar-powered approach will be our primary method of creating, editing, and deleting records throughout this book. We will become very comfortable with CableReady operations throughout this book, and we will use CableReady with WebSockets in future chapters.

We will not explore all of the (36!) operations that CableReady supports in this book, but it is important to know that CableReady operations can be sent from almost anywhere and can do almost anything that you want to do on the client — modifying the DOM, logging to the console, playing audio(!), and emitting events.

In this book, we will use CableReady operations for adding, removing, and updating DOM elements and updating data attributes in response to user actions.

For a more detailed introduction, the CableReady documentation is a great place to get more familiar with operations and what problems they help solve.

Before we can render operations from a controller, we need to include CableReady::Broadcaster in the controller. Because we will be using this technique in a variety of places in our application, update app/controllers/application_controller.rb to include it in all of our controllers:

class ApplicationController < ActionController::Base
  include CableReady::Broadcaster
end

With CableReady broadcasting operations from the server to the browser and the browser processing those operations, we can pause here to test that the slideover works as expected. Head to localhost:3000/jobs and click the Post a job link. You should see the slideover open and populated with the job posting form.

Before moving to handling form submissions, let’s pause and make things look a little more pleasant.

We can animate the entry of the slideover with CSS. From your terminal:

touch app/assets/stylesheets/animation.css
touch app/assets/stylesheets/slideover.css

Add a simple fade animation to animation.css:

@keyframes fadeIn {
  0% {
    opacity: 0;
  }

  100% {
    opacity: 1;
  }
}

And add that animation to the slideover container in slideover.css:

#slideover-background {
  background-color: rgba(0, 0, 0, 0.6);
  animation: fadeIn 0.2s ease-in;
}

Import the new stylesheets in application.tailwind.css

@import "animation.css";
@import "slideover.css";

Now add some basic style to the job posting form:

Nothing too exciting here. The enum fields are rendered in select tags, and their options are populated using options_for_select.

The default Trix editor, rendered with form.rich_text_area, has options that we do not need to display to our users and looks completely broken. Luckily, we can fix its style and remove the unwanted toolbar options with css. Update actiontext.css with:

@import "trix/dist/trix";

.trix-button--icon-increase-nesting-level,
.trix-button--icon-decrease-nesting-level,
.trix-button--icon-strike,
.trix-button--icon-code,
.trix-button-group.trix-button-group--file-tools { display:none; }

trix-editor.trix-content {
  @apply appearance-none w-full max-w-prose bg-white text-gray-700 border-gray-200 rounded-sm text-lg focus:ring-1 focus:ring-blue-300 focus:border-blue-300;
  min-height: 400px;
}

A gif of a user clicking a button to open the job posting form. The form slides in from right edge of the screen. When the user closes the drawer, it slides back off the right edge.

Now, our form looks much nicer. The slideover neatly transitions in, but when the form is submitted, users are redirected to the jobs show page instead of staying on the job index with the newly created job inserted.

Let’s fix that next.

Creating job postings

When a user submits the job posting form, we are going to use CableReady operations to close the slideover drawer and insert the new job into the existing list of jobs. We already have most of the pieces in place for this functionality, but we need to make a few more changes to put it all together, starting in the JobsController. Update the create action like this:

def create
  @job = Job.new(job_params)
  @job.account = current_user.account
  if @job.save
    html = render_to_string(partial: 'job', locals: { job: @job })
    render operations: cable_car
      .prepend('#jobs', html: html)
      .dispatch_event(name: 'submit:success')
  else
    html = render_to_string(partial: 'form', locals: { job: @job })
    render operations: cable_car
      .inner_html('#job-form', html: html), status: :unprocessable_entity
  end
end

We are again rendering a JSON payload that contains CableReady operations for the browser.

When the job is valid and saves successfully, we render the job partial to a string and broadcast two CableReady operations to prepend the job to the jobs container, and then dispatch a submit:success DOM event. Recall that when the slideover opens, our Stimulus controller attaches an event listener for submit:success to the document that closes the slideover when it is received:

open() {
  document.addEventListener("submit:success", () => {
    this.close()
  }, { once: true })
}

The ability for the server to broadcast arbitrary events that can be picked up by the browser is very powerful, and this feature is one of the reasons why I prefer CableReady over Turbo Streams. We can build this same functionality with Turbo Streams (and I will show you one way to do so in the next section!), but CableReady’s flexibility is hard to beat.

It is important to note that we are relying on a custom event, submit:success, to trigger the slideover close method. Mrujs includes a set of Ajax lifecycle events that almost work for our purposes. When the form is submitted successfully, the ajax:success event is fired automatically. The problem is, the ajax:success event also fires after the drawer loads the drawer's content in the new action. If we configured the slideover controller to close on ajax:success, the slideover would close itself immediately after opening. Not ideal. Firing a custom event when the form submission is successful sidesteps this problem neatly.

When the job is invalid, we re-render the job form partial and update the content of the form to display any validation errors.

Staying in the JobsController, we also need to update job_params to allow the description param through. Without this change, Rails will silently discard whatever the user enters in the description field when saving the job.

def job_params
  params.require(:job).permit(:title, :status, :job_type, :location, :account_id, :description)
end

The changes to the JobsController action are all we need on the server to properly render the CableReady operations to power the slideover drawer. We have one last step before we have a fully functional job posting form in the slideover:

Update the job form to add the same data-remote attribute that we put on the new job link, and the #job-form id that we target when the job submission is invalid:

Add the #jobs id to the job container div in the jobs index view:

With those changes in place, you should be able to open and submit the job posting form from the slideover drawer.

When you submit the form with valid information, the drawer will close, and the new job will be prepended to the list of jobs on the index page. Invalid information keeps the drawer open with the errors displayed to the user.

Great work so far!

Next we will add the last new feature of this chapter: editing and deleting job postings with CableReady.

After we add those features, we will conclude the chapter by taking a brief detour to demonstrate how to build this same functionality with Turbo Frames and Turbo Streams, so you can compare the two approaches.

Editing jobs in the slideover is going to feel pretty similar to creating jobs.

First, update the link to the edit page in the job partial with the data-action and remote attributes:

And then update the edit action in the JobsController:

def edit
  html = render_to_string(partial: 'form', locals: { job: @job })
  render operations: cable_car
    .inner_html('#slideover-content', html: html)
    .text_content('#slideover-header', text: 'Update job')
end

Looks pretty familiar, right?

Now the update action:

def update
  if @job.update(job_params)
    html = render_to_string(partial: 'job', locals: { job: @job })
    render operations: cable_car
      .replace(dom_id(@job), html: html)
      .dispatch_event(name: 'submit:success')
  else
    html = render_to_string(partial: 'form', locals: { job: @job })
    render operations: cable_car
      .inner_html('#job-form', html: html), status: :unprocessable_entity
  end
end

Again, pretty familiar. Instead of prepending to the list of jobs, we instead use the job’s dom_id to replace the job in the list when the job updates successfully.

Deleting jobs

To remove deleted jobs from the DOM without a page turn, we can again just add the remote attribute and update the controller. In the job partial:

And then in the JobsController:
def destroy
  @job.destroy
  render operations: cable_car.remove(selector: dom_id(@job))
end

Use the remove operation, target the deleted job’s dom_id, and we are all set.

Sidebar: Adding jobs with Turbo Streams

We used Stimulus with CableReady and Mrujs to open the slideover, populate the content, and process the form submission. Using Stimulus along with Turbo Streams and Turbo Frames to populate the slideover content and handle the form submission is an alternative.

I find CableReady to be more flexible and more enjoyable to work with in complex situations. However, you can accomplish a very similar result without much more trouble with a Turbo-based approach.

We will start in the Jobs Controller. Update the new and create actions like this:

def new
  @job = Job.new
end

def create
  @job = Job.new(job_params)
  @job.account = current_user.account
  if @job.save
    render turbo_stream: turbo_stream.prepend(
      'jobs',
      partial: 'job',
      locals: { job: @job }
    )
  else
    render turbo_stream: turbo_stream.replace(
      'job-form',
      partial: 'form',
      locals: { job: @job }
    ), status: :unprocessable_entity
  end
end

The new action has been simplified becase we will render a Turbo Frame in the new action to replace the slideover content instead of broadcasting CableReady operations to update the DOM.

create now responds with a Turbo Stream prepend action on a successful form submission. This action looks very similar to the equivalent CableReady prepend operation and it is powered by the TurboStreamsTagBuilder, which is provided by turbo-rails.

Turbo Streams can be one of seven actions. In addition to prepend and replace seen here. Available actions are append, update, remove, before, and after. Multiple Turbo Streams can be rendered in a single response, which allows you to make more sophisticated page updates when needed.

We cannot dispatch DOM events in a Turbo Stream response, so our Stimulus controller will need to be adjusted to close the drawer after the form is submitted. This is an important difference when working with Turbo Streams instead of CableReady. Let’s tackle that next.

Update the slideover Stimulus controller like this:

Here, we added a new form target, removed the event listener from open, and added a new handleResponse method at the bottom of the controller:

+ static targets = [ "slideover", "form" ]

- document.addEventListener("submit:success", () => {
-  this.close()
- }, { once: true })

+ handleResponse({ detail: { success } }) {
+   if (success) {
+     this.formTarget.reset()
+     this.close()
+   }
+ }

We call the handleResponse method when the form submission ends. When the form submission is successful, we reset the form to avoid a potential flash of old content when creating multiple jobs and close the slideover.

When the form submission fails, handleResponse does nothing. Because the Turbo Stream response from the server updates the form with validation errors, there is no need for Stimulus to do anything on failed submissions.

Now that the controllers are updated, we will turn to the DOM updates needed to get the slideover working with Turbo.

Update the slideover partial at app/views/shared/_slideover.html.erb, replacing the entire content of the file:

The slideover partial will be an empty <turbo-frame> element when the page initially loads. Each time we want to use the slideover, we will render a view with a matching turbo-frame id="slideover". Turbo will then automatically replace the content of the slideover frame with the updated content from the server.

Notice here that we set the data-action to listen for the turbo:submit-end event. Stimulus is not limited to handling click events; any emitted event is fair game.

To see this in action, update app/views/jobs/new.html.erb like this:

We have a matching slideover turbo frame plus the full structure of the slideover that previously lived in the slideover partial. Since Turbo Frames replace the entire content of the frame, we need our new view to render everything instead of the targeted replacement we did with CableReady.

Update the link to post a new job on the index page:

Here, we removed the remote attribute, and we added the turbo_frame attribute. Because we want the response to the GET request we make to /jobs/new to update the content of the slideover turbo frame, we need to specify the turbo_frame target on the link. This turbo_frame attribute is how Turbo connects the dots.

Update the job form to set the slideover_target. This change allows Stimulus to reset the form after a successful submission (remember the formTarget.reset() call in the handleResponse method that we added to the Stimulus controller).

In app/views/jobs/_form.html.erb:

With those changes in place, you can refresh the jobs page and see that creating jobs with Turbo Frames and Streams works just as it did with CableReady.

If we want to use Turbo Streams to handle removing deleted jobs from the DOM, we can do that too.

Update the destroy action in the JobsController:

def destroy
  @job.destroy
  render turbo_stream: turbo_stream.remove(@job)
end

Here, we render a Turbo Stream remove action, passing in the job that we want to remove, instead of the id of an element in the DOM. turbo-rails figures out which element we want to remove without needing a specific id.

Update the delete button in the job partial to remove the remote attribute.

When we click the delete button, the controller will respond with a <turbo-stream>, and Turbo will process the DOM updates.

Now that we had a chance to compare approaches, we will move forward in this book using CableReady as the base case. We will create several more slideover drawers in this book, and they will all be built with CableReady. Feel free to use Turbo if you prefer the Turbo-based approach!

We will have many more opportunities to explore both Turbo Frames and Turbo Streams in this book. While CableReady is my preference for this particular interaction, Turbo Frames and Turbo Streams are a better fit in other places in the application and both will play important roles as we add more features to our applicant tracking system.

That is all for this chapter! As usual, this is a good time to pause and review any code that still feels mysterious and take a break to let the new concepts have time to marinate before you move on. If you are coding along with me, this is also a good spot to commit your changes.

Now that our users can create job postings, in the next chapter we will build out the admin interface for creating and managing applicants using some of the techniques we learned in this chapter.

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

Change Date PR link
Added a missing bundle install command to the ActionText installation instructions. March 8, 2022 No change to final code.