Modern Rails flash messages (part 2): The undo action for deleted items
Tags: rails, ruby, sidekiq, stimulus
Published: 2020-09-20 08:55:31

In the [previous article](https://dev.to/citronak/modern-rails-flash-messages-part-1-viewcomponent-stimulus-tailwind-css-3alm), 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](https://modern-rails-flash-messages.herokuapp.com/) with source code on [github.com/CiTroNaK/modern-rails-flash-messages](https://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.

```ruby
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:

```bash
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).

```ruby
# 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](https://www.artima.com/intv/ruby.html).

I will include the concern in my model.

```ruby
# 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.

```ruby
Map.active.find(params[:id])
current_user.maps.active
```

## Step 2: Background job for actual delete

I will use [Sidekiq](https://github.com/mperham/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](https://edgeguides.rubyonrails.org/active_job_basics.html#globalid), but Sidekiq does not support it. See [best practices](https://github.com/mperham/sidekiq/wiki/Best-Practices) for Sidekiq.

The job itself is pretty simple.

```ruby
# 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`.

```ruby
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.

```ruby
# 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`:

```ruby
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.

```ruby
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:

```javascript
// 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`.

```javascript
.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):

```ruby
# 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):

```ruby
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:

```javascript
// 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](https://github.com/hopsoft/stimulus_reflex).

And the last part, the final `run` method for stimulus controller:

```javascript
// 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 :)