Exporting CSVs using Hotwire for UI feedback

11 Dec 2022

In this post we’ll take a quick look at how to use Hotwire to show the progress of a CSV export.

Let’s get started by creating a Rails app

Let’s begin by creating a new Rails app.

rails new csv-export-example

Add TailwindCSS

I would like to style my app using TailwindCSS so let’s go ahead and add that using the tailwindcss-rails gem.

cd csv-export-example
./bin/bundle add tailwindcss-rails

Run the installer

./bin/rails tailwindcss:install

Add the models which data we want to export

In order to export data, we need some data to export. Go ahead and create a new Company model using the Rails scaffold generator. I use the scaffold generator here to get going a little faster. This will give us the Company model, controllers and views.

rails generate scaffold Company name

We want a company to have many employees. So let’s add a new Employee model using the scaffold generator as well

rails generate scaffold Employee name email company:references

This will correctly insert the belongs_to :account association in the newly created Employee model, but we want has_many :employes in app/models/company.rb as well, so let’s add that

class Company < ApplicationRecord
  has_many :employees
end

Let’s run rails db:migrate to create the companies and employees database tables.

Start the server with bin/dev and visit http://localhost:4000/companies. We want a company so let’s create one by clicking on the “New company” button.

When viewing the company we see the company title. But it’d be neat if we also saw a list of all the employees.

Open app/controllers/companies_controller.rb and change the show action to look like this

def show
  @employees = @company.employees
end

We’re fetching all associated employees from the company and assigning those to the instance variable @employees so we can use it in the view.

Let’s add a an Employee section to our Company show page.

<h2 class="text-lg font-bold">Employees</h2>

<%= render @employees %>

We can add a few employees real quick by opening a Rails console and create the employees for a specific company there. Run rails c in your terminal and run

company = Company.first
10.times { |n| company.employees.create!(name: "John #{n}", email: "john#{n}@example.org") }

That should give us 10 employees belonging to the first company.

Time to build some simple export functionality

When someone go to export employees, we want the export records to be stored in case they have thousands of employees and the request end up timing out before the export csv is generated.

Go ahead and create a new export model.

rails g model Export company:references progress_count:integer total_count:integer completed:boolean

Run the migrations rails db:migrate.

Let’s nest that under the company route. Open config/routes.rb and change resources :companies to

resources :companies do
  resources :exports
end

We want a controller as well, go create that in app/controllers/exports_controller.rb

class ExportsController < ApplicationController
  before_action :set_company

  def index
  end

  private 

  def set_company
    @company = Company.find(params[:company_id])
  end
end

We can now access all exports for a given company under /companies/123/exports. But first we need to add a simple view for the index-action.

Create a new file app/views/exports/index.html.erb with

<div class="flex w-full mx-auto md:w-2/3">
  <div class="mx-auto">
    <h1 class="text-xl font-bold">Exports</h1>
    <%= button_to "Export", company_exports_path(@company), method: :post, class: "rounded-lg py-1.5 px-4 bg-blue-600 text-white block font-medium" %>

    <div class="mt-8">
      <%= render @exports %>
    </div>
  </div>
</div>

Go add the has_many :exports association to the company model in app/models/company.rb

class Company < ApplicationRecord
  ...
  has_many :exports, dependent: :destroy
end

Add the @exports instance variable with all exports belonging to a company in the index-action of the ExportsController

def index
  @exports = @company.exports
end

The last thing we need to do is adding a create controller action for the “Export” button we added to the Exports index view. Inside app/controllers/exports_controller.rb add:

def create
  @export = @company.exports.create
end

In order to render each of the export-recors, we add a app/views/exports/_export.html.erb partial. For now we’ll just have the following

<div>
  <%= export.created_at %>
</div>

This will just show when the export was created. We’ll make it look nicer later :)

If you click the Export button and refresh the page, you should see new records being added. This is all nice and stuff. But let’s use Hotwire to automatically insert the newly created record without us having to refresh the page.

