Modern Rails flash messages (part 2): The undo action for deleted items

rails, ruby, sidekiq, stimulus

20 Sep 2020 ・ Featured at Ruby Weekly #520

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

  1. Marking the record as soft-deleted.
  2. Creating a background task that will hard-delete the record from the database in the really near future.
  3. Creating a mechanism that will stop the background job and un-mark the record as soft-deleted.
  4. Displaying the “recovered” record back to the user.
  5. 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. With create 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:

  1. hiding the record in the list when the user clicks on the delete button
  2. 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. I can consider to prepare it if I find it interesting :)

There is a third part of this series, where you can read about refactoring with Hotwire.


Do you like it? You can subscribe to RSS (you know how), or follow me on Mastodon.