From fd64dc0425ad37e1dfbe8203b376fd6825517029 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sat, 16 May 2026 16:11:41 -0400 Subject: [PATCH 1/5] feat: programs --- app/models/classroom.rb | 1 + app/models/program.rb | 3 +++ db/migrate/20260516200133_create_programs.rb | 14 ++++++++++++++ db/schema.rb | 15 ++++++++++++++- db/seeds.rb | 10 +++++++++- test/fixtures/classrooms.yml | 2 ++ test/fixtures/programs.yml | 7 +++++++ test/models/program_test.rb | 7 +++++++ 8 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 app/models/program.rb create mode 100644 db/migrate/20260516200133_create_programs.rb create mode 100644 test/fixtures/programs.yml create mode 100644 test/models/program_test.rb diff --git a/app/models/classroom.rb b/app/models/classroom.rb index 7e1ca13..17d8e18 100644 --- a/app/models/classroom.rb +++ b/app/models/classroom.rb @@ -1,4 +1,5 @@ class Classroom < ApplicationRecord belongs_to :school has_many :students + has_and_belongs_to_many :programs end diff --git a/app/models/program.rb b/app/models/program.rb new file mode 100644 index 0000000..d4c6ebe --- /dev/null +++ b/app/models/program.rb @@ -0,0 +1,3 @@ +class Program < ApplicationRecord + has_and_belongs_to_many :classrooms +end diff --git a/db/migrate/20260516200133_create_programs.rb b/db/migrate/20260516200133_create_programs.rb new file mode 100644 index 0000000..553023a --- /dev/null +++ b/db/migrate/20260516200133_create_programs.rb @@ -0,0 +1,14 @@ +class CreatePrograms < ActiveRecord::Migration[8.1] + def change + create_table :programs do |t| + t.string :name + + t.timestamps + end + + create_join_table :classrooms, :programs do |t| + t.index :program_id + t.index :classroom_id + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 4000b96..4b11626 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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 "classrooms", force: :cascade do |t| t.datetime "created_at", null: false t.string "name" @@ -22,6 +22,19 @@ t.index ["uuid"], name: "index_classrooms_on_uuid", unique: true end + create_table "classrooms_programs", id: false, force: :cascade do |t| + t.integer "classroom_id", null: false + t.integer "program_id", null: false + t.index ["classroom_id"], name: "index_classrooms_programs_on_classroom_id" + t.index ["program_id"], name: "index_classrooms_programs_on_program_id" + 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" diff --git a/db/seeds.rb b/db/seeds.rb index 2e02363..021836c 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,13 @@ 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)) + if Faker::Boolean.boolean + classroom.programs << programs.sample + else + classroom.programs << programs + 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/fixtures/classrooms.yml b/test/fixtures/classrooms.yml index 487abe2..9f142e5 100644 --- a/test/fixtures/classrooms.yml +++ b/test/fixtures/classrooms.yml @@ -5,9 +5,11 @@ one: school: one teacher: Teacher 1 uuid: abcd-1234-efgh-5678 + programs: [kyh] two: name: Classroom 2 school: two teacher: uuid: wxyz-9876-ijkl-5432 + programs: [3dw] 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/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 From d72e16d7a542cfa1f82b9fdf09117d0057504c66 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sun, 17 May 2026 13:42:32 -0400 Subject: [PATCH 2/5] feat: classroom programs --- app/controllers/classrooms_controller.rb | 16 ++-- .../program_enrollment_controller.js | 11 +++ app/models/classroom.rb | 16 +++- app/models/classroom_program.rb | 8 ++ app/models/program.rb | 3 +- app/views/classrooms/_form.html.erb | 23 +++++ db/migrate/20260516200133_create_programs.rb | 11 ++- db/schema.rb | 20 +++-- db/seeds.rb | 7 +- .../controllers/classrooms_controller_test.rb | 83 ++++++++++++++++++- test/controllers/students_controller_test.rb | 3 +- test/fixtures/classroom_programs.yml | 9 ++ test/fixtures/classrooms.yml | 2 - test/models/classroom_test.rb | 75 ++++++++++++++++- 14 files changed, 256 insertions(+), 31 deletions(-) create mode 100644 app/javascript/controllers/program_enrollment_controller.js create mode 100644 app/models/classroom_program.rb create mode 100644 test/fixtures/classroom_programs.yml diff --git a/app/controllers/classrooms_controller.rb b/app/controllers/classrooms_controller.rb index 2077184..243f947 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,18 @@ 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 + existing = @classroom.classroom_programs.includes(:program).index_by(&:program_id) + Program.order(:name).each do |program| + existing[program.id] || @classroom.classroom_programs.build(program: program) + 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 17d8e18..d4ca41e 100644 --- a/app/models/classroom.rb +++ b/app/models/classroom.rb @@ -1,5 +1,19 @@ class Classroom < ApplicationRecord belongs_to :school has_many :students - has_and_belongs_to_many :programs + 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 index d4c6ebe..c15e52c 100644 --- a/app/models/program.rb +++ b/app/models/program.rb @@ -1,3 +1,4 @@ class Program < ApplicationRecord - has_and_belongs_to_many :classrooms + 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..b8e7165 100644 --- a/app/views/classrooms/_form.html.erb +++ b/app/views/classrooms/_form.html.erb @@ -18,6 +18,29 @@ <%= 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 index 553023a..63d6dcc 100644 --- a/db/migrate/20260516200133_create_programs.rb +++ b/db/migrate/20260516200133_create_programs.rb @@ -6,9 +6,14 @@ def change t.timestamps end - create_join_table :classrooms, :programs do |t| - t.index :program_id - t.index :classroom_id + 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 4b11626..1d60635 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,6 +11,17 @@ # It's strongly recommended that you check this file into your version control system. 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,13 +33,6 @@ t.index ["uuid"], name: "index_classrooms_on_uuid", unique: true end - create_table "classrooms_programs", id: false, force: :cascade do |t| - t.integer "classroom_id", null: false - t.integer "program_id", null: false - t.index ["classroom_id"], name: "index_classrooms_programs_on_classroom_id" - t.index ["program_id"], name: "index_classrooms_programs_on_program_id" - end - create_table "programs", force: :cascade do |t| t.datetime "created_at", null: false t.string "name" @@ -80,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 021836c..da42488 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -29,10 +29,9 @@ def build_student_attrs(overrides = {}) grades.each do |grade| classrooms = 2.times.map do |i| classroom = school.classrooms.create!(name: "Classroom #{ i + 1 }", teacher: maybe { Faker::Name.name }, uuid: SecureRandom.urlsafe_base64(32)) - if Faker::Boolean.boolean - classroom.programs << programs.sample - else - classroom.programs << programs + 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 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/classrooms.yml b/test/fixtures/classrooms.yml index 9f142e5..487abe2 100644 --- a/test/fixtures/classrooms.yml +++ b/test/fixtures/classrooms.yml @@ -5,11 +5,9 @@ one: school: one teacher: Teacher 1 uuid: abcd-1234-efgh-5678 - programs: [kyh] two: name: Classroom 2 school: two teacher: uuid: wxyz-9876-ijkl-5432 - programs: [3dw] 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 From 0227b2d2c4037bf6b4e47fdbc1d895109eda3684 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sun, 17 May 2026 14:01:02 -0400 Subject: [PATCH 3/5] fix: turbo bug --- app/controllers/classrooms_controller.rb | 5 +++-- app/views/classrooms/_form.html.erb | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/controllers/classrooms_controller.rb b/app/controllers/classrooms_controller.rb index 243f947..d77f3ea 100644 --- a/app/controllers/classrooms_controller.rb +++ b/app/controllers/classrooms_controller.rb @@ -24,9 +24,10 @@ def set_classroom end def build_missing_program_enrollments - existing = @classroom.classroom_programs.includes(:program).index_by(&:program_id) + @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| - existing[program.id] || @classroom.classroom_programs.build(program: program) + @classroom.classroom_programs.build(program: program) unless enrolled_ids.include?(program.id) end end diff --git a/app/views/classrooms/_form.html.erb b/app/views/classrooms/_form.html.erb index b8e7165..2b6fdd8 100644 --- a/app/views/classrooms/_form.html.erb +++ b/app/views/classrooms/_form.html.erb @@ -28,6 +28,7 @@ From 7880e2c904437492ad1e57765d38b78bfdf41ef6 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sun, 17 May 2026 14:01:26 -0400 Subject: [PATCH 4/5] chore: lint --- app/controllers/classrooms_controller.rb | 2 +- db/seeds.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/classrooms_controller.rb b/app/controllers/classrooms_controller.rb index d77f3ea..dec88e9 100644 --- a/app/controllers/classrooms_controller.rb +++ b/app/controllers/classrooms_controller.rb @@ -32,6 +32,6 @@ def build_missing_program_enrollments end def classroom_params - params.expect(classroom: [ :name, :teacher, classroom_programs_attributes: [[ :id, :program_id, :level, :_destroy ]] ]) + params.expect(classroom: [ :name, :teacher, classroom_programs_attributes: [ [ :id, :program_id, :level, :_destroy ] ] ]) end end diff --git a/db/seeds.rb b/db/seeds.rb index da42488..0d353ea 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,6 +1,6 @@ return unless Rails.env.development? -programs = Program.create! [{name: "Know Your Health"}, {name: "3D Wellness"}] +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" From 6027e573145ff9ad527935de84e6583fb239bcbd Mon Sep 17 00:00:00 2001 From: Sean Dickinson <90267290+sean-dickinson@users.noreply.github.com> Date: Mon, 18 May 2026 09:49:44 -0400 Subject: [PATCH 5/5] Content modules (#31) * blank * feat: modules * fix: brakeman warning * feat: add nav * Classroom modules (#32) * blank * feat: classroom modules * feat: add scheduling * Student view (#33) * blank * feat: student page --- .../classroom_modules_controller.rb | 33 ++++++++ app/controllers/classrooms_controller.rb | 10 ++- app/controllers/content_modules_controller.rb | 54 ++++++++++++ app/controllers/links_controller.rb | 47 +++++++++++ app/controllers/student_homes_controller.rb | 10 +++ app/helpers/application_helper.rb | 6 ++ .../controllers/publish_now_controller.js | 10 +++ app/models/classroom_module.rb | 6 ++ app/models/classroom_program.rb | 19 +++++ app/models/content_module.rb | 11 +++ app/models/link.rb | 10 +++ app/models/program.rb | 1 + 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/_form.html.erb | 30 +++++++ app/views/content_modules/edit.html.erb | 56 +++++++++++++ app/views/content_modules/index.html.erb | 58 +++++++++++++ app/views/content_modules/new.html.erb | 16 ++++ app/views/layouts/application.html.erb | 3 + app/views/links/_form.html.erb | 29 +++++++ app/views/links/edit.html.erb | 17 ++++ app/views/links/new.html.erb | 17 ++++ app/views/shared/_admin_nav.html.erb | 20 +++++ app/views/student_homes/index.html.erb | 55 +++++++++++-- config/routes.rb | 8 +- .../20260517180805_create_content_modules.rb | 12 +++ db/migrate/20260517180806_create_links.rb | 13 +++ ...20260517183252_create_classroom_modules.rb | 14 ++++ db/schema.rb | 38 ++++++++- db/seeds.rb | 11 +++ .../classroom_modules_controller_test.rb | 35 ++++++++ .../controllers/classrooms_controller_test.rb | 65 +++++++++++++-- .../content_modules_controller_test.rb | 71 ++++++++++++++++ test/controllers/links_controller_test.rb | 50 +++++++++++ .../student_homes_controller_test.rb | 82 +++++++++++++++++++ test/fixtures/classroom_modules.yml | 9 ++ test/fixtures/content_modules.yml | 17 ++++ test/fixtures/links.yml | 13 +++ test/models/classroom_module_test.rb | 27 ++++++ test/models/classroom_program_test.rb | 49 +++++++++++ test/models/classroom_test.rb | 15 ++-- test/models/content_module_test.rb | 46 +++++++++++ test/models/link_test.rb | 25 ++++++ 45 files changed, 1187 insertions(+), 32 deletions(-) create mode 100644 app/controllers/classroom_modules_controller.rb create mode 100644 app/controllers/content_modules_controller.rb create mode 100644 app/controllers/links_controller.rb create mode 100644 app/javascript/controllers/publish_now_controller.js create mode 100644 app/models/classroom_module.rb create mode 100644 app/models/content_module.rb create mode 100644 app/models/link.rb 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 app/views/content_modules/_form.html.erb create mode 100644 app/views/content_modules/edit.html.erb create mode 100644 app/views/content_modules/index.html.erb create mode 100644 app/views/content_modules/new.html.erb create mode 100644 app/views/links/_form.html.erb create mode 100644 app/views/links/edit.html.erb create mode 100644 app/views/links/new.html.erb create mode 100644 app/views/shared/_admin_nav.html.erb create mode 100644 db/migrate/20260517180805_create_content_modules.rb create mode 100644 db/migrate/20260517180806_create_links.rb create mode 100644 db/migrate/20260517183252_create_classroom_modules.rb create mode 100644 test/controllers/classroom_modules_controller_test.rb create mode 100644 test/controllers/content_modules_controller_test.rb create mode 100644 test/controllers/links_controller_test.rb create mode 100644 test/controllers/student_homes_controller_test.rb create mode 100644 test/fixtures/classroom_modules.yml create mode 100644 test/fixtures/content_modules.yml create mode 100644 test/fixtures/links.yml create mode 100644 test/models/classroom_module_test.rb create mode 100644 test/models/classroom_program_test.rb create mode 100644 test/models/content_module_test.rb create mode 100644 test/models/link_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 dec88e9..5713eab 100644 --- a/app/controllers/classrooms_controller.rb +++ b/app/controllers/classrooms_controller.rb @@ -1,13 +1,21 @@ 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) + @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 new file mode 100644 index 0000000..4dfbb1c --- /dev/null +++ b/app/controllers/content_modules_controller.rb @@ -0,0 +1,54 @@ +class ContentModulesController < AdminController + before_action :set_content_module, only: %i[edit update destroy] + + def index + @programs = Program.order(:name) + @active_program = @programs.find { |p| p.id.to_s == params[:program_id] } || @programs.first + @modules_by_level = @active_program + .content_modules + .includes(:links) + .group_by(&:level) + end + + def new + @content_module = ContentModule.new + end + + def create + @content_module = ContentModule.new(content_module_params) + + if @content_module.save + redirect_to content_modules_path, notice: "Module was successfully created." + else + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + if @content_module.update(content_module_params) + redirect_to content_modules_path, notice: "Module was successfully updated." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + 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 + def set_content_module + @content_module = ContentModule.find(params.expect(:id)) + end + + def content_module_params + params.expect(content_module: [ :name, :program_id, :level, :position ]) + end +end diff --git a/app/controllers/links_controller.rb b/app/controllers/links_controller.rb new file mode 100644 index 0000000..4f9ca7c --- /dev/null +++ b/app/controllers/links_controller.rb @@ -0,0 +1,47 @@ +class LinksController < AdminController + before_action :set_content_module, only: %i[new create] + before_action :set_link, only: %i[edit update destroy] + + def new + @link = @content_module.links.build + end + + def create + @link = @content_module.links.build(link_params) + + if @link.save + redirect_to edit_content_module_path(@content_module), notice: "Link was successfully created." + else + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + if @link.update(link_params) + redirect_to edit_content_module_path(@link.content_module), notice: "Link was successfully updated." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @link.destroy! + redirect_to edit_content_module_path(@link.content_module), notice: "Link was successfully deleted.", status: :see_other + end + + private + def set_content_module + @content_module = ContentModule.find(params.expect(:content_module_id)) + end + + def set_link + @link = Link.find(params.expect(:id)) + end + + def link_params + params.expect(link: [ :title, :url, :link_type, :position ]) + end +end 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/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be79..2020e97 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,8 @@ module ApplicationHelper + def safe_external_url(url) + uri = URI.parse(url.to_s) + uri.scheme.in?(%w[http https]) ? url : "#" + rescue URI::InvalidURIError + "#" + end end 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/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 new file mode 100644 index 0000000..810add8 --- /dev/null +++ b/app/models/content_module.rb @@ -0,0 +1,11 @@ +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 + + validates :name, :level, presence: true + + default_scope { order(:position) } +end diff --git a/app/models/link.rb b/app/models/link.rb new file mode 100644 index 0000000..995a7c4 --- /dev/null +++ b/app/models/link.rb @@ -0,0 +1,10 @@ +class Link < ApplicationRecord + belongs_to :content_module + + enum :link_type, { survey: "survey", game: "game" }, validate: true + + validates :title, :url, :link_type, presence: true + validates :url, format: { with: /\Ahttps?:\/\/.+\z/i, message: "must start with http:// or https://" }, allow_blank: true + + default_scope { order(:position) } +end diff --git a/app/models/program.rb b/app/models/program.rb index c15e52c..edeeb93 100644 --- a/app/models/program.rb +++ b/app/models/program.rb @@ -1,4 +1,5 @@ class Program < ApplicationRecord has_many :classroom_programs, dependent: :destroy has_many :classrooms, through: :classroom_programs + has_many :content_modules, dependent: :destroy end 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/_form.html.erb b/app/views/content_modules/_form.html.erb new file mode 100644 index 0000000..8b20996 --- /dev/null +++ b/app/views/content_modules/_form.html.erb @@ -0,0 +1,30 @@ +<%= form_with(model: content_module) do |form| %> +
    + <% if content_module.errors.any? %> +
    +

    <%= pluralize(content_module.errors.count, "error") %> prohibited this module from being saved:

    +
      + <% content_module.errors.each do |error| %> +
    • <%= error.full_message %>
    • + <% end %> +
    +
    + <% end %> + + <%= form.label :name, class: "label" %> + <%= form.text_field :name, class: "input w-full" %> + + <%= form.label :program_id, "Program", class: "label" %> + <%= form.collection_select :program_id, Program.order(:name), :id, :name, + { include_blank: "Select a program" }, class: "select w-full" %> + + <%= form.label :level, class: "label" %> + <%= form.select :level, ContentModule.levels.keys.map { |l| [ l.humanize, l ] }, + { include_blank: "Select a level" }, class: "select w-full" %> + + <%= form.label :position, class: "label" %> + <%= form.number_field :position, class: "input w-full" %> + + <%= form.submit class: "btn btn-primary" %> +
    +<% end %> diff --git a/app/views/content_modules/edit.html.erb b/app/views/content_modules/edit.html.erb new file mode 100644 index 0000000..4d35c67 --- /dev/null +++ b/app/views/content_modules/edit.html.erb @@ -0,0 +1,56 @@ +<% content_for :title, "Edit Module" %> + + +
    +
    +
    +
    +

    Edit Module

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

    Links

    + <%= link_to "Add link", new_content_module_link_path(@content_module), class: "btn btn-sm btn-primary" %> +
    + <% if @content_module.links.any? %> + + + + + + + + + + <% @content_module.links.each do |link| %> + + + + + + <% end %> + +
    TitleTypeActions
    <%= link.title %> ↗<%= link.link_type.humanize %> +
    + <%= link_to "Edit", edit_link_path(link), class: "btn btn-xs btn-primary" %> + <%= button_to "Delete", link_path(link), method: :delete, + data: { "turbo-confirm": "Delete this link?" }, + class: "btn btn-xs btn-error" %> +
    +
    + <% else %> +

    No links yet.

    + <% end %> +
    +
    +
    +
    diff --git a/app/views/content_modules/index.html.erb b/app/views/content_modules/index.html.erb new file mode 100644 index 0000000..05dec3a --- /dev/null +++ b/app/views/content_modules/index.html.erb @@ -0,0 +1,58 @@ +<% content_for :title, "Content Modules" %> + +

    <%= notice %>

    +

    <%= alert %>

    + +
    +

    Content Modules

    + <%= link_to "New module", new_content_module_path, class: "btn btn-primary" %> +
    + +
    + <% @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 %> + <% end %> +
    + +<% ContentModule.levels.each_key do |level| %> + <% modules = @modules_by_level[level] || [] %> +
    +

    <%= level.humanize %>

    + <% if modules.any? %> +
    + + + + + + + + + + <% modules.each do |mod| %> + + + + + + <% end %> + +
    NameLinksActions
    <%= mod.name %><%= mod.links.size %> +
    + <%= link_to "Edit", edit_content_module_path(mod), class: "btn btn-sm btn-primary" %> + <%= button_to "Delete", content_module_path(mod), method: :delete, + data: { "turbo-confirm": "Delete this module?" }, + class: "btn btn-sm btn-error" %> +
    +
    +
    + <% else %> +

    No modules for this level yet.

    + <% end %> +
    +<% end %> diff --git a/app/views/content_modules/new.html.erb b/app/views/content_modules/new.html.erb new file mode 100644 index 0000000..72dab3e --- /dev/null +++ b/app/views/content_modules/new.html.erb @@ -0,0 +1,16 @@ +<% content_for :title, "New Module" %> + + +
    +
    +
    +

    New Module

    + <%= render "form", content_module: @content_module %> +
    +
    +
    diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 8fcd954..bad086a 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -24,6 +24,9 @@ + <% if controller.is_a?(AdminController) %> + <%= render "shared/admin_nav" %> + <% end %>
    <%= yield %>
    diff --git a/app/views/links/_form.html.erb b/app/views/links/_form.html.erb new file mode 100644 index 0000000..964f54f --- /dev/null +++ b/app/views/links/_form.html.erb @@ -0,0 +1,29 @@ +<%= form_with(model: link.persisted? ? link : [ link.content_module, link ]) do |form| %> +
    + <% if link.errors.any? %> +
    +

    <%= pluralize(link.errors.count, "error") %> prohibited this link from being saved:

    +
      + <% link.errors.each do |error| %> +
    • <%= error.full_message %>
    • + <% end %> +
    +
    + <% end %> + + <%= form.label :title, class: "label" %> + <%= form.text_field :title, class: "input w-full" %> + + <%= form.label :url, "URL", class: "label" %> + <%= form.url_field :url, class: "input w-full" %> + + <%= form.label :link_type, "Type", class: "label" %> + <%= form.select :link_type, Link.link_types.keys.map { |t| [ t.humanize, t ] }, + { include_blank: "Select a type" }, class: "select w-full" %> + + <%= form.label :position, class: "label" %> + <%= form.number_field :position, class: "input w-full" %> + + <%= form.submit class: "btn btn-primary" %> +
    +<% end %> diff --git a/app/views/links/edit.html.erb b/app/views/links/edit.html.erb new file mode 100644 index 0000000..4803953 --- /dev/null +++ b/app/views/links/edit.html.erb @@ -0,0 +1,17 @@ +<% content_for :title, "Edit Link" %> + + +
    +
    +
    +

    Edit Link

    + <%= render "form", link: @link %> +
    +
    +
    diff --git a/app/views/links/new.html.erb b/app/views/links/new.html.erb new file mode 100644 index 0000000..fa0abb4 --- /dev/null +++ b/app/views/links/new.html.erb @@ -0,0 +1,17 @@ +<% content_for :title, "New Link" %> + + +
    +
    +
    +

    New Link

    + <%= render "form", link: @link %> +
    +
    +
    diff --git a/app/views/shared/_admin_nav.html.erb b/app/views/shared/_admin_nav.html.erb new file mode 100644 index 0000000..4ecab84 --- /dev/null +++ b/app/views/shared/_admin_nav.html.erb @@ -0,0 +1,20 @@ + 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/config/routes.rb b/config/routes.rb index 0b0b9f9..832848c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -7,8 +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/db/migrate/20260517180805_create_content_modules.rb b/db/migrate/20260517180805_create_content_modules.rb new file mode 100644 index 0000000..4ab2cfa --- /dev/null +++ b/db/migrate/20260517180805_create_content_modules.rb @@ -0,0 +1,12 @@ +class CreateContentModules < ActiveRecord::Migration[8.1] + def change + create_table :content_modules do |t| + t.references :program, null: false, foreign_key: true + t.string :level, null: false + t.string :name, null: false + t.integer :position + + t.timestamps + end + end +end diff --git a/db/migrate/20260517180806_create_links.rb b/db/migrate/20260517180806_create_links.rb new file mode 100644 index 0000000..4f1f3f7 --- /dev/null +++ b/db/migrate/20260517180806_create_links.rb @@ -0,0 +1,13 @@ +class CreateLinks < ActiveRecord::Migration[8.1] + def change + create_table :links do |t| + t.references :content_module, null: false, foreign_key: true + t.string :title, null: false + t.string :url, null: false + t.string :link_type, null: false + t.integer :position + + t.timestamps + end + end +end 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 1d60635..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_16_200133) 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 @@ -33,6 +44,27 @@ t.index ["uuid"], name: "index_classrooms_on_uuid", unique: true end + create_table "content_modules", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "level", null: false + t.string "name", null: false + t.integer "position" + t.integer "program_id", null: false + t.datetime "updated_at", null: false + t.index ["program_id"], name: "index_content_modules_on_program_id" + end + + create_table "links", force: :cascade do |t| + t.integer "content_module_id", null: false + t.datetime "created_at", null: false + t.string "link_type", null: false + t.integer "position" + t.string "title", null: false + t.datetime "updated_at", null: false + t.string "url", null: false + t.index ["content_module_id"], name: "index_links_on_content_module_id" + end + create_table "programs", force: :cascade do |t| t.datetime "created_at", null: false t.string "name" @@ -84,9 +116,13 @@ 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" + add_foreign_key "content_modules", "programs" + add_foreign_key "links", "content_modules" add_foreign_key "sessions", "users" add_foreign_key "student_sessions", "students" add_foreign_key "students", "classrooms" diff --git a/db/seeds.rb b/db/seeds.rb index 0d353ea..f0bca69 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -2,6 +2,17 @@ programs = Program.create! [ { name: "Know Your Health" }, { name: "3D Wellness" } ] +kyh, tdw = programs +ContentModule.levels.keys.each_with_index do |level, li| + [ kyh, tdw ].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.links.create!(title: "Survey #{i + 1}", url: "https://example.com/survey/#{program.id}-#{level}-#{i}", link_type: "survey", position: 1) + mod.links.create!(title: "Game #{i + 1}", url: "https://example.com/game/#{program.id}-#{level}-#{i}", link_type: "game", position: 2) + end + end +end + User.find_or_create_by!(email_address: "admin@example.com") do |user| user.name = "Admin" user.password = "password" 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 da30fda..3a66e26 100644 --- a/test/controllers/classrooms_controller_test.rb +++ b/test/controllers/classrooms_controller_test.rb @@ -34,29 +34,29 @@ class ClassroomsControllerTest < ActionDispatch::IntegrationTest end test "should update an existing enrollment level" do - cp = classroom_programs(:one) + enrollment = classroom_programs(:one) + enrollment.classroom_modules.update_all(publish_on: nil) 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 +65,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 +93,53 @@ 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 + + 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 new file mode 100644 index 0000000..8dd91d5 --- /dev/null +++ b/test/controllers/content_modules_controller_test.rb @@ -0,0 +1,71 @@ +require "test_helper" + +class ContentModulesControllerTest < ActionDispatch::IntegrationTest + setup do + @content_module = content_modules(:intro) + sign_in_as users(:admin) + end + + test "should get index" do + get content_modules_url + 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 + end + + test "should create content module" do + assert_difference "ContentModule.count" do + post content_modules_url, params: { + content_module: { name: "New Module", program_id: programs(:kyh).id, level: "moderate", position: 1 } + } + end + assert_redirected_to content_modules_url + end + + test "should not create with missing name" do + assert_no_difference "ContentModule.count" do + post content_modules_url, params: { + content_module: { name: "", program_id: programs(:kyh).id, level: "basic" } + } + end + assert_response :unprocessable_entity + end + + test "should get edit" do + get edit_content_module_url(@content_module) + assert_response :success + end + + test "should update content module" do + patch content_module_url(@content_module), params: { + content_module: { name: "Updated Name" } + } + assert_redirected_to content_modules_url + assert_equal "Updated Name", @content_module.reload.name + end + + test "should destroy content module" do + content_module = ContentModule.create!(program: programs(:kyh), level: "basic", name: "To Delete") + assert_difference "ContentModule.count", -1 do + 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/controllers/links_controller_test.rb b/test/controllers/links_controller_test.rb new file mode 100644 index 0000000..78dc4fe --- /dev/null +++ b/test/controllers/links_controller_test.rb @@ -0,0 +1,50 @@ +require "test_helper" + +class LinksControllerTest < ActionDispatch::IntegrationTest + setup do + @link = links(:survey_one) + @mod = content_modules(:intro) + sign_in_as users(:admin) + end + + test "should get new" do + get new_content_module_link_url(@mod) + assert_response :success + end + + test "should create link" do + assert_difference "Link.count" do + post content_module_links_url(@mod), params: { + link: { title: "New Survey", url: "https://example.com/new", link_type: "survey", position: 3 } + } + end + assert_redirected_to edit_content_module_url(@mod) + end + + test "should not create with missing title" do + assert_no_difference "Link.count" do + post content_module_links_url(@mod), params: { + link: { title: "", url: "https://example.com", link_type: "survey" } + } + end + assert_response :unprocessable_entity + end + + test "should get edit" do + get edit_link_url(@link) + assert_response :success + end + + test "should update link" do + patch link_url(@link), params: { link: { title: "Updated Title" } } + assert_redirected_to edit_content_module_url(@mod) + assert_equal "Updated Title", @link.reload.title + end + + test "should destroy link" do + assert_difference "Link.count", -1 do + delete link_url(@link) + end + assert_redirected_to edit_content_module_url(@mod) + end +end 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 new file mode 100644 index 0000000..93021f0 --- /dev/null +++ b/test/fixtures/classroom_modules.yml @@ -0,0 +1,9 @@ +one: + classroom_program: one + content_module: intro + publish_on: 2026-05-01 + +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 new file mode 100644 index 0000000..9093b5c --- /dev/null +++ b/test/fixtures/content_modules.yml @@ -0,0 +1,17 @@ +intro: + program: kyh + level: basic + name: Introduction to Health + position: 1 + +moderate_wellness: + program: 3dw + level: moderate + name: Moderate Wellness + position: 1 + +advanced_wellness: + program: 3dw + level: advanced + name: Advanced Wellness + position: 1 diff --git a/test/fixtures/links.yml b/test/fixtures/links.yml new file mode 100644 index 0000000..f1e1d81 --- /dev/null +++ b/test/fixtures/links.yml @@ -0,0 +1,13 @@ +survey_one: + content_module: intro + title: Health Survey + url: https://example.com/survey/1 + link_type: survey + position: 1 + +game_one: + content_module: intro + title: Health Game + url: https://example.com/game/1 + link_type: game + position: 2 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/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? diff --git a/test/models/content_module_test.rb b/test/models/content_module_test.rb new file mode 100644 index 0000000..95a4039 --- /dev/null +++ b/test/models/content_module_test.rb @@ -0,0 +1,46 @@ +require "test_helper" + +class ContentModuleTest < ActiveSupport::TestCase + setup do + @program = programs(:kyh) + end + + test "is valid with required fields" do + content_module = ContentModule.new(program: @program, level: "basic", name: "Test Module") + assert content_module.valid? + end + + test "is invalid without a name" do + 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 + content_module = ContentModule.new(program: @program, name: "Test Module") + assert_not content_module.valid? + end + + test "is invalid without a program" do + content_module = ContentModule.new(level: "basic", name: "Test Module") + assert_not content_module.valid? + end + + test "destroys associated links" do + 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 diff --git a/test/models/link_test.rb b/test/models/link_test.rb new file mode 100644 index 0000000..ec5e4b9 --- /dev/null +++ b/test/models/link_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class LinkTest < ActiveSupport::TestCase + test "is valid with required fields" do + link = Link.new(content_module: content_modules(:intro), title: "My Link", url: "https://example.com", link_type: "survey") + assert link.valid? + end + + test "is invalid without a title" do + link = Link.new(content_module: content_modules(:intro), url: "https://example.com", link_type: "survey") + assert_not link.valid? + assert_includes link.errors[:title], "can't be blank" + end + + test "is invalid without a url" do + link = Link.new(content_module: content_modules(:intro), title: "My Link", link_type: "survey") + assert_not link.valid? + assert_includes link.errors[:url], "can't be blank" + end + + test "is invalid without a link_type" do + link = Link.new(content_module: content_modules(:intro), title: "My Link", url: "https://example.com") + assert_not link.valid? + end +end