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