Skip to content

Add drag-and-drop positioning for categories scoped by category type#712

Merged
maebeale merged 21 commits intomainfrom
copilot/enable-drag-and-drop-categories
Jan 25, 2026
Merged

Add drag-and-drop positioning for categories scoped by category type#712
maebeale merged 21 commits intomainfrom
copilot/enable-drag-and-drop-categories

Conversation

Copy link
Contributor

Copilot AI commented Jan 19, 2026

What is the goal of this PR and why is this important?

Implements drag-and-drop reordering for categories on the categories index page, matching the existing FAQ implementation. Positions are scoped to metadatum_id (category_type) so categories of different types maintain independent position sequences.

How did you approach the change?

Model (category.rb)

  • Added positioned scope: :metadatum_id to enable position management per category type

Controller (categories_controller.rb)

  • Handle ordering parameter in update action for drag-and-drop position updates
  • Validate ordering is a positive integer (regex check prevents silent conversion of invalid input)
  • Return appropriate HTTP status: 200 (success), 400 (invalid input), 422 (update failed)

View (categories/index.html.erb)

  • Added drag handle column with data-sortable-handle icon
  • Added data-controller="sortable" and data-sortable-url-value to tbody
  • Added data-sortable-id to each row

Tests

  • Request specs for position updates with valid/invalid ordering values
  • Model specs verifying position scoping by metadatum_id
  • Uses factories for consistency

Reuses existing sortable_controller.js Stimulus controller and positioning gem (v0.4.7).

Anything else to add?

Position updates work across paginated results since the positioning gem handles server-side state. Categories can share position values across different category types due to the metadatum_id scope.

Original prompt

Use position gem to be able to drag and drop categories on categories index, and automatically updating position values. (we have an implementation on faq's index)
Positions should be relevant within their category_type(metadatam_id) only.


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Copilot AI changed the title [WIP] Add drag and drop functionality for categories index Add drag-and-drop positioning for categories scoped by category type Jan 19, 2026
Copilot AI requested a review from maebeale January 19, 2026 15:37
@maebeale maebeale marked this pull request as ready for review January 19, 2026 15:49
@maebeale
Copy link
Collaborator

@jmilljr24 lgtm, but you have more context on gem implementation. main thing is scoping the position uniqueness to within the CategoryType (via metadatum_id, until that table gets renamed and it can be category_type_id)

i've got a separate pr to fix the allow_nil in the category model.

@jmilljr24
Copy link
Collaborator

@jmilljr24 lgtm, but you have more context on gem implementation. main thing is scoping the position uniqueness to within the CategoryType (via metadatum_id, until that table gets renamed and it can be category_type_id)

i've got a separate pr to fix the allow_nil in the category model.

Awesome! Yes, scoping is a great feature!!

@jmilljr24
Copy link
Collaborator

Check out the stimulus controller we have sortable_controller.js. That handles all the requests to the controller and a simple
@category.update(category_params) is all you need.

Copy link
Collaborator

@jmilljr24 jmilljr24 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maebeale Love this feature. Can you update the stimulus sortable controller to send the param as position: instead of ordering: I missed it when we changed everything over to the same column name. Faq is broken at the moment.

body: JSON.stringify({ position: newIndex + 1 })

Once merged we can just run Category.heal_position_column! in production to dedup positions and remove the default value.

Comment on lines 47 to 60
if params[:ordering].present?
ordering = params[:ordering]
# Validate ordering is a valid positive integer
if ordering.to_s.match?(/^\d+$/) && (ordering = ordering.to_i) > 0
if @category.update(position: ordering)
head :ok
else
head :unprocessable_entity
end
else
head :bad_request
end
return
end
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't need this. @category.update(category_params) should do the trick and the position gem will handle validation.

Copy link
Collaborator

@maebeale maebeale Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jmilljr24 i removed it and the UI stopped working and my tests were failing, so i'm going to keep it for now and figure out how to remove it later.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nvm. i see now it's bc of the stimulus controller sending ordering param instead of position!

@maebeale maebeale force-pushed the copilot/enable-drag-and-drop-categories branch from 7e07d0e to f6ce54f Compare January 23, 2026 14:40
const url = this.urlValue.replace(":id", id);
put(url, {
body: JSON.stringify({ ordering: newIndex + 1 }), // add 1 to change sortablejs 0-based index to 1-based for positioning gem
body: JSON.stringify({ position: newIndex + 1 }), // add 1 to change sortablejs 0-based index to 1-based for positioning gem
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!!!

@maebeale maebeale force-pushed the copilot/enable-drag-and-drop-categories branch from b9168e5 to 17a4876 Compare January 25, 2026 16:13
@maebeale maebeale force-pushed the copilot/enable-drag-and-drop-categories branch from 17a4876 to 3b9e531 Compare January 25, 2026 17:39
@maebeale maebeale merged commit 41d32c1 into main Jan 25, 2026
3 checks passed
@maebeale maebeale deleted the copilot/enable-drag-and-drop-categories branch January 25, 2026 23:30
" user authentication", required: true
param :sector, String, desc: "One of #{Sector.all.pluck(:name).join(', ')}"
# param :sector, String, desc: "One of #{Sector.all.pluck(:name).join(', ')}"
param :sector, String, desc: "Sector name"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was blowing up after upgrading ruby

render :edit, status: :unprocessable_content
respond_to do |format|
if @category.update(category_params)
format.html { redirect_to categories_path, notice: "Category was successfully updated.", status: :see_other }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added formats

@@ -1,16 +1,25 @@
class Category < ApplicationRecord
include NameFilterable
include Positioning
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jmilljr24 did i need to do this?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so. I don't see any reference in the doc's of having to include anything. just the basic positioned method is all thats needed. Then anything extra for scopes etc.

t.string "country"
t.string "device_type"
t.string "ip"
t.text "landing_page", size: :medium
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i thought we'd gotten out of this back and forth :(

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from production

  create_table "ahoy_visits", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
    t.string "app_version"
    t.string "browser"
    t.string "city"
    t.string "country"
    t.string "device_type"
    t.string "ip"
    t.text "landing_page", size: :medium

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change you pushed seemed to at least fix the charset and collation changes in my db, so progress!

end

add_foreign_key "action_text_mentions", "action_text_rich_texts"
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jmilljr24 is this odd to be removed from schema?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, not sure why thats happening. Looking at the schema.rb file generate in the production server, that line is in there.

it "orders categories by position first, then by name" do
cat_c_pos_30 = create(:category, name: "C Category", position: 30, category_type: category_type)
cat_a_pos_1 = create(:category, name: "A Category", position: 1, category_type: category_type)
cat_b_pos_1 = create(:category, name: "B Category", position: 1, category_type: category_type)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't need two 1's anymore, since that's now invalid

assert_select "form[action=?][method=?]", categories_path, "post" do
assert_select "input[name=?]", "category[name]"
assert_select "select[name=?]", "category[metadatum_id]"
assert_select "input[name=?][type=number]", "category[position]"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's no longer editable in the UI

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants