Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions app/controllers/featured_stories_controller.rb
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

Copy link
Copy Markdown
Collaborator Author

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), 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.

end

def update
story = Story.find(params[:id])
authorize! story, to: :reorder?
story.update!(position: params[:position])
head :no_content

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reads params[:position] (the top-level key SortableJS PUTs as JSON). The positioning gem intercepts the assignment and re-sequences the featured group.

end
Comment on lines +9 to +14
end
2 changes: 1 addition & 1 deletion app/controllers/home/stories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ class StoriesController < ApplicationController
def index
authorize! :home
@stories = authorized_scope(Story.published
.order(:title), with: HomePolicy)
.order(:position), with: HomePolicy)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

.decorate
Comment on lines 6 to 9

render "home/stories/index"
Expand Down
6 changes: 6 additions & 0 deletions app/models/story.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

has_rich_text :rhino_body

belongs_to :created_by, class_name: "User"
Expand Down
4 changes: 4 additions & 0 deletions app/policies/story_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
25 changes: 25 additions & 0 deletions app/views/featured_stories/_featured_story.html.erb
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>
29 changes: 29 additions & 0 deletions app/views/featured_stories/index.html.erb
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>
7 changes: 6 additions & 1 deletion app/views/stories/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
<p class="text-gray-600">Check out stories from Windows Facilitators around the world and the healing they make possible through art</p>
</div>

<div class="text-right text-end">
<div class="text-right text-end space-y-2">
<% 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,
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions db/migrate/20260604143000_add_position_to_stories.rb
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 (

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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 featured) so the gem starts from a valid state; column is set NOT NULL only after backfill.

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
4 changes: 3 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down
37 changes: 37 additions & 0 deletions spec/models/story_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
64 changes: 64 additions & 0 deletions spec/requests/featured_stories_spec.rb
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
21 changes: 21 additions & 0 deletions spec/requests/home/stories_spec.rb
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
Loading