Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions app/controllers/windows_types_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,7 @@ def destroy

# Optional hooks for setting variables for forms or index
def set_form_variables
@categories = Category.age_ranges.published.order(
Arel.sql("categories.position, categories.name"))
@categories = Category.age_ranges.published.ordered_by_position_and_name
@windows_type.categorizable_items.build
end

Expand Down
2 changes: 1 addition & 1 deletion app/frontend/javascript/controllers/sortable_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export default class extends Controller {
const id = item.dataset.sortableId;
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!!!

});
}
}
8 changes: 7 additions & 1 deletion app/models/category.rb
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
class Category < ApplicationRecord
include NameFilterable

positioned on: :metadatum_id

belongs_to :category_type, class_name: "CategoryType", foreign_key: :metadatum_id
has_many :categorizable_items, dependent: :destroy
has_many :workshops, through: :categorizable_items, source: :categorizable, source_type: "Workshop"

scope :age_ranges, -> { joins(:category_type).where("metadata.name = 'AgeRange'") }
scope :published, -> { where(published: true) }
scope :ordered_by_position_and_name, -> { reorder(position: :asc, name: :asc) }

# Validations
validates :name, presence: true, uniqueness: { case_sensitive: false }
validates :position, numericality: { only_integer: true, allow_nil: true }
validates :position, numericality: {
only_integer: true,
allow_nil: true # position gem handles assigning after validations so it needs to allow nil
}

# Scopes
scope :category_type_id, ->(category_type_id) {
Expand Down
13 changes: 8 additions & 5 deletions app/views/categories/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,15 @@
input_html: { class: "form-control" } %>
</div>

<!-- Position -->
<div class="flex-1 min-w-[150px]">
<%= f.input :position,
label: "Position",
as: :integer,
input_html: { class: "form-control", type: "number" } %>
<label class="block text-md font-medium text-gray-700 mb-3">
Position
</label>
<div class="flex gap-x-2 items-center">
<span class="text-gray-900 font-medium mb-2">
<%= f.object.position %>
</span>
</div>
</div>

<!-- Published -->
Expand Down
25 changes: 16 additions & 9 deletions app/views/categories/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@
<% if @categories.any? %>
<table class="w-full table-fixed border-collapse border border-gray-200">
<thead class="bg-gray-100">
<tr>
<tr>
<th class="px-4 py-2 text-left text-sm font-semibold text-gray-700 w-1/3">
Name
Order&nbsp;&nbsp;&nbsp;&nbsp;Name
</th>

<th class="px-4 py-2 text-left text-sm font-semibold text-gray-700 w-1/4">
Expand All @@ -47,12 +47,19 @@
</tr>
</thead>

<tbody class="divide-y divide-gray-200">
<% @categories.each do |category| %>
<tr class=" <%= "bg-gray-200" unless category.published %>
<%= category.published ? "hover:bg-gray-50" : "hover:bg-gray-100" %> transition-colors duration-150">

<tbody
class="divide-y divide-gray-200"
data-controller="sortable"
data-sortable-url-value="<%= category_path(id: ":id") %>">
<% @categories.each do |category| %>
<tr class="<%= "bg-gray-200" unless category.published %> <%= category.published ? "hover:bg-gray-50" : "hover:bg-gray-100" %>
transition-colors duration-150"
data-sortable-id="<%= category.id %>">
<td class="px-4 py-2 text-md text-gray-800 truncate font-bold">
<span class="px-4">
<i class="fa-solid fa-sort cursor-move text-gray-400 hover:text-gray-600"
data-sortable-handle=""></i>
</span>
<%= category.name %>
</td>

Expand All @@ -79,7 +86,7 @@
<% end %>

<!-- Actions -->
<td class="px-4 py-2 text-center">
<td class="px-4 py-2 text-center text-nowrap">
<%= link_to "Edit",
edit_category_path(category),
class: "btn btn-secondary-outline whitespace-nowrap" %>
Expand All @@ -89,7 +96,7 @@
</td>

</tr>
<% end %>
<% end %>
</tbody>
</table>
<% else %>
Expand Down
3 changes: 1 addition & 2 deletions app/views/workshops/_categories_fields.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@
>

<%# Class "category-checkbox" is used to submit on change %>
<% category_type.categories
.order(Arel.sql("categories.position, categories.name")).each do |category| %>
<% category_type.categories.ordered_by_position_and_name.each do |category| %>
<div class="flex items-center gap-2 mb-1 px-4">
<%= check_box_tag "categories[#{category.id}]",
category.id,
Expand Down
5 changes: 5 additions & 0 deletions db/migrate/20260123143036_change_position_default_to_nil.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class ChangePositionDefaultToNil < ActiveRecord::Migration[8.1]
def change
change_column_default :categories, :position, from: 0, to: nil
end
end
4 changes: 2 additions & 2 deletions db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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_01_20_212705) do
ActiveRecord::Schema[8.1].define(version: 2026_01_23_143036) do
create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.bigint "action_text_rich_text_id", null: false
t.datetime "created_at", null: false
Expand Down Expand Up @@ -217,7 +217,7 @@
t.integer "legacy_id"
t.integer "metadatum_id"
t.string "name"
t.integer "position", default: 10, null: false
t.integer "position", null: false
t.boolean "published", default: false
t.datetime "updated_at", precision: nil, null: false
t.index ["metadatum_id"], name: "index_categories_on_metadatum_id"
Expand Down
1 change: 1 addition & 0 deletions spec/factories/categories.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
factory :category do
sequence(:name) { |n| "Category Name #{n}" }
published { false }
# position is managed by positioned gem
association :category_type # belongs_to :metadatum

