diff --git a/app/controllers/classrooms_controller.rb b/app/controllers/classrooms_controller.rb
index 2077184..dec88e9 100644
--- a/app/controllers/classrooms_controller.rb
+++ b/app/controllers/classrooms_controller.rb
@@ -1,16 +1,17 @@
class ClassroomsController < AdminController
before_action :set_classroom, only: %i[ edit update ]
- # GET /classrooms/1/edit
def edit
+ build_missing_program_enrollments
end
- # PATCH/PUT /classrooms/1 or /classrooms/1.json
+
def update
respond_to do |format|
if @classroom.update(classroom_params)
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
+ build_missing_program_enrollments
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @classroom.errors, status: :unprocessable_entity }
end
@@ -18,13 +19,19 @@ def update
end
private
- # Use callbacks to share common setup or constraints between actions.
def set_classroom
@classroom = Classroom.find(params.expect(:id))
end
- # Only allow a list of trusted parameters through.
+ def build_missing_program_enrollments
+ @classroom.classroom_programs.load unless @classroom.classroom_programs.loaded?
+ enrolled_ids = @classroom.classroom_programs.target.map(&:program_id).to_set
+ Program.order(:name).each do |program|
+ @classroom.classroom_programs.build(program: program) unless enrolled_ids.include?(program.id)
+ end
+ end
+
def classroom_params
- params.expect(classroom: [ :name, :teacher ])
+ params.expect(classroom: [ :name, :teacher, classroom_programs_attributes: [ [ :id, :program_id, :level, :_destroy ] ] ])
end
end
diff --git a/app/javascript/controllers/program_enrollment_controller.js b/app/javascript/controllers/program_enrollment_controller.js
new file mode 100644
index 0000000..ed9e670
--- /dev/null
+++ b/app/javascript/controllers/program_enrollment_controller.js
@@ -0,0 +1,11 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["levelSection", "destroy"]
+
+ toggle(event) {
+ const enrolled = event.target.checked
+ this.levelSectionTarget.style.display = enrolled ? "" : "none"
+ this.destroyTarget.value = enrolled ? "0" : "1"
+ }
+}
diff --git a/app/models/classroom.rb b/app/models/classroom.rb
index 7e1ca13..d4ca41e 100644
--- a/app/models/classroom.rb
+++ b/app/models/classroom.rb
@@ -1,4 +1,19 @@
class Classroom < ApplicationRecord
belongs_to :school
has_many :students
+ has_many :classroom_programs, dependent: :destroy
+ has_many :programs, through: :classroom_programs
+
+ accepts_nested_attributes_for :classroom_programs,
+ allow_destroy: true,
+ reject_if: proc { |attrs| attrs["id"].blank? && attrs["_destroy"] == "1" }
+
+ validate :at_least_one_active_program, on: :update
+
+ private
+
+ def at_least_one_active_program
+ active = classroom_programs.reject(&:marked_for_destruction?)
+ errors.add(:base, "must have at least one program enrolled") if active.empty?
+ end
end
diff --git a/app/models/classroom_program.rb b/app/models/classroom_program.rb
new file mode 100644
index 0000000..99dc46c
--- /dev/null
+++ b/app/models/classroom_program.rb
@@ -0,0 +1,8 @@
+class ClassroomProgram < ApplicationRecord
+ belongs_to :classroom
+ belongs_to :program
+
+ enum :level, { basic: "basic", moderate: "moderate", advanced: "advanced" }, validate: true
+
+ validates :level, presence: true
+end
diff --git a/app/models/program.rb b/app/models/program.rb
new file mode 100644
index 0000000..c15e52c
--- /dev/null
+++ b/app/models/program.rb
@@ -0,0 +1,4 @@
+class Program < ApplicationRecord
+ has_many :classroom_programs, dependent: :destroy
+ has_many :classrooms, through: :classroom_programs
+end
diff --git a/app/views/classrooms/_form.html.erb b/app/views/classrooms/_form.html.erb
index 81cccc1..2b6fdd8 100644
--- a/app/views/classrooms/_form.html.erb
+++ b/app/views/classrooms/_form.html.erb
@@ -18,6 +18,30 @@
<%= form.label :teacher, class: "label" %>
<%= form.text_field :teacher, class: "input w-full" %>
+
+
Programs
+ <%= form.fields_for :classroom_programs do |cp_form| %>
+ <% enrolled = cp_form.object.persisted? %>
+
+ <%= cp_form.hidden_field :program_id %>
+ <%= cp_form.hidden_field :_destroy, value: enrolled ? "0" : "1", data: { program_enrollment_target: "destroy" } %>
+
+
>
+ <%= cp_form.select :level,
+ ClassroomProgram.levels.keys.map { |l| [l.humanize, l] },
+ { include_blank: "Select level", selected: cp_form.object.level },
+ class: "select select-bordered select-sm" %>
+
+
+ <% end %>
+
+
<%= form.label :Link, class: "label" %>
<%= link_to classroom_roster_url(classroom.uuid), classroom_roster_path(classroom.uuid), class: 'link' %>
diff --git a/db/migrate/20260516200133_create_programs.rb b/db/migrate/20260516200133_create_programs.rb
new file mode 100644
index 0000000..63d6dcc
--- /dev/null
+++ b/db/migrate/20260516200133_create_programs.rb
@@ -0,0 +1,19 @@
+class CreatePrograms < ActiveRecord::Migration[8.1]
+ def change
+ create_table :programs do |t|
+ t.string :name
+
+ t.timestamps
+ end
+
+ create_table :classroom_programs do |t|
+ t.references :classroom, null: false, foreign_key: true
+ t.references :program, null: false, foreign_key: true
+ t.string :level, null: false
+
+ t.timestamps
+ end
+
+ add_index :classroom_programs, [ :classroom_id, :program_id ], unique: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 4000b96..1d60635 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_04_29_230254) do
+ActiveRecord::Schema[8.1].define(version: 2026_05_16_200133) do
+ create_table "classroom_programs", force: :cascade do |t|
+ t.integer "classroom_id", null: false
+ t.datetime "created_at", null: false
+ t.string "level", null: false
+ t.integer "program_id", null: false
+ t.datetime "updated_at", null: false
+ t.index ["classroom_id", "program_id"], name: "index_classroom_programs_on_classroom_id_and_program_id", unique: true
+ t.index ["classroom_id"], name: "index_classroom_programs_on_classroom_id"
+ t.index ["program_id"], name: "index_classroom_programs_on_program_id"
+ end
+
create_table "classrooms", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "name"
@@ -22,6 +33,12 @@
t.index ["uuid"], name: "index_classrooms_on_uuid", unique: true
end
+ create_table "programs", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "name"
+ t.datetime "updated_at", null: false
+ end
+
create_table "schools", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "name"
@@ -67,6 +84,8 @@
t.index ["email_address"], name: "index_users_on_email_address", unique: true
end
+ add_foreign_key "classroom_programs", "classrooms"
+ add_foreign_key "classroom_programs", "programs"
add_foreign_key "classrooms", "schools"
add_foreign_key "sessions", "users"
add_foreign_key "student_sessions", "students"
diff --git a/db/seeds.rb b/db/seeds.rb
index 2e02363..0d353ea 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -1,5 +1,7 @@
return unless Rails.env.development?
+programs = Program.create! [ { name: "Know Your Health" }, { name: "3D Wellness" } ]
+
User.find_or_create_by!(email_address: "admin@example.com") do |user|
user.name = "Admin"
user.password = "password"
@@ -26,7 +28,12 @@ def build_student_attrs(overrides = {})
grades = (1..12).to_a.sample(3)
grades.each do |grade|
classrooms = 2.times.map do |i|
- school.classrooms.create!(name: "Classroom #{ i + 1 }", teacher: maybe { Faker::Name.name }, uuid: SecureRandom.urlsafe_base64(32))
+ classroom = school.classrooms.create!(name: "Classroom #{ i + 1 }", teacher: maybe { Faker::Name.name }, uuid: SecureRandom.urlsafe_base64(32))
+ assigned = Faker::Boolean.boolean ? [ programs.sample ] : programs
+ assigned.each do |program|
+ classroom.classroom_programs.create!(program: program, level: %w[basic moderate advanced].sample)
+ end
+ classroom
end
students = 10.times.map { |i| build_student_attrs(grade_level: grade, classroom_id: classrooms[i % 2].id) }
school.students.create!(students)
diff --git a/test/controllers/classrooms_controller_test.rb b/test/controllers/classrooms_controller_test.rb
index a4fa5f6..da30fda 100644
--- a/test/controllers/classrooms_controller_test.rb
+++ b/test/controllers/classrooms_controller_test.rb
@@ -11,9 +11,86 @@ class ClassroomsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
- test "should update classroom" do
- sign_in_as users(:admin)
- patch classroom_url(@classroom), params: { classroom: { name: @classroom.name, teacher: @classroom.teacher } }
+ test "should update classroom name and teacher" do
+ patch classroom_url(@classroom), params: { classroom: { name: "Updated Name", teacher: "New Teacher" } }
+ assert_redirected_to school_students_url(@classroom.school)
+ assert_equal "Updated Name", @classroom.reload.name
+ end
+
+ test "should add a program enrollment" do
+ program = programs(:"3dw")
+
+ assert_difference "ClassroomProgram.count" do
+ patch classroom_url(@classroom), params: {
+ classroom: {
+ name: @classroom.name,
+ classroom_programs_attributes: [ { program_id: program.id, level: "moderate" } ]
+ }
+ }
+ end
+
+ assert_redirected_to school_students_url(@classroom.school)
+ assert @classroom.classroom_programs.exists?(program: program, level: "moderate")
+ end
+
+ test "should update an existing enrollment level" do
+ cp = 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" } ]
+ }
+ }
+
+ assert_redirected_to school_students_url(@classroom.school)
+ assert_equal "advanced", cp.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
+ @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" } ]
+ }
+ }
+ end
+
assert_redirected_to school_students_url(@classroom.school)
end
+
+ test "is invalid when removing all programs" do
+ cp = classroom_programs(:one)
+
+ assert_no_difference "ClassroomProgram.count" do
+ patch classroom_url(@classroom), params: {
+ classroom: {
+ name: @classroom.name,
+ classroom_programs_attributes: [ { id: cp.id, _destroy: "1" } ]
+ }
+ }
+ end
+
+ assert_response :unprocessable_entity
+ end
+
+ test "is invalid when a program is selected without a level" do
+ program = programs(:"3dw")
+
+ assert_no_difference "ClassroomProgram.count" do
+ patch classroom_url(@classroom), params: {
+ classroom: {
+ name: @classroom.name,
+ classroom_programs_attributes: [ { program_id: program.id, level: "" } ]
+ }
+ }
+ end
+
+ assert_response :unprocessable_entity
+ end
end
diff --git a/test/controllers/students_controller_test.rb b/test/controllers/students_controller_test.rb
index f2ae2b3..08c7916 100644
--- a/test/controllers/students_controller_test.rb
+++ b/test/controllers/students_controller_test.rb
@@ -41,8 +41,7 @@ class StudentsControllerTest < ActionDispatch::IntegrationTest
end
test "can update a student's classroom" do
- classroom = @student.classroom.dup
- classroom.update! uuid: SecureRandom.urlsafe_base64(32), name: "New Classroom"
+ classroom = @school.classrooms.create!(name: "New Classroom", uuid: SecureRandom.urlsafe_base64(32))
assert_changes -> { @student.reload.classroom_id } do
patch student_url(@student), params: { student: { classroom_id: classroom.id } }
assert_redirected_to student_url(@student)
diff --git a/test/fixtures/classroom_programs.yml b/test/fixtures/classroom_programs.yml
new file mode 100644
index 0000000..cad6043
--- /dev/null
+++ b/test/fixtures/classroom_programs.yml
@@ -0,0 +1,9 @@
+one:
+ classroom: one
+ program: kyh
+ level: basic
+
+two:
+ classroom: two
+ program: 3dw
+ level: moderate
diff --git a/test/fixtures/programs.yml b/test/fixtures/programs.yml
new file mode 100644
index 0000000..f5b4264
--- /dev/null
+++ b/test/fixtures/programs.yml
@@ -0,0 +1,7 @@
+# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+
+kyh:
+ name: Know Your Health
+
+3dw:
+ name: 3D Wellness
diff --git a/test/models/classroom_test.rb b/test/models/classroom_test.rb
index 5f872f0..c529a33 100644
--- a/test/models/classroom_test.rb
+++ b/test/models/classroom_test.rb
@@ -1,7 +1,76 @@
require "test_helper"
class ClassroomTest < ActiveSupport::TestCase
- # test "the truth" do
- # assert true
- # end
+ test "creates enrollment via nested attributes" do
+ classroom = classrooms(:one)
+ program = programs(:"3dw")
+
+ assert_difference "ClassroomProgram.count" do
+ classroom.update!(
+ classroom_programs_attributes: [ { program_id: program.id, level: "advanced" } ]
+ )
+ end
+
+ assert classroom.classroom_programs.exists?(program: program, level: "advanced")
+ end
+
+ test "updates level via nested attributes" do
+ classroom = classrooms(:one)
+ cp = classroom_programs(:one)
+
+ classroom.update!(
+ classroom_programs_attributes: [ { id: cp.id, program_id: cp.program_id, level: "advanced" } ]
+ )
+
+ assert_equal "advanced", cp.reload.level
+ end
+
+ test "destroys enrollment via nested attributes" do
+ classroom = classrooms(:one)
+ cp = 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" } ]
+ )
+ end
+ end
+
+ test "rejects new enrollment when checkbox is unchecked (_destroy: 1)" do
+ classroom = classrooms(:one)
+ program = programs(:"3dw")
+
+ assert_no_difference "ClassroomProgram.count" do
+ classroom.update!(
+ classroom_programs_attributes: [ { program_id: program.id, level: "basic", _destroy: "1" } ]
+ )
+ end
+ end
+
+ test "is invalid when a new enrollment has no level selected" do
+ classroom = classrooms(:one)
+ program = programs(:"3dw")
+
+ classroom.assign_attributes(
+ classroom_programs_attributes: [ { program_id: program.id, level: "" } ]
+ )
+
+ assert_not classroom.valid?
+ assert classroom.errors.any? { |e| e.attribute.to_s.include?("classroom_programs") }
+ end
+
+ test "is invalid when all programs are removed" do
+ classroom = classrooms(:one)
+ cp = classroom_programs(:one)
+
+ classroom.assign_attributes(
+ classroom_programs_attributes: [ { id: cp.id, _destroy: "1" } ]
+ )
+
+ assert_not classroom.valid?
+ assert_includes classroom.errors[:base], "must have at least one program enrolled"
+ end
end
diff --git a/test/models/program_test.rb b/test/models/program_test.rb
new file mode 100644
index 0000000..1658794
--- /dev/null
+++ b/test/models/program_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class ProgramTest < ActiveSupport::TestCase
+ # test "the truth" do
+ # assert true
+ # end
+end