diff --git a/app/controllers/content_modules_controller.rb b/app/controllers/content_modules_controller.rb new file mode 100644 index 0000000..7fa64c5 --- /dev/null +++ b/app/controllers/content_modules_controller.rb @@ -0,0 +1,53 @@ +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 + @content_module.destroy! + redirect_to content_modules_path, notice: "Module was successfully deleted.", status: :see_other + rescue ActiveRecord::DeleteRestrictionError + redirect_to content_modules_path, alert: "Cannot delete a module that has been assigned to classrooms." + 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/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/models/content_module.rb b/app/models/content_module.rb new file mode 100644 index 0000000..061c5b4 --- /dev/null +++ b/app/models/content_module.rb @@ -0,0 +1,10 @@ +class ContentModule < ApplicationRecord + belongs_to :program + has_many :links, dependent: :destroy + + 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/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:

+ +
+ <% 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..6fcd4ca --- /dev/null +++ b/app/views/content_modules/index.html.erb @@ -0,0 +1,57 @@ +<% 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", + 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:

+ +
+ <% 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/config/routes.rb b/config/routes.rb index 0b0b9f9..06d66af 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,6 +9,9 @@ resources :students, shallow: true resources :classrooms, shallow: true, only: %i[edit update] end + resources :content_modules do + resources :links, shallow: true + end 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/schema.rb b/db/schema.rb index 1d60635..ef80c4c 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_05_16_200133) do +ActiveRecord::Schema[8.1].define(version: 2026_05_17_180806) do create_table "classroom_programs", force: :cascade do |t| t.integer "classroom_id", null: false t.datetime "created_at", null: false @@ -33,6 +33,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" @@ -87,6 +108,8 @@ 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/content_modules_controller_test.rb b/test/controllers/content_modules_controller_test.rb new file mode 100644 index 0000000..7e13da0 --- /dev/null +++ b/test/controllers/content_modules_controller_test.rb @@ -0,0 +1,57 @@ +require "test_helper" + +class ContentModulesControllerTest < ActionDispatch::IntegrationTest + setup do + @mod = content_modules(:intro) + sign_in_as users(:admin) + end + + test "should get index" do + get content_modules_url + assert_response :success + 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(@mod) + assert_response :success + end + + test "should update content module" do + patch content_module_url(@mod), params: { + content_module: { name: "Updated Name" } + } + assert_redirected_to content_modules_url + assert_equal "Updated Name", @mod.reload.name + end + + test "should destroy content module" do + mod = content_modules(:advanced_wellness) + assert_difference "ContentModule.count", -1 do + delete content_module_url(mod) + end + assert_redirected_to content_modules_url + 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/fixtures/content_modules.yml b/test/fixtures/content_modules.yml new file mode 100644 index 0000000..66e70fa --- /dev/null +++ b/test/fixtures/content_modules.yml @@ -0,0 +1,11 @@ +intro: + program: kyh + level: basic + name: Introduction to Health + 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/content_module_test.rb b/test/models/content_module_test.rb new file mode 100644 index 0000000..b637878 --- /dev/null +++ b/test/models/content_module_test.rb @@ -0,0 +1,31 @@ +require "test_helper" + +class ContentModuleTest < ActiveSupport::TestCase + test "is valid with required fields" do + mod = ContentModule.new(program: programs(:kyh), level: "basic", name: "Test Module") + assert mod.valid? + end + + test "is invalid without a name" do + mod = ContentModule.new(program: programs(:kyh), level: "basic") + assert_not mod.valid? + assert_includes mod.errors[:name], "can't be blank" + end + + test "is invalid without a level" do + mod = ContentModule.new(program: programs(:kyh), name: "Test Module") + assert_not mod.valid? + end + + test "is invalid without a program" do + mod = ContentModule.new(level: "basic", name: "Test Module") + assert_not mod.valid? + end + + test "destroys associated links" do + mod = content_modules(:intro) + assert_difference "Link.count", -mod.links.count do + mod.destroy! + end + 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