Skip to content

Commit df0f483

Browse files
committed
Handle scope changes when reordering siblings
Detect and handle changes to scoped attributes during saves so siblings are reordered correctly when an object changes scope. _ct_after_save now checks for scope changes and triggers reordering of the prior and current siblings. _ct_reorder_prior_siblings_if_parent_changed was updated to consider scope changes (and early-return for new records), use the previous scope values, and only act when the parent or scope actually changed. Added support methods previous_scope_values_from_instance and scope_changed? to build previous-scope conditions and detect scope changes for Symbol or Array scope configurations. Tests were added to verify reordering behavior when moving items between scopes and with multiple scope attributes.
1 parent 77db2c7 commit df0f483

File tree

4 files changed

+150
-3
lines changed

4 files changed

+150
-3
lines changed

lib/closure_tree/hierarchy_maintenance.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,14 @@ def _ct_before_save
3434
end
3535

3636
def _ct_after_save
37+
scope_changed = _ct.order_is_numeric? && _ct.scope_changed?(self)
38+
3739
if saved_changes[_ct.parent_column_name] || @was_new_record
3840
rebuild!
41+
elsif scope_changed
42+
# Scope changed without parent change - reorder old scope's siblings
43+
_ct_reorder_prior_siblings_if_parent_changed
44+
_ct_reorder_siblings
3945
elsif saved_changes[_ct.order_column_sym]
4046
_ct_reorder_siblings(saved_changes[_ct.order_column_sym].min)
4147
end

lib/closure_tree/numeric_deterministic_ordering.rb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,16 @@ module NumericDeterministicOrdering
1212
end
1313

1414
def _ct_reorder_prior_siblings_if_parent_changed
15-
return unless saved_change_to_attribute?(_ct.parent_column_name) && !@was_new_record
15+
return if @was_new_record
16+
17+
parent_changed = saved_change_to_attribute?(_ct.parent_column_name)
18+
scope_changed = _ct.scope_changed?(self)
19+
20+
return unless parent_changed || scope_changed
1621

1722
was_parent_id = attribute_before_last_save(_ct.parent_column_name)
18-
scope_conditions = _ct.scope_values_from_instance(self)
19-
_ct.reorder_with_parent_id(was_parent_id, nil, scope_conditions)
23+
previous_scope_conditions = _ct.previous_scope_values_from_instance(self)
24+
_ct.reorder_with_parent_id(was_parent_id, nil, previous_scope_conditions)
2025
end
2126

2227
def _ct_reorder_siblings(minimum_sort_order_value = nil)

lib/closure_tree/support.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,45 @@ def scope_values_from_instance(instance)
257257
scope_hash
258258
end
259259

260+
def previous_scope_values_from_instance(instance)
261+
return {} unless options[:scope] && instance
262+
263+
scope_option = options[:scope]
264+
scope_hash = {}
265+
266+
case scope_option
267+
when Symbol
268+
value = instance.attribute_before_last_save(scope_option)
269+
scope_hash[scope_option] = value
270+
when Array
271+
scope_option.each do |item|
272+
if item.is_a?(Symbol)
273+
value = instance.attribute_before_last_save(item)
274+
scope_hash[item] = value
275+
end
276+
end
277+
end
278+
279+
scope_hash
280+
end
281+
282+
def scope_changed?(instance)
283+
return false unless options[:scope] && instance
284+
285+
scope_option = options[:scope]
286+
287+
case scope_option
288+
when Symbol
289+
instance.saved_change_to_attribute?(scope_option)
290+
when Array
291+
scope_option.any? do |item|
292+
item.is_a?(Symbol) && instance.saved_change_to_attribute?(item)
293+
end
294+
else
295+
false
296+
end
297+
end
298+
260299
def apply_scope_conditions(scope, instance = nil)
261300
return scope unless options[:scope] && instance
262301

