Skip to content

Commit bd87bcb

Browse files
feat: give contents ownership of module membership and order (#164)
* feat: give contents ownership of module membership and order Links and games join modules only through contents, which append to the end of their siblings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: updates --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6a09b5d commit bd87bcb

32 files changed

Lines changed: 367 additions & 112 deletions

‎app/controllers/admin/content_modules_controller.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,6 @@ def set_content_module
4949
end
5050

5151
def content_module_params
52-
params.expect(content_module: [ :name, :program_id, :level, :position ])
52+
params.expect(content_module: [ :name, :program_id, :level ])
5353
end
5454
end

‎app/controllers/admin/games_controller.rb‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ def index
66
end
77

88
def new
9-
@game = Game.new(content_module_id: params[:content_module_id])
10-
@content_module = @game.content_module
9+
@game = Game.new
1110
end
1211

1312
def edit
@@ -18,7 +17,6 @@ def create
1817

1918
respond_to do |format|
2019
if @game.save
21-
Content.create!(content_module: @game.content_module, contentable: @game) if @game.content_module
2220
format.html { redirect_back(fallback_location: admin_games_path, notice: "Game was successfully created.") }
2321
else
2422
format.html { render :new, status: :unprocessable_content }
@@ -53,6 +51,6 @@ def set_game
5351
end
5452

5553
def game_params
56-
params.expect(game: [ :title, :slug, :content_module_id, :description ])
54+
params.expect(game: [ :title, :slug, :description ])
5755
end
5856
end

‎app/controllers/admin/links_controller.rb‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ class Admin::LinksController < AdminController
33
before_action :set_link, only: %i[edit update destroy]
44

55
def new
6-
@link = Link.new(content_module: @content_module)
6+
@link = Link.new
77
end
88

99
def create
10-
@link = Link.new(link_params.merge(content_module: @content_module))
10+
@link = Link.new(link_params)
11+
12+
@link.build_content(content_module: @content_module)
1113

1214
if @link.save
13-
@content_module.contents.create!(contentable: @link)
1415
redirect_to edit_admin_content_module_path(@content_module), notice: "Link was successfully created."
1516
else
1617
render :new, status: :unprocessable_entity
@@ -29,8 +30,9 @@ def update
2930
end
3031

3132
def destroy
33+
content_module = @link.content_module
3234
@link.destroy!
33-
redirect_to edit_admin_content_module_path(@link.content_module), notice: "Link was successfully deleted.", status: :see_other
35+
redirect_to edit_admin_content_module_path(content_module), notice: "Link was successfully deleted.", status: :see_other
3436
end
3537

3638
private
@@ -43,6 +45,6 @@ def set_link
4345
end
4446

4547
def link_params
46-
params.expect(link: [ :title, :url, :link_type, :position ])
48+
params.expect(link: [ :title, :url, :link_type ])
4749
end
4850
end

‎app/models/concerns/contentable.rb‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
module Contentable
22
extend ActiveSupport::Concern
33

4-
included do
5-
has_one :content, as: :contentable
4+
# Whether the content record owns this contentable, so the contentable is destroyed together with it.
5+
#
6+
# @return [Boolean]
7+
def owned_by_content?
8+
false
69
end
710
end

‎app/models/concerns/ordered.rb‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,24 @@
1+
# Orders records by an integer +position+ column. New records go to the end of their siblings.
12
module Ordered
23
extend ActiveSupport::Concern
34

45
included do
56
scope :ordered, -> { order(:position) }
7+
8+
before_create :append_to_siblings
9+
end
10+
11+
private
12+
13+
# This method is required to override. It is the relation of records this record is
14+
# ordered among (for example, the contents of one module).
15+
#
16+
# @return [ActiveRecord::Relation]
17+
def position_siblings
18+
raise NotImplementedError, "#{self.class} must define #position_siblings to be ordered"
19+
end
20+
21+
def append_to_siblings
22+
self.position = (position_siblings.maximum(:position) || 0) + 1
623
end
724
end

‎app/models/content.rb‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,26 @@
11
class Content < ApplicationRecord
2+
include Ordered
3+
24
belongs_to :content_module
35

46
delegated_type :contentable, types: %w[Link Game]
57

8+
validates :contentable_id, uniqueness: {
9+
scope: [ :content_module_id, :contentable_type ],
10+
message: "is already in this module"
11+
}
12+
613
# Ensure the associated contentable record is destroyed if allowed before destroying this content.
714
# This allows us to have dependent: :destroy behavior for contentable records.
815
before_destroy :destroy_owned_record
916

1017
private
1118

19+
def position_siblings
20+
content_module.contents
21+
end
22+
1223
def destroy_owned_record
13-
contentable.destroy if contentable.destroy_with_attachable?
24+
contentable.destroy if contentable.owned_by_content?
1425
end
1526
end

‎app/models/content_module.rb‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,23 @@ class ContentModule < ApplicationRecord
22
include Ordered
33

44
belongs_to :program
5-
has_many :contents, dependent: :destroy
6-
has_many :links, -> { ordered },
7-
through: :contents,
8-
source: :contentable,
9-
source_type: "Link"
10-
has_many :games,
11-
through: :contents,
12-
source: :contentable,
13-
source_type: "Game"
5+
has_many :contents, -> { ordered }, dependent: :destroy
6+
has_many :games, through: :contents, source: :contentable, source_type: "Game"
7+
has_many :links, through: :contents, source: :contentable, source_type: "Link"
148
has_many :classroom_modules, dependent: :restrict_with_error
159

1610
enum :level, { basic: "basic", moderate: "moderate", advanced: "advanced" }, validate: true
1711

1812
validates :name, :level, presence: true
13+
14+
# A module that joins another program or level goes to the end of it.
15+
# TODO: Consider if we just don't allow these types of edits to avoid this hidden callback behavior
16+
before_update :append_to_siblings, if: -> { program_id_changed? || level_changed? }
17+
18+
private
19+
20+
# Modules are ordered within one program and level, the same grouping the admin index shows.
21+
def position_siblings
22+
program.content_modules.where(level: level)
23+
end
1924
end

‎app/models/game.rb‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
class Game < ApplicationRecord
22
include Contentable
33

4-
belongs_to :content_module, optional: true
5-
has_many :game_attempts, dependent: :destroy
6-
4+
has_many :contents, as: :contentable, dependent: :destroy
5+
has_many :content_modules, through: :contents
76
has_many :game_attempts, dependent: :destroy
87

98
validates :title, :slug, presence: true
109
validates :title, :slug, uniqueness: true
1110

12-
def destroy_with_attachable?
13-
false
14-
end
11+
scope :available_for, ->(content_module) { where.not(id: content_module.games).order(:title) }
1512
end

‎app/models/link.rb‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
class Link < ApplicationRecord
2-
include Ordered
32
include Contentable
43

5-
belongs_to :content_module
4+
# :delete skips Content callbacks to avoid a destroy loop
5+
has_one :content, as: :contentable, dependent: :delete
6+
has_one :content_module, through: :content
67

78
enum :link_type, { survey: "survey" }, validate: true
89

910
validates :title, :url, :link_type, presence: true
1011
validates :url, format: { with: /\Ahttps?:\/\/.+\z/i, message: "must start with http:// or https://" }, allow_blank: true
1112

12-
def destroy_with_attachable?
13+
def owned_by_content?
1314
true
1415
end
1516
end

‎app/views/admin/content_modules/_form.html.erb‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,6 @@
2222
<%= form.select :level, ContentModule.levels.keys.map { |l| [ l.humanize, l ] },
2323
{ include_blank: "Select a level" }, class: "select w-full" %>
2424

25-
<%= form.label :position, class: "label" %>
26-
<%= form.number_field :position, class: "input w-full" %>
27-
2825
<%= form.submit class: "btn btn-primary" %>
2926
</div>
3027
<% end %>

0 commit comments

Comments
 (0)