I recently wrote about how to add a “Save and Add Another” feature in your Rails app. This was based on the UX from Linear.
But another way to do this is how Todoist allows you to add new tasks.
Again, just like with Linear-style, this too, with Rails and Hotwire is straightforward to do.
I assume you have an up-to-date Rails ready and a model and controller set up that makes sense for such a feature. I’ve run rails generate scaffold Task body:text
for this example.
The goal of this article is when a new task is created
- add the task to the bottom of the list;
- show the form at the bottom of the list.
Set up the basics
I like to start UI’s like these with the bare-minimum. Basic HTML responses without any JS.
Let’s make some tweaks to the scaffold-generated files. Redirect to the index url on task creation:
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
# …
def create
@task = Task.new(task_params)
respond_to do |format|
if @task.save
format.html { redirect_to tasks_url }
# …
end
end
end
# …
end
Clean up the form partial to the absolute minimum (and make it look a bit better):
# app/views/tasks/_form.html.erb
<%= form_with(model: task, id: "task_form", class: "flex items-center gap-2 w-full mt-4") do |form| %>
<%= form.label :body, class: "sr-only" %>
<%= form.text_area :body, rows: 1, class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 w-full" %>
<%= form.submit "Add", class: "rounded-lg py-2 px-4 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
<% end %>
Clean up the task partial:
# app/views/tasks/_task.html.erb
<p id="<%= dom_id task %>">
<%= task.body %>
</p>
With all these tweaks made, it’s already looking pretty close! 😅
Adding a sprinkle of Hotwire
Let’s add the button to add the new task form next. Let’s replace the form_part with the following:
# app/views/tasks/index.html.erb
# …
<div class="w-full">
<!-- … -->
<div id="tasks" class="min-w-full">
<%= render @tasks %>
</div>
<div id="new_form">
<%= button_to "Add new task", new_task_path, method: :get, data: {turbo_stream: true}, class: "rounded-lg py-2 px-4 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
</div>
</div>
Using data: { turbo_stream: true }
makes sure it expects a turbo_stream response. Let’s create that turbo_stream response:
# app/views/tasks/new.turbo_stream.erb
<%= turbo_stream.replace "new_form" do %>
<%= render partial: "form", locals: { task: Task.new } %>
<% end %>
Now when you click the Add new task
, it will replace the #new_form
element with form-partial.
Now upon adding a new task, let’s create a turbo_stream response for the create-action. Let’s also replace the form partial, so its content is reset.
# app/views/tasks/create.turbo_stream.erb
<%= turbo_stream.append "tasks" do %>
<%= render partial: "task", locals: { task: @task } %>
<% end %>
<%= turbo_stream.replace "task_form" do %>
<%= render partial: "form", locals: { task: Task.new } %>
<% end %>
This is now replicating the Todoist example pretty close. And what’s really cool: no JavaScript written so far! 🤯 There’s one element that’s missing to make the UX better, autofocus the textarea when inserting the element. The idea for this set up is coming from Matt Swanson.
For that, a small Stimulus controller is needed.
// app/javascript/controllers/set_focus_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { selector: String }
connect() {
this.#setFocus()
this.element.remove()
}
// private
#setFocus() {
if (this.hasSelectorValue) {
document.querySelector(this.selectorValue)?.focus()
}
}
}
Now in the create turbo_stream response, insert this controller like so:
# app/views/tasks/create.turbo_stream.erb
# …
<%= turbo_stream.append "tasks" do %>
<template data-controller="set-focus" data-set-focus-selector-value="#task_body"></template>
<% end %>
When this turbo_stream response is injected into the dom, this controller queries the the DOM for the element with the #task_body
id, then set the focus and then removes itself from the DOM.
And that’s how you replicate a Todoist-style task creation UX.