-
Notifications
You must be signed in to change notification settings - Fork 24
Add drag-and-drop ordering for featured stories #1546
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
base: main
Are you sure you want to change the base?
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 |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| class FeaturedStoriesController < ApplicationController | ||
| def index | ||
| 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 | ||
| end | ||
|
|
||
| def update | ||
| story = Story.find(params[:id]) | ||
| authorize! story, to: :reorder? | ||
| story.update!(position: params[:position]) | ||
| head :no_content | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reads |
||
| end | ||
|
Comment on lines
+9
to
+14
|
||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,7 +5,7 @@ class StoriesController < ApplicationController | |
| def index | ||
| authorize! :home | ||
| @stories = authorized_scope(Story.published | ||
| .order(:title), with: HomePolicy) | ||
| .order(:position), with: HomePolicy) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Order by |
||
| .decorate | ||
|
Comment on lines
6
to
9
|
||
|
|
||
| render "home/stories/index" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,12 @@ class Story < ApplicationRecord | |
| include AuthorCreditable | ||
| include Featureable, Publishable, TagFilterable, Trendable, WindowsTypeFilterable, RichTextSearchable | ||
|
|
||
| # Featured stories are curated into a manual order (drag-and-drop) so the | ||
| # home page can control which stories surface first. Scoping on :featured | ||
| # keeps a contiguous sequence within the featured set, independent of the | ||
| # much larger pool of non-featured stories. | ||
| positioned on: :featured | ||
|
|
||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scoped on |
||
| has_rich_text :rhino_body | ||
|
|
||
| belongs_to :created_by, class_name: "User" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| <div | ||
| id="<%= dom_id story %>" | ||
| class="flex items-center gap-3 bg-white border border-gray-200 rounded-lg p-3 hover:bg-gray-50 transition-colors" | ||
| data-sortable-id="<%= story.id %>"> | ||
| <i class="fa-solid fa-grip-vertical text-gray-400 cursor-move" data-sortable-handle=""></i> | ||
|
|
||
| <div class="w-16 h-12 shrink-0 rounded bg-gray-200 overflow-hidden"> | ||
| <%= render "assets/display_image", | ||
| resource: story, | ||
| width: 16, height: 12, | ||
| link: false, | ||
| link_to_object: false, | ||
| file: story.display_image, | ||
| variant: :gallery %> | ||
| </div> | ||
|
|
||
| <div class="grow min-w-0"> | ||
| <p class="font-semibold text-gray-900 truncate"><%= story.title %></p> | ||
| <% unless story.published? %> | ||
| <span class="text-xs font-medium text-amber-700">Not published — hidden from the home page</span> | ||
| <% end %> | ||
| </div> | ||
|
|
||
| <%= link_to "Edit", edit_story_path(story), class: "btn btn-secondary-outline shrink-0" %> | ||
| </div> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| <% content_for(:page_bg_class, "public") %> | ||
| <%= render "shared/public_welcome_banner" %> | ||
|
|
||
| <div class="w-full max-w-4xl mx-auto <%= DomainTheme.bg_class_for(:stories) %> border border-gray-200 rounded-xl shadow p-6"> | ||
| <div class="flex items-start justify-between mb-4"> | ||
| <div class="pr-6"> | ||
| <h2 class="text-2xl font-semibold">Reorder featured stories</h2> | ||
| <p class="text-gray-600 mt-2">Drag the stories into the order they should appear in the highlighted stories section of the home page.</p> | ||
| </div> | ||
|
|
||
| <div class="text-right text-end"> | ||
| <%= link_to "Back to stories", stories_path, class: "btn btn-primary-outline" %> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="bg-white rounded-lg shadow-md p-4 mt-4"> | ||
| <% if @stories.present? %> | ||
| <div | ||
| class="space-y-2 animate-fade" | ||
| data-controller="sortable" | ||
| data-sortable-url-value="<%= featured_story_path(id: ":id") %>" | ||
| data-testid="featured-story-list"> | ||
| <%= render partial: "featured_story", collection: @stories, as: :story %> | ||
| </div> | ||
| <% else %> | ||
| <p class="text-start text-gray-500 mt-2">No featured stories yet. Mark a story as featured to curate the home page order.</p> | ||
| <% end %> | ||
| </div> | ||
| </div> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| class AddPositionToStories < ActiveRecord::Migration[8.1] | ||
| 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 ( | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Backfill gives each featured group a contiguous 1..n sequence (ROW_NUMBER per |
||
| 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 | ||
|
Comment on lines
+2
to
+18
|
||
|
|
||
| def down | ||
| remove_index :stories, [ :featured, :position ], if_exists: true | ||
| remove_column :stories, :position, if_exists: true | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| require "rails_helper" | ||
|
|
||
| RSpec.describe "/featured_stories", type: :request do | ||
| let(:admin) { create(:user, :admin) } | ||
| let(:regular_user) { create(:user) } | ||
|
|
||
| let!(:first_story) { create(:story, :featured, :published, title: "First featured") } | ||
| let!(:second_story) { create(:story, :featured, :published, title: "Second featured") } | ||
|
|
||
| describe "GET /index" do | ||
| context "as an admin" do | ||
| before { sign_in admin } | ||
|
|
||
| it "renders the reorder page with featured stories" do | ||
| get featured_stories_path | ||
|
|
||
| expect(response).to have_http_status(:ok) | ||
| expect(response.body).to include("First featured") | ||
| expect(response.body).to include("Second featured") | ||
| end | ||
| end | ||
|
|
||
| context "as a non-admin user" do | ||
| before { sign_in regular_user } | ||
|
|
||
| it "redirects away from the reorder page" do | ||
| get featured_stories_path | ||
| expect(response).to redirect_to(root_path) | ||
| end | ||
| end | ||
|
|
||
| context "as a guest" do | ||
| it "redirects to sign in" do | ||
| get featured_stories_path | ||
| expect(response).to redirect_to(new_user_session_path) | ||
| end | ||
| end | ||
| end | ||
|
|
||
| describe "PUT /update" do | ||
| context "as an admin" do | ||
| before { sign_in admin } | ||
|
|
||
| it "moves the story to the requested position" do | ||
| put featured_story_path(second_story), params: { position: 1 }, as: :json | ||
|
|
||
| expect(response).to have_http_status(:no_content) | ||
| expect(second_story.reload.position).to eq(1) | ||
| expect(first_story.reload.position).to eq(2) | ||
| end | ||
| end | ||
|
|
||
| context "as a non-admin user" do | ||
| before { sign_in regular_user } | ||
|
|
||
| it "is not authorized and leaves the order unchanged" do | ||
| put featured_story_path(second_story), params: { position: 1 }, as: :json | ||
|
|
||
| expect(response).to redirect_to(root_path) | ||
| expect(second_story.reload.position).to eq(2) | ||
| end | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| require "rails_helper" | ||
|
|
||
| RSpec.describe "/home/stories", type: :request do | ||
| let(:user) { create(:user) } | ||
|
|
||
| before { sign_in user } | ||
|
|
||
| describe "GET /home/stories" do | ||
| it "lists featured stories in their curated position order" do | ||
| first = create(:story, :featured, :published, title: "Alpha story") | ||
| second = create(:story, :featured, :published, title: "Bravo story") | ||
| # Curate Bravo ahead of Alpha despite alphabetical order. | ||
| second.update!(position: 1) | ||
|
|
||
| get home_stories_path | ||
|
|
||
| expect(response).to have_http_status(:ok) | ||
| expect(response.body.index(second.title)).to be < response.body.index(first.title) | ||
| end | ||
| end | ||
| end |
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.
Deliberately uses
where(featured: true)(published or not), notStory.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.