Add drag-and-drop ordering for featured stories#1546
Conversation
Stakeholders need to curate which stories surface first in the home page highlighted stories section. Until now featured stories were ordered by title, leaving no way to promote a specific story. Add a `position` column (scoped to the featured set via the positioning gem) and an admin-only reorder page so the home page order can be controlled directly. Closes #1522 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| # keeps a contiguous sequence within the featured set, independent of the | ||
| # much larger pool of non-featured stories. | ||
| positioned on: :featured | ||
|
|
There was a problem hiding this comment.
Scoped on :featured (rather than a global sequence) so the reorder list — which shows exactly the featured set — maps 1:1 to the absolute index SortableJS sends. A global position would desync once non-featured stories interleave.
| authorize! Story, to: :reorder? | ||
| # Operate on the full featured set (published or not) so the drag-and-drop | ||
| # index lines up exactly with the positioning scope (positioned on: :featured). | ||
| @stories = Story.where(featured: true).order(:position).decorate |
There was a problem hiding this comment.
Deliberately uses where(featured: true) (published or not), not Story.featured (published-only). The list must match the positioning scope exactly, otherwise hidden unpublished featured stories would throw off the drag index. The view flags unpublished ones.
| story = Story.find(params[:id]) | ||
| authorize! story, to: :reorder? | ||
| story.update!(position: params[:position]) | ||
| head :no_content |
There was a problem hiding this comment.
Reads params[:position] (the top-level key SortableJS PUTs as JSON). The positioning gem intercepts the assignment and re-sequences the featured group.
| # positioning gem (scoped on :featured) has a valid starting state. | ||
| execute <<~SQL.squish | ||
| UPDATE stories s | ||
| JOIN ( |
There was a problem hiding this comment.
Backfill gives each featured group a contiguous 1..n sequence (ROW_NUMBER per featured) so the gem starts from a valid state; column is set NOT NULL only after backfill.
| authorize! :home | ||
| @stories = authorized_scope(Story.published | ||
| .order(:title), with: HomePolicy) | ||
| .order(:position), with: HomePolicy) |
There was a problem hiding this comment.
Order by :position instead of :title. HomePolicy still scopes to featured/publicly_featured; the curated order flows through to both the authenticated and public home views.
There was a problem hiding this comment.
Pull request overview
Adds a curated (drag-and-drop) ordering mechanism for stories intended to control the display order of “Highlighted stories” on the home page by introducing a position column, a reorder UI, and updated ordering in the home stories feed.
Changes:
- Add
stories.positionwith backfill + ordering index, and enable per-featuredpositioning via the positioning gem. - Add an admin-only
/featured_storiesreorder page (Stimulussortable+ SortableJS) and an update endpoint to persist drag-and-drop reordering. - Update the home stories feed to order by
position, and add request/model coverage for ordering + authorization.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| spec/requests/home/stories_spec.rb | Verifies home stories render in curated order. |
| spec/requests/featured_stories_spec.rb | Covers access control and update behavior for reorder endpoint. |
| spec/models/story_spec.rb | Adds model-level specs for positioning behavior within featured/non-featured groups. |
| db/schema.rb | Reflects new position column and new index on stories. |
| db/migrate/20260604143000_add_position_to_stories.rb | Adds/backfills position and indexes ordering. |
| config/routes.rb | Adds routes for featured stories reorder UI + update. |
| app/views/stories/index.html.erb | Adds “Reorder featured” link for authorized users. |
| app/views/featured_stories/index.html.erb | New reorder page view wiring up sortable. |
| app/views/featured_stories/_featured_story.html.erb | New reorder list item partial (drag handle, hidden-state flag). |
| app/policies/story_policy.rb | Adds reorder? authorization rule. |
| app/models/story.rb | Enables positioning behavior for stories via positioned on: :featured. |
| app/controllers/home/stories_controller.rb | Changes ordering to position for home stories feed. |
| app/controllers/featured_stories_controller.rb | New controller for reorder page + update endpoint. |
| def update | ||
| story = Story.find(params[:id]) | ||
| authorize! story, to: :reorder? | ||
| story.update!(position: params[:position]) | ||
| head :no_content | ||
| end |
| authorize! :home | ||
| @stories = authorized_scope(Story.published | ||
| .order(:title), with: HomePolicy) | ||
| .order(:position), with: HomePolicy) | ||
| .decorate |
| def up | ||
| add_column :stories, :position, :integer | ||
| add_index :stories, [ :featured, :position ] | ||
|
|
||
| # Backfill a contiguous 1..n sequence within each featured group so the | ||
| # positioning gem (scoped on :featured) has a valid starting state. | ||
| execute <<~SQL.squish | ||
| UPDATE stories s | ||
| JOIN ( | ||
| SELECT id, ROW_NUMBER() OVER (PARTITION BY featured ORDER BY created_at, id) AS rn | ||
| FROM stories | ||
| ) ranked ON ranked.id = s.id | ||
| SET s.position = ranked.rn | ||
| SQL | ||
|
|
||
| change_column_null :stories, :position, false | ||
| end |
Closes #1522
What is the goal of this PR and why is this important?
How did you approach the change?
positioncolumn tostories, scoped via thepositioninggem to the featured set (positioned on: :featured), so featured stories keep a contiguous, independently-curated order./featured_stories) that reuses the existingsortableStimulus controller + SortableJS drag-and-drop, mirroring the FAQ/Category pattern already in the app.Home::StoriesControllerto order bypositioninstead oftitle, so both the authenticated (featured) and public (publicly_featured) home views respect the curated order.Anything else to add?
StoryPolicyvia a newreorder?rule (super users only); non-admins are redirected to root, consistent with the app'sActionPolicy::Unauthorizedhandling.publicly_featured; those stories follow the samepositioncolumn, so curation carries over for stories that are both featured and publicly featured.UI Testing Checklist
/featured_stories.