From da267a255cb06962261ae27170e2e29aac2623a2 Mon Sep 17 00:00:00 2001 From: Ernesto Tagwerker Date: Thu, 16 Jul 2026 23:15:21 -0400 Subject: [PATCH 01/11] Upgrade madmin to v2.x --- Gemfile | 2 +- Gemfile.lock | 29 +++++++++++++++++++++++++---- Gemfile.next.lock | 23 ++++++++++++++++++++--- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/Gemfile b/Gemfile index 4649a364..7707a461 100644 --- a/Gemfile +++ b/Gemfile @@ -110,4 +110,4 @@ gem "pundit", "~> 2.2" gem "ostruct" -gem "madmin", "~> 1.2" +gem "madmin", "~> 2.0" diff --git a/Gemfile.lock b/Gemfile.lock index f41e8fc5..f9169408 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -166,6 +166,10 @@ GEM logger i18n (1.15.2) concurrent-ruby (~> 1.0) + importmap-rails (2.2.3) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) io-console (0.8.2) irb (1.18.0) pp (>= 0.6.0) @@ -195,9 +199,13 @@ GEM loofah (2.25.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) - madmin (1.2.8) + madmin (2.3.3) + importmap-rails pagy (>= 3.5) - rails (>= 6.0.3) + propshaft + rails (>= 7.0.0) + stimulus-rails + turbo-rails mail (2.9.1) logger mini_mime (>= 0.1.1) @@ -274,7 +282,10 @@ GEM omniauth (~> 2.0) orm_adapter (0.5.0) ostruct (0.6.3) - pagy (6.1.0) + pagy (43.6.0) + json + uri + yaml parallel (2.1.0) parser (3.3.12.0) ast (~> 2.4.1) @@ -284,6 +295,10 @@ GEM prettyprint prettyprint (0.2.0) prism (1.9.0) + propshaft (1.3.2) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack public_suffix (7.0.5) puma (7.2.1) nio4r (~> 2.0) @@ -457,12 +472,17 @@ GEM rubocop-performance (~> 1.26.0) standardrb (1.0.1) standard + stimulus-rails (1.3.4) + railties (>= 6.0.0) terser (1.1.18) execjs (>= 0.3.0, < 3) thor (1.5.0) tilt (2.3.0) timeout (0.6.1) tsort (0.2.0) + turbo-rails (2.0.23) + actionpack (>= 7.1.0) + railties (>= 7.1.0) turbolinks (5.2.1) turbolinks-source (~> 5.2) turbolinks-source (5.2.0) @@ -493,6 +513,7 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) + yaml (0.4.0) zeitwerk (2.8.2) PLATFORMS @@ -521,7 +542,7 @@ DEPENDENCIES jquery-rails jquery-ui-rails (~> 8.0) listen (~> 3.7) - madmin (~> 1.2) + madmin (~> 2.0) matrix mimemagic (~> 0.3.8) newrelic_rpm diff --git a/Gemfile.next.lock b/Gemfile.next.lock index aa80cf62..c30ae28b 100644 --- a/Gemfile.next.lock +++ b/Gemfile.next.lock @@ -174,6 +174,10 @@ GEM logger i18n (1.14.8) concurrent-ruby (~> 1.0) + importmap-rails (2.2.3) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) io-console (0.8.2) irb (1.18.0) pp (>= 0.6.0) @@ -206,9 +210,13 @@ GEM loofah (2.25.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) - madmin (1.2.11) + madmin (2.3.3) + importmap-rails pagy (>= 3.5) - rails (>= 6.0.3) + propshaft + rails (>= 7.0.0) + stimulus-rails + turbo-rails mail (2.9.0) logger mini_mime (>= 0.1.1) @@ -314,6 +322,10 @@ GEM prettyprint prettyprint (0.2.0) prism (1.9.0) + propshaft (1.3.2) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack psych (5.3.1) date stringio @@ -488,6 +500,8 @@ GEM rubocop-performance (~> 1.26.0) standardrb (1.0.1) standard + stimulus-rails (1.3.4) + railties (>= 6.0.0) stringio (3.2.0) terser (1.2.7) execjs (>= 0.3.0, < 3) @@ -495,6 +509,9 @@ GEM tilt (2.7.0) timeout (0.6.1) tsort (0.2.0) + turbo-rails (2.0.23) + actionpack (>= 7.1.0) + railties (>= 7.1.0) turbolinks (5.2.1) turbolinks-source (~> 5.2) turbolinks-source (5.2.0) @@ -557,7 +574,7 @@ DEPENDENCIES jquery-rails jquery-ui-rails (~> 8.0) listen (~> 3.7) - madmin (~> 1.2) + madmin (~> 2.0) matrix mimemagic (~> 0.3.8) mutex_m From d62f872630c5d9addf720f19fd89b1032198e14f Mon Sep 17 00:00:00 2001 From: Ernesto Tagwerker Date: Thu, 16 Jul 2026 23:22:08 -0400 Subject: [PATCH 02/11] Fix asset precompilation with madmin 2.3.3 Upgraded sass-rails from 5.0 to 6.0 (which uses sassc-rails 2.1) to support modern CSS features like rgb() syntax used in madmin 2.3.3. Updated SCSS files to properly escape calc() and min() expressions using unquote() to prevent SassC from trying to evaluate unit-mixed expressions at compile time. Added variables import to project.scss since each SCSS file is compiled independently. Co-Authored-By: Claude Haiku 4.5 --- Gemfile | 2 +- Gemfile.lock | 34 +++++++++---------- .../stylesheets/1-utilities/_basic.scss | 2 +- .../stylesheets/4-molecules/_floors.scss | 2 +- app/assets/stylesheets/project.scss | 2 ++ app/assets/stylesheets/stories.scss | 2 +- 6 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Gemfile b/Gemfile index 7707a461..902dfafd 100644 --- a/Gemfile +++ b/Gemfile @@ -29,7 +29,7 @@ gem "bootstrap-sass", "3.4.1" # Use Puma as the app server gem "puma", "~> 7.2" # Use SCSS for stylesheets -gem "sass-rails", "~> 5.0" +gem "sass-rails", "~> 6.0" gem "bourbon" gem "matrix" diff --git a/Gemfile.lock b/Gemfile.lock index f9169408..357a2ee3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -414,19 +414,16 @@ GEM rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (1.13.0) rubyzip (2.3.2) - sass (3.7.4) - sass-listen (~> 4.0.0) - sass-listen (4.0.0) - rb-fsevent (~> 0.9, >= 0.9.4) - rb-inotify (~> 0.9, >= 0.9.7) - sass-rails (5.1.0) - railties (>= 5.2.0) - sass (~> 3.1) - sprockets (>= 2.8, < 4.0) - sprockets-rails (>= 2.0, < 4.0) - tilt (>= 1.1, < 3) + sass-rails (6.0.0) + sassc-rails (~> 2.1, >= 2.1.1) sassc (2.4.0) ffi (~> 1.9) + sassc-rails (2.1.2) + railties (>= 4.0.0) + sassc (>= 2.0) + sprockets (> 3.0) + sprockets-rails + tilt securerandom (0.4.1) selenium-webdriver (4.10.0) rexml (~> 3.2, >= 3.2.5) @@ -450,12 +447,13 @@ GEM hashie (>= 0.1.0, < 6) version_gem (~> 1.1, >= 1.1.14) spring (3.0.0) - sprockets (3.7.2) + sprockets (4.2.2) concurrent-ruby (~> 1.0) - rack (> 1, < 3) - sprockets-rails (3.4.2) - actionpack (>= 5.2) - activesupport (>= 5.2) + logger + rack (>= 2.2.4, < 4) + sprockets-rails (3.5.2) + actionpack (>= 6.1) + activesupport (>= 6.1) sprockets (>= 3.0.0) stackprof (0.2.26) standard (1.56.0) @@ -477,7 +475,7 @@ GEM terser (1.1.18) execjs (>= 0.3.0, < 3) thor (1.5.0) - tilt (2.3.0) + tilt (2.8.0) timeout (0.6.1) tsort (0.2.0) turbo-rails (2.0.23) @@ -559,7 +557,7 @@ DEPENDENCIES recursive-open-struct redcarpet (~> 3.5.1) rspec-rails (~> 6.1) - sass-rails (~> 5.0) + sass-rails (~> 6.0) sentry-rails sentry-ruby shoulda-matchers (~> 3.1) diff --git a/app/assets/stylesheets/1-utilities/_basic.scss b/app/assets/stylesheets/1-utilities/_basic.scss index ddedbd03..6dea1aae 100644 --- a/app/assets/stylesheets/1-utilities/_basic.scss +++ b/app/assets/stylesheets/1-utilities/_basic.scss @@ -7,7 +7,7 @@ box-sizing: border-box; } main { - min-height: calc(100vh - 160px); + min-height: unquote("calc(100vh - 160px)"); padding-bottom: 60px; } ::selection { diff --git a/app/assets/stylesheets/4-molecules/_floors.scss b/app/assets/stylesheets/4-molecules/_floors.scss index 7963bd85..b00ddbdf 100644 --- a/app/assets/stylesheets/4-molecules/_floors.scss +++ b/app/assets/stylesheets/4-molecules/_floors.scss @@ -13,7 +13,7 @@ justify-content: center; width: 100%; overflow: hidden; - height: calc(100vh - 160px); + height: unquote("calc(100vh - 160px)"); margin: -100px 0; } } diff --git a/app/assets/stylesheets/project.scss b/app/assets/stylesheets/project.scss index 1a128dd9..a99ec534 100644 --- a/app/assets/stylesheets/project.scss +++ b/app/assets/stylesheets/project.scss @@ -2,6 +2,8 @@ // They will automatically be included in application.css. // You can use Sass (SCSS) here: http://sass-lang.com/ +@import "1-utilities/variables"; + .projects-controller { &.index-action { .btn-group { diff --git a/app/assets/stylesheets/stories.scss b/app/assets/stylesheets/stories.scss index 3ace7f06..ce3bed27 100644 --- a/app/assets/stylesheets/stories.scss +++ b/app/assets/stylesheets/stories.scss @@ -113,7 +113,7 @@ .extra_info_preview .content, .story_preview .content { overflow: auto; - max-height: min(50vh, 700px); + max-height: unquote("min(50vh, 700px)"); // prevent long links from overflowing a { word-break: break-all; From 64dbb87c304deae13b9d2f7607367143448d35a3 Mon Sep 17 00:00:00 2001 From: Ernesto Tagwerker Date: Thu, 16 Jul 2026 23:26:17 -0400 Subject: [PATCH 03/11] Disable SassC CSS compressor to fix production asset precompilation The SassC/libsass CSS compressor re-parses already-compiled CSS and evaluates arithmetic inside calc()/min() (e.g. `100vh - 160px`), raising "Incompatible units: 'px' and 'vh'" on otherwise valid CSS. This only manifests during production precompilation, since the compressor does not run in development. Setting css_compressor to nil explicitly (sprockets-rails defaults to :sass when sassc is present) skips CSS compression while keeping terser for JS. Co-Authored-By: Claude Opus 4.8 (1M context) --- config/environments/production.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/config/environments/production.rb b/config/environments/production.rb index 49f47c6c..ce3ffeb6 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -18,7 +18,11 @@ # Apache or NGINX already handles this. config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? - config.assets.css_compressor = :sass + # Do not use the SassC/libsass CSS compressor: it re-parses already-compiled + # CSS and evaluates arithmetic inside calc()/min() (e.g. `100vh - 160px`), + # raising "Incompatible units" on valid CSS. Set to nil explicitly since + # sprockets-rails otherwise defaults to :sass when sassc is available. + config.assets.css_compressor = nil config.assets.js_compressor = :terser # Do not fallback to assets pipeline if a precompiled asset is missed. From fe9460d290c87207c18f63370895d6c0f1aa74da Mon Sep 17 00:00:00 2001 From: Ernesto Tagwerker Date: Thu, 16 Jul 2026 23:38:55 -0400 Subject: [PATCH 04/11] Patch for madmin v2.x --- app/views/madmin/application/_javascript.html.erb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/views/madmin/application/_javascript.html.erb b/app/views/madmin/application/_javascript.html.erb index fb8d4fd5..127d1e37 100644 --- a/app/views/madmin/application/_javascript.html.erb +++ b/app/views/madmin/application/_javascript.html.erb @@ -1,3 +1,6 @@ +<%# madmin's npm_rails_version helper was removed in 2.x; reproduce it here. + Pins @rails/* unpkg packages to the matching Rails version (or "main" for prereleases). %> +<% npm_rails_version = Rails.version.match?(/\A\d+\.\d+\.\d+\z/) ? Rails.version : "main" %> <%= stylesheet_link_tag "https://unpkg.com/flatpickr/dist/flatpickr.min.css", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "https://unpkg.com/trix/dist/trix.css", "data-turbo-track": "reload" %> From d4d63c96b7a12d1a3465dbede4ec89580cc6f604 Mon Sep 17 00:00:00 2001 From: Ernesto Tagwerker Date: Thu, 16 Jul 2026 23:47:01 -0400 Subject: [PATCH 05/11] Add request specs for the madmin interface Cover every route under /madmin with integration (request) specs: - Authorization: unauthenticated and non-admin users are redirected to the root path; admins get access across all controllers. - Dashboard: GET /madmin renders successfully (guards against layout / partial regressions like the removed npm_rails_version helper). - Each resource (projects, stories, estimates, users, version_jumps): index/new/show, plus create/update/destroy where those routes exist. Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/requests/madmin/authorization_spec.rb | 56 +++++++++++++++ spec/requests/madmin/dashboard_spec.rb | 20 ++++++ spec/requests/madmin/estimates_spec.rb | 44 ++++++++++++ spec/requests/madmin/projects_spec.rb | 45 ++++++++++++ spec/requests/madmin/stories_spec.rb | 83 ++++++++++++++++++++++ spec/requests/madmin/users_spec.rb | 44 ++++++++++++ spec/requests/madmin/version_jumps_spec.rb | 81 +++++++++++++++++++++ 7 files changed, 373 insertions(+) create mode 100644 spec/requests/madmin/authorization_spec.rb create mode 100644 spec/requests/madmin/dashboard_spec.rb create mode 100644 spec/requests/madmin/estimates_spec.rb create mode 100644 spec/requests/madmin/projects_spec.rb create mode 100644 spec/requests/madmin/stories_spec.rb create mode 100644 spec/requests/madmin/users_spec.rb create mode 100644 spec/requests/madmin/version_jumps_spec.rb diff --git a/spec/requests/madmin/authorization_spec.rb b/spec/requests/madmin/authorization_spec.rb new file mode 100644 index 00000000..bd5bc677 --- /dev/null +++ b/spec/requests/madmin/authorization_spec.rb @@ -0,0 +1,56 @@ +require "rails_helper" + +# The madmin interface is gated by Madmin::ApplicationController#ensure_admin, +# which redirects anyone who is not a signed-in admin back to the root path. +# These specs lock that behavior down across a representative set of routes. +RSpec.describe "Madmin authorization", type: :request do + after { Warden.test_reset! } + + # One route per controller under /madmin, enough to prove the shared + # before_action guards every resource and the dashboard. + let(:guarded_paths) do + [ + "/madmin", + "/madmin/projects", + "/madmin/stories", + "/madmin/estimates", + "/madmin/users", + "/madmin/version_jumps" + ] + end + + context "when not signed in" do + it "redirects every madmin path to the root path" do + guarded_paths.each do |path| + get path + expect(response).to redirect_to("/"), "expected #{path} to redirect to /" + end + end + end + + context "when signed in as a non-admin user" do + let(:user) { FactoryBot.create(:user, admin: false) } + + before { login_as(user, scope: :user) } + + it "redirects every madmin path to the root path" do + guarded_paths.each do |path| + get path + expect(response).to redirect_to("/"), "expected #{path} to redirect to /" + end + end + end + + context "when signed in as an admin user" do + let(:admin) { FactoryBot.create(:user, :admin) } + + before { login_as(admin, scope: :user) } + + it "grants access to every madmin path" do + guarded_paths.each do |path| + get path + expect(response).to have_http_status(:ok), "expected #{path} to return 200" + end + end + end +end diff --git a/spec/requests/madmin/dashboard_spec.rb b/spec/requests/madmin/dashboard_spec.rb new file mode 100644 index 00000000..9cd29bd3 --- /dev/null +++ b/spec/requests/madmin/dashboard_spec.rb @@ -0,0 +1,20 @@ +require "rails_helper" + +# The dashboard renders the madmin layout (including _javascript.html.erb), +# so this request spec also guards against layout/partial regressions such as +# the removed `npm_rails_version` helper. +RSpec.describe "Madmin dashboard", type: :request do + let(:admin) { FactoryBot.create(:user, :admin) } + + before { login_as(admin, scope: :user) } + after { Warden.test_reset! } + + describe "GET /madmin" do + it "renders the dashboard successfully" do + get "/madmin" + + expect(response).to have_http_status(:ok) + expect(response.body).to include("madmin") + end + end +end diff --git a/spec/requests/madmin/estimates_spec.rb b/spec/requests/madmin/estimates_spec.rb new file mode 100644 index 00000000..d1bb3fc8 --- /dev/null +++ b/spec/requests/madmin/estimates_spec.rb @@ -0,0 +1,44 @@ +require "rails_helper" + +# Estimates are registered as: resources :estimates, except: [:update, :edit, :create] +RSpec.describe "Madmin estimates", type: :request do + let(:admin) { FactoryBot.create(:user, :admin) } + let!(:estimate) { FactoryBot.create(:estimate) } + + before { login_as(admin, scope: :user) } + after { Warden.test_reset! } + + describe "GET /madmin/estimates" do + it "lists estimates" do + get "/madmin/estimates" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/estimates/new" do + it "renders the new form" do + get "/madmin/estimates/new" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/estimates/:id" do + it "shows the estimate" do + get "/madmin/estimates/#{estimate.id}" + + expect(response).to have_http_status(:ok) + end + end + + describe "DELETE /madmin/estimates/:id" do + it "destroys the estimate" do + expect { + delete "/madmin/estimates/#{estimate.id}" + }.to change(Estimate, :count).by(-1) + + expect(response).to have_http_status(:redirect) + end + end +end diff --git a/spec/requests/madmin/projects_spec.rb b/spec/requests/madmin/projects_spec.rb new file mode 100644 index 00000000..3b1b326d --- /dev/null +++ b/spec/requests/madmin/projects_spec.rb @@ -0,0 +1,45 @@ +require "rails_helper" + +# Projects are registered as: resources :projects, except: [:update, :edit, :create] +RSpec.describe "Madmin projects", type: :request do + let(:admin) { FactoryBot.create(:user, :admin) } + let!(:project) { FactoryBot.create(:project) } + + before { login_as(admin, scope: :user) } + after { Warden.test_reset! } + + describe "GET /madmin/projects" do + it "lists projects" do + get "/madmin/projects" + + expect(response).to have_http_status(:ok) + expect(response.body).to include(project.title.truncate(20)) + end + end + + describe "GET /madmin/projects/new" do + it "renders the new form" do + get "/madmin/projects/new" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/projects/:id" do + it "shows the project" do + get "/madmin/projects/#{project.id}" + + expect(response).to have_http_status(:ok) + end + end + + describe "DELETE /madmin/projects/:id" do + it "destroys the project" do + expect { + delete "/madmin/projects/#{project.id}" + }.to change(Project, :count).by(-1) + + expect(response).to have_http_status(:redirect) + end + end +end diff --git a/spec/requests/madmin/stories_spec.rb b/spec/requests/madmin/stories_spec.rb new file mode 100644 index 00000000..67a49875 --- /dev/null +++ b/spec/requests/madmin/stories_spec.rb @@ -0,0 +1,83 @@ +require "rails_helper" + +# Stories are registered with the full set of resource routes, so this exercises +# create/update in addition to the read-only actions. +RSpec.describe "Madmin stories", type: :request do + let(:admin) { FactoryBot.create(:user, :admin) } + let(:project) { FactoryBot.create(:project) } + let!(:story) { FactoryBot.create(:story, project: project) } + + before { login_as(admin, scope: :user) } + after { Warden.test_reset! } + + describe "GET /madmin/stories" do + it "lists stories" do + get "/madmin/stories" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/stories/new" do + it "renders the new form" do + get "/madmin/stories/new" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/stories/:id" do + it "shows the story" do + get "/madmin/stories/#{story.id}" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/stories/:id/edit" do + it "renders the edit form" do + get "/madmin/stories/#{story.id}/edit" + + expect(response).to have_http_status(:ok) + end + end + + describe "POST /madmin/stories" do + it "creates a story" do + expect { + post "/madmin/stories", params: { + story: { + title: "A brand new story", + description: "Some description", + real_score: 3, + project_id: project.id + } + } + }.to change(Story, :count).by(1) + + expect(response).to have_http_status(:redirect) + expect(Story.last.title).to eq("A brand new story") + end + end + + describe "PATCH /madmin/stories/:id" do + it "updates the story" do + patch "/madmin/stories/#{story.id}", params: { + story: {title: "Updated title"} + } + + expect(response).to have_http_status(:redirect) + expect(story.reload.title).to eq("Updated title") + end + end + + describe "DELETE /madmin/stories/:id" do + it "destroys the story" do + expect { + delete "/madmin/stories/#{story.id}" + }.to change(Story, :count).by(-1) + + expect(response).to have_http_status(:redirect) + end + end +end diff --git a/spec/requests/madmin/users_spec.rb b/spec/requests/madmin/users_spec.rb new file mode 100644 index 00000000..a59d6ee1 --- /dev/null +++ b/spec/requests/madmin/users_spec.rb @@ -0,0 +1,44 @@ +require "rails_helper" + +# Users are registered as: resources :users, except: [:update, :edit, :create] +RSpec.describe "Madmin users", type: :request do + let(:admin) { FactoryBot.create(:user, :admin) } + let!(:user) { FactoryBot.create(:user) } + + before { login_as(admin, scope: :user) } + after { Warden.test_reset! } + + describe "GET /madmin/users" do + it "lists users" do + get "/madmin/users" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/users/new" do + it "renders the new form" do + get "/madmin/users/new" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/users/:id" do + it "shows the user" do + get "/madmin/users/#{user.id}" + + expect(response).to have_http_status(:ok) + end + end + + describe "DELETE /madmin/users/:id" do + it "destroys the user" do + expect { + delete "/madmin/users/#{user.id}" + }.to change(User, :count).by(-1) + + expect(response).to have_http_status(:redirect) + end + end +end diff --git a/spec/requests/madmin/version_jumps_spec.rb b/spec/requests/madmin/version_jumps_spec.rb new file mode 100644 index 00000000..b12c967d --- /dev/null +++ b/spec/requests/madmin/version_jumps_spec.rb @@ -0,0 +1,81 @@ +require "rails_helper" + +# Version jumps are registered with the full set of resource routes, so this +# exercises create/update in addition to the read-only actions. +RSpec.describe "Madmin version jumps", type: :request do + let(:admin) { FactoryBot.create(:user, :admin) } + let!(:version_jump) { FactoryBot.create(:version_jump) } + + before { login_as(admin, scope: :user) } + after { Warden.test_reset! } + + describe "GET /madmin/version_jumps" do + it "lists version jumps" do + get "/madmin/version_jumps" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/version_jumps/new" do + it "renders the new form" do + get "/madmin/version_jumps/new" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/version_jumps/:id" do + it "shows the version jump" do + get "/madmin/version_jumps/#{version_jump.id}" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/version_jumps/:id/edit" do + it "renders the edit form" do + get "/madmin/version_jumps/#{version_jump.id}/edit" + + expect(response).to have_http_status(:ok) + end + end + + describe "POST /madmin/version_jumps" do + it "creates a version jump" do + expect { + post "/madmin/version_jumps", params: { + version_jump: { + technology: "Rails-new", + initial_version: "6.1", + target_version: "7.0" + } + } + }.to change(VersionJump, :count).by(1) + + expect(response).to have_http_status(:redirect) + expect(VersionJump.last.technology).to eq("Rails-new") + end + end + + describe "PATCH /madmin/version_jumps/:id" do + it "updates the version jump" do + patch "/madmin/version_jumps/#{version_jump.id}", params: { + version_jump: {target_version: "8.0"} + } + + expect(response).to have_http_status(:redirect) + expect(version_jump.reload.target_version).to eq("8.0") + end + end + + describe "DELETE /madmin/version_jumps/:id" do + it "destroys the version jump" do + expect { + delete "/madmin/version_jumps/#{version_jump.id}" + }.to change(VersionJump, :count).by(-1) + + expect(response).to have_http_status(:redirect) + end + end +end From 9fd8246ee5edcbc2e04887ed4f345b7f9a32dce6 Mon Sep 17 00:00:00 2001 From: Ernesto Tagwerker Date: Fri, 17 Jul 2026 07:41:20 -0400 Subject: [PATCH 06/11] Fix madmin index pagination for madmin 2.x madmin 2.x dropped the madmin/pagy/_nav partial and renders pagination via pagy's own helpers. The app's overridden index.html.erb still rendered the old partial, raising ActionView::MissingTemplate for any resource list with more than one page (pagy's default limit is 20). Replace the stale partial render with pagy's helpers, mirroring the gem's own index template (series_nav/info_tag on pagy 43+, pagy_nav/pagy_info on older versions). Add a request spec that creates 21 records to exercise the multi-page path, which the single-record index specs never hit. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/views/madmin/application/index.html.erb | 13 +++++++++- spec/requests/madmin/pagination_spec.rb | 28 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 spec/requests/madmin/pagination_spec.rb diff --git a/app/views/madmin/application/index.html.erb b/app/views/madmin/application/index.html.erb index ea987e0d..8fbce9c0 100644 --- a/app/views/madmin/application/index.html.erb +++ b/app/views/madmin/application/index.html.erb @@ -63,4 +63,15 @@ -<%== render(partial: 'madmin/pagy/nav', locals: { pagy: @pagy }) if @pagy.pages > 1 %> +<%# madmin 2.x dropped the madmin/pagy/_nav partial in favor of pagy's own + helpers. series_nav/info_tag are the pagy 43+ API; pagy_nav/pagy_info the + older one. %> + diff --git a/spec/requests/madmin/pagination_spec.rb b/spec/requests/madmin/pagination_spec.rb new file mode 100644 index 00000000..1c171955 --- /dev/null +++ b/spec/requests/madmin/pagination_spec.rb @@ -0,0 +1,28 @@ +require "rails_helper" + +# Guards the shared madmin/application/index.html.erb pagination path. +# +# The index partial only renders the pagination nav when there is more than +# one page of results (pagy's default limit is 20). An older madmin override +# rendered a `madmin/pagy/_nav` partial that no longer ships with madmin 2.x, +# raising ActionView::MissingTemplate once a list grew past one page. The +# single-record index specs never hit this branch, so this spec creates enough +# records to paginate and exercise the nav rendering. +RSpec.describe "Madmin pagination", type: :request do + let(:admin) { FactoryBot.create(:user, :admin) } + + before { login_as(admin, scope: :user) } + after { Warden.test_reset! } + + describe "GET /madmin/version_jumps with more than one page of records" do + before { FactoryBot.create_list(:version_jump, 21) } + + it "renders the index with pagination navigation" do + get "/madmin/version_jumps" + + expect(response).to have_http_status(:ok) + # pagy builds links to subsequent pages using the `page` query param + expect(response.body).to include("page=2") + end + end +end From f166facb30ae57d93a9a7fd559f3bf990e00be48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Mon, 20 Jul 2026 11:33:08 -0600 Subject: [PATCH 07/11] Disable SassC CSS compressor globally to fix test asset compilation The compressor re-parses compiled CSS and evaluates calc()/min() unit arithmetic (e.g. 100vh - 160px), raising "Incompatible units" on valid CSS. It was only disabled in production, but madmin request specs compile assets in the test env too. Move the setting to application.rb so all envs skip it. --- config/application.rb | 7 +++++++ config/environments/production.rb | 7 ++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/config/application.rb b/config/application.rb index b855c2a3..91cfb27a 100644 --- a/config/application.rb +++ b/config/application.rb @@ -15,5 +15,12 @@ class Application < Rails::Application # Settings in config/environments/* take precedence over those specified here. # Application configuration should go into files in config/initializers # -- all .rb files in that directory are automatically loaded. + + # Do not use the SassC/libsass CSS compressor: it re-parses already-compiled + # CSS and evaluates arithmetic inside calc()/min() (e.g. `100vh - 160px`), + # raising "Incompatible units" on valid CSS. Set to nil explicitly since + # sprockets-rails otherwise defaults to :sass when sassc is available. This + # affects any env that compiles assets (test + production), so set it here. + config.assets.css_compressor = nil end end diff --git a/config/environments/production.rb b/config/environments/production.rb index ce3ffeb6..d815e688 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -18,11 +18,8 @@ # Apache or NGINX already handles this. config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? - # Do not use the SassC/libsass CSS compressor: it re-parses already-compiled - # CSS and evaluates arithmetic inside calc()/min() (e.g. `100vh - 160px`), - # raising "Incompatible units" on valid CSS. Set to nil explicitly since - # sprockets-rails otherwise defaults to :sass when sassc is available. - config.assets.css_compressor = nil + # css_compressor is disabled globally in config/application.rb (SassC re-parses + # compiled CSS and chokes on calc()/min() unit arithmetic). config.assets.js_compressor = :terser # Do not fallback to assets pipeline if a precompiled asset is missed. From b3c55a796a1cfbe68e1878f2c37a35fdc8ae7690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Mon, 20 Jul 2026 12:00:43 -0600 Subject: [PATCH 08/11] Simplify madmin pagination to the pinned pagy 43 API The previous version branched at runtime on @pagy.respond_to?(:series_nav) to support both the pagy 43+ object helpers and the older pagy_nav/pagy_info module helpers. pagy is pinned to 43.x in both Gemfile.lock and Gemfile.next.lock, so the else branch was dead code that never executed. Call the pagy 43 object helpers directly (series_nav/info_tag), dropping the feature-detection branch. --- app/views/madmin/application/index.html.erb | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/app/views/madmin/application/index.html.erb b/app/views/madmin/application/index.html.erb index 8fbce9c0..4e8e3fa0 100644 --- a/app/views/madmin/application/index.html.erb +++ b/app/views/madmin/application/index.html.erb @@ -63,15 +63,9 @@ -<%# madmin 2.x dropped the madmin/pagy/_nav partial in favor of pagy's own - helpers. series_nav/info_tag are the pagy 43+ API; pagy_nav/pagy_info the - older one. %> +<%# madmin 2.x renders pagination with pagy's own object helpers instead of + the removed madmin/pagy/_nav partial. %> From 15080dbdf8d70ab65d10dd1be7e453aaaf3c6b65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Mon, 20 Jul 2026 12:09:07 -0600 Subject: [PATCH 09/11] Fix brittle title assertion in madmin projects request spec madmin 2.x renders the full project title in the index table, not a 20-char truncation. Assert the full title instead of title.truncate(20), which produced 'Dietrich, Reichel...' and never matched the rendered 'Dietrich, Reichel and Lueilwitz'. --- spec/requests/madmin/projects_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/requests/madmin/projects_spec.rb b/spec/requests/madmin/projects_spec.rb index 3b1b326d..23b9e782 100644 --- a/spec/requests/madmin/projects_spec.rb +++ b/spec/requests/madmin/projects_spec.rb @@ -13,7 +13,7 @@ get "/madmin/projects" expect(response).to have_http_status(:ok) - expect(response.body).to include(project.title.truncate(20)) + expect(response.body).to include(project.title) end end From e3463416f17ee881a01ad35dc6b4a2dde0a2bd30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Mon, 20 Jul 2026 15:30:55 -0600 Subject: [PATCH 10/11] Remove dead calc() unquote hacks; document CSS minification tradeoff The unquote("calc(...)") wrappings in _basic.scss and _floors.scss were only needed to appease the SassC CSS compressor, which is now disabled globally. The SassC compile pass passes calc() through untouched, so the wrappings were dead. Verified assets still precompile without the 'Incompatible units' error and emit calc(100vh - 160px) correctly. (stories.scss keeps unquote("min(...)"): libsass evaluates min() at compile time, independent of the compressor.) Also document in application.rb that disabling the CSS compressor means production CSS is no longer minified. --- app/assets/stylesheets/1-utilities/_basic.scss | 2 +- app/assets/stylesheets/4-molecules/_floors.scss | 2 +- config/application.rb | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/1-utilities/_basic.scss b/app/assets/stylesheets/1-utilities/_basic.scss index 6dea1aae..ddedbd03 100644 --- a/app/assets/stylesheets/1-utilities/_basic.scss +++ b/app/assets/stylesheets/1-utilities/_basic.scss @@ -7,7 +7,7 @@ box-sizing: border-box; } main { - min-height: unquote("calc(100vh - 160px)"); + min-height: calc(100vh - 160px); padding-bottom: 60px; } ::selection { diff --git a/app/assets/stylesheets/4-molecules/_floors.scss b/app/assets/stylesheets/4-molecules/_floors.scss index b00ddbdf..7963bd85 100644 --- a/app/assets/stylesheets/4-molecules/_floors.scss +++ b/app/assets/stylesheets/4-molecules/_floors.scss @@ -13,7 +13,7 @@ justify-content: center; width: 100%; overflow: hidden; - height: unquote("calc(100vh - 160px)"); + height: calc(100vh - 160px); margin: -100px 0; } } diff --git a/config/application.rb b/config/application.rb index 91cfb27a..d8a75f56 100644 --- a/config/application.rb +++ b/config/application.rb @@ -21,6 +21,10 @@ class Application < Rails::Application # raising "Incompatible units" on valid CSS. Set to nil explicitly since # sprockets-rails otherwise defaults to :sass when sassc is available. This # affects any env that compiles assets (test + production), so set it here. + # + # Tradeoff: this disables CSS minification in production, so application.css + # ships unminified (gzip at the web/CDN layer still applies). Revisit with a + # non-SassC minifier if CSS payload size becomes a concern. config.assets.css_compressor = nil end end From f3ebd03eb7c7d4cf62ad95c00efdc58d4355c79c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20V=C3=A1squez?= Date: Mon, 20 Jul 2026 16:59:46 -0600 Subject: [PATCH 11/11] Use madmin's own importmap instead of a hand-rolled one madmin 1.x shipped an `npm_rails_version` view helper that this override used to pin the `@rails/actiontext` and `@rails/activestorage` packages to unpkg by the running Rails version. madmin 2.x removed that helper, so the override reproduced it locally, which is fragile: it hard-codes CDN URLs and pins @rails/* to the exact Rails patch (e.g. @8.0.5), which 404s if that version was never published to npm. madmin 2.x solved this differently: it ships its own importmap (Madmin.importmap) that pins @hotwired/*, @rails/actiontext, @rails/activestorage, trix, tom-select and tailwindcss-stimulus-components to gem-vendored assets (served locally by propshaft, no Rails-version coupling), and it bundles the select / nested_form / dropdown Stimulus controllers this app was registering inline. So the entire hand-written importmap + inline module is replaced by: javascript_importmap_tags "application", importmap: Madmin.importmap The Tailwind CDN and the trix/tom-select stylesheet links stay because the customized madmin views depend on them. The flatpickr wiring is dropped: it had no call site (no madmin field uses data-controller="flatpickr"). ActiveStorage.start() and the standalone trix import are dropped too: this app registers no ActiveStorage attachments and no ActionText rich text, so they had nothing to drive (madmin's own field partials handle that JS if such fields are ever added). Verified in a real headless browser: tom-select still initializes on association selects (native