Modern Rails flash messages (part 2): The undo action for deleted items
Petr Hlavicka
Posted on September 20, 2020
In the previous article, I've prepared a way how to add actions to the Rails flash messages. In this article, I will explore one way, how to use them for the undo action for deleted records.
TL;DR: You can find running demo based on this series of articles on modern-rails-flash-messages.herokuapp.com with source code on github.com/CiTroNaK/modern-rails-flash-messages.
How to deal with recovering the deleted records
For my needs (and for this article) I chose to use the soft-delete method.
This solution using a mechanism to mark a record as deleted instead of deleting it. The application is then set up to display only records without the mark (like active only records).
As a short-term solution, it is quite easy and quick. For the long-term, you need to take care of soft-deleted records, which could take a lot of space in your database.
In our case, we will have these documents marked only for a few seconds before the hard deletion. So we are safe.
BTW another option is to store the deleted records outside of the main database. This can bring a lot of complexity especially with IDs and associated records.
The plan
- Marking the record as soft-deleted.
- Creating a background task that will hard-delete the record from the database in the really near future.
- Creating a mechanism that will stop the background job and un-mark the record as soft-deleted.
- Displaying the "recovered" record back to the user.
- Profit ...
Step 1: Soft-deleting
For every model that will be using for this soft-delete, we will need to add a new attribute. Don't forget to add an index to the column, because it will be used for querying.
class AddDeletedAtToMaps < ActiveRecord::Migration[6.0]
def change
add_column :maps, :deleted_at, :datetime
add_index :maps, :deleted_at
end
end
That can be generated by:
rails g migration add_deleted_at_to_maps deleted_at:datetime:index
Then we can add scopes to the model using a concern (I suppose it will be used many times and we should stay DRY).
# app/models/concerns/soft_delete.rb
module SoftDelete
extend ActiveSupport::Concern
included do
scope :active, -> { where(deleted_at: nil) }
scope :deleted, -> { where.not(deleted_at: nil) }
end
end
Someone could be tempted to use default_scope
instead of active
scope, please don't. It will make your life much easier if you stay out of using the default_scope
. It breaks the premise of the least surprises.
I will include the concern in my model.
# app/models/map.rb
class Map < ApplicationRecord
include SoftDelete
# rest of the code
end
Now, the last thing you need to do is set the active
scope everywhere were you using that model. Eg.
Map.active.find(params[:id])
current_user.maps.active
Step 2: Background job for actual delete
I will use Sidekiq for background jobs.
As to making it as DRY as possible, we need to pass class and id of the model as parameters to our worker. You could be tempted to pass the object as for ActiveJob, but Sidekiq does not support it. See best practices for Sidekiq.
The job itself is pretty simple.
# app/workers/delete_worker.rb
class DeleteWorker
include Sidekiq::Worker
sidekiq_options retry: false
def perform(record_class, record_id)
record_class.constantize.deleted.find(record_id).destroy
end
end
As you can see, I am using the second scope which I created in the concern. If the worker did not find the record, it will throw an error, so your error tracking solution could let you know about it, but it will not try it again (sidekiq_options retry: false
). To be clear, there should not be any error, unless you will have a different solution when you can delete the record permanently without this and the user will use it in meantime (like in 10+ seconds).
Now, we can use it in our concern (app/models/concerns/soft_delete.rb
) with a new methods schedule_destroy
and recover
.
def schedule_destroy
timeout = defined?(UNDO_TIMEOUT) ? UNDO_TIMEOUT : 8
update(deleted_at: Time.zone.now.utc)
DeleteWorker.perform_in(timeout.seconds + 5.seconds, self.class.name, id)
end
def recover
update(deleted_at: nil)
end
The schedule_destroy
method basically updates the record with deleted_at
and schedules the job for hard deleting. I've added support for setting the timeout per model using UNDO_TIMEOUT
constant. We will use the timeout for notification and I've added a few seconds more to be sure it will not finish before the notification goes away.
Calling eg. map.schedule_destroy
will now schedule a job and return his job id, that we will need later.
Step 3: Stopping the deletion
Let's start with an action, which will remove the scheduled job. We could have one per model, like (maps#undo
), but that would lead to a lot of duplications. Instead, we will create one controller to handle it.
# app/controllers/undo_controller.rb
class UndoController < ApplicationController
def destroy
job = Sidekiq::ScheduledSet.new.find_job(params[:id])
done = recover_record(job)
if done
respond_to do |format|
format.json { render json: { message: 'Done!' }, status: status }
end
else
head :unprocessable_entity
end
end
private
def recover_record(job)
return false unless job
job.delete
record = job.item['args'][0].constantize.find(job.item['args'][1])
record.recover
true
end
end
The destroy
action will find the scheduled job by his ID, remove it, and recover the record (will set deleted_at
to nil).
To be honest, I am not sure, if the
destroy
action is the right action for it. I am using it because I am able to easily pass an ID to it and I am basically removing a scheduled job. Withcreate
it would be harder because I will need to send the body with the request. I will gladly discuss a better solution in the comments.
Also, don't forget to add it to the routes.rb
:
resources :undo, only: [:destroy]
Now, we can finally use it in any controller where we want to the user to use the new undo action. I will use maps_controller.rb
as the example.
def destroy
map = current_user.maps.active.find(params[:id])
job_id = map.schedule_destroy
flash[:success] = flash_message_with_undo(job_id: job_id, map: map)
respond_to do |format|
format.html { redirect_to maps_url }
end
end
private
def flash_message_with_undo(job_id:, map:)
{
title: "Map #{map.title} was removed",
body: 'You can recover it using the undo action below.',
timeout: Map::UNDO_TIMEOUT, countdown: true,
action: {
url: undo_path(job_id),
method: 'delete',
name: 'Undo'
}
}
end
We will need to update our stimulus controller for notification to support our new response with better handling of an error:
// app/javascript/controllers/notification_controller.js
run(e) {
e.preventDefault();
this.stop();
let _this = this;
this.buttonsTarget.innerHTML = '<span class="text-sm leading-5 font-medium text-grey-700">Processing...</span>';
// Call the action
fetch(this.data.get("action-url"), {
method: this.data.get("action-method").toUpperCase(),
dataType: 'script',
credentials: "include",
headers: {
"X-CSRF-Token": this.csrfToken
},
})
.then(response => {
// Handle our unprocessable entity status
if (!response.ok) {
throw Error(response.statusText);
}
return response;
})
.then(response => response.json())
.then(data => {
// Set new content
_this.buttonsTarget.innerHTML = '<span class="text-sm leading-5 font-medium text-green-700">' + data.message + '</span>';
// Close
setTimeout(() => {
_this.close();
}, 1000);
})
.catch(error => {
console.log(error);
_this.buttonsTarget.innerHTML = '<span class="text-sm leading-5 font-medium text-red-700">Error!</span>';
setTimeout(() => {
_this.close();
}, 1000);
});
}
And that's it. A user can now recover the record using our notification with the undo action. Just one small thing... it will not show him the record. Right now, the user will need to manually reload the page and that's not a proper way how to do it.
Step 4: Displaying the result to the user
The easiest solution would be, of course, just reload the page using javascript. But... we have at least two very different scenarios to cover.
When a user deletes a record from its detail
Typically from #show
action. We will need redirect user to somewhere else (typically to the #index
). With this scenario, we can reload the page for the user.
We can do it easily with Turbolinks
.
.then(data => {
// Set new content
_this.buttonsTarget.innerHTML = '<span class="text-sm leading-5 font-medium text-green-700">' + data.message + '</span>';
// Close
setTimeout(() => {
// Reload the page using Turbolinks
Turbolinks.visit(window.location.toString(), {action: 'replace'})
}
}, 1000);
})
When a user deletes a record from the list
Typically from #index
action. When this happens, we should do two actions:
- hiding the record in the list when the user clicks on the delete button
- displaying it back using the undo action
First of all, we will add the option to the undo controller (final version):
# app/controllers/undo_controller.rb
class UndoController < ApplicationController
def destroy
job = Sidekiq::ScheduledSet.new.find_job(params[:id])
done = recover_record(job)
if done
response = prepare_response(job)
respond_to do |format|
format.json { render json: response, status: :ok }
end
else
head :unprocessable_entity
end
end
private
def recover_record(job)
return false unless job
job.delete
record = job.item['args'][0].constantize.find(job.item['args'][1])
record.recover
true
end
def prepare_response(job)
inline = params[:inline].presence ? true : false
{
message: inline ? 'Done!' : 'Done! Reloading the page...',
inline: inline,
record_id: (job.item['args'][1] if job),
record_class: (job.item['args'][0] if job)
}
end
end
I've expanded the response to support our needs. A different message for reloading, the attribute (inline
) that we need to hide / show the record and information that we need to know for it (record_id
and record_class
).
Now the record controller (in my case the maps controller):
def destroy
map = current_user.maps.active.find(params[:id])
job_id = map.schedule_destroy
respond_to do |format|
format.html do
flash[:success] = flash_message_with_undo(job_id: job_id, map: map)
redirect_to maps_url
end
format.js do
flash.now[:success] = flash_message_with_undo(job_id: job_id, map: map, inline: true)
render :destroy, locals: { map: map }
end
end
end
private
def flash_message_with_undo(job_id:, map:, inline: nil)
{
title: "Map #{map.title} was removed",
body: 'You can recover it using the undo action below.',
timeout: Map::UNDO_TIMEOUT, countdown: true,
action: {
url: undo_path(job_id, inline: inline),
method: 'delete',
name: 'Undo'
}
}
end
I've added a new response format (js
) and the inline attribute for the undo controller.
The new destroy.js.erb
view:
// finding and hiding the record
document.querySelector('[data-key$="<%= map.id %>"]').classList.toggle('hidden');
// displaying the notification
<% flash.each do |type, data| %>
document.getElementById('notifications').insertAdjacentHTML("afterBegin", "<%=j render(NotificationComponent.new(type: type, data: data)) %>");
<% end %>
In my example, I am adding the data-key
attribute to the HTML of the record with its ID. You can also add the class name to it. In my case, I am using UUIDs instead of numerical IDs and this data-key
is related to Stimulus Reflex.
And the last part, the final run
method for stimulus controller:
// app/javascript/controllers/notification_controller.js
run(e) {
e.preventDefault();
this.stop();
let _this = this;
this.buttonsTarget.innerHTML = '<span class="text-sm leading-5 font-medium text-grey-700">Processing...</span>';
// Call the action
fetch(this.data.get("action-url"), {
method: this.data.get("action-method").toUpperCase(),
dataType: 'script',
credentials: "include",
headers: {
"X-CSRF-Token": this.csrfToken
},
})
.then(response => {
if (!response.ok) {
throw Error(response.statusText);
}
return response;
})
.then(response => response.json())
.then(data => {
// Set new content
_this.buttonsTarget.innerHTML = '<span class="text-sm leading-5 font-medium text-green-700">' + data.message + '</span>';
// Remove hidden class and display the record
if (data.inline) {
document.querySelector('[data-key$="' + data.record_id + '"]').classList.toggle('hidden');
}
// Close
setTimeout(() => {
if (data.inline) {
// Just close the notification
_this.close();
} else {
// Reload the page using Turbolinks
Turbolinks.visit(window.location.toString(), {action: 'replace'})
}
}, 1000);
})
.catch(error => {
console.log(error);
_this.buttonsTarget.innerHTML = '<span class="text-sm leading-5 font-medium text-red-700">Error!</span>';
setTimeout(() => {
_this.close();
}, 1000);
});
}
I am using the same way to show the record back and just close the notification instead of reloading the page. You can also use transitions instead of just hide / show the record.
With this, you can remove many records without reloading the page and still be able to undo them.
A few notes at the end
When you add another action, you will need to deal that maybe you will not need to reload or show the record. For it, just add another attribute to the response that will help you with it. Like action_type
or similar.
Using a typical "trash" solution would work a little bit differently. There would be only one background job that would run once per day and would delete old records (like after 30 days and so). So, the only thing you will need to do in the undo controller is to null the deleted_at
for the record.
If you have some feedback on my solution, don't hesitate to use comments here.
I can imagine a lot of ways how to use the action in the notification. For example, I am thinking about a rollback for the content (like rollback to the previous version of the record). If you would be interested in any particular action, let me know in the comments. I can consider to prepare it if I find it interesting :)
Thanks for reading it to the end :)
Posted on September 20, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.