From ba9edc968315a3f2beabfdd167b1867ca6c5e06c Mon Sep 17 00:00:00 2001 From: maebeale Date: Fri, 5 Jun 2026 09:41:15 -0400 Subject: [PATCH] Add display author to public content forms Content editors need to attribute public-facing stories, events, resources, and tutorials to a chosen author independently of who created the record, so the displayed byline can differ from the internal creator. - Add nullable author reference (User) to stories, events, resources, tutorials - Add created_by reference to tutorials so it can track its internal creator - Add a "Display author" select to each public form, defaulting to the existing author/creator or the current user - Switch Story search and author sort to use the new author association - Permit author_id params and update factories Co-Authored-By: Claude Opus 4.8 --- app/controllers/events_controller.rb | 4 ++++ app/controllers/resources_controller.rb | 7 +++---- app/controllers/stories_controller.rb | 9 ++++++--- app/controllers/tutorials_controller.rb | 5 +++++ app/models/event.rb | 1 + app/models/resource.rb | 1 + app/models/story.rb | 3 ++- app/models/tutorial.rb | 3 +++ app/views/events/_form.html.erb | 14 +++++++++++++- app/views/resources/_form.html.erb | 8 ++++++++ app/views/stories/_form.html.erb | 9 +++++++++ app/views/tutorials/_form.html.erb | 9 +++++++++ .../20260308143000_add_author_to_public_forms.rb | 9 +++++++++ spec/factories/events.rb | 1 + spec/factories/resources.rb | 1 + spec/factories/stories.rb | 1 + spec/factories/tutorials.rb | 2 ++ 17 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 db/migrate/20260308143000_add_author_to_public_forms.rb diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index c043b3f52..1dba6c6bf 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -222,6 +222,9 @@ def set_form_variables .select { |type, _| type.nil? || (type.published? && !type.story_specific? && !type.profile_specific?) } .sort_by { |type, _| type&.name.to_s.downcase } @sectors = Sector.published.order(:name) + @authors = authorized_scope(User.has_access.or(User.where(id: @event.author_id))) + .includes(:person) + .map { |u| [ u.full_name, u.id ] }.sort_by(&:first) end def set_event @@ -230,6 +233,7 @@ def set_event def event_params params.require(:event).permit(:cost, + :author_id, :created_by_id, :location_id, :title, diff --git a/app/controllers/resources_controller.rb b/app/controllers/resources_controller.rb index a519531a7..d9c781e89 100644 --- a/app/controllers/resources_controller.rb +++ b/app/controllers/resources_controller.rb @@ -149,10 +149,9 @@ def set_form_variables @resource.build_downloadable_asset if @resource.downloadable_asset.blank? @resource.gallery_assets.build @windows_types = WindowsType.all - @authors = authorized_scope(User.has_access.or(User.where(id: @resource.created_by_id))) + @authors = authorized_scope(User.has_access.or(User.where(id: @resource.author_id))) .includes(:person) - .order("people.first_name, people.last_name") - .map { |u| [ u.full_name, u.id ] } + .map { |u| [ u.full_name, u.id ] }.sort_by(&:first) @categories_grouped = Category .includes(:category_type) @@ -171,7 +170,7 @@ def resource_id_param def resource_params params.require(:resource).permit( :rhino_body, :kind, :male, :female, :title, :featured, :published, :publicly_visible, :publicly_featured, - :agency, :author, :filemaker_code, :windows_type_id, :position, + :agency, :author, :author_id, :filemaker_code, :windows_type_id, :position, primary_asset_attributes: [ :id, :file, :_destroy ], downloadable_asset_attributes: [ :id, :file, :_destroy ], gallery_assets_attributes: [ :id, :file, :_destroy ], diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 493cfcd3c..84c8cf5ab 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -9,7 +9,7 @@ def index if turbo_frame_request? per_page = params[:number_of_items_per_page].presence || 12 base_scope = authorized_scope(Story.includes(:windows_type, :organization, :workshop, - :created_by, :bookmarks, :primary_asset, + :author, :created_by, :bookmarks, :primary_asset, :story_idea)) filtered = base_scope.search_by_params(params) sortable = %w[title updated_at created_at windows_type workshop author organization] @@ -131,6 +131,9 @@ def set_form_variables .order(:created_at) @people = Person.order(Arel.sql("LOWER(first_name), LOWER(last_name)")) @users = User.has_access.includes(:person).left_joins(:person).order(Arel.sql("people.first_name IS NULL, LOWER(people.first_name), LOWER(people.last_name), LOWER(users.email)")) + @authors = authorized_scope(User.has_access.or(User.where(id: @story.author_id))) + .includes(:person) + .map { |u| [ u.full_name, u.id ] }.sort_by(&:first) @windows_types = WindowsType.all @workshops = authorized_scope(Workshop.all).includes(:windows_type).order(:title) @categories_grouped = @@ -173,7 +176,7 @@ def apply_sort(scope, column, direction) scope.left_joins(:workshop) .reorder(Workshop.arel_table[:title].public_send(dir)) when "author" - scope.left_joins(created_by: :person) + scope.left_joins(author: :person) .reorder(Person.arel_table[:first_name].public_send(dir)) when "organization" scope.left_joins(:organization) @@ -188,7 +191,7 @@ def story_params params.require(:story).permit( :title, :rhino_body, :featured, :published, :publicly_visible, :publicly_featured, :youtube_url, :website_url, :windows_type_id, :organization_id, :workshop_id, :external_workshop_title, - :created_by_id, :updated_by_id, :story_idea_id, :spotlighted_facilitator_id, :author_credit_preference, + :author_id, :created_by_id, :updated_by_id, :story_idea_id, :spotlighted_facilitator_id, :author_credit_preference, category_ids: [], sector_ids: [], primary_asset_attributes: [ :id, :file, :_destroy ], diff --git a/app/controllers/tutorials_controller.rb b/app/controllers/tutorials_controller.rb index b6e2ddd15..ad6842a03 100644 --- a/app/controllers/tutorials_controller.rb +++ b/app/controllers/tutorials_controller.rb @@ -42,6 +42,7 @@ def edit def create @tutorial = Tutorial.new(tutorial_params) + @tutorial.created_by ||= current_user authorize! @tutorial success = false @@ -108,6 +109,9 @@ def set_form_variables .select { |type, _| type.nil? || type.published? } .sort_by { |type, _| type&.name.to_s.downcase } @sectors = Sector.published.order(:name) + @authors = authorized_scope(User.has_access.or(User.where(id: @tutorial.author_id))) + .includes(:person) + .map { |u| [ u.full_name, u.id ] }.sort_by(&:first) end private @@ -120,6 +124,7 @@ def set_tutorial def tutorial_params params.require(:tutorial).permit( :title, :body, :rhino_body, :position, :youtube_url, + :author_id, :created_by_id, :featured, :published, :publicly_visible, :publicly_featured, category_ids: [], sector_ids: [], diff --git a/app/models/event.rb b/app/models/event.rb index 8f67557f0..19668d69f 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -5,6 +5,7 @@ class Event < ApplicationRecord has_rich_text :rhino_header has_rich_text :rhino_description + belongs_to :author, class_name: "User", optional: true belongs_to :created_by, class_name: "User", optional: true belongs_to :location, optional: true has_many :bookmarks, as: :bookmarkable, dependent: :destroy diff --git a/app/models/resource.rb b/app/models/resource.rb index 0981c9fe0..80d154b10 100644 --- a/app/models/resource.rb +++ b/app/models/resource.rb @@ -14,6 +14,7 @@ def self.mentionable_rich_text_fields has_rich_text :rhino_body + belongs_to :author, class_name: "User", optional: true belongs_to :created_by, class_name: "User" belongs_to :workshop, optional: true belongs_to :windows_type, optional: true diff --git a/app/models/story.rb b/app/models/story.rb index cf5833992..9e2768d74 100644 --- a/app/models/story.rb +++ b/app/models/story.rb @@ -4,6 +4,7 @@ class Story < ApplicationRecord has_rich_text :rhino_body + belongs_to :author, class_name: "User", optional: true belongs_to :created_by, class_name: "User" belongs_to :updated_by, class_name: "User" belongs_to :windows_type @@ -48,7 +49,7 @@ class Story < ApplicationRecord attributes person_first: "people.first_name", person_last: "people.last_name" options :all, type: :text, default: true, default_operator: :or - scope { join_rich_texts.left_joins(created_by: :person) } + scope { join_rich_texts.left_joins(author: :person) } attributes action_text_body: "action_text_rich_texts.plain_text_body" options :action_text_body, type: :text, default: true, default_operator: :or end diff --git a/app/models/tutorial.rb b/app/models/tutorial.rb index 88936a372..d03774d86 100644 --- a/app/models/tutorial.rb +++ b/app/models/tutorial.rb @@ -3,6 +3,9 @@ class Tutorial < ApplicationRecord has_rich_text :rhino_body + belongs_to :author, class_name: "User", optional: true + belongs_to :created_by, class_name: "User", optional: true + has_many :bookmarks, as: :bookmarkable, dependent: :destroy has_many :categorizable_items, dependent: :destroy, inverse_of: :categorizable, as: :categorizable has_many :sectorable_items, dependent: :destroy, inverse_of: :sectorable, as: :sectorable diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index fe725c838..bc02d545c 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -411,8 +411,20 @@ <% end %> +
+ <%= f.input :author_id, + as: :select, + label: "Display author", + collection: @authors, + selected: f.object.author_id || current_user&.id, + input_html: { class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %> +
+ <%= "Created by: #{f.object.created_by.name}" if f.object.created_by %> +
+
+
-
<%= "Created by: #{f.object.created_by.name}" if f.object.created_by %>
+
<% if allowed_to?(:destroy?, f.object) %> diff --git a/app/views/resources/_form.html.erb b/app/views/resources/_form.html.erb index 32feb2750..242ac98a9 100644 --- a/app/views/resources/_form.html.erb +++ b/app/views/resources/_form.html.erb @@ -70,6 +70,14 @@ input_html: { class: "w-full rounded border-gray-300 px-2 py-1", min: 1, step: 1 }, label_html: { class: "font-medium mb-1 block" } %>
+
+ <%= f.input :author_id, + as: :select, + label: "Display author", + collection: @authors, + selected: f.object.author_id || f.object.created_by_id || current_user&.id, + input_html: { class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %> +
diff --git a/app/views/stories/_form.html.erb b/app/views/stories/_form.html.erb index e061662fc..7ac7e8822 100644 --- a/app/views/stories/_form.html.erb +++ b/app/views/stories/_form.html.erb @@ -261,6 +261,15 @@ <% end %>
+
+ <%= f.input :author_id, + as: :select, + label: "Display author", + collection: @authors, + selected: f.object.author_id || current_user&.id, + input_html: { class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %> +
+
<%= f.input :created_by_id, collection: @users.map { |u| [ u.full_name_with_email, u.id ] }, diff --git a/app/views/tutorials/_form.html.erb b/app/views/tutorials/_form.html.erb index 4714e96da..363049f65 100644 --- a/app/views/tutorials/_form.html.erb +++ b/app/views/tutorials/_form.html.erb @@ -1,4 +1,5 @@ <%= simple_form_for(@tutorial, html: { multipart: true }) do |f| %> + <%= f.hidden_field :created_by_id, value: f.object.created_by_id || current_user&.id %> <%= render 'shared/errors', resource: @tutorial if @tutorial.errors.any? %>
@@ -28,6 +29,14 @@ as: :integer, hint: "Order to display videos in (lower numbers display first)" %>
+
+ <%= f.input :author_id, + as: :select, + label: "Display author", + collection: @authors, + selected: f.object.author_id || current_user&.id, + input_html: { class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %> +
<%= render "shared/form_image_fields", f: f, include_primary_asset: true %> diff --git a/db/migrate/20260308143000_add_author_to_public_forms.rb b/db/migrate/20260308143000_add_author_to_public_forms.rb new file mode 100644 index 000000000..f3b57dbbf --- /dev/null +++ b/db/migrate/20260308143000_add_author_to_public_forms.rb @@ -0,0 +1,9 @@ +class AddAuthorToPublicForms < ActiveRecord::Migration[8.1] + def change + add_reference :stories, :author, foreign_key: { to_table: :users }, null: true + add_reference :events, :author, foreign_key: { to_table: :users }, null: true + add_reference :resources, :author, foreign_key: { to_table: :users }, null: true + add_reference :tutorials, :author, foreign_key: { to_table: :users }, null: true + add_reference :tutorials, :created_by, foreign_key: { to_table: :users }, null: true + end +end diff --git a/spec/factories/events.rb b/spec/factories/events.rb index 8bf9f2e77..578c01fb5 100644 --- a/spec/factories/events.rb +++ b/spec/factories/events.rb @@ -1,5 +1,6 @@ FactoryBot.define do factory :event do + association :author, factory: :user association :created_by, factory: :user title { "sample title" } description { "sample description" } diff --git a/spec/factories/resources.rb b/spec/factories/resources.rb index c2d2229f6..823f4daa4 100644 --- a/spec/factories/resources.rb +++ b/spec/factories/resources.rb @@ -1,5 +1,6 @@ FactoryBot.define do factory :resource do + association :author, factory: :user association :created_by, factory: :user title { Faker::Lorem.sentence } kind { Resource::PUBLISHED_KINDS.sample } diff --git a/spec/factories/stories.rb b/spec/factories/stories.rb index c2e0848bf..db7bc6e1f 100644 --- a/spec/factories/stories.rb +++ b/spec/factories/stories.rb @@ -3,6 +3,7 @@ association :windows_type association :organization association :workshop + association :author, factory: :user association :created_by, factory: :user association :updated_by, factory: :user sequence(:title) { |n| "Story #{n}" } diff --git a/spec/factories/tutorials.rb b/spec/factories/tutorials.rb index 87c630367..8f4061c39 100644 --- a/spec/factories/tutorials.rb +++ b/spec/factories/tutorials.rb @@ -1,5 +1,7 @@ FactoryBot.define do factory :tutorial do + association :author, factory: :user + association :created_by, factory: :user title { "MyString" } body { "MyText" } featured { false }