├── spec ├── rails_helper.rb ├── support │ ├── templates │ │ ├── admin │ │ │ └── stores.rb │ │ ├── cucumber_with_reloading.rb │ │ ├── en.yml │ │ └── cucumber.rb │ ├── rails_template_with_data.rb │ └── rails_template.rb ├── xls │ └── unit │ │ ├── resource_spec.rb │ │ ├── resource_controller_spec.rb │ │ ├── build_download_format_links_spec.rb │ │ ├── dsl_spec.rb │ │ └── builder_spec.rb └── spec_helper.rb ├── .bundle └── config ├── .gemignore ├── .rspec ├── .yardopts ├── lib ├── active_admin │ └── xls │ │ ├── version.rb │ │ ├── engine.rb │ │ ├── extensions.rb │ │ ├── resource_extension.rb │ │ ├── resource_controller_extension.rb │ │ ├── dsl.rb │ │ └── builder.rb └── activeadmin-xls.rb ├── .gitignore ├── .editorconfig ├── .travis.yml ├── Gemfile ├── gemfiles ├── rails_42.gemfile ├── rails_52.gemfile └── rails_60.gemfile ├── activeadmin-xls.gemspec ├── LICENSE ├── Rakefile ├── CHANGELOG.md └── README.md /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.bundle/config: -------------------------------------------------------------------------------- 1 | --- {} 2 | -------------------------------------------------------------------------------- /.gemignore: -------------------------------------------------------------------------------- 1 | .bundle/* 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format documentation 3 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --no-private --protected lib/**/*.rb - README.md LICENSE 2 | -------------------------------------------------------------------------------- /spec/support/templates/admin/stores.rb: -------------------------------------------------------------------------------- 1 | ActiveAdmin.register Store 2 | -------------------------------------------------------------------------------- /lib/active_admin/xls/version.rb: -------------------------------------------------------------------------------- 1 | module ActiveAdmin 2 | module Xls 3 | # ActiveAdmin XLS gem version 4 | VERSION = '3.0.0'.freeze 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | spec/rails 2 | *.gem 3 | coverage 4 | *.xls 5 | doc 6 | *.un~ 7 | .yardoc 8 | .rbenv-version 9 | .ruby-version 10 | /Gemfile.lock 11 | gemfiles/*.gemfile.lock 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = crlf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = false -------------------------------------------------------------------------------- /spec/support/templates/cucumber_with_reloading.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('config/environments/cucumber', Rails.root) 2 | 3 | Rails.application.class.configure do 4 | config.cache_classes = false 5 | end 6 | -------------------------------------------------------------------------------- /lib/activeadmin-xls.rb: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'active_admin' 3 | 4 | require 'active_admin/xls/version' 5 | require 'active_admin/xls/builder' 6 | require 'active_admin/xls/engine' 7 | require 'active_admin/xls/extensions' 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | rvm: 4 | - 2.6.5 5 | 6 | gemfile: 7 | - gemfiles/rails_42.gemfile 8 | - gemfiles/rails_52.gemfile 9 | - gemfiles/rails_60.gemfile 10 | 11 | script: 12 | - bundle exec rake setup 13 | - bundle exec rake 14 | -------------------------------------------------------------------------------- /spec/support/templates/en.yml: -------------------------------------------------------------------------------- 1 | # Sample translations used to test ActiveAdmin's I18n integration. 2 | xls: 3 | post: 4 | id: ID 5 | title: Title 6 | body: Content 7 | published_at: Published On 8 | author: Publisher 9 | created_at: Created 10 | updated_at: Updated 11 | activerecord: 12 | models: 13 | store: 14 | one: Bookstore 15 | other: Bookstores 16 | -------------------------------------------------------------------------------- /lib/active_admin/xls/engine.rb: -------------------------------------------------------------------------------- 1 | module ActiveAdmin 2 | module Xls 3 | # Extends ActiveAdmin with xls downloads 4 | class Engine < ::Rails::Engine 5 | engine_name 'active_admin_xls' 6 | 7 | initializer 'active_admin.xls', group: :all do 8 | if Mime::Type.lookup_by_extension(:xls).nil? 9 | Mime::Type.register 'application/vnd.ms-excel', :xls 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'spreadsheet', '~> 1.1', '>= 1.1.4' 4 | 5 | group :development, :test do 6 | gem 'rails-i18n' # Gives us default i18n for many languages 7 | gem 'yard' 8 | end 9 | 10 | group :test do 11 | gem 'codecov', require: false 12 | gem 'cucumber-rails', require: false 13 | gem 'database_cleaner' 14 | gem 'rspec-mocks', '~> 3.7' 15 | gem 'rspec-rails', '~> 3.7' 16 | gem 'simplecov', require: false 17 | end 18 | -------------------------------------------------------------------------------- /lib/active_admin/xls/extensions.rb: -------------------------------------------------------------------------------- 1 | ActiveAdmin.before_load do 2 | require 'active_admin/xls/dsl' 3 | require 'active_admin/xls/resource_extension' 4 | require 'active_admin/xls/resource_controller_extension' 5 | 6 | ActiveAdmin::Views::PaginatedCollection.add_format :xls 7 | 8 | ActiveAdmin::ResourceDSL.send :include, ActiveAdmin::Xls::DSL 9 | ActiveAdmin::Resource.send :include, ActiveAdmin::Xls::ResourceExtension 10 | ActiveAdmin::ResourceController.send( 11 | :prepend, 12 | ActiveAdmin::Xls::ResourceControllerExtension 13 | ) 14 | end 15 | -------------------------------------------------------------------------------- /gemfiles/rails_42.gemfile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | source 'https://rubygems.org' 3 | 4 | ruby_major_version = RUBY_VERSION.split('.')[0].to_i 5 | ruby_minor_version = RUBY_VERSION.split('.')[1].to_i 6 | 7 | eval_gemfile(File.expand_path(File.join('..', 'Gemfile'), __dir__)) 8 | 9 | gem 'activeadmin', '1.4.3' 10 | gem 'devise', '~> 4.2' 11 | gem 'rails', '4.2.11' 12 | gem 'sprockets', '< 4.0' 13 | gem 'sqlite3', '~> 1.3.0' 14 | gem 'turbolinks', '~> 5.0.0' 15 | gem 'tzinfo-data' 16 | 17 | group :test do 18 | gem 'shoulda-matchers', '~> 3.1' 19 | if ruby_major_version > 2 || (ruby_major_version == 2 && ruby_minor_version > 1) 20 | gem 'test-unit', '~> 3.0' 21 | end 22 | end 23 | 24 | gemspec path: "../" -------------------------------------------------------------------------------- /gemfiles/rails_52.gemfile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | source 'https://rubygems.org' 3 | 4 | ruby_major_version = RUBY_VERSION.split('.')[0].to_i 5 | ruby_minor_version = RUBY_VERSION.split('.')[1].to_i 6 | 7 | eval_gemfile(File.expand_path(File.join('..', 'Gemfile'), __dir__)) 8 | 9 | gem 'activeadmin', '2.4.0' 10 | gem 'bootsnap', require: false 11 | gem 'devise', '~> 4.7' 12 | gem 'rails', ' ~> 5.2' 13 | gem 'sqlite3', '~> 1.4.0' 14 | gem 'turbolinks', '~> 5.2.0' 15 | gem 'tzinfo-data' 16 | 17 | group :test do 18 | gem 'shoulda-matchers', '~> 3.1' 19 | if ruby_major_version > 2 || (ruby_major_version == 2 && ruby_minor_version > 1) 20 | gem 'test-unit', '~> 3.0' 21 | end 22 | end 23 | 24 | gemspec path: "../" 25 | -------------------------------------------------------------------------------- /spec/xls/unit/resource_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | include ActiveAdmin 3 | 4 | module ActiveAdmin 5 | module Xls 6 | describe Resource do 7 | let(:resource) { ActiveAdmin.register(Post) } 8 | 9 | let(:custom_builder) do 10 | Builder.new(Post) do 11 | column(:fake) { :fake } 12 | end 13 | end 14 | 15 | context 'when registered' do 16 | it 'each resource has an xls_builder' do 17 | expect(resource.xls_builder).to be_a(Builder) 18 | end 19 | 20 | it 'We can specify our own configured builder' do 21 | expect { resource.xls_builder = custom_builder }.not_to raise_error 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /gemfiles/rails_60.gemfile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | source 'https://rubygems.org' 3 | 4 | ruby_major_version = RUBY_VERSION.split('.')[0].to_i 5 | ruby_minor_version = RUBY_VERSION.split('.')[1].to_i 6 | 7 | eval_gemfile(File.expand_path(File.join('..', 'Gemfile'), __dir__)) 8 | 9 | gem 'activeadmin', '2.4.0' 10 | gem 'bootsnap', require: false 11 | gem 'devise', '~> 4.7' 12 | gem 'rails', '~> 6' 13 | gem 'sqlite3', '~> 1.4.0' 14 | gem 'turbolinks', '~> 5.2.0' 15 | gem 'tzinfo-data' 16 | gem 'webpacker', '~> 4.x' 17 | 18 | group :test do 19 | gem 'shoulda-matchers', '~> 3.1' 20 | if ruby_major_version > 2 || (ruby_major_version == 2 && ruby_minor_version > 1) 21 | gem 'test-unit', '~> 3.0' 22 | end 23 | end 24 | 25 | gemspec path: "../" 26 | -------------------------------------------------------------------------------- /spec/xls/unit/resource_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | describe Admin::CategoriesController, type: :controller do 3 | let(:mime) { Mime::Type.lookup_by_extension(:xls) } 4 | 5 | let(:filename) do 6 | "categories-#{Time.now.strftime('%Y-%m-%d')}.xls" 7 | end 8 | 9 | it 'generates an xls filename' do 10 | expect(controller.xls_filename).to eq(filename) 11 | end 12 | 13 | context 'when making requests with the xls mime type' do 14 | it 'returns xls attachment when requested' do 15 | request.accept = mime 16 | get :index 17 | disposition = "attachment; filename=\"#{filename}\"" 18 | expect(response.headers['Content-Disposition']).to start_with(disposition) 19 | expect(response.headers['Content-Transfer-Encoding']).to eq('binary') 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/support/templates/cucumber.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('config/environments/test', Rails.root) 2 | 3 | # rails/railties/lib/rails/test_help.rb aborts if the environment is not 'test'. (Rails 3.0.0.beta3) 4 | # We can't run Cucumber/RSpec/Test_Unit tests in different environments then. 5 | # 6 | # For now, I patch StringInquirer so that Rails.env.test? returns true when Rails.env is 'test' or 'cucumber' 7 | # 8 | # https://rails.lighthouseapp.com/projects/8994-ruby-on-rails/tickets/4458-rails-should-allow-test-to-run-in-cucumber-environment 9 | module ActiveSupport 10 | class StringInquirer < String 11 | def method_missing(method_name, *arguments) 12 | if method_name.to_s[-1,1] == "?" 13 | test_string = method_name.to_s[0..-2] 14 | if test_string == 'test' 15 | self == 'test' or self == 'cucumber' 16 | else 17 | self == test_string 18 | end 19 | else 20 | super 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/active_admin/xls/resource_extension.rb: -------------------------------------------------------------------------------- 1 | module ActiveAdmin 2 | # Provides xls functionality to ActiveAdmin resources 3 | module Xls 4 | # Extends ActiveAdmin Resource 5 | module ResourceExtension 6 | # Sets the XLS Builder 7 | # 8 | # @param builder [Builder] the new builder object 9 | # @return [Builder] the builder for this resource 10 | def xls_builder=(builder) 11 | @xls_builder = builder 12 | end 13 | 14 | # Returns the XLS Builder. Creates a new Builder if none exists. 15 | # 16 | # @return [Builder] the builder for this resource 17 | # 18 | # @example Localize column headers 19 | # # app/admin/posts.rb 20 | # ActiveAdmin.register Post do 21 | # config.xls_builder.i18n_scope = [:active_record, :models, :posts] 22 | # end 23 | def xls_builder 24 | @xls_builder ||= ActiveAdmin::Xls::Builder.new(resource_class) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /activeadmin-xls.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path('../lib/active_admin/xls/version', __FILE__) 2 | 3 | Gem::Specification.new do |s| 4 | s.name = 'activeadmin-xls' 5 | s.version = ActiveAdmin::Xls::VERSION 6 | s.author = 'Todd Hambley' 7 | s.email = 'thambley@travelleaders.com' 8 | s.homepage = 'https://github.com/thambley/activeadmin-xls' 9 | s.platform = Gem::Platform::RUBY 10 | s.date = Time.now.strftime('%Y-%m-%d') 11 | s.license = 'MIT' 12 | s.summary = <<-SUMMARY 13 | Adds excel (xls) downloads for resources within the Active Admin framework. 14 | SUMMARY 15 | s.description = <<-DESC 16 | This gem provides excel/xls downloads for resources in Active Admin. 17 | DESC 18 | 19 | git_tracked_files = `git ls-files`.split("\n").sort 20 | gem_ignored_files = `git ls-files -i -X .gemignore`.split("\n") 21 | 22 | s.files = (git_tracked_files - gem_ignored_files).reject { |f| f.match(%r{^(test|spec|features)/}) } 23 | 24 | s.add_runtime_dependency 'activeadmin', '>= 2.0.0', '< 4.0' 25 | s.add_runtime_dependency 'spreadsheet', '~> 1.0' 26 | 27 | s.required_ruby_version = '>= 2.7.0' 28 | s.require_path = 'lib' 29 | end 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Todd Hambley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require File.expand_path('../lib/active_admin/xls/version', __FILE__) 3 | require 'rspec/core/rake_task' 4 | 5 | desc 'Creates a test rails app for the specs to run against' 6 | task :setup do 7 | require 'rails/version' 8 | base_dir = 'spec/rails' 9 | app_dir = "#{base_dir}/rails-#{Rails::VERSION::STRING}" 10 | template = 'rails_template_with_data' 11 | 12 | if File.exist? app_dir 13 | puts "test app #{app_dir} already exists; skipping" 14 | else 15 | system "mkdir -p #{base_dir}" 16 | args = %W[ 17 | -m spec/support/#{template}.rb 18 | --skip-bundle 19 | --skip-listen 20 | --skip-turbolinks 21 | --skip-test-unit 22 | --skip-coffee 23 | ] 24 | 25 | command = ['bundle', 'exec', 'rails', 'new', app_dir, *args].join(' ') 26 | env = { 'BUNDLE_GEMFILE' => ENV['BUNDLE_GEMFILE'] } 27 | Bundler.with_clean_env { Kernel.exec(env, command) } 28 | end 29 | end 30 | 31 | RSpec::Core::RakeTask.new 32 | task default: :spec 33 | task test: :spec 34 | 35 | desc 'build the gem' 36 | task :build do 37 | system 'gem build activeadmin-xls.gemspec' 38 | end 39 | 40 | desc 'build and release the gem' 41 | task release: :build do 42 | system "gem push activeadmin-xls-#{ActiveAdmin::Xls::VERSION}.gem" 43 | end 44 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | SimpleCov.start do 3 | add_filter '/rails/' 4 | add_filter '/spec/' 5 | end 6 | 7 | if ENV['CI'] == 'true' 8 | require 'codecov' 9 | SimpleCov.formatters = [ 10 | SimpleCov::Formatter::HTMLFormatter, 11 | SimpleCov::Formatter::Codecov 12 | ] 13 | end 14 | 15 | ENV['RAILS_ENV'] = 'test' 16 | 17 | # prepare ENV for rails 18 | require 'rails' 19 | ENV['RAILS_ROOT'] = File.expand_path( 20 | "../rails/rails-#{Rails::VERSION::STRING}", 21 | __FILE__ 22 | ) 23 | 24 | # ensure testing application is in place 25 | unless File.exist?(ENV['RAILS_ROOT']) 26 | puts 'Please run bundle exec rake setup before running the specs.' 27 | exit 28 | end 29 | 30 | # load up activeadmin and activeadmin-xls 31 | require 'active_record' 32 | require 'active_admin' 33 | require 'devise' 34 | require 'activeadmin-xls' 35 | ActiveAdmin.application.load_paths = [ENV['RAILS_ROOT'] + '/app/admin'] 36 | 37 | # start up rails 38 | require ENV['RAILS_ROOT'] + '/config/environment' 39 | 40 | # and finally,here's rspec 41 | require 'rspec/rails' 42 | 43 | # Rails 4.2 call `initialize` inside `recycle!`. 44 | # Ruby 2.6 doesn't allow calling `initialize` twice. 45 | # https://github.com/rails/rails/issues/34790 46 | if RUBY_VERSION >= '2.6.0' 47 | if Rails.version < '5' 48 | class ActionController::TestResponse < ActionDispatch::TestResponse 49 | def recycle! 50 | # HACK: avoid MonitorMixin double-initialize error: 51 | @mon_mutex_owner_object_id = nil 52 | @mon_mutex = nil 53 | initialize 54 | end 55 | end 56 | else 57 | puts "Monkeypatch for ActionController::TestResponse not needed for rails #{Rails.version}" 58 | end 59 | end 60 | 61 | # Disabling authentication in specs so that we don't have to worry about 62 | # it allover the place 63 | ActiveAdmin.application.authentication_method = false 64 | ActiveAdmin.application.current_user_method = false 65 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ## 2.0.3 6 | 7 | ### Changed 8 | 9 | * ignore test/ folder for gem building [#21][] by [@cprodhomme][] 10 | 11 | ## 2.0.2 12 | 13 | ### Changed 14 | 15 | * Remove ActiveAdmin runtime dependency version limit '<2' [#16][] by [@reaper][] 16 | 17 | ## 2.0.1 18 | 19 | ### Fixed 20 | 21 | * fix issue with xls_builder retaining data between requests when there is an exception on a computed field [#13][] 22 | 23 | ## 2.0.0 24 | 25 | ### Changed 26 | 27 | * Drop support for ruby 1.9, rails 3.2, and ActiveAdmin 0.6.6. 28 | * Add support for rails 5.1 [#8][] 29 | 30 | ## 1.1.0 31 | 32 | ### Added 33 | 34 | * Add only_columns [#7][] 35 | 36 | ### Fixed 37 | 38 | * Fix typo in README.md [#11][] by [@cpunion][] 39 | 40 | ### Changed 41 | 42 | * Update tests for ActiveAdmin 1.2 43 | 44 | ## 1.0.5 45 | 46 | ### Fixed 47 | 48 | * Fix #1 - Unnecessary database access 49 | * Fix broken tests 50 | 51 | ## 1.0.4 52 | 53 | ### Fixed 54 | 55 | * Minor bug fixes / typo corrections 56 | 57 | ## 1.0.3 58 | 59 | ### Fixed 60 | 61 | * Move require rake from gemspec to lib/activeadmin-xls.rb [#4][] by [@ejaypcanaria][] 62 | 63 | ## 1.0.2 64 | 65 | ### Fixed 66 | 67 | * Fixes undefined local variable or `method max_per_page` [#3][] by [@rewritten][] 68 | 69 | 70 | [#3]: https://github.com/thambley/activeadmin-xls/issues/3 71 | [#4]: https://github.com/thambley/activeadmin-xls/pull/4 72 | [#7]: https://github.com/thambley/activeadmin-xls/issues/7 73 | [#8]: https://github.com/thambley/activeadmin-xls/issues/8 74 | [#11]: https://github.com/thambley/activeadmin-xls/pull/11 75 | [#13]: https://github.com/thambley/activeadmin-xls/issues/13 76 | [#16]: https://github.com/thambley/activeadmin-xls/pull/16 77 | [#21]: https://github.com/thambley/activeadmin-xls/pull/21 78 | 79 | [@rewritten]: https://github.com/rewritten 80 | [@ejaypcanaria]: https://github.com/ejaypcanaria 81 | [@cpunion]: https://github.com/cpunion 82 | [@reaper]: https://github.com/reaper 83 | [@cprodhomme]: https://github.com/cprodhomme 84 | -------------------------------------------------------------------------------- /spec/xls/unit/build_download_format_links_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | describe ActiveAdmin::Views::PaginatedCollection do 3 | def arbre(assigns = {}, helpers = mock_action_view, &block) 4 | Arbre::Context.new(assigns, helpers, &block) 5 | end 6 | 7 | def render_arbre_component(assigns = {}, helpers = mock_action_view, &block) 8 | arbre(assigns, helpers, &block).children.first 9 | end 10 | 11 | # Returns a fake action view instance to use with our renderers 12 | def mock_action_view(assigns = {}) 13 | controller = ActionView::TestCase::TestController.new 14 | ActionView::Base.send :include, ActionView::Helpers 15 | ActionView::Base.send :include, ActiveAdmin::ViewHelpers 16 | ActionView::Base.send :include, Rails.application.routes.url_helpers 17 | ActionView::Base.new(ActionController::Base.view_paths, assigns, controller) 18 | end 19 | 20 | let(:view) do 21 | view = mock_action_view 22 | allow(view.request).to receive(:query_parameters) { { page: '1' } } 23 | allow(view.request).to receive(:path_parameters) do 24 | { controller: 'admin/posts', action: 'index' } 25 | end 26 | view 27 | end 28 | 29 | # Helper to render paginated collections within an arbre context 30 | def paginated_collection(*args) 31 | render_arbre_component({ paginated_collection_args: args }, view) do 32 | paginated_collection(*paginated_collection_args) 33 | end 34 | end 35 | 36 | let(:collection) do 37 | posts = [Post.new(title: 'First Post')] 38 | Kaminari.paginate_array(posts).page(1).per(5) 39 | end 40 | 41 | let(:pagination) { paginated_collection(collection) } 42 | 43 | before do 44 | allow(collection).to receive(:except) { collection } unless collection.respond_to? :except 45 | allow(collection).to receive(:group_values) { [] } unless collection.respond_to? :group_values 46 | allow(collection).to receive(:reorder) { collection } 47 | end 48 | 49 | it 'renders the xls download link' do 50 | expect(pagination.children.last.content).to match(/XLS/) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/xls/unit/dsl_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module ActiveAdmin 4 | # tests for dsl 5 | module Xls 6 | describe ::ActiveAdmin::ResourceDSL do 7 | context 'in a registration block' do 8 | let(:builder) do 9 | config = ActiveAdmin.register(Post) do 10 | xls(i18n_scope: [:rspec], header_style: { size: 20 }) do 11 | delete_columns :id, :created_at 12 | column(:author) { |post| post.author.first_name } 13 | before_filter do |sheet| 14 | row_number = sheet.dimensions[1] 15 | sheet.update_row(row_number, 'before_filter') 16 | end 17 | after_filter do |sheet| 18 | row_number = sheet.dimensions[1] 19 | sheet.update_row(row_number, 'after_filter') 20 | end 21 | skip_header 22 | end 23 | end 24 | config.xls_builder 25 | end 26 | 27 | it 'uses our customized i18n scope' do 28 | expect(builder.i18n_scope).to eq([:rspec]) 29 | end 30 | 31 | it 'removed the columns we told it to ignore' do 32 | %i[id create_at].each do |removed| 33 | column_index = builder.columns.index { |col| col.name == removed } 34 | expect(column_index).to be_nil 35 | end 36 | end 37 | 38 | it 'added the columns we declared' do 39 | added_index = builder.columns.index { |col| col.name == :author } 40 | expect(added_index).not_to be_nil 41 | end 42 | 43 | it 'has a before filter set' do 44 | expect(builder.instance_values['before_filter']).to be_a(Proc) 45 | end 46 | 47 | it 'has an after filter set' do 48 | expect(builder.instance_values['after_filter']).to be_a(Proc) 49 | end 50 | 51 | it 'indicates that the header should be excluded' do 52 | expect(builder.instance_values['skip_header']).to be_truthy 53 | end 54 | 55 | it 'updates the header style' do 56 | expect(builder.header_style[:size]).to eq(20) 57 | end 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/active_admin/xls/resource_controller_extension.rb: -------------------------------------------------------------------------------- 1 | module ActiveAdmin 2 | module Xls 3 | # Extends the resource controller to respond to xls requests 4 | module ResourceControllerExtension 5 | def self.prepended(base) 6 | base.send :respond_to, :xls, only: :index 7 | end 8 | 9 | # Patches index to respond to requests with xls mime type by 10 | # sending a generated xls document serializing the current 11 | # collection 12 | def index 13 | super do |format| 14 | format.xls do 15 | xls = active_admin_config.xls_builder.serialize(xls_collection, 16 | view_context) 17 | send_data(xls, 18 | filename: xls_filename, 19 | type: Mime::Type.lookup_by_extension(:xls)) 20 | end 21 | 22 | yield(format) if block_given? 23 | end 24 | end 25 | 26 | # Patches rescue_active_admin_access_denied to respond to xls 27 | # mime type. Provides administrators information on how to 28 | # configure activeadmin to respond propertly to xls requests 29 | # 30 | # param exception [Exception] unauthorized access error 31 | def rescue_active_admin_access_denied(exception) 32 | if request.format == Mime::Type.lookup_by_extension(:xls) 33 | respond_to do |format| 34 | format.xls do 35 | flash[:error] = "#{exception.message} Review download_links in initializers/active_admin.rb" 36 | redirect_backwards_or_to_root 37 | end 38 | end 39 | else 40 | super(exception) 41 | end 42 | end 43 | 44 | # Returns a filename for the xls file using the collection_name 45 | # and current date such as 'my-articles-2011-06-24.xls'. 46 | # 47 | # @return [String] with default filename 48 | def xls_filename 49 | timestamp = Time.now.strftime('%Y-%m-%d') 50 | "#{resource_collection_name.to_s.tr('_', '-')}-#{timestamp}.xls" 51 | end 52 | 53 | # Returns the collection to use when generating an xls file. 54 | def xls_collection 55 | find_collection except: :pagination 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/support/rails_template_with_data.rb: -------------------------------------------------------------------------------- 1 | # Use the default 2 | apply File.expand_path('../rails_template.rb', __FILE__) 3 | 4 | # Register Active Admin controllers 5 | %w[Post User Category].each do |type| 6 | generate :'active_admin:resource', type 7 | end 8 | 9 | scopes = <<-SCOPES 10 | scope :all, default: true 11 | 12 | scope :drafts do |posts| 13 | posts.where(['published_at IS NULL']) 14 | end 15 | 16 | scope :scheduled do |posts| 17 | posts.where(['posts.published_at IS NOT NULL AND posts.published_at > ?', Time.now.utc]) 18 | end 19 | 20 | scope :published do |posts| 21 | posts.where(['posts.published_at IS NOT NULL AND posts.published_at < ?', Time.now.utc]) 22 | end 23 | 24 | scope :my_posts do |posts| 25 | posts.where(author_id: current_admin_user.id) 26 | end 27 | SCOPES 28 | 29 | if File.exist?('app/admin/post.rb') 30 | inject_into_file 'app/admin/post.rb', 31 | scopes, 32 | after: "ActiveAdmin.register Post do\n" 33 | else 34 | inject_into_file 'app/admin/posts.rb', 35 | scopes, 36 | after: "ActiveAdmin.register Post do\n" 37 | end 38 | 39 | # Setup some default data 40 | append_file 'db/seeds.rb', <<-SEEDS 41 | 42 | users = ['Jimi Hendrix', 'Jimmy Page', 'Yngwie Malmsteen', 'Eric Clapton', 'Kirk Hammett'].collect do |name| 43 | first, last = name.split(" ") 44 | User.create! first_name: first, 45 | last_name: last, 46 | username: [first, last].join('-').downcase, 47 | age: rand(80) 48 | end 49 | 50 | categories = ['Rock', 'Pop Rock', 'Alt-Country', 'Blues', 'Dub-Step'].collect do |name| 51 | Category.create! name: name 52 | end 53 | 54 | published_at_values = [Time.now.utc - 5.days, Time.now.utc - 1.day, nil, Time.now.utc + 3.days] 55 | 56 | 1_000.times do |i| 57 | user = users[i % users.size] 58 | cat = categories[i % categories.size] 59 | published_at = published_at_values[i % published_at_values.size] 60 | Post.create title: "Blog Post \#{i}", 61 | body: "Blog post \#{i} is written by \#{user.username} about \#{cat.name}", 62 | category_id: cat.id, 63 | published_at: published_at, 64 | author: user 65 | end 66 | SEEDS 67 | 68 | rake 'db:seed' 69 | -------------------------------------------------------------------------------- /lib/active_admin/xls/dsl.rb: -------------------------------------------------------------------------------- 1 | module ActiveAdmin 2 | module Xls 3 | # Extends activeadmin dsl to include xls 4 | module DSL 5 | delegate(:after_filter, 6 | :before_filter, 7 | :column, 8 | :delete_columns, 9 | :header_format, 10 | :header_style, 11 | :i18n_scope, 12 | :only_columns, 13 | :skip_header, 14 | :whitelist, 15 | to: :xls_builder, 16 | prefix: :config) 17 | 18 | # Creates a default XLS Builder to respond to xls requests for this 19 | # resource. Options are passed to the Builder initialize method. 20 | # 21 | # @param [Hash] options the options for the builder 22 | # @option options [Hash] :header_format a hash of format properties to 23 | # apply to the header row. Any properties specified will be merged with 24 | # the default header styles. 25 | # @option options [Array] :i18n_scope the I18n scope to use when looking 26 | # up localized column headers. 27 | # @param [Block] block block given will evaluated against the instance of 28 | # Builder. That means you can call any method on the builder from within 29 | # that block. 30 | # @return A new instance of Builder 31 | # 32 | # @example Using the DSL 33 | # xls(i18n_scope: [:active_admin, :xls, :post], 34 | # header_format: { weight: :bold, color: :blue }) do 35 | # # Specify that you want to white list column output. 36 | # # whitelist 37 | # 38 | # # Do not serialize the header, only output data. 39 | # # skip_header 40 | # 41 | # # restrict columns to a list without customization 42 | # # only_columns :title, :author 43 | # 44 | # # deleting columns from the report 45 | # delete_columns :id, :created_at, :updated_at 46 | # 47 | # # adding a column to the report with customization 48 | # column(:author) do |post| 49 | # "#{post.author.first_name} #{post.author.last_name}" 50 | # end 51 | # 52 | # # inserting additional data with after_filter 53 | # after_filter do |sheet| 54 | # # todo 55 | # end 56 | # 57 | # # inserting data with before_filter 58 | # before_filter do |sheet| 59 | # # todo 60 | # end 61 | # end 62 | # 63 | # @see Builder 64 | # @see https://github.com/zdavatz/spreadsheet/blob/master/lib/spreadsheet/format.rb 65 | def xls(options = {}, &block) 66 | config.xls_builder = ActiveAdmin::Xls::Builder.new( 67 | config.resource_class, 68 | options, 69 | &block 70 | ) 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /spec/support/rails_template.rb: -------------------------------------------------------------------------------- 1 | # Rails template to build the sample app for specs 2 | 3 | # Create a cucumber database and environment 4 | copy_file File.expand_path('../templates/cucumber.rb', __FILE__), 5 | 'config/environments/cucumber.rb' 6 | copy_file File.expand_path('../templates/cucumber_with_reloading.rb', __FILE__), 7 | 'config/environments/cucumber_with_reloading.rb' 8 | 9 | gsub_file 'config/database.yml', /^test:.*\n/, "test: &test\n" 10 | gsub_file 'config/database.yml', 11 | /\z/, 12 | "\ncucumber:\n <<: *test\n database: db/cucumber.sqlite3" 13 | gsub_file( 14 | 'config/database.yml', 15 | /\z/, 16 | "\ncucumber_with_reloading:\n <<: *test\n database: db/cucumber.sqlite3" 17 | ) 18 | 19 | if File.exist?('config/secrets.yml') 20 | require 'securerandom' 21 | cucumber_secret = SecureRandom.hex(64) 22 | gsub_file 'config/secrets.yml', 23 | /\z/, 24 | "\ncucumber:\n secret_key_base: #{cucumber_secret}" 25 | end 26 | 27 | base_record = if Rails::VERSION::MAJOR >= 5 28 | 'ApplicationRecord' 29 | else 30 | 'ActiveRecord::Base' 31 | end 32 | 33 | # Generate some test models 34 | generate :model, 'post title:string body:text published_at:datetime author_id:integer category_id:integer' 35 | post_model_setup = if Rails::VERSION::MAJOR >= 5 36 | <<-MODEL 37 | belongs_to :author, class_name: 'User' 38 | belongs_to :category, optional: true 39 | accepts_nested_attributes_for :author 40 | MODEL 41 | else 42 | <<-MODEL 43 | belongs_to :author, class_name: 'User' 44 | belongs_to :category 45 | accepts_nested_attributes_for :author 46 | MODEL 47 | end 48 | inject_into_file 'app/models/post.rb', 49 | post_model_setup, 50 | after: "class Post < #{base_record}\n" 51 | 52 | generate :model, 'user type:string first_name:string last_name:string username:string age:integer' 53 | inject_into_file 'app/models/user.rb', 54 | " has_many :posts, foreign_key: 'author_id'\n", 55 | after: "class User < #{base_record}\n" 56 | 57 | generate :model, 'publisher --migration=false --parent=User' 58 | 59 | generate :model, 'category name:string description:text' 60 | inject_into_file 'app/models/category.rb', 61 | " has_many :posts\n accepts_nested_attributes_for :posts\n", 62 | after: "class Category < #{base_record}\n" 63 | 64 | generate :model, 'store name:string' 65 | 66 | # Generate a model with string ids 67 | generate :model, 'tag name:string' 68 | gsub_file( 69 | Dir['db/migrate/*_create_tags.rb'][0], 70 | /\:tags\sdo\s.*/, 71 | ":tags, id: false, primary_key: :id do |t|\n\t\t\tt.string :id\n" 72 | ) 73 | id_model_setup = <<-MODEL 74 | self.primary_key = :id 75 | before_create :set_id 76 | 77 | private 78 | def set_id 79 | self.id = 8.times.inject('') do |s,e| 80 | s << (i = Kernel.rand(62); i += ((i < 10) ? 48 : ((i < 36) ? 55 : 61 ))).chr 81 | end 82 | end 83 | MODEL 84 | 85 | inject_into_file 'app/models/tag.rb', 86 | id_model_setup, 87 | after: "class Tag < #{base_record}\n" 88 | 89 | # Configure default_url_options in test environment 90 | inject_into_file( 91 | 'config/environments/test.rb', 92 | " config.action_mailer.default_url_options = { host: 'example.com' }\n", 93 | after: "config.cache_classes = true\n" 94 | ) 95 | 96 | # Add our local Active Admin to the load path 97 | lib_path = File.expand_path('../../../lib/activeadmin-xls', __FILE__) 98 | inject_into_file 'config/environment.rb', 99 | "\nrequire '#{lib_path}'\n", 100 | after: "require File.expand_path('../application', __FILE__)" 101 | 102 | # Add some translations 103 | append_file 'config/locales/en.yml', 104 | File.read(File.expand_path('../templates/en.yml', __FILE__)) 105 | 106 | # Add predefined admin resources 107 | directory File.expand_path('../templates/admin', __FILE__), 'app/admin' 108 | 109 | run 'rm Gemfile' 110 | 111 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 112 | 113 | generate :'active_admin:install' 114 | 115 | run 'rm -r test' 116 | run 'rm -r spec' 117 | 118 | inject_into_file 'config/initializers/active_admin.rb', 119 | " config.download_links = %i[csv xml json xls]\n", 120 | after: " # == Download Links\n" 121 | 122 | # Setup a root path for devise 123 | route "root to: 'admin/dashboard#index'" 124 | 125 | rake 'db:migrate' 126 | rake 'db:test:prepare' 127 | run '/usr/bin/env RAILS_ENV=cucumber rake db:migrate' 128 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Active Admin Xls 2 | 3 | Excel Spreadsheet Export for [Active Admin] 4 | 5 | [![Version][rubygems_badge]][rubygems] 6 | [![Travis CI][travis_badge]][travis] 7 | [![Quality][codeclimate_badge]][codeclimate] 8 | [![Coverage][codecov_badge]][codecov] 9 | [![Inch CI][inch_badge]][inch] 10 | 11 | ## Synopsis 12 | 13 | This gem provides xls downloads for Active Admin resources. 14 | 15 | This gem borrows heavily from [activeadmin-axlsx] and [to_xls]. 16 | 17 | ## Usage 18 | 19 | Add the following to your Gemfile. All resource index views will now include a link for download directly to xls. 20 | 21 | ```ruby 22 | gem 'activeadmin-xls', '~>2.0.0' 23 | ``` 24 | 25 | For Active Admin 1.0 and above, you will also have to update config/initializers/active_admin.rb. Update the download\_links setting to include xls: 26 | 27 | ```ruby 28 | config.download_links = %i[csv xml json xls] 29 | ``` 30 | 31 | ## Dependencies 32 | 33 | This gem depends on [spreadsheet] to generate xls files. 34 | 35 | ## Examples 36 | 37 | Here are a few quick examples of things you can easily tweak. 38 | 39 | ### Localize column headers 40 | 41 | ```ruby 42 | # app/admin/posts.rb 43 | ActiveAdmin.register Post do 44 | config.xls_builder.i18n_scope = [:active_record, :models, :posts] 45 | end 46 | ``` 47 | 48 | ### Use blocks for adding computed fields 49 | 50 | ```ruby 51 | # app/admin/posts.rb 52 | ActiveAdmin.register Post do 53 | config.xls_builder.column('author_name') do |resource| 54 | resource.author.name 55 | end 56 | end 57 | ``` 58 | 59 | ### Change the column header format 60 | 61 | ```ruby 62 | # app/admin/posts.rb 63 | ActiveAdmin.register Post do 64 | config.xls_builder.header_format = { weight: :bold, 65 | color: :blue } 66 | end 67 | ``` 68 | 69 | ### Remove columns 70 | 71 | ```ruby 72 | # app/admin/posts.rb 73 | ActiveAdmin.register Post do 74 | config.xls_builder.delete_columns :id, :created_at, :updated_at 75 | end 76 | ``` 77 | 78 | ### Restrict columns to a list 79 | 80 | ```ruby 81 | # app/admin/posts.rb 82 | ActiveAdmin.register Post do 83 | config.xls_builder.only_columns :title, :author 84 | end 85 | ``` 86 | 87 | ## Using the DSL 88 | 89 | Everything that you do with the config's default builder can be done via 90 | the resource DSL. 91 | 92 | Below is an example of the DSL 93 | 94 | ```ruby 95 | ActiveAdmin.register Post do 96 | 97 | # i18n_scope and header style are set via options 98 | xls(i18n_scope: [:active_admin, :xls, :post], 99 | header_format: { weight: :bold, color: :blue }) do 100 | 101 | # Specify that you want to white list column output. 102 | # whitelist 103 | 104 | # Do not serialize the header, only output data. 105 | # skip_header 106 | 107 | # restrict columns to a list without customization 108 | # only_columns :title, :author 109 | 110 | # deleting columns from the report 111 | delete_columns :id, :created_at, :updated_at 112 | 113 | # adding a column to the report with customization 114 | column(:author) { |post| "#{post.author.first_name} #{post.author.last_name}" } 115 | 116 | # inserting additional data with after_filter 117 | after_filter do |sheet| 118 | # todo 119 | end 120 | 121 | # inserting data with before_filter 122 | before_filter do |sheet| 123 | # todo 124 | end 125 | end 126 | end 127 | ``` 128 | 129 | ## Testing 130 | 131 | Running specs for this gem requires that you construct a rails application. 132 | 133 | To execute the specs, navigate to the gem directory, run bundle install and run these to rake tasks: 134 | 135 | ### Rails 4.2 136 | 137 | ```text 138 | bundle install --gemfile=gemfiles/rails_42.gemfile 139 | ``` 140 | 141 | ```text 142 | BUNDLE_GEMFILE=gemfiles/rails_42.gemfile bundle exec rake setup 143 | ``` 144 | 145 | ```text 146 | BUNDLE_GEMFILE=gemfiles/rails_42.gemfile bundle exec rake 147 | ``` 148 | 149 | ### Rails 5.2 150 | 151 | ```text 152 | bundle install --gemfile=gemfiles/rails_52.gemfile 153 | ``` 154 | 155 | ```text 156 | BUNDLE_GEMFILE=gemfiles/rails_52.gemfile bundle exec rake setup 157 | ``` 158 | 159 | ```text 160 | BUNDLE_GEMFILE=gemfiles/rails_52.gemfile bundle exec rake 161 | ``` 162 | 163 | ### Rails 6.0 164 | 165 | ```text 166 | bundle install --gemfile=gemfiles/rails_60.gemfile 167 | ``` 168 | 169 | ```text 170 | BUNDLE_GEMFILE=gemfiles/rails_60.gemfile bundle exec rake setup 171 | ``` 172 | 173 | ```text 174 | BUNDLE_GEMFILE=gemfiles/rails_60.gemfile bundle exec rake 175 | ``` 176 | 177 | [Active Admin]:https://www.activeadmin.info/ 178 | [activeadmin-axlsx]:https://github.com/randym/activeadmin-axlsx 179 | [to_xls]:https://github.com/splendeo/to_xls 180 | [spreadsheet]:https://github.com/zdavatz/spreadsheet 181 | 182 | [rubygems_badge]: https://img.shields.io/gem/v/activeadmin-xls.svg 183 | [rubygems]: https://rubygems.org/gems/activeadmin-xls 184 | [travis_badge]: https://img.shields.io/travis/thambley/activeadmin-xls/master.svg 185 | [travis]: https://travis-ci.org/thambley/activeadmin-xls 186 | [codeclimate_badge]: https://api.codeclimate.com/v1/badges/e294712bac54d4520182/maintainability 187 | [codeclimate]: https://codeclimate.com/github/thambley/activeadmin-xls/maintainability 188 | [codecov_badge]: https://codecov.io/gh/thambley/activeadmin-xls/branch/master/graph/badge.svg 189 | [codecov]: https://codecov.io/gh/thambley/activeadmin-xls 190 | [inch_badge]: http://inch-ci.org/github/thambley/activeadmin-xls.svg?branch=master 191 | [inch]: http://inch-ci.org/github/thambley/activeadmin-xls 192 | -------------------------------------------------------------------------------- /spec/xls/unit/builder_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module ActiveAdmin 4 | # tests for builder 5 | module Xls 6 | describe Builder do 7 | let(:builder) { Builder.new(Post) } 8 | let(:content_columns) { Post.content_columns } 9 | 10 | context 'the default builder' do 11 | it 'has no header style' do 12 | expect(builder.header_style).to eq({}) 13 | end 14 | it 'has no i18n scope' do 15 | expect(builder.i18n_scope).to be_nil 16 | end 17 | it 'has default columns' do 18 | expect(builder.columns.size).to eq(content_columns.size + 1) 19 | end 20 | end 21 | 22 | context 'customizing a builder' do 23 | it 'deletes columns we tell it we dont want' do 24 | builder.delete_columns :id, :body 25 | expect(builder.columns.size).to eq(content_columns.size - 1) 26 | end 27 | 28 | it 'lets us use specific columns in a list' do 29 | builder.only_columns :title, :author 30 | expect(builder.columns.size).to eq(2) 31 | end 32 | 33 | it 'lets us say we dont want the header' do 34 | builder.skip_header 35 | expect(builder.instance_values['skip_header']).to be_truthy 36 | end 37 | 38 | it 'lets us add custom columns' do 39 | builder.column(:hoge) 40 | expect(builder.columns.size).to eq(content_columns.size + 2) 41 | end 42 | 43 | it 'lets us clear all columns' do 44 | builder.clear_columns 45 | expect(builder.columns.size).to eq(0) 46 | end 47 | 48 | context 'Using Procs for delayed content generation' do 49 | let(:post) { Post.new(title: 'Hot Dawg') } 50 | 51 | before do 52 | builder.column(:hoge) do |resource| 53 | "#{resource.title} - with cheese" 54 | end 55 | end 56 | 57 | it 'stores the block when defining a column for later execution.' do 58 | expect(builder.columns.last.data).to be_a(Proc) 59 | end 60 | 61 | it 'evaluates custom column blocks' do 62 | expect(builder.columns.last.data.call(post)).to eq( 63 | 'Hot Dawg - with cheese' 64 | ) 65 | end 66 | end 67 | end 68 | 69 | context 'sheet generation without headers' do 70 | let!(:users) { [User.new(first_name: 'bob', last_name: 'nancy')] } 71 | 72 | let!(:posts) do 73 | [Post.new(title: 'bob', body: 'is a swell guy', author: users.first)] 74 | end 75 | 76 | let!(:builder) do 77 | options = { 78 | header_format: { weight: :bold }, 79 | i18n_scope: %i[xls post] 80 | } 81 | Builder.new(Post, options) do 82 | skip_header 83 | end 84 | end 85 | 86 | before do 87 | @book = Spreadsheet.open(StringIO.new(builder.serialize(posts))) 88 | end 89 | 90 | it 'does not serialize the header' do 91 | expect(@book.worksheets.first[0, 0]).not_to eq('Title') 92 | end 93 | end 94 | 95 | context 'whitelisted sheet generation' do 96 | let!(:users) { [User.new(first_name: 'bob', last_name: 'nancy')] } 97 | 98 | let!(:posts) do 99 | [Post.new(title: 'bob', body: 'is a swell guy', author: users.first)] 100 | end 101 | 102 | let!(:builder) do 103 | Builder.new(Post, header_style: {}, i18n_scope: %i[xls post]) do 104 | skip_header 105 | whitelist 106 | column :title 107 | end 108 | end 109 | 110 | before do 111 | @book = Spreadsheet.open(StringIO.new(builder.serialize(posts))) 112 | @collection = builder.collection 113 | end 114 | 115 | it 'does not serialize the header' do 116 | sheet = @book.worksheets.first 117 | expect(sheet.column_count).to eq(1) 118 | expect(sheet[0, 0]).to eq(@collection.first.title) 119 | end 120 | end 121 | 122 | context 'Sheet generation with a highly customized configuration.' do 123 | let!(:builder) do 124 | options = { 125 | header_style: { size: 10, color: 'red' }, 126 | i18n_scope: %i[xls post] 127 | } 128 | Builder.new(Post, options) do 129 | delete_columns :id, :created_at, :updated_at 130 | column(:author) do |resource| 131 | "#{resource.author.first_name} #{resource.author.last_name}" 132 | end 133 | after_filter do |sheet| 134 | row_number = sheet.dimensions[1] 135 | sheet.update_row(row_number) 136 | row_number += 1 137 | sheet.update_row(row_number, 'Author Name', 'Number of Posts') 138 | users = collection.map(&:author).uniq(&:id) 139 | users.each do |user| 140 | row_number += 1 141 | sheet.update_row(row_number, 142 | "#{user.first_name} #{user.last_name}", 143 | user.posts.size) 144 | end 145 | end 146 | before_filter do |sheet| 147 | users = collection.map(&:author) 148 | users.each do |user| 149 | user.first_name = 'Set In Proc' if user.first_name == 'bob' 150 | end 151 | row_number = sheet.dimensions[1] 152 | sheet.update_row(row_number, 'Created', Time.zone.now) 153 | row_number += 1 154 | sheet.update_row(row_number, '') 155 | end 156 | end 157 | end 158 | 159 | before do 160 | Post.all.each(&:destroy) 161 | User.all.each(&:destroy) 162 | @user = User.create!(first_name: 'bob', last_name: 'nancy') 163 | @post = Post.create!(title: 'bob', 164 | body: 'is a swell guy', 165 | author: @user) 166 | @book = Spreadsheet.open(StringIO.new(builder.serialize(Post.all))) 167 | @collection = builder.collection 168 | end 169 | 170 | it 'provides the collection object' do 171 | expect(@collection.count).to eq(Post.all.count) 172 | end 173 | 174 | it 'merges our customizations with the default header style' do 175 | expect(builder.header_style[:size]).to eq(10) 176 | expect(builder.header_style[:color]).to eq('red') 177 | # expect(builder.header_style[:pattern_bg_color]).to eq('00') 178 | end 179 | 180 | it 'uses the specified i18n_scope' do 181 | expect(builder.i18n_scope).to eq(%i[xls post]) 182 | end 183 | 184 | it 'translates the header row based on our i18n scope' do 185 | header_row = @book.worksheets.first.row(2) 186 | expect(header_row).to eq( 187 | ['Title', 'Content', 'Published On', 'Publisher'] 188 | ) 189 | end 190 | 191 | it 'processes the before filter' do 192 | expect(@book.worksheets.first.cell(0, 0)).to eq('Created') 193 | end 194 | 195 | it 'lets us work against the collection in the before filter' do 196 | expect(@book.worksheets.first.last_row[0]).to eq('Set In Proc nancy') 197 | end 198 | end 199 | 200 | ################################ 201 | context 'Sheet generation with a exceptions.' do 202 | let!(:users) { [User.new(first_name: 'bob', last_name: 'nancy')] } 203 | 204 | let!(:posts) do 205 | [Post.new(title: 'bob', body: 'is a swell guy', author: users.first)] 206 | end 207 | 208 | let!(:exposts) do 209 | [Post.new(title: 'sal', body: 'is a swell guy', author: users.first), 210 | Post.new(title: 'err', body: 'is a swell guy', author: users.first)] 211 | end 212 | 213 | let!(:builder) do 214 | Builder.new(Post, header_style: {}, i18n_scope: %i[xls post]) do 215 | delete_columns :id, :created_at, :updated_at 216 | column(:author) do |resource| 217 | raise 'err' if resource.title == 'err' 218 | "#{resource.author.first_name} #{resource.author.last_name}" 219 | end 220 | end 221 | end 222 | 223 | before do 224 | begin 225 | @book1 = Spreadsheet.open(StringIO.new(builder.serialize(exposts))) 226 | rescue StandardError => err 227 | raise unless err.message == 'err' 228 | end 229 | @book2 = Spreadsheet.open(StringIO.new(builder.serialize(posts))) 230 | @collection = builder.collection 231 | end 232 | 233 | it 'does not contain data from other collections with errors' do 234 | sheet = @book2.worksheets.first 235 | expect(sheet.dimensions[1]).to eq(2) 236 | expect(sheet[0, 0]).to eq('Title') 237 | expect(sheet[1, 0]).to eq(@collection.first.title) 238 | end 239 | end 240 | ################################ 241 | end 242 | end 243 | end 244 | -------------------------------------------------------------------------------- /lib/active_admin/xls/builder.rb: -------------------------------------------------------------------------------- 1 | require 'stringio' 2 | require 'spreadsheet' 3 | 4 | module ActiveAdmin 5 | module Xls 6 | # Builder for xls data. 7 | class Builder 8 | include MethodOrProcHelper 9 | 10 | # @param [Class] resource_class The resource this builder generate column 11 | # information for. 12 | # @param [Hash] options the options for the builder 13 | # @option options [Hash] :header_format A hash of format properties to 14 | # apply to the header row. Any properties specified will be merged with 15 | # the default header styles. 16 | # @option options [Array] :i18n_scope the I18n scope to use when looking 17 | # up localized column headers. 18 | # @param [Block] block Block given will evaluated against this instance of 19 | # Builder. That means you can call any method on the builder from within 20 | # that block. 21 | # @example 22 | # ActiveAdmin::Xls::Builder.new(Post, i18n: [:xls]) do 23 | # delete_columns :id, :created_at, :updated_at 24 | # column(:author_name) { |post| post.author.name } 25 | # after_filter do |sheet| 26 | # # todo 27 | # end 28 | # end 29 | # 30 | # @see DSL 31 | # @see https://github.com/zdavatz/spreadsheet/blob/master/lib/spreadsheet/format.rb 32 | def initialize(resource_class, options = {}, &block) 33 | @skip_header = false 34 | @resource_class = resource_class 35 | @columns = [] 36 | @columns_loaded = false 37 | @column_updates = [] 38 | parse_options options 39 | instance_eval(&block) if block_given? 40 | end 41 | 42 | # The default header style 43 | # @return [Hash] 44 | # 45 | # @see https://github.com/zdavatz/spreadsheet/blob/master/lib/spreadsheet/format.rb 46 | def header_format 47 | @header_format ||= {} 48 | end 49 | 50 | alias header_style header_format 51 | 52 | # This has can be used to override the default header style for your 53 | # sheet. Any values you provide will be merged with the default styles. 54 | # Precedence is given to your hash 55 | # 56 | # @example In Builder.new 57 | # options = { 58 | # header_format: { weight: :bold }, 59 | # i18n_scope: %i[xls post] 60 | # } 61 | # Builder.new(Post, options) do 62 | # skip_header 63 | # end 64 | # 65 | # @example With DSL 66 | # ActiveAdmin.register Post do 67 | # xls(header_format: { weight: :bold }, i18n_scope: %i[xls post]) do 68 | # skip_header 69 | # end 70 | # end 71 | # 72 | # @example Simple DSL without block 73 | # xls header_format: { weight: :bold } 74 | # 75 | # @see https://github.com/zdavatz/spreadsheet/blob/master/lib/spreadsheet/format.rb 76 | def header_format=(format_hash) 77 | @header_format = header_format.merge(format_hash) 78 | end 79 | 80 | alias header_style= header_format= 81 | 82 | # Indicates that we do not want to serialize the column headers 83 | # 84 | # @example In Builder.new 85 | # options = { 86 | # header_format: { weight: :bold }, 87 | # i18n_scope: %i[xls post] 88 | # } 89 | # Builder.new(Post, options) do 90 | # skip_header 91 | # end 92 | # 93 | # @example With DSL 94 | # ActiveAdmin.register Post do 95 | # xls(header_format: { weight: :bold }, i18n_scope: %i[xls post]) do 96 | # skip_header 97 | # end 98 | # end 99 | def skip_header 100 | @skip_header = true 101 | end 102 | 103 | # The I18n scope that will be used when looking up your 104 | # column names in the current I18n locale. 105 | # If you set it to [:active_admin, :resources, :posts] the 106 | # serializer will render the value at active_admin.resources.posts.title 107 | # in the current translations 108 | # 109 | # @note If you do not set this, the column name will be titleized. 110 | attr_accessor :i18n_scope 111 | 112 | # The stored block that will be executed after your report is generated. 113 | # 114 | # @yieldparam sheet [Spreadsheet::Worksheet] the worksheet where the 115 | # collection has been serialized 116 | # 117 | # @example With DSL 118 | # xls do 119 | # after_filter do |sheet| 120 | # row_number = sheet.dimensions[1] 121 | # sheet.update_row(row_number) 122 | # row_number += 1 123 | # sheet.update_row(row_number, 'Author Name', 'Number of Posts') 124 | # users = collection.map(&:author).uniq(&:id) 125 | # users.each do |user| 126 | # row_number += 1 127 | # sheet.update_row(row_number, 128 | # "#{user.first_name} #{user.last_name}", 129 | # user.posts.size) 130 | # end 131 | # end 132 | # end 133 | def after_filter(&block) 134 | @after_filter = block 135 | end 136 | 137 | # the stored block that will be executed before your report is generated. 138 | # 139 | # @yieldparam sheet [Spreadsheet::Worksheet] the worksheet where the 140 | # collection has been serialized 141 | # 142 | # @example with DSL 143 | # xls do 144 | # before_filter do |sheet| 145 | # users = collection.map(&:author) 146 | # users.each do |user| 147 | # user.first_name = 'Set In Proc' if user.first_name == 'bob' 148 | # end 149 | # row_number = sheet.dimensions[1] 150 | # sheet.update_row(row_number, 'Created', Time.zone.now) 151 | # row_number += 1 152 | # sheet.update_row(row_number, '') 153 | # end 154 | # end 155 | def before_filter(&block) 156 | @before_filter = block 157 | end 158 | 159 | # Returns the columns the builder will serialize. 160 | # 161 | # @return [Array] columns configued on the builder. 162 | def columns 163 | # execute each update from @column_updates 164 | # set @columns_loaded = true 165 | load_columns unless @columns_loaded 166 | @columns 167 | end 168 | 169 | # The collection we are serializing. 170 | # 171 | # @note This is only available after serialize has been called, 172 | # and is reset on each subsequent call. 173 | attr_reader :collection 174 | 175 | # Removes all columns from the builder. This is useful when you want to 176 | # only render specific columns. To remove specific columns use 177 | # ignore_column. 178 | # 179 | # @example Using alias whitelist 180 | # Builder.new(Post, header_style: {}, i18n_scope: %i[xls post]) do 181 | # whitelist 182 | # column :title 183 | # end 184 | def clear_columns 185 | @columns_loaded = true 186 | @column_updates = [] 187 | 188 | @columns = [] 189 | end 190 | 191 | # Clears the default columns array so you can whitelist only the columns 192 | # you want to export 193 | alias whitelist clear_columns 194 | 195 | # Add a column 196 | # @param [Symbol] name The name of the column. 197 | # @param [Proc] block A block of code that is executed on the resource 198 | # when generating row data for this column. 199 | # 200 | # @example With block 201 | # xls(i18n_scope: [:rspec], header_style: { size: 20 }) do 202 | # delete_columns :id, :created_at 203 | # column(:author) { |post| post.author.first_name } 204 | # end 205 | # 206 | # @example With default value 207 | # Builder.new(Post, header_style: {}, i18n_scope: %i[xls post]) do 208 | # whitelist 209 | # column :title 210 | # end 211 | def column(name, &block) 212 | if @columns_loaded 213 | columns << Column.new(name, block) 214 | else 215 | column_lambda = lambda do 216 | column(name, &block) 217 | end 218 | @column_updates << column_lambda 219 | end 220 | end 221 | 222 | # Removes columns by name. 223 | # Each column_name should be a symbol. 224 | # 225 | # @example In Builder.new 226 | # options = { 227 | # header_style: { size: 10, color: 'red' }, 228 | # i18n_scope: %i[xls post] 229 | # } 230 | # Builder.new(Post, options) do 231 | # delete_columns :id, :created_at, :updated_at 232 | # column(:author) do |resource| 233 | # "#{resource.author.first_name} #{resource.author.last_name}" 234 | # end 235 | # end 236 | def delete_columns(*column_names) 237 | if @columns_loaded 238 | columns.delete_if { |column| column_names.include?(column.name) } 239 | else 240 | delete_lambda = lambda do 241 | delete_columns(*column_names) 242 | end 243 | @column_updates << delete_lambda 244 | end 245 | end 246 | 247 | # Removes all columns, and add columns by name. 248 | # Each column_name should be a symbol 249 | # 250 | # @example 251 | # config.xls_builder.only_columns :title, :author 252 | def only_columns(*column_names) 253 | clear_columns 254 | column_names.each do |column_name| 255 | column column_name 256 | end 257 | end 258 | 259 | # Serializes the collection provided 260 | # 261 | # @param collection [Enumerable] list of resources to serialize 262 | # @param view_context object on which unknown methods may be executed 263 | # @return [Spreadsheet::Workbook] 264 | def serialize(collection, view_context = nil) 265 | @collection = collection 266 | @view_context = view_context 267 | book = Spreadsheet::Workbook.new 268 | sheet = book.create_worksheet 269 | load_columns unless @columns_loaded 270 | apply_filter @before_filter, sheet 271 | export_collection collection, sheet 272 | apply_filter @after_filter, sheet 273 | to_stream book 274 | end 275 | 276 | # Xls column information 277 | class Column 278 | # @param name [String, Symbol] Name of the column. If the name of the 279 | # column is an existing attribute of the resource class, the value 280 | # can be retreived automatically if no block is specified 281 | # @param block [Proc] A procedure to generate data for the column 282 | # instead of retreiving the value from the resource directly 283 | def initialize(name, block = nil) 284 | @name = name 285 | @data = block || @name 286 | end 287 | 288 | # @return [String, Symbol] Column name 289 | attr_reader :name 290 | 291 | # @return [String, Symbol, Proc] The column name used to look up the 292 | # value, or a block used to generate the value to display. 293 | attr_reader :data 294 | 295 | # Returns a localized version of the column name if a scope is given. 296 | # Otherwise, it returns the titleized column name without translation. 297 | # 298 | # @param i18n_scope [String, Symbol, Array, Array] 299 | # Translation scope. If not provided, the column name will be used. 300 | # 301 | # @see I18n 302 | def localized_name(i18n_scope = nil) 303 | return name.to_s.titleize unless i18n_scope 304 | I18n.t name, scope: i18n_scope 305 | end 306 | end 307 | 308 | private 309 | 310 | def load_columns 311 | return if @columns_loaded 312 | @columns = resource_columns(@resource_class) 313 | @columns_loaded = true 314 | @column_updates.each(&:call) 315 | @column_updates = [] 316 | columns 317 | end 318 | 319 | def to_stream(book) 320 | stream = StringIO.new('') 321 | book.write stream 322 | stream.string 323 | end 324 | 325 | def export_collection(collection, sheet) 326 | return if columns.none? 327 | row_index = sheet.dimensions[1] 328 | 329 | unless @skip_header 330 | header_row(sheet.row(row_index), collection) 331 | row_index += 1 332 | end 333 | 334 | collection.each do |resource| 335 | fill_row(sheet.row(row_index), resource_data(resource)) 336 | row_index += 1 337 | end 338 | end 339 | 340 | # tranform column names into array of localized strings 341 | # @return [Array] 342 | def header_row(row, collection) 343 | apply_format_to_row(row, create_format(header_format)) 344 | fill_row(row, header_data_for(collection)) 345 | end 346 | 347 | def header_data_for(collection) 348 | resource = collection.first || @resource_class.new 349 | columns.map do |column| 350 | column.localized_name(i18n_scope) if in_scope(resource, column) 351 | end.compact 352 | end 353 | 354 | def apply_filter(filter, sheet) 355 | filter.call(sheet) if filter 356 | end 357 | 358 | def parse_options(options) 359 | options.each do |key, value| 360 | send("#{key}=", value) if respond_to?("#{key}=") && !value.nil? 361 | end 362 | end 363 | 364 | def resource_data(resource) 365 | columns.map do |column| 366 | call_method_or_proc_on resource, column.data if in_scope(resource, 367 | column) 368 | end 369 | end 370 | 371 | def in_scope(resource, column) 372 | return true unless column.name.is_a?(Symbol) 373 | resource.respond_to?(column.name) 374 | end 375 | 376 | def resource_columns(resource) 377 | [Column.new(:id)] + resource.content_columns.map do |column| 378 | Column.new(column.name.to_sym) 379 | end 380 | end 381 | 382 | def create_format(format_hash) 383 | Spreadsheet::Format.new format_hash 384 | end 385 | 386 | def apply_format_to_row(row, format) 387 | row.default_format = format if format 388 | end 389 | 390 | def fill_row(row, column) 391 | case column 392 | when Hash 393 | column.each_value { |values| fill_row(row, values) } 394 | when Array 395 | column.each { |value| fill_row(row, value) } 396 | else 397 | # raise ArgumentError, 398 | # "column #{column} has an invalid class (#{ column.class })" 399 | row.push(column) 400 | end 401 | end 402 | 403 | def method_missing(method_name, *arguments) 404 | if @view_context.respond_to? method_name 405 | @view_context.send method_name, *arguments 406 | else 407 | super 408 | end 409 | end 410 | 411 | def respond_to_missing?(method_name, include_private = false) 412 | @view_context.respond_to?(method_name) || super 413 | end 414 | end 415 | end 416 | end 417 | --------------------------------------------------------------------------------