Wrap the change the <%= render @exports %> part to

<%= turbo_stream_from [@company, "exports"] %>

<%= turbo_frame_tag "exports" do %>
  <%= render @exports %>
<% end %>

This tells Rails that we want to stream updates sent to [@company, "exports"]. The turbo_frame_tag "exports" is the container we’re going to prepend updates to.

Go ahead and open app/models/export.rb - to the top of that add:

after_create_commit -> { broadcast_prepend_to company, "exports" }

Here we’re saying that after the update has been saved to the database, prepend it to the exports turbo_frame for @company. We’re scoping this to @company so we won’t see it when viewing the Exports page belonging to other companies. We only want to see it if it is related to the exports page for the current company.

If you go ahead and hit the export button again, you should see it appear at the top of the list:

But if you refresh you’ll see the newest export is rendered at the bottom. Let’s update the order we show records in by sorting them by created_at in descending order. Open app/controllers/exports_controller.rb and change the index action total_count

def index
  @exports = @company.exports.order(created_at: :desc)
end

Let’s work on the appearance of an export. In the rails console (rails c) we’ll delete existing exports and add two new ones:

Export.destroy_all
company = Company.first

company.exports.create(completed: false, progress_count: 5, total_count: 10)
company.exports.create(completed: true, progress_count: 15, total_count: 15)

Open app/views/exports/_export.html.erb and change it to

<div class="flex items-center">
  <% if export.completed? %>
    <div>
      <%= export.total_count %> users exported
    </div>
  <% else %>
  <div class="flex items-center text-slate-400">
    <svg class="w-4 h-4 mr-3 -ml-1 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
      <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
      <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
    </svg>
    <%= export.progress_count %>/<%= export.total_count %> users exporting
  </div>
  <% end %>
</div>

There are a few things happening here. We render an svg that animates (it spins) using TailwindCSS if export.completed? is false and it shows how many processed records out of the total number. Only problem is that we only insert new exports, we don’t update existing ones when they change. Let’s change that and add the following to app/models/export.rb:

after_update_commit -> { broadcast_replace_to company, "exports" }

Notice how it says replace. We need to be able to identify what to replace in the views. So in app/views/exports/_export.html.erb we replace the outer div with a turbo_frame_tag:

<%= turbo_frame_tag export do %>
  <% if export.completed? %>
    <%= export.total_count %> users exported
  <% else %>
  <div class="flex items-center text-slate-400">
    <svg class="w-4 h-4 mr-3 -ml-1 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
      <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
      <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
    </svg>
    <%= export.progress_count %>/<%= export.total_count %> users exporting
  </div>
  <% end %>
<% end %>

Go ahead and open a Rails console (rails c) and update one of the Export records.

Export.last.update(completed: false)

You should now see the record update in your browser without you having to refresh.

This gives us a decent foundation to build upon and we can now finally get to work with the actual exporting of records 😎

Let’s export some employees

Let’s go ahead and make a change to the ExportsController - we want to introduce a background job in case we have tens of thousands of rows that can’t export within a request and we don’t want to keep the server busy exporting records for us when we can do it in the background.

Run the job generator

rails generate job export

Open the job we just created app/jobs/export_job.rb to

class ExportJob < ApplicationJob
  queue_as :default

  def perform(company_id)
    company = Company.find(company_id)
    export = company.exports.create!
  end
end

We’ll enqueue the job with a company_id in the ExportsController and then we’ll create the export. Go change the ExportController’s create-action to:

def create
  ExportJob.perform_later(@company.id)
end

Now it’s time to create a CSV. The first line of the CSV is the header with the name of the attributes and each line after that are the employees:

id,name,email,company_id,created_at,updated_at\n
1,John 0,john0@example.org,1,2022-12-11 22:40:45 UTC,2022-12-11 22:40:45 UTC\n

Let’s do that in a generate_csv method on the ExportJob:

