diff --git a/Gemfile b/Gemfile index 4649a364..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" @@ -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..357a2ee3 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) @@ -399,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) @@ -435,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) @@ -457,12 +470,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) @@ -493,6 +511,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 +540,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 @@ -538,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/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 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..8309152a 100644 --- a/app/views/madmin/application/_javascript.html.erb +++ b/app/views/madmin/application/_javascript.html.erb @@ -1,107 +1,13 @@ +<%# Styling for these customized madmin views comes from the Tailwind runtime + CDN; the JS comes from madmin's own importmap. %> -<%= 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" %> <%= stylesheet_link_tag "https://unpkg.com/tom-select/dist/css/tom-select.min.css", "data-turbo-track": "reload" %> - - - - +<%# Madmin.importmap pins @hotwired/*, @rails/actiontext, @rails/activestorage, + trix, tom-select and tailwindcss-stimulus-components (all vendored by the + madmin gem, no Rails-version-pinned unpkg URLs), and eager-loads madmin's + select / nested_form / dropdown Stimulus controllers. This replaces the + hand-written importmap and the npm_rails_version helper that madmin 2.x + removed. %> +<%= javascript_importmap_tags "application", importmap: Madmin.importmap %> diff --git a/app/views/madmin/application/index.html.erb b/app/views/madmin/application/index.html.erb index ea987e0d..4e8e3fa0 100644 --- a/app/views/madmin/application/index.html.erb +++ b/app/views/madmin/application/index.html.erb @@ -63,4 +63,9 @@ -<%== render(partial: 'madmin/pagy/nav', locals: { pagy: @pagy }) if @pagy.pages > 1 %> +<%# madmin 2.x renders pagination with pagy's own object helpers instead of + the removed madmin/pagy/_nav partial. %> + diff --git a/config/application.rb b/config/application.rb index b855c2a3..d8a75f56 100644 --- a/config/application.rb +++ b/config/application.rb @@ -15,5 +15,16 @@ 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. + # + # 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 diff --git a/config/environments/production.rb b/config/environments/production.rb index 49f47c6c..d815e688 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -18,7 +18,8 @@ # Apache or NGINX already handles this. config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? - config.assets.css_compressor = :sass + # 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. 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..23b9e782 --- /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) + 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