diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4c98ddf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +jobs: + test: + name: Ruby ${{ matrix.ruby }} / Rails ${{ matrix.rails }} / AA ${{ matrix.activeadmin }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Ruby 3.3 is the floor: 3.1 (eol 2025-03-26) and 3.2 (eol 2026-03-31) + # are both out of support. + ruby: ['3.3', '3.4', '4.0'] + # Rails 7.2 went eol 2026-08-09; 8.2 is not released yet. + rails: ['8.0.0', '8.1.0'] + # Every activeadmin 3.2+ resolves and passes against Rails 8.0 and 8.1, + # so no exclude rules are needed. + activeadmin: ['3.2.0', '3.3.0', '3.4.0', '3.5.0'] + env: + RAILS: ${{ matrix.rails }} + AA: ${{ matrix.activeadmin }} + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Run tests + run: bundle exec rspec spec diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de4622b --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/.bundle/ +/bin/ +/Gemfile.lock +/pkg/ +/tmp/ +spec/rails/rails-* +/coverage diff --git a/Gemfile b/Gemfile index d65e2a6..bf16145 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,21 @@ -source 'http://rubygems.org' +source 'https://rubygems.org' gemspec + +default_rails_version = '8.1.0' +default_activeadmin_version = '3.5.0' + +gem 'rails', "~> #{ENV['RAILS'] || default_rails_version}" +gem 'activeadmin', "~> #{ENV['AA'] || default_activeadmin_version}" +gem 'sprockets-rails' +gem 'sass-rails' + +group :test do + gem 'simplecov', require: false + gem 'rspec-rails' + gem 'sqlite3', '~> 2.0' + gem 'database_cleaner' + gem 'capybara' + gem 'cuprite' + gem 'webrick', require: false +end diff --git a/Rakefile b/Rakefile index c702cfc..dedca81 100644 --- a/Rakefile +++ b/Rakefile @@ -1 +1,5 @@ -require 'bundler/gem_tasks' +require "bundler" +require 'rake' +Bundler.setup +Bundler::GemHelper.install_tasks +FileList['tasks/**/*.rake'].each { |task| import task } diff --git a/active_admin_filters_visibility.gemspec b/active_admin_filters_visibility.gemspec index 3c19831..364c69a 100644 --- a/active_admin_filters_visibility.gemspec +++ b/active_admin_filters_visibility.gemspec @@ -11,7 +11,9 @@ Gem::Specification.new do |s| s.summary = %q{active_admin_filters_visibility gem} s.description = %q{extension for activeadmin gem to hide any filters from sidebar-filters panel} - s.add_dependency 'activeadmin' + s.required_ruby_version = '>= 3.3' + + s.add_dependency 'activeadmin', '>= 3.2', '< 4.0' s.files = `git ls-files`.split("\n") s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") diff --git a/spec/filters_visibility_spec.rb b/spec/filters_visibility_spec.rb new file mode 100644 index 0000000..d6527e2 --- /dev/null +++ b/spec/filters_visibility_spec.rb @@ -0,0 +1,90 @@ +require 'spec_helper' + +describe 'filters visibility', type: :feature, js: true do + + # The dummy app's active_admin.js calls + # `$('#filters_sidebar_section').activeAdminFiltersVisibility()`, so these + # are the filters Active Admin derives from the authors table. + FILTERS = { + 'Name' => '#q_name_input', + 'Last name' => '#q_last_name_input', + 'Birthday' => '#q_birthday_input', + 'Created at' => '#q_created_at_input', + 'Updated at' => '#q_updated_at_input' + }.freeze + + # The visibility panel is `display: none` until the gear button is clicked. + def open_visibility_panel + page.find('#filters_sidebar_section .filters-visibility-button').click + expect(page).to have_css('.filters-visibility-panel') + end + + def visibility_checkbox(label) + page.find(".filters-visibility-panel input[type=checkbox][value='#{label}']") + end + + before do + Author.create!(name: 'John', last_name: 'Doe') + Author.create!(name: 'Jane', last_name: 'Roe') + add_author_resource + visit '/admin/authors' + end + + it 'renders the gear button and the visibility panel in the filters sidebar' do + expect(page).to have_css('#filters_sidebar_section h3 span.filters-visibility-button') + # hidden until the button is clicked + expect(page).to have_css('#filters_sidebar_section .panel_contents > .filters-visibility-panel', visible: :hidden) + expect(page).to have_css('.filters-visibility-panel > div > strong', text: 'Visibility:', visible: :hidden) + + open_visibility_panel + end + + it 'has one checked checkbox per filter and shows every filter' do + open_visibility_panel + + expect(page).to have_css('.filters-visibility-panel input[type=checkbox]', count: FILTERS.size) + FILTERS.each do |label, selector| + expect(visibility_checkbox(label)).to be_checked + expect(page).to have_css(selector) + end + expect(page).not_to have_css('.filters-visibility-button.active') + end + + it 'hides a filter when its checkbox is unchecked' do + open_visibility_panel + visibility_checkbox('Birthday').click + + expect(page).to have_css('#q_birthday_input', visible: :hidden) + expect(page).to have_css('.filters-visibility-button.active') + # the other filters are untouched + expect(page).to have_css('#q_name_input') + expect(page).to have_css('#q_created_at_input') + end + + it 'keeps the filter hidden after a page reload' do + open_visibility_panel + visibility_checkbox('Birthday').click + expect(page).to have_css('#q_birthday_input', visible: :hidden) + + visit '/admin/authors' + + expect(page).to have_css('#q_birthday_input', visible: :hidden) + expect(page).to have_css('.filters-visibility-button.active') + + open_visibility_panel + expect(visibility_checkbox('Birthday')).not_to be_checked + expect(visibility_checkbox('Name')).to be_checked + end + + it 'shows the filter again when the checkbox is re-checked' do + open_visibility_panel + visibility_checkbox('Birthday').click + expect(page).to have_css('#q_birthday_input', visible: :hidden) + + visibility_checkbox('Birthday').click + + expect(page).to have_css('#q_birthday_input') + expect(page).not_to have_css('.filters-visibility-button.active') + end + +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..ed44357 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,56 @@ +require 'simplecov' +SimpleCov.start do + add_filter '/spec/' +end + +$LOAD_PATH.unshift(File.dirname(__FILE__)) +$LOAD_PATH << File.expand_path('../support', __FILE__) + +ENV['BUNDLE_GEMFILE'] = File.expand_path('../../Gemfile', __FILE__) +require "bundler" +Bundler.setup + +ENV['RAILS_ENV'] = 'test' +require 'rails' +ENV['RAILS'] = Rails.version +ENV['RAILS_ROOT'] = File.expand_path("../rails/rails-#{ENV['RAILS']}", __FILE__) +unless File.exist?(ENV['RAILS_ROOT']) + system 'rake setup' +end + +require 'rails/all' +require 'active_admin' +ActiveAdmin.application.load_paths = [ENV['RAILS_ROOT'] + "/app/admin"] +require ENV['RAILS_ROOT'] + '/config/environment.rb' +ActiveAdmin.application.authentication_method = false +ActiveAdmin.application.current_user_method = false + +# Rails 8.1 draws routes lazily, so ActiveAdmin's `before_load` hooks -- the +# ones that mix BatchActions/Filters into ActiveAdmin::Resource -- have not run +# by the time a spec calls `ActiveAdmin.register`. Resources built before them +# come out half-initialized (`@batch_actions` nil) and the index page blows up. +ActiveAdmin.application.load! + +require 'rspec/rails' +require 'capybara/rails' +require 'capybara/rspec' + +require 'support/admin' +require 'support/capybara' + +RSpec.configure do |config| + config.use_transactional_fixtures = false + + config.before(:suite) do + ActiveRecord::Migration.maintain_test_schema! + DatabaseCleaner.strategy = :truncation + DatabaseCleaner.clean_with(:truncation) + end + config.before(:each) do + DatabaseCleaner.strategy = :truncation + DatabaseCleaner.start + end + config.after(:each) do + DatabaseCleaner.clean + end +end diff --git a/spec/support/admin.rb b/spec/support/admin.rb new file mode 100644 index 0000000..19c0c2a --- /dev/null +++ b/spec/support/admin.rb @@ -0,0 +1,9 @@ +def add_author_resource(options = {}, &block) + + ActiveAdmin.register Author do + config.filters = true + end + + Rails.application.reload_routes! + +end diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb new file mode 100644 index 0000000..afdcda4 --- /dev/null +++ b/spec/support/capybara.rb @@ -0,0 +1,29 @@ +require 'capybara/cuprite' + +Capybara.server = :webrick +Capybara.register_driver :cuprite do |app| + # Chrome intermittently fails to hand back a websocket URL on GitHub + # runners and the leg dies with Ferrum::ProcessTimeoutError. The cause + # is not established. Two theories were tried and both are ruled out: + # + # * /dev/shm exhaustion -- Ferrum already passes + # --disable-dev-shm-usage by default + # (Ferrum::Browser::Options::Chrome::DEFAULT_OPTIONS), so the runs + # that failed were already using it. + # * the ubuntu-24.04 AppArmor restriction on unprivileged user + # namespaces -- the sibling repo capybara_active_admin passes + # --no-sandbox and --disable-setuid-sandbox already, and flakes + # the same way. + # + # So: --no-sandbox is kept because it is harmless on a single-tenant + # CI VM serving only this suite's dummy app and may still help, and + # process_timeout is raised from Ferrum's 10s default because a slow + # start is at least a failure mode a budget can cover. Neither is + # demonstrated to fix it. A rerun is currently the answer when it + # happens. + Capybara::Cuprite::Driver.new(app, headless: true, window_size: [1280, 800], + process_timeout: 30, + browser_options: { 'no-sandbox': nil }) +end +Capybara.javascript_driver = :cuprite +Capybara.default_max_wait_time = 5 diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb new file mode 100644 index 0000000..9a755f8 --- /dev/null +++ b/spec/support/rails_template.rb @@ -0,0 +1,67 @@ +# Rails 8.1 generates `stale_when_importmap_changes` into ApplicationController, +# but importmap-rails is not in this bundle (the suite serves assets through +# Sprockets), so the constant never gets defined and loading the controller +# raises NameError. +# +# This has to happen before anything else in this template: `generate` shells +# out to `rails generate`, which boots the app, and `config/environments/test.rb` +# sets `config.eager_load = ENV["CI"].present?` -- so on CI the controller is +# eager-loaded during generation and the template dies on its first model. +# Locally (no CI env var) it stays lazy and only the specs would notice. +gsub_file "app/controllers/application_controller.rb", /^\s*stale_when_importmap_changes\s*\n/, "", verbose: false + +# Ensure Sprockets manifest exists (required by Rails 8+) +FileUtils.mkdir_p("app/assets/config") +File.write("app/assets/config/manifest.js", + "//= link_directory ../javascripts .js\n//= link_directory ../stylesheets .css\n") + +generate :model, 'author name:string{10}:uniq last_name:string birthday:date --force' +generate :model, 'post title:string:uniq body:text author:references --force' + +inject_into_file "app/models/author.rb", " validates_presence_of :name\n validates_uniqueness_of :last_name\n", after: "ApplicationRecord\n" +inject_into_file "app/models/post.rb", " validates_presence_of :author\n", after: ":author\n" + +# Add ransackable_attributes for Ransack 4+ +inject_into_file "app/models/author.rb", + " def self.ransackable_attributes(auth_object = nil)\n" \ + " [\"name\", \"last_name\", \"birthday\", \"created_at\"]\n" \ + " end\n", + after: "ApplicationRecord\n" + +inject_into_file "app/models/post.rb", + " def self.ransackable_attributes(auth_object = nil)\n" \ + " [\"title\", \"body\", \"author_id\"]\n" \ + " end\n" \ + " def self.ransackable_associations(auth_object = nil)\n" \ + " [\"author\"]\n" \ + " end\n", + after: "ApplicationRecord\n" + +# Add our local Active Admin to the load path (Rails 7.1+) +gsub_file "config/environment.rb", + 'require_relative "application"', + "require_relative \"application\"\n$LOAD_PATH.unshift('#{File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'lib'))}')\nrequire \"active_admin\"\n" + +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) + +generate :'active_admin:install --skip-users' +generate :'formtastic:install' + +# Wire the plugin into the dummy app: require the asset, then invoke the +# jQuery plugin on the filters sidebar panel. Without the invocation the +# plugin never runs and no spec can see anything. +inject_into_file "app/assets/javascripts/active_admin.js", + "//= require active_admin_filters_visibility\n", + after: "//= require active_admin/base\n" + +append_to_file "app/assets/javascripts/active_admin.js", <<~JS + $(document).ready(function() { + $('#filters_sidebar_section').activeAdminFiltersVisibility(); + }); +JS + +run "rm -rf test" +route "root :to => 'admin/dashboard#index'" +rake "db:migrate" + +run "rm -f Gemfile Gemfile.lock" diff --git a/tasks/test.rake b/tasks/test.rake new file mode 100644 index 0000000..f46bf45 --- /dev/null +++ b/tasks/test.rake @@ -0,0 +1,13 @@ +desc "Creates a test rails app for the specs to run against" +task :setup do + require 'rails/version' + + rails_new_opts = %w( + --skip-turbolinks + --skip-spring + --skip-bootsnap + -m + spec/support/rails_template.rb + ) + system "bundle exec rails new spec/rails/rails-#{Rails::VERSION::STRING} #{rails_new_opts.join(' ')}" +end