class ExportJob < ApplicationJob
  queue_as :default

  def perform(company_id)
    company = Company.find(company_id)
    employees = company.employees

    generate_csv(employees)
  end

  private

  def generate_csv(employees)
    CSV.generate do |csv|
      csv << employees.first.attributes.keys # Header
      employees.each do |record|
        csv << record.attributes.values
      end
    end
  end
end

We use CSV.generate (docs here) - the first line we insert is the header line. If you call attributes on an Employee instance, you get a hash with keys and values, something similar to:

{"id"=>1, "name"=>"John 0", "email"=>"john0@example.org", "company_id"=>1, "created_at"=>Sun, 11 Dec 2022 22:40:45.429959000 UTC +00:00, "updated_at"=>Sun, 11 Dec 2022 22:40:45.429959000 UTC +00:00}

But we only want the keys for the header, so we can call attributes.keys which gives us an array of the keys.

We then iterate over each of the employee records and ask for just the values from employee.attributes.

When all that is done it’ll will gives us a string that is our CSV :) Since this is all running asynchronously in a background job, we need to give the user a way to retrieve it. We could send it to them in an email or we could have them download it - let’s take the download route.

Say hi to Active Storage

Active Storage gives us a convenient way to attach files. Go ahead and install it

rails active_storage:install
rails db:migrate

We can then add it to our Export model in app/models/export.rb by adding the following line

has_one_attached :file

Now it’s time to actually attach the csv content to our export record. In app/jobs/export_job.rb add a attach_file! method that takes the csv string as input:

def attach_file!(csv)
  @export.file.attach(
    io: StringIO.new(csv),
    filename: "employees.csv",
    content_type: "text/csv",
    identify: false
  )
end

In the line where we call generate_csv assign the return value to a variable named csv and below that call attach_file!(csv) like so:

csv = generate_csv(employees)
attach_file!(csv)

We can then add a download link to our exports partial app/views/exports/_export.html.erb

<% if export.completed? %>
  <div>
    <%= export.total_count %> users exported

    <% if export.file.persisted? %>
      <%= link_to "Download", rails_blob_path(export.file, disposition: "attachment"), class: "text-blue-600" %>
    <% end %>
  </div>
<% else %>
.
.
.

If you click the Export button again you should see an export row but there’s no download link?! That’s because we never mark the export as completed.

Almost there! Let’s add some progress

We’re almost there! Let’s actually show some progress when we’re in the middle of exporting the CSV. This will only really be needed for companies with A LOT of employees - as an export of a few employees will finish too quickly for us to ever really see it.

At the top of the generate_csv method add an exported_count = 0 variable.

def generate_csv
  exported_count = 0
  .
  .
  .

Then where we’re iterating over the employees when we generate the CSV inside of generate_csv let’s increment this newly added variable and update the export record

exported_count += 1
@export.update(progress_count: exported_count)

Next thing is to update the export as completed. So at the bottom of the perform method, we add the following

@export.update!(completed: true)

Our job now looks like

class ExportJob < ApplicationJob
  queue_as :default

  def perform(company_id)
    company = Company.find(company_id)
    employees = company.employees
    @export = company.exports.create!(progress_count: 0, total_count: employees.count)

    csv = generate_csv(employees)
    attach_file!(csv)

    @export.update!(completed: true)
  end

  private

  def generate_csv(employees)
    exported_count = 0

    csv = CSV.generate do |csv|
      csv << employees.first.attributes.keys # Header
      employees.each do |record|
        csv << record.attributes.values
        exported_count += 1
        @export.update(progress_count: exported_count)
      end
    end
  end

  def attach_file!(csv)
    @export.file.attach(
      io: StringIO.new(csv),
      filename: "employees.csv",
      content_type: "text/csv",
      identify: false
    )
  end
end

And now everything should work! You should see a download link on the newest export if you hit the export button. Let’s try and create A LOT of employees so we can see the progress while exporting. Open a Rails console:

company = Company.first
5_000.times {|n| company.employees.create(name: "John #{n}", email: "john#{n}@example.org")}