Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ ATTRIBUTE_TYPES = {
By default the gem adds the Export button to the partial `views/admin/application/_index_header.html.erb`. But if you have your own Administrate `index` views or override that partial in your application you can add the link manually:

```ruby
link_to('Export', [:export, namespace.to_sym, page.resource_name.to_s.pluralize.to_sym, sanitized_order_params(page, :id).to_h.merge(format: :csv)], class: 'button') if existing_action?(resource_class, :export)
link_to('Export', url_for(sanitized_order_params(page, :id).to_h.merge(action: :export, format: :csv)), class: 'button', target: '_blank', data: { turbolinks: false, turbo: false }) if existing_action?(resource_class, :export) && authorized_action?(resource_name, :export)
```

Example:
Expand All @@ -82,13 +82,31 @@ Example:

<%= link_to(
'Export',
[:export, namespace.to_sym, page.resource_name.to_s.pluralize.to_sym, sanitized_order_params(page, :id).to_h.merge(format: :csv)],
class: 'button'
) if existing_action?(resource_class, :export) %>
url_for(sanitized_order_params(page, :id).to_h.merge(action: :export, format: :csv)),
class: 'button',
target: '_blank',
data: { turbolinks: false, turbo: false }
) if existing_action?(resource_class, :export) && authorized_action?(resource_name, :export) %>
</div>
....
```

The link uses `url_for(action: :export)` so it works regardless of the route name (e.g. models with plural names such as `UserSettings`, whose collection routes end in `_index`), and sets `data-turbolinks="false"`/`data-turbo="false"` so the CSV is downloaded instead of being rendered by Turbolinks/Turbo.

### Associations and performance

Associations (`Field::BelongsTo`, `Field::HasMany`, `Field::HasOne`, `Field::Polymorphic`, ...) that are exported are preloaded automatically to avoid N+1 queries. Associations excluded with `export: false` are not loaded.

If your `transform_on_export` procs access nested associations, you can preload them by overriding `scoped_resource` in your controller:

```ruby
def scoped_resource
return super.preload(organizations: :address) if action_name == "export"

super
end
```

### Using with Pundit

If you are using [Pundit](https://github.com/varvet/pundit) in your project you will receive an error saying `undefined method 'export?' for #<Admin::OrganizationPolicy>`. This is because the `export` action is not defined in the policy.
Expand Down
5 changes: 3 additions & 2 deletions app/views/admin/application/_index_header.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@

<%= link_to(
'Export',
[:export, namespace, page.resource_name.to_s.pluralize.to_sym, sanitized_order_params(page, :id).to_h.merge(format: :csv)],
url_for(sanitized_order_params(page, :id).to_h.merge(action: :export, format: :csv)),
class: 'button',
target: '_blank'
target: '_blank',
data: { turbolinks: false, turbo: false }
) if existing_action?(resource_class, :export) && authorized_action?(resource_name, :export) %>
</div>
</header>
19 changes: 15 additions & 4 deletions lib/administrate_exportable/exporter_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ def self.csv(dashboard, resource_class, resources)
end

def initialize(dashboard, resource_class, resources)
@dashboard = dashboard
@dashboard = dashboard.is_a?(Class) ? dashboard.new : dashboard
@resource_class = resource_class
@resources = resources
end
Expand Down Expand Up @@ -40,7 +40,7 @@ def record_attribute(record, attribute_key, attribute_type)
when Administrate::Field::BelongsTo.to_s, Administrate::Field::HasOne.to_s, Administrate::Field::Polymorphic.to_s
field.display_associated_resource if field.data
when Administrate::Field::HasMany.to_s
field.data&.count
field.data&.size
when Administrate::Field::DateTime.to_s
field.datetime if field.data
when Administrate::Field::Date.to_s
Expand Down Expand Up @@ -72,15 +72,26 @@ def headers
end

def attributes_to_export
@attributes_to_export ||= dashboard.class::ATTRIBUTE_TYPES.select do |attribute_key, attribute_type|
@attributes_to_export ||= dashboard.attribute_types.select do |attribute_key, attribute_type|
attribute_options = attribute_type.try(:options)

!attribute_options || attribute_options[:export] != false
end
end

def collection
resources
return resources if associations_to_preload.empty?

resources.preload(*associations_to_preload)
end

# Preload every exported association to avoid N+1 queries
def associations_to_preload
@associations_to_preload ||= attributes_to_export.select do |attribute_key, attribute_type|
attribute_type.respond_to?(:associative?) &&
attribute_type.associative? &&
resource_class.reflect_on_association(attribute_key)
end.keys
end
end
end
30 changes: 30 additions & 0 deletions spec/administrate_exportable/exporter_concern_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,35 @@

get export_admin_users_url(format: :csv)
end

it "exports only records matching the search term" do
User.create(first_name: "John", last_name: "Doe", email: "john@email.com")
User.create(first_name: "Jane", last_name: "Roe", email: "jane@email.com")

get export_admin_users_url(format: :csv, search: "Jane")

rows = CSV.parse(response.body)
expect(rows.size).to eq 2
expect(rows[1][1]).to eq "Jane"
end

it "exports only headers when the search has no results" do
get export_admin_users_url(format: :csv, search: "nobody")

expect(response).to have_http_status(:ok)
expect(CSV.parse(response.body).size).to eq 1
end
end

describe "GET index" do
it "renders an export link that bypasses Turbolinks/Turbo" do
get admin_users_url(search: "John")

link = Nokogiri::HTML(response.body).at_css("a[href*='/admin/users/export.csv']")
expect(link).to be_present
expect(link["href"]).to include("search=John")
expect(link["data-turbolinks"]).to eq "false"
expect(link["data-turbo"]).to eq "false"
end
end
end
46 changes: 46 additions & 0 deletions spec/administrate_exportable/exporter_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -108,5 +108,51 @@
expect(parsed_data[1][4]).to eq "04:05AM"
end
end

context "when there are no records to export" do
let(:result) { AdministrateExportable::ExporterService.csv(UserDashboard.new, User, User.none) }

it "exports only the headers" do
expect(parsed_data.size).to eq 1
expect(parsed_data[0].first).to eq "Id"
end
end

context "when passing the dashboard class instead of an instance" do
let(:result) { AdministrateExportable::ExporterService.csv(UserDashboard, User, User.all) }

it "exports correct data" do
expect(parsed_data[1][1]).to eq "John"
end
end

context "preloading associations" do
before do
user = User.create(first_name: "Jane", last_name: "Roe", email: "jane@email.com", password: "1234567891011213")
user.dogs.create(name: "Rex")
user.create_cat(name: "Tom")
end

def count_queries(&block)
queries = []
callback = ->(*, payload) { queries << payload[:sql] unless payload[:name] == "SCHEMA" }
ActiveSupport::Notifications.subscribed(callback, "sql.active_record", &block)
queries.size
end

it "does not issue a query per record for exported associations" do
queries = count_queries { AdministrateExportable::ExporterService.csv(UserDashboard.new, User, User.all) }

# users + dogs + cats
expect(queries).to eq 3
end

it "preloads belongs_to associations" do
queries = count_queries { AdministrateExportable::ExporterService.csv(CatDashboard.new, Cat, Cat.all) }

# cats + users
expect(queries).to eq 2
end
end
end
end
4 changes: 4 additions & 0 deletions spec/rails_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@
require "rspec/rails"
require "administrate_exportable"
require "spec_helper"

RSpec.configure do |config|
config.use_transactional_fixtures = true
end
Loading