From d748aeeb7d078f9d22f71ee07cd3f41ca3330fdf Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sun, 17 May 2026 15:33:13 -0400 Subject: [PATCH 1/4] blank From 1a504d1d7fad8af1e8a7ce86385b3a035211fcdc Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sun, 17 May 2026 15:32:51 -0400 Subject: [PATCH 2/4] feat: classroom modules --- app/controllers/classrooms_controller.rb | 1 + app/controllers/content_modules_controller.rb | 9 ++-- app/models/classroom_module.rb | 6 +++ app/models/classroom_program.rb | 19 +++++++ app/models/content_module.rb | 1 + ...20260517183252_create_classroom_modules.rb | 14 ++++++ db/schema.rb | 15 +++++- .../controllers/classrooms_controller_test.rb | 47 +++++++++++++++--- .../content_modules_controller_test.rb | 20 +++++--- test/fixtures/classroom_modules.yml | 8 +++ test/fixtures/content_modules.yml | 6 +++ test/models/classroom_module_test.rb | 27 ++++++++++ test/models/classroom_program_test.rb | 49 +++++++++++++++++++ test/models/content_module_test.rb | 39 ++++++++++----- 14 files changed, 230 insertions(+), 31 deletions(-) create mode 100644 app/models/classroom_module.rb create mode 100644 db/migrate/20260517183252_create_classroom_modules.rb create mode 100644 test/fixtures/classroom_modules.yml create mode 100644 test/models/classroom_module_test.rb create mode 100644 test/models/classroom_program_test.rb diff --git a/app/controllers/classrooms_controller.rb b/app/controllers/classrooms_controller.rb index dec88e9..485e876 100644 --- a/app/controllers/classrooms_controller.rb +++ b/app/controllers/classrooms_controller.rb @@ -8,6 +8,7 @@ def edit def update respond_to do |format| if @classroom.update(classroom_params) + @classroom.classroom_programs.reload.each(&:generate_modules!) format.html { redirect_to school_students_url(@classroom.school), notice: "Classroom was successfully updated.", status: :see_other } format.json { render :show, status: :ok, location: @classroom } else diff --git a/app/controllers/content_modules_controller.rb b/app/controllers/content_modules_controller.rb index 7fa64c5..4dfbb1c 100644 --- a/app/controllers/content_modules_controller.rb +++ b/app/controllers/content_modules_controller.rb @@ -36,10 +36,11 @@ def update end def destroy - @content_module.destroy! - redirect_to content_modules_path, notice: "Module was successfully deleted.", status: :see_other - rescue ActiveRecord::DeleteRestrictionError - redirect_to content_modules_path, alert: "Cannot delete a module that has been assigned to classrooms." + if @content_module.destroy + redirect_to content_modules_path, notice: "Module was successfully deleted.", status: :see_other + else + redirect_to content_modules_path, alert: "Cannot delete a module that has been assigned to classrooms." + end end private diff --git a/app/models/classroom_module.rb b/app/models/classroom_module.rb new file mode 100644 index 0000000..0751d34 --- /dev/null +++ b/app/models/classroom_module.rb @@ -0,0 +1,6 @@ +class ClassroomModule < ApplicationRecord + belongs_to :classroom_program + belongs_to :content_module + + scope :published, -> { where.not(publish_on: nil).where("publish_on <= ?", Date.current) } +end diff --git a/app/models/classroom_program.rb b/app/models/classroom_program.rb index 99dc46c..202f5a5 100644 --- a/app/models/classroom_program.rb +++ b/app/models/classroom_program.rb @@ -1,8 +1,27 @@ class ClassroomProgram < ApplicationRecord belongs_to :classroom belongs_to :program + has_many :classroom_modules, dependent: :destroy enum :level, { basic: "basic", moderate: "moderate", advanced: "advanced" }, validate: true validates :level, presence: true + validate :level_unchanged_if_modules_scheduled, on: :update + + def generate_modules! + current_cm_ids = program.content_modules.where(level: level).pluck(:id) + classroom_modules.where.not(content_module_id: current_cm_ids).destroy_all + current_cm_ids.each do |cm_id| + classroom_modules.find_or_create_by!(content_module_id: cm_id) + end + end + + private + + def level_unchanged_if_modules_scheduled + return unless level_changed? + if classroom_modules.where.not(publish_on: nil).exists? + errors.add(:level, "cannot be changed when modules have publish dates set") + end + end end diff --git a/app/models/content_module.rb b/app/models/content_module.rb index 061c5b4..810add8 100644 --- a/app/models/content_module.rb +++ b/app/models/content_module.rb @@ -1,6 +1,7 @@ class ContentModule < ApplicationRecord belongs_to :program has_many :links, dependent: :destroy + has_many :classroom_modules, dependent: :restrict_with_error enum :level, { basic: "basic", moderate: "moderate", advanced: "advanced" }, validate: true diff --git a/db/migrate/20260517183252_create_classroom_modules.rb b/db/migrate/20260517183252_create_classroom_modules.rb new file mode 100644 index 0000000..e258c9a --- /dev/null +++ b/db/migrate/20260517183252_create_classroom_modules.rb @@ -0,0 +1,14 @@ +class CreateClassroomModules < ActiveRecord::Migration[8.1] + def change + create_table :classroom_modules do |t| + t.references :classroom_program, null: false, foreign_key: true + t.references :content_module, null: false, foreign_key: true + t.date :publish_on + + t.timestamps + end + + add_index :classroom_modules, [ :classroom_program_id, :content_module_id ], unique: true, + name: "index_classroom_modules_on_classroom_program_and_content_module" + end +end diff --git a/db/schema.rb b/db/schema.rb index ef80c4c..ba3d4cb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,18 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_05_17_180806) do +ActiveRecord::Schema[8.1].define(version: 2026_05_17_183252) do + create_table "classroom_modules", force: :cascade do |t| + t.integer "classroom_program_id", null: false + t.integer "content_module_id", null: false + t.datetime "created_at", null: false + t.date "publish_on" + t.datetime "updated_at", null: false + t.index ["classroom_program_id", "content_module_id"], name: "index_classroom_modules_on_classroom_program_and_content_module", unique: true + t.index ["classroom_program_id"], name: "index_classroom_modules_on_classroom_program_id" + t.index ["content_module_id"], name: "index_classroom_modules_on_content_module_id" + end + create_table "classroom_programs", force: :cascade do |t| t.integer "classroom_id", null: false t.datetime "created_at", null: false @@ -105,6 +116,8 @@ t.index ["email_address"], name: "index_users_on_email_address", unique: true end + add_foreign_key "classroom_modules", "classroom_programs" + add_foreign_key "classroom_modules", "content_modules" add_foreign_key "classroom_programs", "classrooms" add_foreign_key "classroom_programs", "programs" add_foreign_key "classrooms", "schools" diff --git a/test/controllers/classrooms_controller_test.rb b/test/controllers/classrooms_controller_test.rb index da30fda..d6f2005 100644 --- a/test/controllers/classrooms_controller_test.rb +++ b/test/controllers/classrooms_controller_test.rb @@ -34,29 +34,28 @@ class ClassroomsControllerTest < ActionDispatch::IntegrationTest end test "should update an existing enrollment level" do - cp = classroom_programs(:one) + enrollment = classroom_programs(:one) patch classroom_url(@classroom), params: { classroom: { name: @classroom.name, - classroom_programs_attributes: [ { id: cp.id, program_id: cp.program_id, level: "advanced" } ] + classroom_programs_attributes: [ { id: enrollment.id, program_id: enrollment.program_id, level: "advanced" } ] } } assert_redirected_to school_students_url(@classroom.school) - assert_equal "advanced", cp.reload.level + assert_equal "advanced", enrollment.reload.level end test "should remove an enrollment" do - cp = classroom_programs(:one) - # Add a second enrollment so the classroom still has one after removal + enrollment = classroom_programs(:one) @classroom.classroom_programs.create!(program: programs(:"3dw"), level: "basic") assert_difference "ClassroomProgram.count", -1 do patch classroom_url(@classroom), params: { classroom: { name: @classroom.name, - classroom_programs_attributes: [ { id: cp.id, _destroy: "1" } ] + classroom_programs_attributes: [ { id: enrollment.id, _destroy: "1" } ] } } end @@ -65,13 +64,13 @@ class ClassroomsControllerTest < ActionDispatch::IntegrationTest end test "is invalid when removing all programs" do - cp = classroom_programs(:one) + enrollment = @classroom.classroom_programs.first! assert_no_difference "ClassroomProgram.count" do patch classroom_url(@classroom), params: { classroom: { name: @classroom.name, - classroom_programs_attributes: [ { id: cp.id, _destroy: "1" } ] + classroom_programs_attributes: [ { id: enrollment.id, _destroy: "1" } ] } } end @@ -93,4 +92,36 @@ class ClassroomsControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_entity end + + test "generates modules when a new enrollment is added" do + program = programs(:"3dw") + + assert_difference "ClassroomModule.count" do + patch classroom_url(@classroom), params: { + classroom: { + name: @classroom.name, + classroom_programs_attributes: [ { program_id: program.id, level: "moderate" } ] + } + } + end + + cp = @classroom.classroom_programs.find_by!(program: program) + assert cp.classroom_modules.exists?(content_module: content_modules(:moderate_wellness)) + end + + test "is invalid when changing a level that has scheduled modules" do + enrollment = classroom_programs(:one) + enrollment.generate_modules! + enrollment.classroom_modules.first.update!(publish_on: Date.current) + + patch classroom_url(@classroom), params: { + classroom: { + name: @classroom.name, + classroom_programs_attributes: [ { id: enrollment.id, program_id: enrollment.program_id, level: "advanced" } ] + } + } + + assert_response :unprocessable_entity + assert_equal "basic", enrollment.reload.level + end end diff --git a/test/controllers/content_modules_controller_test.rb b/test/controllers/content_modules_controller_test.rb index 7e13da0..6ec287f 100644 --- a/test/controllers/content_modules_controller_test.rb +++ b/test/controllers/content_modules_controller_test.rb @@ -2,7 +2,7 @@ class ContentModulesControllerTest < ActionDispatch::IntegrationTest setup do - @mod = content_modules(:intro) + @content_module = content_modules(:intro) sign_in_as users(:admin) end @@ -35,23 +35,31 @@ class ContentModulesControllerTest < ActionDispatch::IntegrationTest end test "should get edit" do - get edit_content_module_url(@mod) + get edit_content_module_url(@content_module) assert_response :success end test "should update content module" do - patch content_module_url(@mod), params: { + patch content_module_url(@content_module), params: { content_module: { name: "Updated Name" } } assert_redirected_to content_modules_url - assert_equal "Updated Name", @mod.reload.name + assert_equal "Updated Name", @content_module.reload.name end test "should destroy content module" do - mod = content_modules(:advanced_wellness) + content_module = ContentModule.create!(program: programs(:kyh), level: "basic", name: "To Delete") assert_difference "ContentModule.count", -1 do - delete content_module_url(mod) + delete content_module_url(content_module) end assert_redirected_to content_modules_url end + + test "should not destroy content module assigned to classrooms" do + assert_no_difference "ContentModule.count" do + delete content_module_url(@content_module) + end + assert_redirected_to content_modules_url + assert_equal "Cannot delete a module that has been assigned to classrooms.", flash[:alert] + end end diff --git a/test/fixtures/classroom_modules.yml b/test/fixtures/classroom_modules.yml new file mode 100644 index 0000000..22daaf1 --- /dev/null +++ b/test/fixtures/classroom_modules.yml @@ -0,0 +1,8 @@ +one: + classroom_program: one + content_module: intro + +scheduled: + classroom_program: two + content_module: moderate_wellness + publish_on: 2026-05-01 diff --git a/test/fixtures/content_modules.yml b/test/fixtures/content_modules.yml index 66e70fa..9093b5c 100644 --- a/test/fixtures/content_modules.yml +++ b/test/fixtures/content_modules.yml @@ -4,6 +4,12 @@ intro: name: Introduction to Health position: 1 +moderate_wellness: + program: 3dw + level: moderate + name: Moderate Wellness + position: 1 + advanced_wellness: program: 3dw level: advanced diff --git a/test/models/classroom_module_test.rb b/test/models/classroom_module_test.rb new file mode 100644 index 0000000..9aef6e4 --- /dev/null +++ b/test/models/classroom_module_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class ClassroomModuleTest < ActiveSupport::TestCase + setup do + @cm = classroom_modules(:one) + end + + test "published scope excludes modules with no publish_on" do + @cm.update!(publish_on: nil) + assert_not_includes ClassroomModule.published, @cm + end + + test "published scope includes modules with publish_on today" do + @cm.update!(publish_on: Date.current) + assert_includes ClassroomModule.published, @cm + end + + test "published scope includes modules with publish_on in the past" do + @cm.update!(publish_on: Date.current - 1) + assert_includes ClassroomModule.published, @cm + end + + test "published scope excludes modules with publish_on in the future" do + @cm.update!(publish_on: Date.current + 1) + assert_not_includes ClassroomModule.published, @cm + end +end diff --git a/test/models/classroom_program_test.rb b/test/models/classroom_program_test.rb new file mode 100644 index 0000000..5bfbec8 --- /dev/null +++ b/test/models/classroom_program_test.rb @@ -0,0 +1,49 @@ +require "test_helper" + +class ClassroomProgramTest < ActiveSupport::TestCase + setup do + @classroom_program = classroom_programs(:one) + @classroom_program.classroom_modules.destroy_all + end + + test "generate_modules! creates a ClassroomModule for each matching content module" do + assert_difference "ClassroomModule.count", 1 do + @classroom_program.generate_modules! + end + end + + test "generate_modules! is idempotent" do + @classroom_program.generate_modules! + + assert_no_difference "ClassroomModule.count" do + @classroom_program.generate_modules! + end + end + + test "generate_modules! prunes modules that no longer match after a level change" do + @classroom_program.generate_modules! + original_count = @classroom_program.classroom_modules.count + + @classroom_program.update!(level: "advanced") + @classroom_program.generate_modules! + + assert_not_equal original_count, @classroom_program.classroom_modules.reload.count + assert @classroom_program.classroom_modules.none? { |m| m.content_module.level == "basic" } + end + + test "is invalid when changing level that has modules with publish dates" do + @classroom_program.generate_modules! + @classroom_program.classroom_modules.first.update!(publish_on: Date.current) + + @classroom_program.level = "advanced" + assert_not @classroom_program.valid? + assert_includes @classroom_program.errors[:level], "cannot be changed when modules have publish dates set" + end + + test "is valid when changing level that has no modules with publish dates" do + @classroom_program.generate_modules! + + @classroom_program.level = "advanced" + assert @classroom_program.valid? + end +end diff --git a/test/models/content_module_test.rb b/test/models/content_module_test.rb index b637878..95a4039 100644 --- a/test/models/content_module_test.rb +++ b/test/models/content_module_test.rb @@ -1,31 +1,46 @@ require "test_helper" class ContentModuleTest < ActiveSupport::TestCase + setup do + @program = programs(:kyh) + end + test "is valid with required fields" do - mod = ContentModule.new(program: programs(:kyh), level: "basic", name: "Test Module") - assert mod.valid? + content_module = ContentModule.new(program: @program, level: "basic", name: "Test Module") + assert content_module.valid? end test "is invalid without a name" do - mod = ContentModule.new(program: programs(:kyh), level: "basic") - assert_not mod.valid? - assert_includes mod.errors[:name], "can't be blank" + content_module = ContentModule.new(program: @program, level: "basic") + assert_not content_module.valid? + assert_includes content_module.errors[:name], "can't be blank" end test "is invalid without a level" do - mod = ContentModule.new(program: programs(:kyh), name: "Test Module") - assert_not mod.valid? + content_module = ContentModule.new(program: @program, name: "Test Module") + assert_not content_module.valid? end test "is invalid without a program" do - mod = ContentModule.new(level: "basic", name: "Test Module") - assert_not mod.valid? + content_module = ContentModule.new(level: "basic", name: "Test Module") + assert_not content_module.valid? end test "destroys associated links" do - mod = content_modules(:intro) - assert_difference "Link.count", -mod.links.count do - mod.destroy! + content_module = ContentModule.create!(program: @program, level: "basic", name: "With Links") + content_module.links.create!(title: "A Link", url: "https://example.com", link_type: "survey") + + assert_difference "Link.count", -1 do + content_module.destroy! end 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) + classroom_program.classroom_modules.create!(content_module: content_module) + + assert_not content_module.destroy + assert content_module.persisted? + end end From d8429af9b720777a15c939ecd84b4585942474e1 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sun, 17 May 2026 16:03:31 -0400 Subject: [PATCH 3/4] feat: add scheduling --- .../classroom_modules_controller.rb | 33 ++++++++++++ app/controllers/classrooms_controller.rb | 9 +++- .../controllers/publish_now_controller.js | 10 ++++ app/views/classroom_modules/_row.html.erb | 24 +++++++++ .../classroom_modules/update.turbo_stream.erb | 3 ++ app/views/classrooms/edit.html.erb | 21 ++++++-- app/views/classrooms/schedule.html.erb | 53 +++++++++++++++++++ app/views/content_modules/index.html.erb | 1 + config/routes.rb | 5 +- .../classroom_modules_controller_test.rb | 35 ++++++++++++ .../controllers/classrooms_controller_test.rb | 17 ++++++ .../content_modules_controller_test.rb | 6 +++ 12 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 app/controllers/classroom_modules_controller.rb create mode 100644 app/javascript/controllers/publish_now_controller.js create mode 100644 app/views/classroom_modules/_row.html.erb create mode 100644 app/views/classroom_modules/update.turbo_stream.erb create mode 100644 app/views/classrooms/schedule.html.erb create mode 100644 test/controllers/classroom_modules_controller_test.rb diff --git a/app/controllers/classroom_modules_controller.rb b/app/controllers/classroom_modules_controller.rb new file mode 100644 index 0000000..210745e --- /dev/null +++ b/app/controllers/classroom_modules_controller.rb @@ -0,0 +1,33 @@ +class ClassroomModulesController < AdminController + before_action :set_classroom_module + + def update + if @classroom_module.update(classroom_module_params) + respond_to do |format| + format.turbo_stream + format.html { redirect_to schedule_classroom_url(@classroom_module.classroom_program.classroom) } + end + else + respond_to do |format| + format.turbo_stream do + render turbo_stream: turbo_stream.replace( + dom_id(@classroom_module), + partial: "classroom_modules/row", + locals: { classroom_module: @classroom_module } + ), status: :unprocessable_entity + end + format.html { redirect_to schedule_classroom_url(@classroom_module.classroom_program.classroom) } + end + end + end + + private + + def set_classroom_module + @classroom_module = ClassroomModule.find(params[:id]) + end + + def classroom_module_params + params.expect(classroom_module: [ :publish_on ]) + end +end diff --git a/app/controllers/classrooms_controller.rb b/app/controllers/classrooms_controller.rb index 485e876..5713eab 100644 --- a/app/controllers/classrooms_controller.rb +++ b/app/controllers/classrooms_controller.rb @@ -1,10 +1,17 @@ class ClassroomsController < AdminController - before_action :set_classroom, only: %i[ edit update ] + before_action :set_classroom, only: %i[edit update schedule] def edit build_missing_program_enrollments end + def schedule + @classroom_programs = @classroom.classroom_programs + .includes(:program, classroom_modules: :content_module) + .order("programs.name") + @active_enrollment = @classroom_programs.find { |cp| cp.id.to_s == params[:classroom_program_id] } || @classroom_programs.first + end + def update respond_to do |format| if @classroom.update(classroom_params) diff --git a/app/javascript/controllers/publish_now_controller.js b/app/javascript/controllers/publish_now_controller.js new file mode 100644 index 0000000..2d94db6 --- /dev/null +++ b/app/javascript/controllers/publish_now_controller.js @@ -0,0 +1,10 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["date"] + + setToday() { + this.dateTarget.value = new Date().toISOString().split("T")[0] + this.element.requestSubmit() + } +} diff --git a/app/views/classroom_modules/_row.html.erb b/app/views/classroom_modules/_row.html.erb new file mode 100644 index 0000000..214b9ef --- /dev/null +++ b/app/views/classroom_modules/_row.html.erb @@ -0,0 +1,24 @@ + + <%= classroom_module.content_module.name %> + + <%= form_with model: classroom_module, url: classroom_module_path(classroom_module), + data: { controller: "publish-now" } do |form| %> +
+ <%= form.date_field :publish_on, value: classroom_module.publish_on, class: "input input-sm", + data: { publish_now_target: "date" } %> + <%= form.submit "Save", class: "btn btn-xs btn-primary" %> + +
+ <% end %> + + + <% if classroom_module.publish_on.nil? %> + Unscheduled + <% elsif classroom_module.publish_on > Date.current %> + Scheduled + <% else %> + Published + <% end %> + + diff --git a/app/views/classroom_modules/update.turbo_stream.erb b/app/views/classroom_modules/update.turbo_stream.erb new file mode 100644 index 0000000..0ac64e6 --- /dev/null +++ b/app/views/classroom_modules/update.turbo_stream.erb @@ -0,0 +1,3 @@ +<%= turbo_stream.replace dom_id(@classroom_module) do %> + <%= render "row", classroom_module: @classroom_module %> +<% end %> diff --git a/app/views/classrooms/edit.html.erb b/app/views/classrooms/edit.html.erb index bc8a7a2..e953d58 100644 --- a/app/views/classrooms/edit.html.erb +++ b/app/views/classrooms/edit.html.erb @@ -6,11 +6,22 @@
  • Edit Classroom
  • -
    -
    -
    -

    Editing Classroom

    - <%= render "form", classroom: @classroom %> +
    +
    +
    +
    +

    Editing Classroom

    + <%= render "form", classroom: @classroom %> +
    +
    + +
    +
    +
    +

    Module Schedule

    + <%= link_to "Manage Schedule", schedule_classroom_path(@classroom), class: "btn btn-sm btn-primary" %> +
    +
    diff --git a/app/views/classrooms/schedule.html.erb b/app/views/classrooms/schedule.html.erb new file mode 100644 index 0000000..32cf01c --- /dev/null +++ b/app/views/classrooms/schedule.html.erb @@ -0,0 +1,53 @@ +<% content_for :title, "Schedule — #{@classroom.name}" %> + + + +
    +

    Schedule: <%= @classroom.name %>

    +
    + +<% if @classroom_programs.any? %> +
    + <% @classroom_programs.each do |enrollment| %> + <%= link_to schedule_classroom_path(@classroom, classroom_program_id: enrollment.id), + role: "tab", + aria: { selected: enrollment == @active_enrollment }, + class: "tab #{"tab-active" if enrollment == @active_enrollment}" do %> + <%= enrollment.program.name %> + <% end %> + <% end %> +
    + + <% if @active_enrollment %> + <% modules = @active_enrollment.classroom_modules.sort_by { |m| m.content_module.position.to_i } %> + <% if modules.any? %> +
    + + + + + + + + + + <% modules.each do |classroom_module| %> + <%= render "classroom_modules/row", classroom_module: classroom_module %> + <% end %> + +
    ModulePublish DateStatus
    +
    + <% else %> +

    No modules for this enrollment yet.

    + <% end %> + <% end %> +<% else %> +

    No program enrollments yet. <%= link_to "Edit the classroom", edit_classroom_path(@classroom), class: "link" %> to add programs.

    +<% end %> diff --git a/app/views/content_modules/index.html.erb b/app/views/content_modules/index.html.erb index 6fcd4ca..05dec3a 100644 --- a/app/views/content_modules/index.html.erb +++ b/app/views/content_modules/index.html.erb @@ -12,6 +12,7 @@ <% @programs.each do |program| %> <%= link_to content_modules_path(program_id: program.id), role: "tab", + aria: { selected: program == @active_program }, class: "tab #{"tab-active" if program == @active_program}" do %> <%= program.name %> <% end %> diff --git a/config/routes.rb b/config/routes.rb index 06d66af..832848c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -7,11 +7,14 @@ scope :admin do resources :schools do resources :students, shallow: true - resources :classrooms, shallow: true, only: %i[edit update] + resources :classrooms, shallow: true, only: %i[edit update] do + member { get :schedule } + end end resources :content_modules do resources :links, shallow: true end + resources :classroom_modules, only: %i[update] end root to: "schools#index" diff --git a/test/controllers/classroom_modules_controller_test.rb b/test/controllers/classroom_modules_controller_test.rb new file mode 100644 index 0000000..c03e2d3 --- /dev/null +++ b/test/controllers/classroom_modules_controller_test.rb @@ -0,0 +1,35 @@ +require "test_helper" + +class ClassroomModulesControllerTest < ActionDispatch::IntegrationTest + setup do + @classroom_module = classroom_modules(:one) + sign_in_as users(:admin) + end + + test "update sets publish_on and responds with turbo stream" do + patch classroom_module_url(@classroom_module), + params: { classroom_module: { publish_on: "2026-06-01" } }, + headers: { "Accept" => "text/vnd.turbo-stream.html" } + + assert_response :success + assert_equal "2026-06-01", @classroom_module.reload.publish_on.to_s + end + + test "update clears publish_on when blank" do + @classroom_module.update!(publish_on: Date.current) + + patch classroom_module_url(@classroom_module), + params: { classroom_module: { publish_on: "" } }, + headers: { "Accept" => "text/vnd.turbo-stream.html" } + + assert_response :success + assert_nil @classroom_module.reload.publish_on + end + + test "update HTML fallback redirects to the schedule page" do + patch classroom_module_url(@classroom_module), + params: { classroom_module: { publish_on: "2026-06-01" } } + + assert_redirected_to schedule_classroom_url(@classroom_module.classroom_program.classroom) + end +end diff --git a/test/controllers/classrooms_controller_test.rb b/test/controllers/classrooms_controller_test.rb index d6f2005..9fc0d49 100644 --- a/test/controllers/classrooms_controller_test.rb +++ b/test/controllers/classrooms_controller_test.rb @@ -124,4 +124,21 @@ class ClassroomsControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_entity assert_equal "basic", enrollment.reload.level end + + test "should get schedule" do + get schedule_classroom_url(@classroom) + assert_response :success + end + + test "schedule marks the first enrollment tab as selected by default" do + first_enrollment = @classroom.classroom_programs.includes(:program).order("programs.name").first + get schedule_classroom_url(@classroom) + assert_select "a[role='tab'][aria-selected='true'][href*='classroom_program_id=#{first_enrollment.id}']" + end + + test "schedule marks the requested enrollment tab as selected" do + enrollment = classroom_programs(:one) + get schedule_classroom_url(@classroom, classroom_program_id: enrollment.id) + assert_select "a[role='tab'][aria-selected='true'][href*='classroom_program_id=#{enrollment.id}']" + end end diff --git a/test/controllers/content_modules_controller_test.rb b/test/controllers/content_modules_controller_test.rb index 6ec287f..8dd91d5 100644 --- a/test/controllers/content_modules_controller_test.rb +++ b/test/controllers/content_modules_controller_test.rb @@ -11,6 +11,12 @@ class ContentModulesControllerTest < ActionDispatch::IntegrationTest assert_response :success end + test "index marks the active program tab as selected" do + program = programs(:kyh) + get content_modules_url(program_id: program.id) + assert_select "a[role='tab'][aria-selected='true'][href*='program_id=#{program.id}']" + end + test "should get new" do get new_content_module_url assert_response :success From 06252ee623f20537e319146592f614f6fde25817 Mon Sep 17 00:00:00 2001 From: Sean Dickinson <90267290+sean-dickinson@users.noreply.github.com> Date: Mon, 18 May 2026 09:41:41 -0400 Subject: [PATCH 4/4] Student view (#33) * blank * feat: student page --- app/controllers/student_homes_controller.rb | 10 +++ app/views/student_homes/index.html.erb | 55 +++++++++++-- .../controllers/classrooms_controller_test.rb | 1 + .../student_homes_controller_test.rb | 82 +++++++++++++++++++ test/fixtures/classroom_modules.yml | 1 + test/models/classroom_test.rb | 15 ++-- 6 files changed, 148 insertions(+), 16 deletions(-) create mode 100644 test/controllers/student_homes_controller_test.rb diff --git a/app/controllers/student_homes_controller.rb b/app/controllers/student_homes_controller.rb index 36a2740..30386a7 100644 --- a/app/controllers/student_homes_controller.rb +++ b/app/controllers/student_homes_controller.rb @@ -3,5 +3,15 @@ class StudentHomesController < ApplicationController def index @student = Current.student + classroom = @student.classroom + @classroom_programs = classroom.classroom_programs + .includes(:program) + .order("programs.name") + @active_program = @classroom_programs.find { |cp| cp.id.to_s == params[:classroom_program_id] } || @classroom_programs.first + @published_modules = if @active_program + @active_program.classroom_modules.published.includes(content_module: :links) + else + [] + end end end diff --git a/app/views/student_homes/index.html.erb b/app/views/student_homes/index.html.erb index ed74f03..00511cc 100644 --- a/app/views/student_homes/index.html.erb +++ b/app/views/student_homes/index.html.erb @@ -1,11 +1,48 @@ -
    -
    -
    -

    Welcome to EndsideOut!

    -

    You are logged in as <%= @student.full_name %>

    -
    - <%= button_to "Logout", student_session_path, method: :delete, class: "btn btn-primary" %> -
    +<% content_for :title, "Home" %> + +
    +
    +

    Welcome, <%= @student.first_name %>!

    + <%= button_to "Logout", student_session_path, method: :delete, class: "btn btn-sm btn-ghost" %> +
    + + <% if @classroom_programs.size > 1 %> +
    + <% @classroom_programs.each do |enrollment| %> + <%= link_to student_homes_path(classroom_program_id: enrollment.id), + role: "tab", + aria: { selected: enrollment == @active_program }, + class: "tab #{"tab-active" if enrollment == @active_program}" do %> + <%= enrollment.program.name %> + <% end %> + <% end %>
    -
    + <% end %> + + <% sorted = @published_modules.sort_by { |m| m.content_module.position.to_i } + latest_date = sorted.map(&:publish_on).max %> + <% if sorted.any? %> + <% sorted.each do |classroom_module| %> +
    > + + <%= classroom_module.content_module.name %> + +
    + +
    +
    + <% end %> + <% else %> +

    No modules published yet.

    + <% end %>
    \ No newline at end of file diff --git a/test/controllers/classrooms_controller_test.rb b/test/controllers/classrooms_controller_test.rb index 9fc0d49..3a66e26 100644 --- a/test/controllers/classrooms_controller_test.rb +++ b/test/controllers/classrooms_controller_test.rb @@ -35,6 +35,7 @@ class ClassroomsControllerTest < ActionDispatch::IntegrationTest test "should update an existing enrollment level" do enrollment = classroom_programs(:one) + enrollment.classroom_modules.update_all(publish_on: nil) patch classroom_url(@classroom), params: { classroom: { diff --git a/test/controllers/student_homes_controller_test.rb b/test/controllers/student_homes_controller_test.rb new file mode 100644 index 0000000..32e6aa1 --- /dev/null +++ b/test/controllers/student_homes_controller_test.rb @@ -0,0 +1,82 @@ +require "test_helper" + +class StudentHomesControllerTest < ActionDispatch::IntegrationTest + setup do + @student = students(:ada) + student_sign_in_as @student + end + + test "unauthenticated request redirects to student login" do + student_sign_out + get student_homes_url + assert_redirected_to new_student_session_url + end + + test "shows published module names" do + get student_homes_url + assert_response :success + assert_select "summary", text: /Introduction to Health/ + end + + test "shows links inside published modules" do + get student_homes_url + assert_response :success + link = content_modules(:intro).links.first + assert_select "a[target='_blank']", text: /#{link.title}/ if link + end + + test "most recently published module has the open attribute" do + # Add a second published module so there's a distinct "most recent" + second_module = ContentModule.create!( + program: programs(:kyh), level: "basic", name: "Second Module", position: 2 + ) + classroom_programs(:one).classroom_modules.create!( + content_module: second_module, publish_on: Date.current + ) + + get student_homes_url + assert_select "details[open] summary", text: /Second Module/ + end + + test "all modules published on the same latest date have the open attribute" do + second_module = ContentModule.create!( + program: programs(:kyh), level: "basic", name: "Second Module", position: 2 + ) + classroom_modules(:one).update!(publish_on: Date.current) + classroom_programs(:one).classroom_modules.create!( + content_module: second_module, publish_on: Date.current + ) + + get student_homes_url + assert_select "details[open]", count: 2 + end + + test "shows empty state when no modules are published" do + classroom_modules(:one).update!(publish_on: nil) + get student_homes_url + assert_select "p", text: /No modules published yet/ + end + + test "does not show tab bar for single program enrollment" do + get student_homes_url + assert_select "[role='tablist']", count: 0 + end + + test "shows tab bar when classroom has multiple program enrollments" do + classroom_programs(:one).classroom.classroom_programs.create!( + program: programs(:"3dw"), level: "moderate" + ) + + get student_homes_url + assert_select "[role='tablist']" + assert_select "a[role='tab']", minimum: 2 + end + + test "tab switching shows the selected program as active" do + enrollment = classroom_programs(:one) + enrollment.classroom.classroom_programs.create!(program: programs(:"3dw"), level: "moderate") + + get student_homes_url(classroom_program_id: enrollment.id) + assert_select "a[role='tab'][aria-selected='true'][href*='classroom_program_id=#{enrollment.id}']" + end +end diff --git a/test/fixtures/classroom_modules.yml b/test/fixtures/classroom_modules.yml index 22daaf1..93021f0 100644 --- a/test/fixtures/classroom_modules.yml +++ b/test/fixtures/classroom_modules.yml @@ -1,6 +1,7 @@ one: classroom_program: one content_module: intro + publish_on: 2026-05-01 scheduled: classroom_program: two diff --git a/test/models/classroom_test.rb b/test/models/classroom_test.rb index c529a33..3405fe9 100644 --- a/test/models/classroom_test.rb +++ b/test/models/classroom_test.rb @@ -16,25 +16,26 @@ class ClassroomTest < ActiveSupport::TestCase test "updates level via nested attributes" do classroom = classrooms(:one) - cp = classroom_programs(:one) + enrollment = classroom_programs(:one) + enrollment.classroom_modules.update_all(publish_on: nil) classroom.update!( - classroom_programs_attributes: [ { id: cp.id, program_id: cp.program_id, level: "advanced" } ] + classroom_programs_attributes: [ { id: enrollment.id, program_id: enrollment.program_id, level: "advanced" } ] ) - assert_equal "advanced", cp.reload.level + assert_equal "advanced", enrollment.reload.level end test "destroys enrollment via nested attributes" do classroom = classrooms(:one) - cp = classroom_programs(:one) + enrollment = classroom_programs(:one) # Add a second enrollment first so the classroom still has one after destruction classroom.classroom_programs.create!(program: programs(:"3dw"), level: "basic") assert_difference "ClassroomProgram.count", -1 do classroom.update!( - classroom_programs_attributes: [ { id: cp.id, _destroy: "1" } ] + classroom_programs_attributes: [ { id: enrollment.id, _destroy: "1" } ] ) end end @@ -64,10 +65,10 @@ class ClassroomTest < ActiveSupport::TestCase test "is invalid when all programs are removed" do classroom = classrooms(:one) - cp = classroom_programs(:one) + enrollment = classroom_programs(:one) classroom.assign_attributes( - classroom_programs_attributes: [ { id: cp.id, _destroy: "1" } ] + classroom_programs_attributes: [ { id: enrollment.id, _destroy: "1" } ] ) assert_not classroom.valid?