+ <%= render "admin/shared/move_buttons", url: admin_content_position_path(content), title: game.title, first: first, last: last %>
<%= link_to "Edit", edit_admin_game_path(game), class: "btn btn-xs btn-primary" %>
<%= button_to "Remove", admin_content_path(content), method: :delete,
data: { "turbo-confirm": "Remove this game from the module?" },
diff --git a/app/views/admin/contents/contentables/_link.html.erb b/app/views/admin/contents/contentables/_link.html.erb
index d62367a..3d6349a 100644
--- a/app/views/admin/contents/contentables/_link.html.erb
+++ b/app/views/admin/contents/contentables/_link.html.erb
@@ -4,6 +4,7 @@
<%= link.link_type.humanize %> |
+ <%= render "admin/shared/move_buttons", url: admin_content_position_path(content), title: link.title, first: first, last: last %>
<%= link_to "Edit", edit_admin_link_path(link), class: "btn btn-xs btn-primary" %>
<%= button_to "Delete", admin_content_path(content), method: :delete,
data: { "turbo-confirm": "Delete this link?" },
diff --git a/app/views/admin/shared/_move_buttons.html.erb b/app/views/admin/shared/_move_buttons.html.erb
new file mode 100644
index 0000000..920d605
--- /dev/null
+++ b/app/views/admin/shared/_move_buttons.html.erb
@@ -0,0 +1,5 @@
+<%# locals: (url:, title:, first:, last:) %>
+<%= button_to "↑", url, method: :patch, params: { direction: "up" },
+ disabled: first, class: "btn btn-xs", aria: { label: "Move #{title} up" } %>
+<%= button_to "↓", url, method: :patch, params: { direction: "down" },
+ disabled: last, class: "btn btn-xs", aria: { label: "Move #{title} down" } %>
diff --git a/config/routes.rb b/config/routes.rb
index 8443f6d..f0aa686 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -23,8 +23,11 @@
post :csv_import, to: "csv#import"
end
resources :content_modules, except: [ :show ] do
+ resource :position, only: :update, module: :content_modules
resources :links, shallow: true, except: %i[index show]
- resources :contents, shallow: true, only: %i[create destroy]
+ resources :contents, shallow: true, only: %i[create destroy] do
+ resource :position, only: :update, module: :contents
+ end
end
resources :classroom_modules, only: %i[update]
end
diff --git a/db/seeds.rb b/db/seeds.rb
index d8385d2..5b7b201 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -19,7 +19,7 @@ def maybe(&block)
ContentModule.levels.keys.each do |level|
programs.each do |program|
2.times do |i|
- mod = program.content_modules.create!(name: "#{program.name} #{level.humanize} Module #{i + 1}", level: level, position: i + 1)
+ mod = program.content_modules.create!(name: "#{program.name} #{level.humanize} Module #{i + 1}", level: level)
# TODO: Update this when the delegated types migration is completed
link = Link.new(title: "Survey #{i + 1}", url: "https://example.com/survey/#{program.id}-#{level}-#{i}", link_type: "survey", position: 1, content_module: mod)
mod.contents.create! contentable: link
diff --git a/test/controllers/admin/content_modules/positions_controller_test.rb b/test/controllers/admin/content_modules/positions_controller_test.rb
new file mode 100644
index 0000000..cdf06e4
--- /dev/null
+++ b/test/controllers/admin/content_modules/positions_controller_test.rb
@@ -0,0 +1,57 @@
+require "test_helper"
+
+class Admin::ContentModules::PositionsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @program = programs(:one)
+ @first = create_module("First")
+ @second = create_module("Second")
+ sign_in_as users(:one)
+ end
+
+ test "moves module down" do
+ patch admin_content_module_position_url(@first), params: { direction: "down" }
+
+ assert_redirected_to admin_content_modules_url(program_id: @program.id)
+ assert_equal [ @second.id, @first.id ], ordered_ids
+ end
+
+ test "moves module up" do
+ patch admin_content_module_position_url(@second), params: { direction: "up" }
+
+ assert_redirected_to admin_content_modules_url(program_id: @program.id)
+ assert_equal [ @second.id, @first.id ], ordered_ids
+ end
+
+ test "moving the first module up changes nothing" do
+ patch admin_content_module_position_url(@first), params: { direction: "up" }
+
+ assert_redirected_to admin_content_modules_url(program_id: @program.id)
+ assert_equal [ @first.id, @second.id ], ordered_ids
+ end
+
+ test "rejects an unknown direction" do
+ patch admin_content_module_position_url(@first), params: { direction: "sideways" }
+
+ assert_response :unprocessable_entity
+ assert_equal [ @first.id, @second.id ], ordered_ids
+ end
+
+ test "requires admin sign in" do
+ sign_out
+
+ patch admin_content_module_position_url(@first), params: { direction: "down" }
+
+ assert_redirected_to new_admin_session_url
+ assert_equal [ @first.id, @second.id ], ordered_ids
+ end
+
+ private
+
+ def create_module(name)
+ ContentModule.create!(program: @program, level: "advanced", name: name)
+ end
+
+ def ordered_ids
+ @program.content_modules.advanced.ordered.map(&:id)
+ end
+end
diff --git a/test/controllers/admin/content_modules_controller_test.rb b/test/controllers/admin/content_modules_controller_test.rb
index 0f797cd..07152cb 100644
--- a/test/controllers/admin/content_modules_controller_test.rb
+++ b/test/controllers/admin/content_modules_controller_test.rb
@@ -25,7 +25,7 @@ class Admin::ContentModulesControllerTest < ActionDispatch::IntegrationTest
test "should create content module" do
assert_difference "ContentModule.count" do
post admin_content_modules_url, params: {
- content_module: { name: "New Module", program_id: programs(:one).id, level: "moderate", position: 1 }
+ content_module: { name: "New Module", program_id: programs(:one).id, level: "moderate" }
}
end
assert_redirected_to admin_content_modules_url
@@ -56,6 +56,15 @@ class Admin::ContentModulesControllerTest < ActionDispatch::IntegrationTest
assert_select "table a[target=_blank][rel='noopener noreferrer']", text: "Health Survey ↗"
end
+ test "edit disables moving the first row up and the last row down" do
+ get edit_admin_content_module_url(@content_module)
+
+ assert_select "tbody tr:first-child button[aria-label$='up'][disabled]", count: 1
+ assert_select "tbody tr:first-child button[aria-label$='down'][disabled]", count: 0
+ assert_select "tbody tr:last-child button[aria-label$='down'][disabled]", count: 1
+ assert_select "tbody tr:last-child button[aria-label$='up'][disabled]", count: 0
+ end
+
test "edit offers only games not yet in the module" do
get edit_admin_content_module_url(@content_module)
@@ -77,6 +86,45 @@ class Admin::ContentModulesControllerTest < ActionDispatch::IntegrationTest
assert_select "tbody tr", text: /#{@content_module.name}\s+#{@content_module.contents.count}/
end
+ test "index disables moving the first module up and the last module down within a level" do
+ second = ContentModule.create!(program: programs(:one), level: "basic", name: "Second Basic")
+ ContentModule.create!(program: programs(:one), level: "moderate", name: "Only Moderate")
+
+ get admin_content_modules_url(program_id: programs(:one).id)
+
+ assert_select "section", text: /Basic/ do
+ assert_select "tbody tr:first-child button[aria-label$='up'][disabled]", count: 1
+ assert_select "tbody tr:first-child button[aria-label$='down'][disabled]", count: 0
+ assert_select "tbody tr:last-child button[aria-label='Move #{second.name} down'][disabled]", count: 1
+ assert_select "tbody tr:last-child button[aria-label$='up'][disabled]", count: 0
+ end
+ assert_select "section", text: /Moderate/ do
+ assert_select "tbody tr button[disabled]", count: 2
+ end
+ end
+
+ test "index move buttons patch the module position" do
+ get admin_content_modules_url(program_id: @content_module.program_id)
+
+ assert_select "form[action='#{admin_content_module_position_path(@content_module)}'] button[aria-label='Move #{@content_module.name} up']"
+ end
+
+ test "create appends the module and ignores a submitted position" do
+ ContentModule.create!(program: programs(:one), level: "moderate", name: "Existing")
+
+ post admin_content_modules_url, params: {
+ content_module: { name: "New Module", program_id: programs(:one).id, level: "moderate", position: 99 }
+ }
+
+ assert_equal 2, ContentModule.find_by!(name: "New Module").position
+ end
+
+ test "form has no position field" do
+ get new_admin_content_module_url
+
+ assert_select "input[name='content_module[position]']", count: 0
+ end
+
test "should update content module" do
patch admin_content_module_url(@content_module), params: {
content_module: { name: "Updated Name" }
diff --git a/test/controllers/admin/contents/positions_controller_test.rb b/test/controllers/admin/contents/positions_controller_test.rb
new file mode 100644
index 0000000..83a5619
--- /dev/null
+++ b/test/controllers/admin/contents/positions_controller_test.rb
@@ -0,0 +1,47 @@
+require "test_helper"
+
+class Admin::Contents::PositionsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @content_module = content_modules(:one)
+ @first = contents(:one)
+ @second = contents(:two)
+ sign_in_as users(:one)
+ end
+
+ test "moves content down" do
+ patch admin_content_position_url(@first), params: { direction: "down" }
+
+ assert_redirected_to edit_admin_content_module_url(@content_module)
+ assert_equal [ @second.id, @first.id ], @content_module.contents.reload.map(&:id)
+ end
+
+ test "moves content up" do
+ patch admin_content_position_url(@second), params: { direction: "up" }
+
+ assert_redirected_to edit_admin_content_module_url(@content_module)
+ assert_equal [ @second.id, @first.id ], @content_module.contents.reload.map(&:id)
+ end
+
+ test "moving the first content up changes nothing" do
+ patch admin_content_position_url(@first), params: { direction: "up" }
+
+ assert_redirected_to edit_admin_content_module_url(@content_module)
+ assert_equal [ @first.id, @second.id ], @content_module.contents.reload.map(&:id)
+ end
+
+ test "rejects an unknown direction" do
+ patch admin_content_position_url(@first), params: { direction: "sideways" }
+
+ assert_response :unprocessable_entity
+ assert_equal [ @first.id, @second.id ], @content_module.contents.reload.map(&:id)
+ end
+
+ test "requires admin sign in" do
+ sign_out
+
+ patch admin_content_position_url(@first), params: { direction: "down" }
+
+ assert_redirected_to new_admin_session_url
+ assert_equal [ @first.id, @second.id ], @content_module.contents.reload.map(&:id)
+ end
+end
diff --git a/test/models/content_module_test.rb b/test/models/content_module_test.rb
index b7b3200..4c44fd7 100644
--- a/test/models/content_module_test.rb
+++ b/test/models/content_module_test.rb
@@ -48,6 +48,92 @@ class ContentModuleTest < ActiveSupport::TestCase
assert_equal [ contents(:two), contents(:one) ], content_modules(:one).contents.to_a
end
+ test "appends new modules after the highest position in their program and level" do
+ first = create_module("First")
+ second = create_module("Second")
+
+ assert_equal [ 1, 2 ], [ first.position, second.position ]
+ end
+
+ test "positions are separate for each level and program" do
+ create_module("Advanced")
+ moderate = create_module("Moderate", level: "moderate")
+ other_program = create_module("Other", program: programs(:two))
+
+ assert_equal 1, moderate.position
+ assert_equal 1, other_program.position
+ end
+
+ test "keeps an explicit position" do
+ assert_equal 7, create_module("Explicit", position: 7).position
+ end
+
+ test "moves to the end of its new level when the level changes" do
+ mod = create_module("Advanced")
+ create_module("Moderate", level: "moderate")
+
+ mod.update!(level: "moderate")
+
+ assert_equal 2, mod.position
+ end
+
+ test "moves to the end of its new program when the program changes" do
+ mod = create_module("Advanced")
+ create_module("Other", program: programs(:two))
+
+ mod.update!(program: programs(:two))
+
+ assert_equal 2, mod.position
+ end
+
+ test "keeps its position when other fields change" do
+ first = create_module("First")
+ create_module("Second")
+
+ first.update!(name: "Renamed")
+
+ assert_equal 1, first.reload.position
+ end
+
+ test "move_up swaps position with the previous module" do
+ first = create_module("First")
+ second = create_module("Second")
+
+ second.move_up
+
+ assert_equal [ second, first ], ordered_advanced_modules
+ end
+
+ test "move_down swaps position with the next module" do
+ first = create_module("First")
+ second = create_module("Second")
+
+ first.move_down
+
+ assert_equal [ second, first ], ordered_advanced_modules
+ end
+
+ test "move_up and move_down do nothing at the ends" do
+ first = create_module("First")
+ second = create_module("Second")
+
+ first.move_up
+ second.move_down
+
+ assert_equal [ first, second ], ordered_advanced_modules
+ end
+
+ test "moving ignores modules in other levels and programs" do
+ only = create_module("Only")
+ create_module("Moderate", level: "moderate")
+ create_module("Other", program: programs(:two))
+
+ only.move_up
+ only.move_down
+
+ assert_equal 1, only.reload.position
+ end
+
test "cannot be destroyed when classroom modules exist" do
content_module = ContentModule.create!(program: @program, level: "basic", name: "Assigned Module")
classroom_program = classroom_programs(:one)
@@ -56,4 +142,15 @@ class ContentModuleTest < ActiveSupport::TestCase
assert_not content_module.destroy
assert content_module.persisted?
end
+
+ private
+
+ # Defaults to a level no fixture uses, so positions start empty.
+ def create_module(name, program: @program, level: "advanced", **attributes)
+ ContentModule.create!(program: program, level: level, name: name, **attributes)
+ end
+
+ def ordered_advanced_modules
+ @program.content_modules.advanced.ordered.to_a
+ end
end
|