From 72c7c945fcbd8889a1cae0458d31c346d7f1ffe7 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sat, 31 Oct 2026 14:36:27 -0400 Subject: [PATCH 1/5] feat: link games in the admin nav and explain an empty game picker Co-Authored-By: Claude Opus 5.5 --- app/views/admin/content_modules/edit.html.erb | 7 +++++++ app/views/shared/_admin_nav.html.erb | 4 ++++ .../admin/content_modules_controller_test.rb | 12 ++++++++++++ test/controllers/admin/games_controller_test.rb | 6 ++++++ 4 files changed, 29 insertions(+) diff --git a/app/views/admin/content_modules/edit.html.erb b/app/views/admin/content_modules/edit.html.erb index db9e96f..9cf5531 100644 --- a/app/views/admin/content_modules/edit.html.erb +++ b/app/views/admin/content_modules/edit.html.erb @@ -28,6 +28,13 @@ {}, class: "select select-sm", aria: { label: "Game" } %> <%= form.submit "Add game", class: "btn btn-sm btn-primary" %> <% end %> + <% elsif Game.none? %> +

+ No games yet. + <%= link_to "Create a game", new_admin_game_path, class: "link" %> +

+ <% else %> +

All games are in this module.

<% end %> <%= link_to "Add link", new_admin_content_module_link_path(@content_module), class: "btn btn-sm btn-primary" %> diff --git a/app/views/shared/_admin_nav.html.erb b/app/views/shared/_admin_nav.html.erb index 913b6e9..4f900d7 100644 --- a/app/views/shared/_admin_nav.html.erb +++ b/app/views/shared/_admin_nav.html.erb @@ -13,6 +13,10 @@ <%= link_to "Content Modules", admin_content_modules_path, class: "#{controller_path.start_with?("admin/content_modules", "admin/links", "admin/contents") ? "active" : ""}" %> +
  • + <%= link_to "Games", admin_games_path, + class: "#{controller_path.start_with?("admin/games") ? "active" : ""}" %> +
  • <%= button_to "Log out", admin_session_path, method: :delete, class: "btn btn-sm btn-ghost ml-2" %> diff --git a/test/controllers/admin/content_modules_controller_test.rb b/test/controllers/admin/content_modules_controller_test.rb index 07152cb..675d9ee 100644 --- a/test/controllers/admin/content_modules_controller_test.rb +++ b/test/controllers/admin/content_modules_controller_test.rb @@ -77,6 +77,18 @@ class Admin::ContentModulesControllerTest < ActionDispatch::IntegrationTest get edit_admin_content_module_url(@content_module) assert_select "select[name='content[contentable_id]']", count: 0 + assert_select "p", text: /All games are in this module\./ + end + + test "edit links to creating a game when no games exist" do + Content.where(contentable_type: "Game").destroy_all + Game.destroy_all + + get edit_admin_content_module_url(@content_module) + + assert_select "select[name='content[contentable_id]']", count: 0 + assert_select "p", text: /No games yet/ + assert_select "a.link[href='#{new_admin_game_path}']", text: "Create a game" end test "index shows the contents count" do diff --git a/test/controllers/admin/games_controller_test.rb b/test/controllers/admin/games_controller_test.rb index 40da7d4..caa56f9 100644 --- a/test/controllers/admin/games_controller_test.rb +++ b/test/controllers/admin/games_controller_test.rb @@ -11,6 +11,12 @@ class Admin::GamesControllerTest < ActionDispatch::IntegrationTest assert_response :success end + test "index marks the Games nav link as active" do + get admin_games_url + + assert_select "nav a.active", text: "Games" + end + test "should get new" do get new_admin_game_url assert_response :success From be5721dc656a4f7a4ae1d6e8b88fa528f0386159 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sat, 31 Oct 2026 14:37:29 -0400 Subject: [PATCH 2/5] refactor: restyle the games admin to match other admin pages Show the modules using each game and warn before deleting a game that is in modules. Co-Authored-By: Claude Sonnet 5.5 --- app/controllers/admin/games_controller.rb | 8 ++- app/helpers/admin/games_helper.rb | 8 +++ app/models/game.rb | 1 + app/views/admin/games/_form.html.erb | 3 + app/views/admin/games/_game.html.erb | 14 ----- app/views/admin/games/edit.html.erb | 25 +++++--- app/views/admin/games/index.html.erb | 62 ++++++++++++------- app/views/admin/games/new.html.erb | 4 +- app/views/admin/games/show.html.erb | 52 ++++++++++++---- .../admin/games_controller_test.rb | 25 ++++++++ 10 files changed, 140 insertions(+), 62 deletions(-) create mode 100644 app/helpers/admin/games_helper.rb delete mode 100644 app/views/admin/games/_game.html.erb diff --git a/app/controllers/admin/games_controller.rb b/app/controllers/admin/games_controller.rb index 3e40cb6..0caf8ad 100644 --- a/app/controllers/admin/games_controller.rb +++ b/app/controllers/admin/games_controller.rb @@ -2,7 +2,11 @@ class Admin::GamesController < AdminController before_action :set_game, only: %i[ show edit update destroy ] def index - @games = Game.all + @games = Game.includes(:contents).order(:title) + end + + def show + @content_modules = @game.content_modules.order(:name) end def new @@ -17,7 +21,7 @@ def create respond_to do |format| if @game.save - format.html { redirect_back(fallback_location: admin_games_path, notice: "Game was successfully created.") } + format.html { redirect_to admin_games_path, notice: "Game was successfully created." } else format.html { render :new, status: :unprocessable_content } end diff --git a/app/helpers/admin/games_helper.rb b/app/helpers/admin/games_helper.rb new file mode 100644 index 0000000..fdae35b --- /dev/null +++ b/app/helpers/admin/games_helper.rb @@ -0,0 +1,8 @@ +module Admin::GamesHelper + def delete_game_confirmation(game) + module_count = game.contents.size + return "Delete this game?" if module_count.zero? + + "Delete this game? It will be removed from #{pluralize(module_count, "module")}." + end +end diff --git a/app/models/game.rb b/app/models/game.rb index 3f41fd0..595b80c 100644 --- a/app/models/game.rb +++ b/app/models/game.rb @@ -1,5 +1,6 @@ class Game < ApplicationRecord has_many :contents, as: :contentable, dependent: :destroy + has_many :content_modules, through: :contents has_many :game_attempts, dependent: :destroy validates :title, :slug, presence: true diff --git a/app/views/admin/games/_form.html.erb b/app/views/admin/games/_form.html.erb index a17e651..b51210c 100644 --- a/app/views/admin/games/_form.html.erb +++ b/app/views/admin/games/_form.html.erb @@ -17,6 +17,9 @@ <%= form.label :slug, "Slug", class: "label" %> <%= form.text_field :slug, class: "input w-full" %> + <%= form.label :description, class: "label" %> + <%= form.text_area :description, rows: 4, class: "textarea w-full" %> + <%= form.submit class: "btn btn-primary" %> <% end %> diff --git a/app/views/admin/games/_game.html.erb b/app/views/admin/games/_game.html.erb deleted file mode 100644 index f585356..0000000 --- a/app/views/admin/games/_game.html.erb +++ /dev/null @@ -1,14 +0,0 @@ -
    -
    - Title: - <%= game.title %> -
    -
    - Slug: - <%= game.slug %> -
    -
    - Description: - <%= game.description %> -
    -
    diff --git a/app/views/admin/games/edit.html.erb b/app/views/admin/games/edit.html.erb index d3759e6..9c3eaf5 100644 --- a/app/views/admin/games/edit.html.erb +++ b/app/views/admin/games/edit.html.erb @@ -1,10 +1,17 @@ -<% content_for :title, "Editing game" %> +<% content_for :title, "Edit Game" %> -
    -

    Editing game

    - - <%= render "form", game: @game %> - - <%= link_to "Show this game", [:admin, @game], class: "w-full sm:w-auto text-center mt-2 sm:mt-0 sm:ml-2 rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium" %> - <%= link_to "Back to games", admin_games_path, class: "w-full sm:w-auto text-center mt-2 sm:mt-0 sm:ml-2 rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium" %> -
    + +
    +
    +
    +

    Edit Game

    + <%= render "form", game: @game %> +
    +
    +
    diff --git a/app/views/admin/games/index.html.erb b/app/views/admin/games/index.html.erb index 6d14df9..571855e 100644 --- a/app/views/admin/games/index.html.erb +++ b/app/views/admin/games/index.html.erb @@ -1,29 +1,43 @@ <% content_for :title, "Games" %> -
    - <% if notice.present? %> -

    <%= notice %>

    - <% end %> +

    <%= notice %>

    +

    <%= alert %>

    -
    -

    Games

    - <%= link_to "New game", new_admin_game_path, class: "rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white block font-medium" %> -
    +
    +

    Games

    + <%= link_to "New game", new_admin_game_path, class: "btn btn-primary" %> +
    -
    - <% if @games.any? %> - <% @games.each do |game| %> -
    - <%= render game %> -
    - <%= link_to "Show", [:admin, game], class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium" %> - <%= link_to "Edit", edit_admin_game_path(game), class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium" %> - <%= button_to "Destroy", [:admin, game], method: :delete, class: "w-full sm:w-auto rounded-md px-3.5 py-2.5 text-white bg-red-600 hover:bg-red-500 font-medium cursor-pointer", data: { turbo_confirm: "Are you sure?" } %> -
    -
    - <% end %> - <% else %> -

    No games found.

    - <% end %> +<% if @games.any? %> +
    + + + + + + + + + + + <% @games.each do |game| %> + + + + + + + <% end %> + +
    TitleSlugModulesActions
    <%= link_to game.title, [:admin, game], class: "link" %><%= game.slug %><%= game.contents.size %> +
    + <%= link_to "Edit", edit_admin_game_path(game), class: "btn btn-sm btn-primary" %> + <%= button_to "Delete", [:admin, game], method: :delete, + data: { "turbo-confirm": delete_game_confirmation(game) }, + class: "btn btn-sm btn-error" %> +
    +
    -
    +<% else %> +

    No games yet.

    +<% end %> diff --git a/app/views/admin/games/new.html.erb b/app/views/admin/games/new.html.erb index 99983d5..7eb65c6 100644 --- a/app/views/admin/games/new.html.erb +++ b/app/views/admin/games/new.html.erb @@ -1,8 +1,8 @@ -<% content_for :title, "New game" %> +<% content_for :title, "New Game" %> diff --git a/app/views/admin/games/show.html.erb b/app/views/admin/games/show.html.erb index f6b290a..b7ae621 100644 --- a/app/views/admin/games/show.html.erb +++ b/app/views/admin/games/show.html.erb @@ -1,15 +1,45 @@ -<% content_for :title, "Showing game" %> +<% content_for :title, @game.title %> -
    - <% if notice.present? %> -

    <%= notice %>

    - <% end %> +

    <%= notice %>

    +

    <%= alert %>

    + + -

    Showing game

    +
    +

    <%= @game.title %>

    +
    + <%= link_to "Edit this game", edit_admin_game_path(@game), class: "btn btn-primary" %> + <%= button_to "Delete this game", [:admin, @game], method: :delete, + data: { "turbo-confirm": delete_game_confirmation(@game) }, + class: "btn btn-error" %> +
    +
    - <%= render @game %> +
    +
    +
    Slug
    +
    <%= @game.slug %>
    +
    +
    +
    Description
    +
    <%= @game.description.presence || "No description." %>
    +
    +
    - <%= link_to "Edit this game", edit_admin_game_path(@game), class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium" %> - <%= link_to "Back to games", admin_games_path, class: "w-full sm:w-auto text-center mt-2 sm:mt-0 sm:ml-2 rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium" %> - <%= button_to "Destroy this game", [:admin, @game], method: :delete, form_class: "sm:inline-block mt-2 sm:mt-0 sm:ml-2", class: "w-full rounded-md px-3.5 py-2.5 text-white bg-red-600 hover:bg-red-500 font-medium cursor-pointer", data: { turbo_confirm: "Are you sure?" } %> -
    +
    +

    Modules

    + <% if @content_modules.any? %> +
      + <% @content_modules.each do |content_module| %> +
    • <%= link_to content_module.name, edit_admin_content_module_path(content_module), class: "link" %>
    • + <% end %> +
    + <% else %> +

    Not in any module yet.

    + <% end %> +
    diff --git a/test/controllers/admin/games_controller_test.rb b/test/controllers/admin/games_controller_test.rb index caa56f9..2c5ac22 100644 --- a/test/controllers/admin/games_controller_test.rb +++ b/test/controllers/admin/games_controller_test.rb @@ -32,6 +32,31 @@ class Admin::GamesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to admin_games_url end + test "index shows how many modules use each game" do + get admin_games_url + + assert_select "tr", text: /#{@game.title}\s+#{@game.slug}\s+1/ + end + + test "show lists the modules that use the game" do + get admin_game_url(@game) + + assert_select "a[href=?]", edit_admin_content_module_path(content_modules(:one)), text: content_modules(:one).name + end + + test "show says when the game is in no module" do + get admin_game_url(games(:two)) + + assert_select "p", text: "Not in any module yet." + end + + test "delete confirmation warns when the game is in modules" do + get admin_game_url(@game) + + assert_select "form[action=?] button[data-turbo-confirm=?]", admin_game_path(@game), + "Delete this game? It will be removed from 1 module." + end + test "should get edit" do get edit_admin_game_url(@game) assert_response :success From 752bba22ffe77328e5482f00742ca07f4fdf988a Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sat, 31 Oct 2026 14:38:47 -0400 Subject: [PATCH 3/5] chore: seed games and mixed module contents Co-Authored-By: Claude Opus 5.5 --- db/seeds.rb | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/db/seeds.rb b/db/seeds.rb index 4c325cf..2867d18 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -16,12 +16,32 @@ def maybe(&block) # Production should not have any seeded data for the content or schools return if Rails.env.production? +fruit_veggie_quiz = Game.find_or_create_by!(slug: "fruit-veggie-quiz") do |game| + game.title = "Fruit and Veggie Quiz" + game.description = "Sort fruits and vegetables into the right food groups." +end +sleep_habits = Game.find_or_create_by!(slug: "sleep-habits") do |game| + game.title = "Sleep Habits" + game.description = "Pick the habits that help you get a good night's sleep." +end +# Intentionally not attached to any module so the "Add game" dropdown has an option. +Game.find_or_create_by!(slug: "hydration-heroes") do |game| + game.title = "Hydration Heroes" + game.description = "Learn how much water your body needs each day." +end +games_by_program = { programs.first => fruit_veggie_quiz, programs.last => sleep_habits } + +# The first module of each program and level gets a game after its survey. +# The second module of the basic level gets its game before the survey. ContentModule.levels.keys.each do |level| programs.each do |program| + game = games_by_program.fetch(program) 2.times do |i| mod = program.content_modules.create!(name: "#{program.name} #{level.humanize} Module #{i + 1}", level: level) link = Link.new(title: "Survey #{i + 1}", url: "https://example.com/survey/#{program.id}-#{level}-#{i}", link_type: "survey") + mod.contents.create!(contentable: game) if i == 1 && level == "basic" mod.contents.create!(contentable: link) + mod.contents.create!(contentable: game) if i == 0 end end end From 93d90954c284252610ef0d3970abb4c747a3a8d5 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sat, 31 Oct 2026 14:40:31 -0400 Subject: [PATCH 4/5] fix: warn that deleting a game removes student attempts Co-Authored-By: Claude Opus 5.5 --- app/controllers/admin/games_controller.rb | 2 +- app/helpers/admin/games_helper.rb | 9 +++- app/models/game.rb | 11 +++++ .../admin/games_controller_test.rb | 2 +- test/helpers/admin/games_helper_test.rb | 44 +++++++++++++++++++ 5 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 test/helpers/admin/games_helper_test.rb diff --git a/app/controllers/admin/games_controller.rb b/app/controllers/admin/games_controller.rb index 0caf8ad..300366f 100644 --- a/app/controllers/admin/games_controller.rb +++ b/app/controllers/admin/games_controller.rb @@ -2,7 +2,7 @@ class Admin::GamesController < AdminController before_action :set_game, only: %i[ show edit update destroy ] def index - @games = Game.includes(:contents).order(:title) + @games = Game.includes(:contents).with_attempt_counts.order(:title) end def show diff --git a/app/helpers/admin/games_helper.rb b/app/helpers/admin/games_helper.rb index fdae35b..02106fc 100644 --- a/app/helpers/admin/games_helper.rb +++ b/app/helpers/admin/games_helper.rb @@ -1,8 +1,13 @@ module Admin::GamesHelper def delete_game_confirmation(game) module_count = game.contents.size - return "Delete this game?" if module_count.zero? + attempt_count = game.attempts_count - "Delete this game? It will be removed from #{pluralize(module_count, "module")}." + consequences = [] + consequences << "It will be removed from #{pluralize(module_count, "module")}" if module_count.positive? + consequences << "#{pluralize(attempt_count, "student attempt")} will be deleted" if attempt_count.positive? + return "Delete this game?" if consequences.empty? + + "Delete this game? #{consequences.join(" and ")}." end end diff --git a/app/models/game.rb b/app/models/game.rb index 595b80c..a3ba3c8 100644 --- a/app/models/game.rb +++ b/app/models/game.rb @@ -1,6 +1,8 @@ class Game < ApplicationRecord has_many :contents, as: :contentable, dependent: :destroy has_many :content_modules, through: :contents + # TODO: Ask the client whether deleting a game should be blocked when students have attempts + # (dependent: :restrict_with_error) instead of deleting their attempt history. has_many :game_attempts, dependent: :destroy validates :title, :slug, presence: true @@ -8,6 +10,15 @@ class Game < ApplicationRecord scope :not_in, ->(content_module) { where.not(id: content_module.contents.games.select(:contentable_id)) } + scope :with_attempt_counts, -> { + left_joins(:game_attempts).select("games.*, COUNT(game_attempts.id) AS attempts_count").group(:id) + } + + # Uses the preselected count from with_attempt_counts when present, avoiding N+1 on lists. + def attempts_count + has_attribute?(:attempts_count) ? self[:attempts_count] : game_attempts.count + end + def destroy_with_attachable? false end diff --git a/test/controllers/admin/games_controller_test.rb b/test/controllers/admin/games_controller_test.rb index 2c5ac22..51ffc48 100644 --- a/test/controllers/admin/games_controller_test.rb +++ b/test/controllers/admin/games_controller_test.rb @@ -54,7 +54,7 @@ class Admin::GamesControllerTest < ActionDispatch::IntegrationTest get admin_game_url(@game) assert_select "form[action=?] button[data-turbo-confirm=?]", admin_game_path(@game), - "Delete this game? It will be removed from 1 module." + "Delete this game? It will be removed from 1 module and 1 student attempt will be deleted." end test "should get edit" do diff --git a/test/helpers/admin/games_helper_test.rb b/test/helpers/admin/games_helper_test.rb new file mode 100644 index 0000000..26b6425 --- /dev/null +++ b/test/helpers/admin/games_helper_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class Admin::GamesHelperTest < ActionView::TestCase + setup do + @game = games(:one) + end + + test "plain confirmation when the game has no modules or attempts" do + @game.contents.destroy_all + @game.game_attempts.destroy_all + + assert_equal "Delete this game?", delete_game_confirmation(@game.reload) + end + + test "warns about modules only" do + @game.game_attempts.destroy_all + contents(:one).dup.update!(content_module: content_modules(:two), position: 1) + + assert_equal "Delete this game? It will be removed from 2 modules.", delete_game_confirmation(@game.reload) + end + + test "warns about attempts only" do + @game = games(:two) + attempt = game_attempts(:two) + 4.times { |i| attempt.dup.update!(token: "extra-#{i}") } + + assert_equal "Delete this game? 5 student attempts will be deleted.", delete_game_confirmation(@game.reload) + end + + test "warns about modules and attempts" do + contents(:one).dup.update!(content_module: content_modules(:two), position: 1) + 4.times { |i| game_attempts(:one).dup.update!(token: "extra-#{i}") } + + assert_equal "Delete this game? It will be removed from 2 modules and 5 student attempts will be deleted.", + delete_game_confirmation(@game.reload) + end + + test "uses the preselected attempt count for games from with_attempt_counts" do + game = Game.includes(:contents).with_attempt_counts.find(@game.id) + + assert_no_queries { delete_game_confirmation(game) } + assert_includes delete_game_confirmation(game), "1 student attempt will be deleted" + end +end From f45a7a1e718a82447f243932180d0042c4712276 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sat, 31 Oct 2026 14:40:59 -0400 Subject: [PATCH 5/5] test: assert the games index module count by cell Co-Authored-By: Claude Opus 5.5 --- test/controllers/admin/games_controller_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/controllers/admin/games_controller_test.rb b/test/controllers/admin/games_controller_test.rb index 51ffc48..6e2d4a9 100644 --- a/test/controllers/admin/games_controller_test.rb +++ b/test/controllers/admin/games_controller_test.rb @@ -35,7 +35,7 @@ class Admin::GamesControllerTest < ActionDispatch::IntegrationTest test "index shows how many modules use each game" do get admin_games_url - assert_select "tr", text: /#{@game.title}\s+#{@game.slug}\s+1/ + assert_select "tr:has(a[href='#{admin_game_path(@game)}']) td:nth-child(3)", text: "1" end test "show lists the modules that use the game" do