Add drag-and-drop reordering for link cards #10
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feature/sortable-link-cards"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
positioncolumn and Stimulus sortable controller for drag-and-drop card reorderingChanges
db/migrate/*_add_position_to_links.rb: add position integer column (default 0, not null)app/models/link.rb: ordered scope now sorts by position asc, updated_at descapp/controllers/links_controller.rb: addsortaction, accepts ordered IDs via PATCHconfig/routes.rb: addpatch :sort, on: :collectionto links resourceconfig/importmap.rb: new -- pins for Turbo, Stimulus, controllersapp/javascript/: new -- application.js, Stimulus bootstrap, sortable_controller.jsapp/views/links/_link.html.erb: add drag handle, wrap body in flex layoutapp/views/links/index.html.erb: adddata-controller="sortable"to gridapp/views/layouts/application.html.erb: addjavascript_importmap_tagsapp/assets/stylesheets/application.css: compact cards, handle styles,.is-dragging/.is-drag-overstatesconfig/environments/development.rb: allow dev-tunnel-palinks hostdocker-compose.yml: remap to port 9998 (avoids conflict with landscaping-assistant)Test Plan
bin/rails db:migrateruns cleanlyReview Checklist
Related Notes
ldraney/palinks #9-- the Forgejo issue this PR implementsPR #10 Review
DOMAIN REVIEW
Tech stack: Ruby on Rails 8.1, Stimulus.js (Hotwire), importmap-rails, PostgreSQL, Propshaft. This is a Rails + Stimulus review.
Migration (
db/migrate/20260524194416_add_position_to_links.rb): Clean.integer, default: 0, null: falseis correct. All existing rows get position 0, which is fine sinceorderedscope breaks ties withupdated_at: :desc. One consideration: for a large table this would benefit from a database index onposition, but given this is a personal links app with low cardinality, this is a nit, not a blocker.Model (
app/models/link.rb): Scope change fromorder(updated_at: :desc)toorder(position: :asc, updated_at: :desc)is correct. Composite ordering with tie-breaking is good.Routes (
config/routes.rb):patch :sort, on: :collectionproducesPATCH /links/sort-- idiomatic Rails for a collection-level action. Correct.Controller (
app/controllers/links_controller.rb):The
sortaction is the most important piece to scrutinize:Findings:
Input validation -- no type checking on
params[:ids](see BLOCKERS). The action iteratesparams[:ids]without validating that it is an array or that each element is a valid integer. A malformed request body (e.g.,idsas a string, or containing non-integer values) will silently pass through or raise an unhandled error.Link.where(id: id)with ActiveRecord does parameterize the query (preventing SQL injection), but the lack of any validation is still a concern for robustness.N+1 queries: Each ID issues a separate
UPDATEstatement. For N links, this is N queries in a loop. Consider a singleCASEupdate orupdate_allwith a values list. Not a blocker for a small personal app, but worth noting.No authentication/authorization on sort: The app has no authentication layer at all (
ApplicationControllerhas no auth concern), so this is consistent with the rest of the app. Not a regression. However, the sort endpoint accepts arbitrary IDs -- a caller can submit IDs for links they should not reorder. Since there is no auth anywhere in the app, this is noted but not a blocker specific to this PR.No transaction wrapping: If the server crashes mid-loop, positions will be partially updated. Wrapping in
ActiveRecord::Base.transactionwould be safer.Stimulus Controller (
app/javascript/controllers/sortable_controller.js):Well-structured. Dual desktop (HTML5 DnD) and mobile (touch) support is solid. Observations:
disconnect()does not remove touch event listeners from handles. Theconnect()method addstouchstart,touchmove, andtouchendlisteners onthis.handleTargets, butdisconnect()only removes the six drag listeners fromthis.itemTargets. This is a memory leak if Turbo navigates away and reconnects. Should add correspondingremoveEventListenercalls for the three touch events.persist()does not handle fetch failures. If the PATCH fails (network error, 500, etc.), the DOM reorder sticks but the server state diverges. The user sees the new order but on reload it reverts. Consider a.catch()that reverts the DOM or shows a flash.Hardcoded URL:
fetch("/links/sort", ...)has a hardcoded path. If the app is ever mounted at a subpath, this breaks. Adata-sortable-url-valueStimulus value sourced fromsort_links_pathin the template would be more robust. Low risk for this app.CSRF token lookup (
meta[name='csrf-token']): This is correct -- the layout includes<%= csrf_meta_tags %>which renders that meta tag. Good.Importmap setup (
config/importmap.rb,app/javascript/): Standard Rails 8 importmap + Stimulus bootstrap.pin_all_fromfor controllers is correct.javascript_importmap_tagsadded to layout.stale_when_importmap_changesalready present inApplicationController. All good.View changes (
_link.html.erb,index.html.erb): Clean restructuring. The drag handle☰(hamburger/trigram) is a reasonable icon choice.data-sortable-target="item"anddata-sortable-target="handle"correctly wire up the Stimulus targets.CSS (
application.css): Uses CSS custom properties (theme tokens) consistently..is-dragging,.is-drag-overstates are well-defined.touch-action: noneon the handle prevents browser scroll interference during touch drag.user-select: noneprevents text selection. Layout switch to flex withgapis clean.docker-compose.yml: Port remap
9998:9999-- operational preference, not a code quality concern. Fine.development.rb: Adding
dev-tunnel-palinks.tail5b443a.ts.nettoconfig.hosts-- this is a Tailscale dev tunnel hostname. Development-only, no security concern.BLOCKERS
Missing touch event listener cleanup in
disconnect()(sortable_controller.jsline 24-33). Theconnect()method attaches three touch event listeners (touchstart,touchmove,touchend) to each handle target, butdisconnect()never removes them. This is a genuine bug -- when Turbo navigates away and the controller disconnects, those listeners remain attached. On reconnection, duplicate listeners accumulate. This will cause erratic behavior (e.g., the same touch triggering multiple drag operations) and constitutes a memory/event leak. Must be fixed.No input validation on
sortaction (links_controller.rbline 36-41).params[:ids]is consumed with no validation that it is an array of integers, no empty-array guard, and no size bounds. While ActiveRecord parameterizes theWHERE id = ?clause (preventing SQL injection), the action should at minimum: (a) verifyparams[:ids]is present and is an array, (b) cast or filter to integers, (c) optionally verify all IDs exist. A malformed request will produce a 500 (callingeach_with_indexonnilif:idsis missing), which is poor API behavior for a public-facing endpoint.NITS
N+1 updates in
sort: Each ID fires a separateUPDATE. A single SQLCASEstatement orupdate_allwith values could batch this. Low impact at current scale.No transaction around the sort loop: Wrapping the position updates in
ActiveRecord::Base.transaction { ... }would make the operation atomic.Hardcoded
/links/sortURL inpersist(): Consider using a Stimulus value attribute sourced from a Rails path helper for maintainability.Missing
fetcherror handling inpersist(): The Promise returned byfetch()is fire-and-forget. A.catch()to log or show user feedback on failure would improve UX.No index on
positioncolumn: For a personal app with a handful of links this is fine, but if the dataset grows,ORDER BY positionbenefits from an index.Accessibility: The drag handle
<span>with☰has no ARIA label orroleattribute. Screen readers will either skip it or read the Unicode character name. Addingrole="button"andaria-label="Reorder"would improve accessibility.SOP COMPLIANCE
feature/sortable-link-cards. Expected convention is{issue-number}-{kebab-case-purpose}(e.g.,9-drag-drop-reorder). Does not follow convention.rails/railsdev credentials which are standard for local dev.PROCESS OBSERVATIONS
test/orspec/directory in the repository at all. The entire app has zero automated tests. While this PR's Test Plan describes manual verification steps, the absence of any automated test infrastructure means there is no safety net for regressions. This is a pre-existing condition, not introduced by this PR, but it compounds risk as features are added.sortcontroller action is the highest-risk addition. Without input validation, a malformed client request can produce a 500 error. Without a transaction, a partial failure leaves positions inconsistent.VERDICT: NOT APPROVED
Two blockers must be addressed before merge:
disconnect()-- addremoveEventListenerfortouchstart,touchmove,touchendon handle targets.sortcontroller action -- verifyparams[:ids]is present, is an array, and contains valid values before iterating.