test/closure_tree/scope_test.rb

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,4 +234,101 @@ def test_build_scope_where_clause_with_nil_value_sqlite
234234
assert_includes clause, 'IS NULL'
235235
assert_includes clause, '456'
236236
end
237+
238+
def test_reorder_previous_scope_siblings_when_scope_changes
239+
# Create items in scope user_id: 1
240+
item1 = ScopedItem.create!(name: 'item1', user_id: 1)
241+
item2 = ScopedItem.create!(name: 'item2', user_id: 1)
242+
item3 = ScopedItem.create!(name: 'item3', user_id: 1)
243+
244+
# Verify initial order values
245+
assert_equal 0, item1.order_value
246+
assert_equal 1, item2.order_value
247+
assert_equal 2, item3.order_value
248+
249+
# Move item2 to a different scope (user_id: 2)
250+
item2.update!(user_id: 2)
251+
252+
# Reload all items
253+
item1.reload
254+
item2.reload
255+
item3.reload
256+
257+
# item2 should be first in its new scope
258+
assert_equal 0, item2.order_value
259+
260+
# item1 and item3 should be reordered without gaps in old scope
261+
assert_equal 0, item1.order_value
262+
assert_equal 1, item3.order_value
263+
end
264+
265+
def test_reorder_previous_scope_siblings_when_multiple_scope_changes
266+
# Create items in scope user_id: 1, group_id: 10
267+
item1 = MultiScopedItem.create!(name: 'item1', user_id: 1, group_id: 10)
268+
item2 = MultiScopedItem.create!(name: 'item2', user_id: 1, group_id: 10)
269+
item3 = MultiScopedItem.create!(name: 'item3', user_id: 1, group_id: 10)
270+
271+
# Verify initial order values
272+
assert_equal 0, item1.order_value
273+
assert_equal 1, item2.order_value
274+
assert_equal 2, item3.order_value
275+
276+
# Move item2 to a different scope (user_id: 2, group_id: 10)
277+
item2.update!(user_id: 2)
278+
279+
# Reload all items
280+
item1.reload
281+
item2.reload
282+
item3.reload
283+
284+
# item2 should be first in its new scope
285+
assert_equal 0, item2.order_value
286+
287+
# item1 and item3 should be reordered without gaps in old scope
288+
assert_equal 0, item1.order_value
289+
assert_equal 1, item3.order_value
290+
end
291+
292+
def test_scope_changed_detection
293+
# Test scope_changed? by checking the actual reordering behavior
294+
# which implicitly tests that scope_changed? works during callbacks
295+
item1 = ScopedItem.create!(name: 'item1', user_id: 1)
296+
item2 = ScopedItem.create!(name: 'item2', user_id: 1)
297+
298+
assert_equal 0, item1.order_value
299+
assert_equal 1, item2.order_value
300+
301+
# Change scope - if scope_changed? works, item1 will be reordered in new scope
302+
item2.update!(user_id: 2)
303+
304+
item1.reload
305+
item2.reload
306+
307+
# item2 should be 0 in new scope (proves scope change was detected)
308+
assert_equal 0, item2.order_value
309+
# item1 should remain 0 (was already 0, reordering removes gap from item2)
310+
assert_equal 0, item1.order_value
311+
end
312+
313+
def test_previous_scope_values_from_instance
314+
# Test previous_scope_values by checking that OLD scope siblings are reordered
315+
# which implicitly tests that previous scope values are correctly retrieved
316+
item1 = ScopedItem.create!(name: 'item1', user_id: 1)
317+
item2 = ScopedItem.create!(name: 'item2', user_id: 1)
318+
item3 = ScopedItem.create!(name: 'item3', user_id: 1)
319+
320+
assert_equal 0, item1.order_value
321+
assert_equal 1, item2.order_value
322+
assert_equal 2, item3.order_value
323+
324+
# Move middle item to different scope
325+
item2.update!(user_id: 2)
326+
327+
item1.reload
328+
item3.reload
329+
330+
# If previous_scope_values works, item3 should now be 1 (gap filled)
331+
assert_equal 0, item1.order_value
332+
assert_equal 1, item3.order_value
333+
end
237334
end

0 commit comments

Comments
 (0)