diff --git a/README.md b/README.md index 8132cd3..8fcb509 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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) %> .... ``` +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 #`. This is because the `export` action is not defined in the policy. diff --git a/app/views/admin/application/_index_header.html.erb b/app/views/admin/application/_index_header.html.erb index 3c334e1..116518c 100644 --- a/app/views/admin/application/_index_header.html.erb +++ b/app/views/admin/application/_index_header.html.erb @@ -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) %> diff --git a/lib/administrate_exportable/exporter_service.rb b/lib/administrate_exportable/exporter_service.rb index f0604aa..3f8eb93 100644 --- a/lib/administrate_exportable/exporter_service.rb +++ b/lib/administrate_exportable/exporter_service.rb @@ -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 @@ -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 @@ -72,7 +72,7 @@ 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 @@ -80,7 +80,18 @@ def attributes_to_export 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 diff --git a/spec/administrate_exportable/exporter_concern_spec.rb b/spec/administrate_exportable/exporter_concern_spec.rb index 40f5d1e..f7918f3 100644 --- a/spec/administrate_exportable/exporter_concern_spec.rb +++ b/spec/administrate_exportable/exporter_concern_spec.rb @@ -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 diff --git a/spec/administrate_exportable/exporter_service_spec.rb b/spec/administrate_exportable/exporter_service_spec.rb index abe9adc..560e2d7 100644 --- a/spec/administrate_exportable/exporter_service_spec.rb +++ b/spec/administrate_exportable/exporter_service_spec.rb @@ -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 diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index ca382ca..47d447a 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -13,3 +13,7 @@ require "rspec/rails" require "administrate_exportable" require "spec_helper" + +RSpec.configure do |config| + config.use_transactional_fixtures = true +end