@@ -453,9 +456,13 @@ watch(
:is-category-articles="isCategoryArticles"
:is-searching="isSearching"
:selected-article-ids="selectedArticleIds"
+ :current-page="Number(meta.currentPage)"
+ :total-pages="totalPages"
class="relative z-0"
@translate-article="handleTranslateArticle"
@toggle-select="handleToggleSelect"
+ @navigate-page="handlePageChange"
+ @dragging="isArticleDragging = $event"
/>
{
);
});
+ it('adopts the backend re-spaced positions when the response returns them', async () => {
+ const serverPositions = { 1: 10, 2: 30, 3: 20 };
+ axios.post.mockResolvedValue({ data: { positions: serverPositions } });
+
+ await actions.reorder(
+ { commit, state },
+ {
+ portalSlug: 'test-portal',
+ categorySlug: 'test-category',
+ reorderedGroup: { 3: 25 },
+ }
+ );
+
+ expect(commit).toHaveBeenCalledWith(
+ types.default.SET_ARTICLE_POSITIONS,
+ serverPositions
+ );
+ });
+
it('rolls back positions and throws when API call fails', async () => {
axios.post.mockRejectedValue({ message: 'Network error' });
const reorderedGroup = { 1: 1, 2: 2 };
diff --git a/app/models/article.rb b/app/models/article.rb
index a04ca05fe..9d1247e8b 100644
--- a/app/models/article.rb
+++ b/app/models/article.rb
@@ -137,15 +137,41 @@ class Article < ApplicationRecord
end
def self.update_positions(portal:, positions_hash:)
- return if positions_hash.blank?
+ return {} if positions_hash.blank?
+
+ moved_ids = positions_hash.keys.map(&:to_i)
transaction do
positions_hash.each do |article_id, new_position|
portal.articles.find(article_id).update!(position: new_position)
end
+ # Re-space touched categories to clean gaps and return the final positions
+ rebalance_positions(portal, moved_ids)
end
end
+ def self.rebalance_positions(portal, moved_ids)
+ category_ids = portal.articles.where(id: moved_ids).distinct.pluck(:category_id).compact
+ category_ids.each_with_object({}) do |category_id, positions|
+ resequence_category(portal, category_id, moved_ids, positions)
+ end
+ end
+
+ def self.resequence_category(portal, category_id, moved_ids, positions)
+ ordered = portal.articles.where(category_id: category_id)
+ .sort_by { |article| [article.position || 0, moved_ids.include?(article.id) ? 1 : 0, article.id] }
+ return if ordered.length < 2 # a lone article can't collide, leave it as-is
+
+ ordered.each_with_index do |article, index|
+ new_position = (index + 1) * 10
+ positions[article.id] = new_position
+ next if article.position == new_position
+
+ article.update_column(:position, new_position) # rubocop:disable Rails/SkipsModelValidations
+ end
+ end
+ private_class_method :rebalance_positions, :resequence_category
+
private
def category_id_changed_action
diff --git a/spec/models/article_spec.rb b/spec/models/article_spec.rb
index 04466ccd1..cdad2d9f4 100644
--- a/spec/models/article_spec.rb
+++ b/spec/models/article_spec.rb
@@ -207,4 +207,29 @@ RSpec.describe Article do
expect(article.to_llm_text).to eq(expected_output)
end
end
+
+ describe '.update_positions' do
+ let!(:article_a) { create(:article, portal: portal_1, category: category_1, author: user, position: 10) }
+ let!(:article_b) { create(:article, portal: portal_1, category: category_1, author: user, position: 11) }
+ let!(:article_c) { create(:article, portal: portal_1, category: category_1, author: user, position: 30) }
+
+ it 're-spaces the category to clean gaps and places a collided move after its tie' do
+ # Dropping C into the tight 10/11 gap gives a floored midpoint of 10, colliding with A
+ positions = described_class.update_positions(portal: portal_1, positions_hash: { article_c.id => 10 })
+
+ expect(article_a.reload.position).to eq(10)
+ expect(article_c.reload.position).to eq(20)
+ expect(article_b.reload.position).to eq(30)
+ expect(positions).to eq(article_a.id => 10, article_c.id => 20, article_b.id => 30)
+ end
+
+ it 'leaves a lone article untouched and returns nothing to sync' do
+ lone = create(:article, portal: portal_1, category: create(:category, portal_id: portal_1.id), author: user, position: 20)
+
+ positions = described_class.update_positions(portal: portal_1, positions_hash: { lone.id => 20 })
+
+ expect(lone.reload.position).to eq(20)
+ expect(positions).to be_empty
+ end
+ end
end