diff --git a/app/controllers/admin/games_controller.rb b/app/controllers/admin/games_controller.rb index 3e40cb6..300366f 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).with_attempt_counts.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..02106fc --- /dev/null +++ b/app/helpers/admin/games_helper.rb @@ -0,0 +1,13 @@ +module Admin::GamesHelper + def delete_game_confirmation(game) + module_count = game.contents.size + attempt_count = game.attempts_count + + 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 3f41fd0..a3ba3c8 100644 --- a/app/models/game.rb +++ b/app/models/game.rb @@ -1,5 +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 @@ -7,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/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/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? %> + + <% else %> +

Not in any module yet.

+ <% end %> +
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/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 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..6e2d4a9 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 @@ -26,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:has(a[href='#{admin_game_path(@game)}']) td:nth-child(3)", text: "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 and 1 student attempt will be deleted." + end + test "should get edit" do get edit_admin_game_url(@game) assert_response :success 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