trait :published do
Expand Down
47 changes: 42 additions & 5 deletions spec/models/category_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@
expect(category.errors[:position]).to be_present
end

it "allows nil position" do
it "does not allow nil position" do
category = build(:category, position: nil)
expect(category).to be_valid
expect(category).to_not be_valid
end

it "allows integer position" do
Expand All @@ -49,17 +49,54 @@
let!(:category_type) { create(:category_type) }

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)
cat_d_pos_20 = create(:category, name: "D Category", position: 20, category_type: category_type)
cat_c_pos_30 = create(:category, name: "C Category", position: 30, category_type: category_type)

cat_a_pos_1.update_columns(position: 1)
cat_b_pos_1.update_columns(position: 1)
cat_d_pos_20.update_columns(position: 20)
cat_c_pos_30.update_columns(position: 30)

# Using COALESCE to place NULL values last
categories = Category.where(category_type: category_type)
.order(Arel.sql("categories.position, categories.name"))
.ordered_by_position_and_name

# The order should be: position 1 in name order (A, B), then position 2 (C), then nil (D)
expect(categories.to_a).to eq([ cat_a_pos_1, cat_b_pos_1, cat_d_pos_20, cat_c_pos_30 ])
end
end

describe "positioning" do
let!(:category_type1) { create(:category_type, name: "Type 1") }
let!(:category_type2) { create(:category_type, name: "Type 2") }

it "maintains separate position sequences for different category types" do
cat1_type1 = create(:category, name: "Cat1 Type1", category_type: category_type1, position: 1)
cat2_type1 = create(:category, name: "Cat2 Type1", category_type: category_type1, position: 2)
cat1_type2 = create(:category, name: "Cat1 Type2", category_type: category_type2, position: 1)
cat2_type2 = create(:category, name: "Cat2 Type2", category_type: category_type2, position: 2)

expect(cat1_type1.position).to eq(1)
expect(cat2_type1.position).to eq(2)
expect(cat1_type2.position).to eq(1)
expect(cat2_type2.position).to eq(2)
end

it "allows updating position within the same category type scope" do
cat1 = create(:category, name: "First", category_type: category_type1, position: 1)
cat2 = create(:category, name: "Second", category_type: category_type1, position: 2)
cat3 = create(:category, name: "Third", category_type: category_type1, position: 3)

# Update cat3 to position 1 - the positioning gem should handle reordering
cat3.update(position: 1)
# Reload to get updated positions from the database
cat1.reload
cat2.reload
cat3.reload

# cat3 should now be at position 1
expect(cat3.position).to eq(1)
end
end
end
47 changes: 47 additions & 0 deletions spec/requests/categories_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,53 @@
end
end

context "with ordering parameter (drag-and-drop)" do
it "updates the position of the category" do
category_type = create(:category_type)
category1 = create(:category, name: "First", category_type: category_type, position: 1)
category2 = create(:category, name: "Second", category_type: category_type, position: 2)
patch category_url(category2), params: { ordering: 1 }
category2.reload
expect(response).to have_http_status(:ok)
expect(category.position).to be > 0
end

it "rejects invalid ordering values" do
category_type = create(:category_type)
category = create(:category, name: "Test", category_type: category_type, position: 1)
patch category_url(category), params: { ordering: 0 }
expect(response).to have_http_status(:bad_request)
end

it "handles update failures gracefully" do
category_type = create(:category_type)
category = create(:category, name: "Test", category_type: category_type, position: 1)
# Mock update failure by finding and stubbing the specific instance
allow(Category).to receive(:find).with(category.id.to_s).and_return(category)
allow(category).to receive(:update).and_return(false)
patch category_url(category), params: { ordering: 2 }
expect(response).to have_http_status(:unprocessable_entity)
end


it "scopes position updates by metadatum_id" do
category_type1 = create(:category_type, name: "Type 1")
category_type2 = create(:category_type, name: "Type 2")
cat1_type1 = create(:category, name: "Cat1 Type1", category_type: category_type1, position: 1)
cat2_type1 = create(:category, name: "Cat2 Type1", category_type: category_type1, position: 2)
cat1_type2 = create(:category, name: "Cat1 Type2", category_type: category_type2, position: 1)
# Update position of cat2_type1
patch category_url(cat2_type1), params: { ordering: 1 }
cat2_type1.reload
cat1_type1.reload
cat1_type2.reload
# cat2_type1 should be moved to position 1
expect(cat2_type1.position).to eq(1)
# cat1_type2 should remain at position 1 since it's in a different scope
expect(cat1_type2.position).to eq(1)
end
end

context "with invalid parameters" do
it "renders a response with 422 status (i.e. to display the 'edit' template)" do
category = Category.create! valid_attributes
Expand Down
3 changes: 0 additions & 3 deletions spec/views/categories/edit.html.erb_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,6 @@
# Category Type select
assert_select "select[name=?]", "category[metadatum_id]"

# Position field
assert_select "input[name=?][type=number]", "category[position]"

# Published checkbox
assert_select "input[name=?][type=checkbox]", "category[published]"
end
Expand Down
1 change: 0 additions & 1 deletion spec/views/categories/new.html.erb_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
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]"
assert_select "input[name=?]", "category[published]"
end
end
Expand Down
Loading