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