-
Notifications
You must be signed in to change notification settings - Fork 0
Orderable projects #78
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -69,6 +69,22 @@ def toggle_complete | |||||||
| end | ||||||||
| end | ||||||||
|
|
||||||||
| def reorder | ||||||||
| ids = Array(params[:order]).map(&:to_i) | ||||||||
| section = params[:section] | ||||||||
|
|
||||||||
| return head :unprocessable_entity if ids.empty? || section.blank? | ||||||||
|
|
||||||||
| ProjectReorderingService.new(current_user).reorder!( | ||||||||
| ordered_ids: ids, | ||||||||
| section: section | ||||||||
| ) | ||||||||
|
|
||||||||
| head :ok | ||||||||
| rescue ProjectReorderingService::Error => e | ||||||||
|
||||||||
| rescue ProjectReorderingService::Error => e | |
| rescue ProjectReorderingService::Error => e | |
| Rails.logger.error("Project reordering failed: #{e.message}") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import Sortable from "stimulus-sortable" | ||
| import { patch } from "@rails/request.js" | ||
|
|
||
| export default class extends Sortable { | ||
| static values = { | ||
| url: String, | ||
| section: String | ||
| } | ||
|
|
||
| get options() { | ||
| const options = super.options | ||
|
|
||
| return { | ||
| ...options, | ||
| fallbackOnBody: true, | ||
| delay: 200, | ||
| delayOnTouchOnly: true, | ||
| touchStartThreshold: 8, | ||
| group: { | ||
| name: 'projects', | ||
| pull: false, | ||
| put: false | ||
| } | ||
| } | ||
| } | ||
|
|
||
| async onUpdate(event) { | ||
| await super.onUpdate(event) | ||
|
|
||
| if (!this.hasUrlValue) return | ||
|
|
||
| const ids = this.sortable.toArray() | ||
| if (ids.length === 0) return | ||
|
|
||
| const body = new FormData() | ||
| ids.forEach((id) => body.append("order[]", id)) | ||
| body.append("section", this.sectionValue) | ||
|
|
||
| try { | ||
| await patch(this.urlValue, { | ||
| body, | ||
| responseKind: "json" | ||
| }) | ||
| } catch (error) { | ||
| console.error("Failed to reorder projects", error) | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| class ProjectReorderingService | ||
| class Error < StandardError; end | ||
| class InvalidSectionError < Error; end | ||
| class InvalidIdsError < Error; end | ||
| class PartialReorderError < Error; end | ||
|
|
||
| def initialize(user) | ||
| @user = user | ||
| end | ||
|
|
||
| def reorder!(ordered_ids:, section:) | ||
| validate_section!(section) | ||
| validate_ordered_ids!(ordered_ids) | ||
|
|
||
| section_sym = section.to_sym | ||
|
|
||
| Project.transaction do | ||
| section_projects = @user.projects.active | ||
| .where(section: section_sym) | ||
| .lock("FOR UPDATE") | ||
|
|
||
| section_ids = section_projects.pluck(:id) | ||
|
|
||
| validate_ids_match_section!(section_ids, ordered_ids) | ||
|
|
||
| reorder_projects_in_section(section_projects, ordered_ids) | ||
| end | ||
|
Comment on lines
+13
to
+29
|
||
| end | ||
|
|
||
| private | ||
|
|
||
| def validate_section!(section) | ||
| section_sym = section.to_sym | ||
| return if Project::SECTIONS.include?(section_sym) | ||
|
|
||
| raise InvalidSectionError, | ||
| "Invalid section: #{section}. Must be one of: #{Project::SECTIONS.join(', ')}" | ||
| end | ||
|
|
||
| def validate_ordered_ids!(ids) | ||
| if ids.blank? | ||
| raise InvalidIdsError, "ordered_ids cannot be empty" | ||
| end | ||
|
|
||
| unless ids.all? { |id| id.is_a?(Integer) && id.positive? } | ||
| raise InvalidIdsError, | ||
| "ordered_ids must contain only positive integers. Got: #{ids.inspect}" | ||
| end | ||
|
|
||
| if ids.size != ids.uniq.size | ||
| raise InvalidIdsError, "ordered_ids contains duplicates" | ||
| end | ||
| end | ||
|
|
||
| def validate_ids_match_section!(section_ids, ordered_ids) | ||
| section_set = Set.new(section_ids) | ||
| ordered_set = Set.new(ordered_ids) | ||
|
|
||
| return if section_set == ordered_set | ||
|
|
||
| missing = (section_set - ordered_set).to_a | ||
| extra = (ordered_set - section_set).to_a | ||
|
|
||
| parts = [] | ||
| parts << "Missing IDs: #{missing.join(', ')}" if missing.any? | ||
| parts << "Extra IDs: #{extra.join(', ')}" if extra.any? | ||
|
|
||
| raise PartialReorderError, | ||
| "ordered_ids must include all projects in section. #{parts.join('. ')}" | ||
| end | ||
|
|
||
| def reorder_projects_in_section(section_projects, ordered_ids) | ||
| max_position = section_projects.maximum(:position).to_i | ||
| bump = max_position + ordered_ids.size + 1 | ||
|
|
||
| section_projects.update_all([ "position = position + ?", bump ]) | ||
|
|
||
| when_clauses = ordered_ids.map { "WHEN ? THEN ?" }.join(" ") | ||
| params = ordered_ids.each_with_index.flat_map { |id, idx| [ id, idx + 1 ] } | ||
|
|
||
| update_sql = ActiveRecord::Base.sanitize_sql_array([ | ||
| "position = CASE id #{when_clauses} END, updated_at = ?", | ||
| *params, | ||
| Time.current | ||
| ]) | ||
|
|
||
| section_projects.where(id: ordered_ids).update_all(update_sql) | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -10,14 +10,26 @@ | |||||
| <table class="w-full"> | ||||||
| <thead> | ||||||
| <tr class="border-b border-slate-200 text-left text-sm font-medium text-slate-500"> | ||||||
| <th class="pb-2 w-8"></th> | ||||||
|
||||||
| <th class="pb-2 w-8"></th> | |
| <th class="pb-2 w-8" scope="col" aria-label="Reorder"></th> |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The drag handle is a plain <div>/<svg> with no accessible name; unlike the todo drag handle elsewhere, this one is missing an aria-label and the SVG lacks aria-hidden. Add an accessible label (or use a <button type="button">) and mark the decorative SVG as aria-hidden to avoid noisy announcements.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| class BackfillProjectPositions < ActiveRecord::Migration[8.0] | ||
| def up | ||
| # Assign positions to existing projects based on their created_at order | ||
| # within each user/section combination. | ||
| # | ||
| # The previous ordering was created_at DESC (newest first), so we assign | ||
| # position 1 to the newest project, position 2 to the second newest, etc. | ||
| # This preserves the existing display order when switching to position ASC. | ||
| execute <<-SQL | ||
| UPDATE projects | ||
| SET position = ( | ||
| SELECT COUNT(*) + 1 | ||
| FROM projects AS p2 | ||
| WHERE p2.user_id = projects.user_id | ||
| AND p2.section = projects.section | ||
| AND p2.archived_at IS NULL | ||
| AND p2.completed_at IS NULL | ||
| AND p2.created_at > projects.created_at | ||
| ) | ||
| WHERE archived_at IS NULL AND completed_at IS NULL | ||
| SQL | ||
| end | ||
|
|
||
| def down | ||
| # Reset all positions to 0 | ||
| execute "UPDATE projects SET position = 0" | ||
| end | ||
| end |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new
ProjectsController#reorderbehavior isn’t covered by tests. Add an integration test (similar to existingtoggle_completetests) to assert: 200 on valid reorder, 422 on missing/invalid params, and that positions actually change only for the current user/section.