Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2433294
Upgrading ruby choked on this line
maebeale Jan 25, 2026
d2621e5
Initial plan
Copilot Jan 19, 2026
6868f51
Add positioning functionality to categories with drag-and-drop support
Copilot Jan 19, 2026
f48878c
Add tests for category positioning functionality
Copilot Jan 19, 2026
fc75a3b
Add validation and error handling for position updates, improve test …
Copilot Jan 19, 2026
bf30088
Improve validation robustness, fix test mocking, and clean up view fo…
Copilot Jan 19, 2026
84414bc
Update tags ux (#714)
maebeale Jan 19, 2026
dacfd50
Move dnd arrow into column w name so it doesn't take up as much width…
maebeale Jan 23, 2026
ebe566a
Rubocop and header
maebeale Jan 23, 2026
6f25c55
Use new scope on categories to order first by position and then by na…
maebeale Jan 23, 2026
acc05c8
Hide manually inputted position data, and remove the extra validation
maebeale Jan 23, 2026
2db8644
Keep this for now
maebeale Jan 23, 2026
567036f
Change position to be display only, and test unhappy paths
maebeale Jan 23, 2026
7bbfe6d
Fix sortable_controller param
maebeale Jan 23, 2026
5cb1fbe
Change position default to nil on categories table
maebeale Jan 23, 2026
26ad452
Allow nil so tests can run (and it's what gem expects)
maebeale Jan 24, 2026
3b9e531
Change test to confirm nil is valid
maebeale Jan 25, 2026
45de6eb
Ensure positioning is scoping to within metadataum_id (category_type)
maebeale Jan 25, 2026
03ef134
Update category spec
maebeale Jan 25, 2026
d2fd70f
Refresh gems
maebeale Jan 25, 2026
b5aef7c
Rubocop spacing
maebeale Jan 25, 2026
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
443 changes: 339 additions & 104 deletions Gemfile.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion app/controllers/api/v1/quotes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ class Api::V1::QuotesController < Api::V1::ApiController
api :GET, "/v1/quotes"
param :Authorization, String, desc: "API Authorization token returned by successful"\
" 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

desc "Without a Sector parameter, gives a list of all quotes grouped by Sector."
def index
sector = Sector.find_by(name: params[:sector]) || Sector.all
Expand Down
26 changes: 18 additions & 8 deletions app/controllers/categories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,17 @@ def create
end

def update
if @category.update(category_params)
redirect_to categories_path, notice: "Category was successfully updated.", status: :see_other
else
set_form_variables
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

format.json { head :ok }
else
format.html do
set_form_variables
render :edit, status: :unprocessable_entity
end
format.json { render json: { errors: @category.errors }, status: :unprocessable_entity }
end
end
end

Expand All @@ -69,8 +75,12 @@ def set_category

# Strong parameters
def category_params
params.require(:category).permit(
:name, :category_type_id, :metadatum_id, :published, :position
)
if params[:category]
params.require(:category).permit(
:name, :category_type_id, :metadatum_id, :published, :position
)
else
params.permit(:position)
end
end
end
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!!!

});
}
}
11 changes: 10 additions & 1 deletion app/models/category.rb
Original file line number Diff line number Diff line change
@@ -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.


positioned on: :metadatum_id

belongs_to :category_type, class_name: "CategoryType", foreign_key: :metadatum_id
# belongs_to :category_type, class_name: "Metadatum", 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,
greater_than: 0,
allow_nil: true # position gem handles assigning after validations so it needs to allow nil
}

# Cache expiration
after_save :expire_categories_cache
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
6 changes: 6 additions & 0 deletions db/migrate/20260125175620_add_position_index_to_category.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class AddPositionIndexToCategory < ActiveRecord::Migration[8.1]
def change
Category.heal_position_column!(name: :desc)
add_index :categories, [ :metadatum_id, :position ], unique: true
end
end
22 changes: 11 additions & 11 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_25_175620) 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 @@ -129,17 +129,17 @@
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!

t.text "landing_page"
t.float "latitude"
t.float "longitude"
t.string "os"
t.string "os_version"
t.string "platform"
t.text "referrer", size: :medium
t.text "referrer"
t.string "referring_domain"
t.string "region"
t.datetime "started_at"
t.text "user_agent", size: :medium
t.text "user_agent"
t.bigint "user_id"
t.string "utm_campaign"
t.string "utm_content"
Expand Down Expand Up @@ -217,9 +217,10 @@
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", "position"], name: "index_categories_on_metadatum_id_and_position", unique: true
t.index ["metadatum_id"], name: "index_categories_on_metadatum_id"
end

Expand Down Expand Up @@ -331,7 +332,7 @@
t.string "last_name", null: false
t.string "linked_in_url"
t.date "member_since"
t.text "notes", size: :medium
t.text "notes"
t.boolean "profile_is_searchable", default: true, null: false
t.boolean "profile_show_affiliations", default: true, null: false
t.boolean "profile_show_bio", default: true, null: false
Expand Down Expand Up @@ -484,9 +485,9 @@
create_table "notifications", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false
t.datetime "delivered_at"
t.text "email_body_html", size: :medium
t.text "email_body_text", size: :medium
t.text "email_subject", size: :medium
t.text "email_body_html"
t.text "email_body_text"
t.text "email_subject"
t.string "kind", null: false
t.integer "noticeable_id"
t.string "noticeable_type"
Expand Down Expand Up @@ -742,7 +743,7 @@
end

create_table "tutorials", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.text "body", size: :medium
t.text "body"
t.datetime "created_at", null: false
t.boolean "featured", default: false, null: false
t.integer "position", default: 10, null: false
Expand Down Expand Up @@ -1072,7 +1073,6 @@
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.

add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "age_ranges", "windows_types"
add_foreign_key "banners", "users", column: "created_by_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
45 changes: 41 additions & 4 deletions spec/models/category_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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

cat_b_pos_1 = create(:category, name: "B Category", position: 2, 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: 2)
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: { position: 1 }, as: :json
category2.reload
expect(response).to have_http_status(:ok)
expect(category2.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: { position: 0 }
expect(response).to have_http_status(:unprocessable_entity)
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: { position: 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: { position: 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
Loading