diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 48867d04cf..5f93cf13ce 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -51,7 +51,7 @@ def update authorize! @category respond_to do |format| if @category.update(category_params) - format.html { redirect_to @category, notice: "Category was successfully updated.", status: :see_other } + format.html { redirect_to after_update_path, notice: "Category was successfully updated.", status: :see_other } format.json { head :ok } else format.html do @@ -89,6 +89,12 @@ def dedupe_config } end + def after_update_path + return story_shares_path(category_names_all: @category.name) if params[:return_to] == "story_share" + + @category + end + def set_category @category = Category.find(params[:id]) end @@ -97,7 +103,7 @@ def set_category def category_params if params[:category] params.require(:category).permit( - :name, :category_type_id, :published, :position, :description, :story_share_position + :name, :category_type_id, :published, :position, :description, :story_share_position, :rhino_story_share_intro ) else params.permit(:position) diff --git a/app/controllers/sectors_controller.rb b/app/controllers/sectors_controller.rb index bfaae9510a..d72380d695 100644 --- a/app/controllers/sectors_controller.rb +++ b/app/controllers/sectors_controller.rb @@ -43,7 +43,7 @@ def create def update authorize! @sector if @sector.update(sector_params) - redirect_to @sector, notice: "Sector was successfully updated.", status: :see_other + redirect_to after_update_path, notice: "Sector was successfully updated.", status: :see_other else set_form_variables render :edit, status: :unprocessable_content @@ -69,6 +69,12 @@ def dedupe_config } end + def after_update_path + return story_shares_path(sector_names_all: @sector.name) if params[:return_to] == "story_share" + + @sector + end + def set_sector @sector = Sector.find(params[:id]) end @@ -76,7 +82,7 @@ def set_sector # Strong parameters def sector_params params.require(:sector).permit( - :name, :published, :description, :story_share_position + :name, :published, :description, :story_share_position, :rhino_story_share_intro ) end end diff --git a/app/controllers/story_shares_controller.rb b/app/controllers/story_shares_controller.rb index ed8e7d14a4..e5a362507b 100644 --- a/app/controllers/story_shares_controller.rb +++ b/app/controllers/story_shares_controller.rb @@ -119,6 +119,8 @@ def load_browse @count_display = count_display(filtered.count) return load_search_results(filtered, per_page) if searching? + @intro_tag = intro_tag + highlights = primary_tag_matches(filtered) lead_ids = highlights.reorder(publicly_featured: :desc, created_at: :desc).limit(single_hero? ? 1 : BROWSE_CAROUSEL_LIMIT).pluck(:id) grid_ids = highlights.where.not(id: lead_ids).reorder(created_at: :desc).limit(BROWSE_GRID_LIMIT).pluck(:id) @@ -148,6 +150,16 @@ def in_id_order(ids) preloaded(Story.where(id: ids)).in_order_of(:id, ids).decorate end + # The sector or audience category whose own page this is, so its intro leads the page. + def intro_tag + sector_names = params[:sector_names_all].to_s.split("--") + return Sector.sector_names_all(sector_names).first if sector_names.one? + return if sector_names.any? + + category_names = params[:category_names_all].to_s.split("--") + Category.audiences.category_names_all(category_names).first if category_names.one? + end + def first_page? params[:page].to_i <= 1 end diff --git a/app/models/category.rb b/app/models/category.rb index bfe3743660..b99ebd7838 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -9,6 +9,7 @@ class Category < ApplicationRecord belongs_to :updated_by, class_name: "User", optional: true has_many :categorizable_items, dependent: :destroy has_many :workshops, through: :categorizable_items, source: :categorizable, source_type: "Workshop" + has_rich_text :rhino_story_share_intro # Scopes # See NameFilterable, Publishable @@ -35,6 +36,10 @@ def self.story_share_audience_nav story_share_audience_groups.values.flat_map { |scope| scope.published.story_share_featured.to_a } end + def story_share_audience? + category_type&.name.in?(CategoriesTaggable::AUDIENCE_CATEGORY_TYPES) + end + # Validations normalizes :name, with: ->(name) { name.squish } validates :name, presence: true, uniqueness: { case_sensitive: false }, length: { maximum: 255 } diff --git a/app/models/sector.rb b/app/models/sector.rb index 9ce29a5081..af7364e6ab 100644 --- a/app/models/sector.rb +++ b/app/models/sector.rb @@ -58,6 +58,7 @@ class Sector < ApplicationRecord source: :sectorable, source_type: "Story" # has_many through has_many :quotes, through: :workshops + has_rich_text :rhino_story_share_intro # Validations normalizes :name, with: ->(name) { name.squish } diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index 7390a1d414..3c8deed1e3 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -4,6 +4,7 @@ <%= f.error_notification %> <%= render "shared/errors", resource: @category if @category.errors.any? %> + <%= hidden_field_tag :return_to, params[:return_to] if params[:return_to].present? %>
@@ -49,6 +50,15 @@ input_html: { class: "form-control", rows: 2 } %>
+ <% if f.object.story_share_audience? %> + +
+ <%= rhino_editor(f, :story_share_intro, + label: "Story Share intro", + hint: "Optional. Shown at the top of this #{f.object.category_type.name.underscore.humanize.downcase}'s Story Share page.") %> +
+ <% end %> +
<%= f.input :story_share_position, diff --git a/app/views/categories/edit.html.erb b/app/views/categories/edit.html.erb index 9d03b33817..b47ca42170 100644 --- a/app/views/categories/edit.html.erb +++ b/app/views/categories/edit.html.erb @@ -3,6 +3,9 @@
<%= link_to "Home", root_path, class: "text-sm #{eyebrow_link_class} px-2 py-1" %> <%= link_to "Categories", categories_path, class: "text-sm #{eyebrow_link_class} px-2 py-1" %> + <% if params[:return_to] == "story_share" %> + <%= link_to "Story Share", story_shares_path(category_names_all: @category.name), class: "text-sm #{eyebrow_link_class} px-2 py-1" %> + <% end %> <%= link_to "Taggings", taggings_path(category_names_all: @category.name), class: "text-sm #{eyebrow_link_class} px-2 py-1" %> <%= link_to "View", category_path(@category), class: "text-sm #{eyebrow_link_class} px-2 py-1" %>
diff --git a/app/views/sectors/_form.html.erb b/app/views/sectors/_form.html.erb index cba693cb8b..7444b722e8 100644 --- a/app/views/sectors/_form.html.erb +++ b/app/views/sectors/_form.html.erb @@ -3,6 +3,7 @@ <%= render "shared/errors", resource: @sector if @sector.errors.any? %> + <%= hidden_field_tag :return_to, params[:return_to] if params[:return_to].present? %>
@@ -22,6 +23,13 @@ input_html: { class: "form-control", rows: 2 } %>
+ +
+ <%= rhino_editor(f, :story_share_intro, + label: "Story Share intro", + hint: "Optional. Shown at the top of this sector's Story Share page.") %> +
+
<%= f.input :story_share_position, diff --git a/app/views/sectors/edit.html.erb b/app/views/sectors/edit.html.erb index aa6b50ab35..8b9d6910e4 100644 --- a/app/views/sectors/edit.html.erb +++ b/app/views/sectors/edit.html.erb @@ -3,6 +3,9 @@
<%= link_to "Home", root_path, class: "text-sm #{eyebrow_link_class} px-2 py-1" %> <%= link_to "Sectors", sectors_path, class: "text-sm #{eyebrow_link_class} px-2 py-1" %> + <% if params[:return_to] == "story_share" %> + <%= link_to "Story Share", story_shares_path(sector_names_all: @sector.name), class: "text-sm #{eyebrow_link_class} px-2 py-1" %> + <% end %> <%= link_to "Taggings", taggings_path(sector_names_all: @sector.name), class: "text-sm #{eyebrow_link_class} px-2 py-1" %> <%= link_to "View", sector_path(@sector), class: "text-sm #{eyebrow_link_class} px-2 py-1" %>
diff --git a/app/views/story_shares/_browse.html.erb b/app/views/story_shares/_browse.html.erb index 0c58cfa2d2..931795a9d3 100644 --- a/app/views/story_shares/_browse.html.erb +++ b/app/views/story_shares/_browse.html.erb @@ -9,6 +9,21 @@

