From c06c3215ed67efb20da062c6ddd96d49ab8e216a Mon Sep 17 00:00:00 2001 From: maebeale Date: Thu, 4 Jun 2026 20:40:42 -0400 Subject: [PATCH] Add drag-and-drop ordering for featured stories 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 --- .../featured_stories_controller.rb | 15 +++++ app/controllers/home/stories_controller.rb | 2 +- app/models/story.rb | 6 ++ app/policies/story_policy.rb | 4 ++ .../featured_stories/_featured_story.html.erb | 25 ++++++++ app/views/featured_stories/index.html.erb | 29 +++++++++ app/views/stories/index.html.erb | 7 +- config/routes.rb | 1 + .../20260604143000_add_position_to_stories.rb | 24 +++++++ db/schema.rb | 4 +- spec/models/story_spec.rb | 37 +++++++++++ spec/requests/featured_stories_spec.rb | 64 +++++++++++++++++++ spec/requests/home/stories_spec.rb | 21 ++++++ 13 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 app/controllers/featured_stories_controller.rb create mode 100644 app/views/featured_stories/_featured_story.html.erb create mode 100644 app/views/featured_stories/index.html.erb create mode 100644 db/migrate/20260604143000_add_position_to_stories.rb create mode 100644 spec/requests/featured_stories_spec.rb create mode 100644 spec/requests/home/stories_spec.rb diff --git a/app/controllers/featured_stories_controller.rb b/app/controllers/featured_stories_controller.rb new file mode 100644 index 000000000..2fe7d376a --- /dev/null +++ b/app/controllers/featured_stories_controller.rb @@ -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 + end +end diff --git a/app/controllers/home/stories_controller.rb b/app/controllers/home/stories_controller.rb index c9f44d33c..827e1b77a 100644 --- a/app/controllers/home/stories_controller.rb +++ b/app/controllers/home/stories_controller.rb @@ -5,7 +5,7 @@ class StoriesController < ApplicationController def index authorize! :home @stories = authorized_scope(Story.published - .order(:title), with: HomePolicy) + .order(:position), with: HomePolicy) .decorate render "home/stories/index" diff --git a/app/models/story.rb b/app/models/story.rb index 7c479dc68..f0fd3cbda 100644 --- a/app/models/story.rb +++ b/app/models/story.rb @@ -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 + has_rich_text :rhino_body belongs_to :created_by, class_name: "User" diff --git a/app/policies/story_policy.rb b/app/policies/story_policy.rb index 960f6d4e2..4d53ffa12 100644 --- a/app/policies/story_policy.rb +++ b/app/policies/story_policy.rb @@ -9,6 +9,10 @@ def show? admin? || record.publicly_visible? || (authenticated? && record.published?) end + def reorder? + admin? + end + # Scoping # See https://actionpolicy.evilmartians.io/#/scoping # diff --git a/app/views/featured_stories/_featured_story.html.erb b/app/views/featured_stories/_featured_story.html.erb new file mode 100644 index 000000000..4d9f0e74c --- /dev/null +++ b/app/views/featured_stories/_featured_story.html.erb @@ -0,0 +1,25 @@ +
+ + +
+ <%= render "assets/display_image", + resource: story, + width: 16, height: 12, + link: false, + link_to_object: false, + file: story.display_image, + variant: :gallery %> +
+ +
+

<%= story.title %>

+ <% unless story.published? %> + Not published — hidden from the home page + <% end %> +
+ + <%= link_to "Edit", edit_story_path(story), class: "btn btn-secondary-outline shrink-0" %> +
diff --git a/app/views/featured_stories/index.html.erb b/app/views/featured_stories/index.html.erb new file mode 100644 index 000000000..19dcc1fd9 --- /dev/null +++ b/app/views/featured_stories/index.html.erb @@ -0,0 +1,29 @@ +<% content_for(:page_bg_class, "public") %> +<%= render "shared/public_welcome_banner" %> + +
+
+
+

Reorder featured stories

+

Drag the stories into the order they should appear in the highlighted stories section of the home page.

+
+ +
+ <%= link_to "Back to stories", stories_path, class: "btn btn-primary-outline" %> +
+
+ +
+ <% if @stories.present? %> +
" + data-testid="featured-story-list"> + <%= render partial: "featured_story", collection: @stories, as: :story %> +
+ <% else %> +

