diff --git a/Gemfile b/Gemfile
index fd18f501..4235abf9 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"
@@ -108,4 +108,4 @@ gem "coffee-script"
gem "pundit", "~> 2.2"
-gem "madmin", "~> 1.2"
+gem "madmin", "~> 2.0"
diff --git a/Gemfile.lock b/Gemfile.lock
index afb692a8..2634140d 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)
@@ -273,7 +281,10 @@ GEM
actionpack (>= 4.2)
omniauth (~> 2.0)
orm_adapter (0.5.0)
- pagy (6.1.0)
+ pagy (43.6.0)
+ json
+ uri
+ yaml
parallel (2.1.0)
parser (3.3.12.0)
ast (~> 2.4.1)
@@ -283,6 +294,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)
@@ -398,19 +413,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)
@@ -434,12 +446,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)
@@ -456,12 +469,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)
+ tilt (2.8.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)
@@ -492,6 +510,7 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
+ yaml (0.4.0)
zeitwerk (2.8.2)
PLATFORMS
@@ -520,7 +539,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
@@ -536,7 +555,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;
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" %>
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/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.
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/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
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