<%= pluralize(@count_display, "story") %>

+ <% intro = @intro_tag&.rhino_story_share_intro %> + <% can_edit_intro = @intro_tag && allowed_to?(:edit?, @intro_tag) %> + <% if intro.present? || can_edit_intro %> +
+ <% if intro.present? %> +
<%= intro %>
+ <% end %> + <% if can_edit_intro %> + <%= link_to intro.present? ? "Edit intro" : "Add intro", + polymorphic_path(@intro_tag, action: :edit, return_to: "story_share"), + class: button_classes(:secondary_outline, extra: "admin-only bg-blue-100 mt-3 whitespace-nowrap") %> + <% end %> +
+ <% end %> + <%= render "search_scope" if searching? %> <% if searching? && @stories.any? %> diff --git a/db/seeds/features/2026-10-22-story-share-sector-and-age-range-intros.yml b/db/seeds/features/2026-10-22-story-share-sector-and-age-range-intros.yml new file mode 100644 index 0000000000..4576f383e8 --- /dev/null +++ b/db/seeds/features/2026-10-22-story-share-sector-and-age-range-intros.yml @@ -0,0 +1,11 @@ +name: "Intro text on Story Share sector and age range pages" +area: stories +display_status: admin_facing +released_on: 2026-10-22 +pr_number: 2777 +summary: >- + Each sector, age range, and story population can carry its own intro, shown at the top of its + Story Share page. Write it with the same editor used for stories. +pro_tips: + - "Open a sector or age range page in Story Share and click Add intro to write one." + - "Pages that combine several sectors don't show an intro." diff --git a/spec/requests/categories_spec.rb b/spec/requests/categories_spec.rb index cd93dc4535..79cb828b4c 100644 --- a/spec/requests/categories_spec.rb +++ b/spec/requests/categories_spec.rb @@ -96,6 +96,15 @@ get edit_category_path(category) expect(response).to have_http_status(:ok) end + + it "offers the Story Share intro only for age ranges and story populations" do + age_range = create(:category, category_type: create(:category_type, name: "AgeRange")) + get edit_category_path(age_range) + expect(response.body).to include("Story Share intro") + + get edit_category_path(create(:category, valid_attributes)) + expect(response.body).not_to include("Story Share intro") + end end describe "POST create" do @@ -131,6 +140,14 @@ expect(response).to have_http_status(:see_other) expect(category.reload.name).to eq("Updated Name") end + + it "saves the Story Share intro and returns to the category's Story Share page" do + patch category_path(category), + params: { return_to: "story_share", category: { rhino_story_share_intro: "

For our youngest artists

" } } + + expect(category.reload.rhino_story_share_intro.to_plain_text).to eq("For our youngest artists") + expect(response).to redirect_to(story_shares_path(category_names_all: category.name)) + end end context "JSON ordering (drag & drop)" do diff --git a/spec/requests/sectors_spec.rb b/spec/requests/sectors_spec.rb index c84ee3c3a0..989aa9ce99 100644 --- a/spec/requests/sectors_spec.rb +++ b/spec/requests/sectors_spec.rb @@ -141,6 +141,14 @@ sector.reload expect(response).to redirect_to(sector_url(sector)) end + + it "saves the Story Share intro and returns to the sector's Story Share page" do + sector = Sector.create! valid_attributes + patch sector_url(sector), params: { return_to: "story_share", sector: { rhino_story_share_intro: "

Healing together

" } } + sector.reload + expect(sector.rhino_story_share_intro.to_plain_text).to eq("Healing together") + expect(response).to redirect_to(story_shares_path(sector_names_all: sector.name)) + end end context "with invalid parameters" do diff --git a/spec/requests/story_share_spec.rb b/spec/requests/story_share_spec.rb index f12df74947..b79f8a689f 100644 --- a/spec/requests/story_share_spec.rb +++ b/spec/requests/story_share_spec.rb @@ -524,6 +524,35 @@ def listed_titles expect(listed_titles).to eq([ "An adult story with teens too" ]) end + it "leads a sector page with the sector's intro" do + Sector.find_by(name: "Domestic Violence").update!(rhino_story_share_intro: "

Survivors finding their voice

") + get story_shares_path(sector_names_all: "Domestic Violence") + expect(response.body).to include("Survivors finding their voice") + expect(response.body).not_to include("Edit intro") + end + + it "leads an age range page with the age range's intro" do + age_range = create(:category_type, name: "AgeRange") + create(:category, :published, name: "Children", category_type: age_range, + rhino_story_share_intro: "

Art with our youngest participants

") + get story_shares_path(category_names_all: "Children") + expect(response.body).to include("Art with our youngest participants") + end + + it "skips intros when several sectors are combined" do + Sector.find_by(name: "Domestic Violence").update!(rhino_story_share_intro: "

Survivors finding their voice

") + get story_shares_path(sector_names_all: "Domestic Violence--Mental Health") + expect(response.body).not_to include("Survivors finding their voice") + end + + it "offers admins a link to add the intro" do + sign_in create(:user, :admin) + sector = Sector.find_by(name: "Domestic Violence") + get story_shares_path(sector_names_all: sector.name) + expect(response.body).to include("Add intro") + expect(response.body).to include(edit_sector_path(sector, return_to: "story_share")) + end + it "does not leak non-public stories to guests when browsing" do create(:story, :published, title: "A published-only secret", publicly_visible: false).tap { |s| tag_sector(s, "Domestic Violence") } get story_shares_path(sector_names_all: "Domestic Violence")