No featured stories yet. Mark a story as featured to curate the home page order.

+ <% end %> +
+
diff --git a/app/views/stories/index.html.erb b/app/views/stories/index.html.erb index 6c17afc97..4cf2bce50 100644 --- a/app/views/stories/index.html.erb +++ b/app/views/stories/index.html.erb @@ -9,7 +9,12 @@

Check out stories from Windows Facilitators around the world and the healing they make possible through art

-
+
+ <% if allowed_to?(:reorder?, Story) %> + <%= link_to "Reorder featured", + featured_stories_path, + class: "admin-only bg-blue-100 btn btn-secondary-outline" %> + <% end %> <% if allowed_to?(:new?, Story) %> <%= link_to "New Story", new_story_path, diff --git a/config/routes.rb b/config/routes.rb index 7eb89603c..2d86c3df3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -162,6 +162,7 @@ get "search/:model", to: "search#index" resources :story_ideas resources :stories + resources :featured_stories, only: [ :index, :update ] resources :story_shares, only: [ :index, :show ] resources :video_recordings resources :user_forms diff --git a/db/migrate/20260604143000_add_position_to_stories.rb b/db/migrate/20260604143000_add_position_to_stories.rb new file mode 100644 index 000000000..04164ed58 --- /dev/null +++ b/db/migrate/20260604143000_add_position_to_stories.rb @@ -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 ( + 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 + + def down + remove_index :stories, [ :featured, :position ], if_exists: true + remove_column :stories, :position, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index e0f387ba6..55738accf 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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_05_29_155200) do +ActiveRecord::Schema[8.1].define(version: 2026_06_04_143000) 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 @@ -906,6 +906,7 @@ t.boolean "featured", default: false, null: false t.integer "organization_id" t.boolean "permission_given" + t.integer "position", null: false t.boolean "publicly_featured", default: false, null: false t.boolean "publicly_visible", default: false, null: false t.boolean "published", default: false, null: false @@ -919,6 +920,7 @@ t.integer "workshop_id" t.string "youtube_url" t.index ["created_by_id"], name: "index_stories_on_created_by_id" + t.index ["featured", "position"], name: "index_stories_on_featured_and_position" t.index ["organization_id"], name: "index_stories_on_organization_id" t.index ["published"], name: "index_stories_on_published" t.index ["spotlighted_facilitator_id"], name: "index_stories_on_spotlighted_facilitator_id" diff --git a/spec/models/story_spec.rb b/spec/models/story_spec.rb index 36e94234e..3828c928f 100644 --- a/spec/models/story_spec.rb +++ b/spec/models/story_spec.rb @@ -139,4 +139,41 @@ expect(results).not_to include(published_story, draft_story, old_story) end end + + describe "positioning of featured stories" do + it "assigns sequential positions within the featured set as stories are created" do + first = create(:story, :featured) + second = create(:story, :featured) + + expect([ first.position, second.position ]).to eq([ 1, 2 ]) + end + + it "scopes positions to featured, so non-featured stories keep their own sequence" do + featured = create(:story, :featured) + non_featured = create(:story) + + expect(featured.position).to eq(1) + expect(non_featured.position).to eq(1) + end + + it "reorders the featured set when a position is assigned" do + first = create(:story, :featured) + second = create(:story, :featured) + third = create(:story, :featured) + + third.update!(position: 1) + + expect(first.reload.position).to eq(2) + expect(second.reload.position).to eq(3) + expect(third.reload.position).to eq(1) + end + + it "orders the featured scope by position" do + first = create(:story, :featured, :published) + second = create(:story, :featured, :published) + second.update!(position: 1) + + expect(Story.featured.order(:position)).to eq([ second, first ]) + end + end end diff --git a/spec/requests/featured_stories_spec.rb b/spec/requests/featured_stories_spec.rb new file mode 100644 index 000000000..ff2c83889 --- /dev/null +++ b/spec/requests/featured_stories_spec.rb @@ -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 diff --git a/spec/requests/home/stories_spec.rb b/spec/requests/home/stories_spec.rb new file mode 100644 index 000000000..100f70f4f --- /dev/null +++ b/spec/requests/home/stories_spec.rb @@ -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