diff --git a/app/controllers/story_ideas_controller.rb b/app/controllers/story_ideas_controller.rb index fae4b9508c..e885455cd1 100644 --- a/app/controllers/story_ideas_controller.rb +++ b/app/controllers/story_ideas_controller.rb @@ -121,6 +121,10 @@ def set_form_variables @organizations = (@user || current_user)&.organizations&.order(:name) || Organization.none @windows_types = WindowsType.all + # Create a special "New Workshop" option + new_workshop_option = OpenStruct.new(id: "new", type_name: "New Workshop") + @workshops = [ new_workshop_option ] + authorized_scope(Workshop.all).includes(:windows_type).order(:title).to_a + users = authorized_scope(User.has_access.includes(:person)) users = users.or(User.where(id: @story_idea.created_by_id)) if @story_idea&.created_by_id @users = users.distinct.order("people.first_name, people.last_name") @@ -156,7 +160,7 @@ def set_story_idea end def story_idea_params - params.require(:story_idea).permit( + permitted_params = params.require(:story_idea).permit( :title, :rhino_body, :youtube_url, :permission_given, :author_credit_preference, :promoted_to_story, :windows_type_id, :organization_id, :workshop_id, :external_workshop_title, @@ -166,5 +170,12 @@ def story_idea_params primary_asset_attributes: [ :id, :file, :_destroy ], gallery_assets_attributes: [ :id, :file, :_destroy ] ) + + # Clear workshop_id if "new" was selected (triggers external_workshop_title) + if permitted_params[:workshop_id] == "new" + permitted_params[:workshop_id] = nil + end + + permitted_params end end diff --git a/app/frontend/javascript/controllers/workshop_toggle_controller.js b/app/frontend/javascript/controllers/workshop_toggle_controller.js new file mode 100644 index 0000000000..a20f274731 --- /dev/null +++ b/app/frontend/javascript/controllers/workshop_toggle_controller.js @@ -0,0 +1,47 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="workshop-toggle" +// Handles toggling between workshop dropdown and external title field +export default class extends Controller { + static targets = ["dropdown", "externalField"]; + + connect() { + this.checkInitialState(); + } + + checkInitialState() { + const select = this.dropdownTarget.querySelector("select"); + if (select && select.value === "new") { + this.showExternalField(); + } + } + + handleChange(event) { + const value = event.target.value; + if (value === "new") { + this.showExternalField(); + } + } + + showExternalField() { + this.dropdownTarget.classList.add("hidden"); + this.externalFieldTarget.classList.remove("hidden"); + } + + showDropdown() { + this.externalFieldTarget.classList.add("hidden"); + this.dropdownTarget.classList.remove("hidden"); + + // Clear the selection back to prompt + const select = this.dropdownTarget.querySelector("select"); + if (select) { + select.value = ""; + } + + // Clear the external workshop title field + const externalField = this.externalFieldTarget.querySelector("input, textarea"); + if (externalField) { + externalField.value = ""; + } + } +} diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 5d84242875..b49a8f8932 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -577,4 +577,16 @@ def us_time_zone_fundamentals ] ActiveSupport::TimeZone.us_zones.select { |z| zone_names.include?(z.name) }.sort_by { |z| zone_names.index(z.name) }.map { |z| [ z.to_s, z.name ] } end + + def workshop_selected_value(story_idea, params) + # If editing and has external title but no workshop, don't select anything + return nil if story_idea.workshop_id.nil? && story_idea.external_workshop_title.present? + + # Otherwise use param or object value + params[:workshop_id].presence || story_idea.workshop_id + end + + def show_external_workshop_field?(story_idea) + story_idea.workshop_id.nil? && story_idea.external_workshop_title.present? + end end diff --git a/app/models/story_idea.rb b/app/models/story_idea.rb index 65155be0bb..d245f5bb3e 100644 --- a/app/models/story_idea.rb +++ b/app/models/story_idea.rb @@ -43,6 +43,7 @@ def self.search_by_params(params) validates :permission_given, presence: true validates :author_credit_preference, presence: true validates :rhino_body, presence: true + validate :workshop_or_external_title_present # Nested attributes accepts_nested_attributes_for :primary_asset, allow_destroy: true, reject_if: :all_blank @@ -73,4 +74,12 @@ def organization_locality def organization_description organization&.organization_description end + + private + + def workshop_or_external_title_present + if workshop_id.blank? && external_workshop_title.blank? + errors.add(:base, "Please select a workshop or enter an external workshop title") + end + end end diff --git a/app/views/story_ideas/_form.html.erb b/app/views/story_ideas/_form.html.erb index f96d2d17c7..98122e9671 100644 --- a/app/views/story_ideas/_form.html.erb +++ b/app/views/story_ideas/_form.html.erb @@ -56,29 +56,49 @@ onchange: select_caret_onchange } %> -
- <%= f.input :workshop_id, - collection: f.object.workshop.present? ? [[ f.object.workshop.remote_search_label[:label], f.object.workshop.id]] : [], - include_blank: true, - required: true, - input_html: { - class: "w-full px-3 py-2 border border-gray-300 rounded-lg", - data: { - controller: "remote-select", - remote_select_model_value: "workshop" - } - }, - prompt: "Type to search workshops…", - label: "Workshop", - label_html: { class: "block font-medium mb-1 text-gray-700 #{ 'readonly' if promoted_to_story }" } %> - - <%= f.input :external_workshop_title, - as: :text, - label: "Workshop title (if different or unlisted)", - input_html: { - rows: 1, - class: ("readonly" if promoted_to_story) - } %> +
+
+ <%= f.input :workshop_id, + as: :select, + collection: @workshops, + label_method: :type_name, + value_method: :id, + label: "Workshop", + prompt: "Select a Workshop", + required: false, + disabled: promoted_to_story, + selected: workshop_selected_value(f.object, params), + input_html: { value: params[:workshop_id].presence || f.object.workshop_id, + class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 + focus:border-blue-500 #{ 'readonly' if promoted_to_story }", + data: { action: "change->workshop-toggle#handleChange" } }, + label_html: { class: "block text-sm font-medium text-gray-700 mb-1" } %> +
+
+
+
+ <%= f.input :external_workshop_title, + as: :text, + label: "Workshop Title", + wrapper: false, + input_html: { + rows: 1, + class: ("readonly" if promoted_to_story) + }, + label_html: { class: "block text-sm font-medium text-gray-700 mb-1" } %> +
+ <% unless promoted_to_story %> + + <% end %> +
+
<% default_org = default_organization_for_form(f.object) %> diff --git a/spec/models/story_idea_spec.rb b/spec/models/story_idea_spec.rb index 663e12a8a5..f0b08737ac 100644 --- a/spec/models/story_idea_spec.rb +++ b/spec/models/story_idea_spec.rb @@ -3,6 +3,40 @@ RSpec.describe StoryIdea, type: :model do it_behaves_like "author_creditable", factory: :story_idea + describe "validations" do + context "workshop selection" do + it "is valid with a workshop_id" do + story_idea = create(:story_idea, external_workshop_title: nil) + expect(story_idea).to be_persisted + expect(story_idea.workshop).to be_present + end + + it "is valid with external_workshop_title and no workshop" do + story_idea = create(:story_idea, + workshop: nil, + external_workshop_title: "My External Workshop") + expect(story_idea).to be_persisted + expect(story_idea.external_workshop_title).to eq("My External Workshop") + end + + it "is invalid without workshop_id or external_workshop_title" do + story_idea = build(:story_idea, + workshop: nil, + external_workshop_title: nil) + expect(story_idea).not_to be_valid + expect(story_idea.errors[:base]).to include("Please select a workshop or enter an external workshop title") + end + + it "is valid with both workshop_id and external_workshop_title" do + story_idea = create(:story_idea, + external_workshop_title: "My External Workshop") + expect(story_idea).to be_persisted + expect(story_idea.workshop).to be_present + expect(story_idea.external_workshop_title).to eq("My External Workshop") + end + end + end + describe "#workshop_title" do it "returns workshop title when only workshop is present" do workshop = create(:workshop, title: "Healing Art") @@ -22,12 +56,12 @@ end it "returns nil when both workshop and external_workshop_title are absent" do - idea = create(:story_idea, workshop: nil, external_workshop_title: nil) + idea = build(:story_idea, workshop: nil, external_workshop_title: nil) expect(idea.workshop_title).to be_nil end it "returns nil when external_workshop_title is blank" do - idea = create(:story_idea, workshop: nil, external_workshop_title: "") + idea = build(:story_idea, workshop: nil, external_workshop_title: "") expect(idea.workshop_title).to be_nil end end @@ -40,29 +74,31 @@ end it "omits workshop section when no workshop or external title" do - idea = create(:story_idea, workshop: nil, external_workshop_title: nil) + idea = create(:story_idea, workshop: nil, external_workshop_title: "Temp") + idea.update_column(:external_workshop_title, nil) + idea.reload expect(idea.full_name).not_to include(":") expect(idea.full_name).to include(idea.author_credit) end end - describe '.search_by_params' do - let!(:idea_alpha) { create(:story_idea, title: 'Art Healing Journey') } - let!(:idea_beta) { create(:story_idea, title: 'Community Impact Report') } + describe ".search_by_params" do + let!(:idea_alpha) { create(:story_idea, title: "Art Healing Journey") } + let!(:idea_beta) { create(:story_idea, title: "Community Impact Report") } - it 'returns all when no params' do + it "returns all when no params" do results = StoryIdea.search_by_params({}) expect(results).to include(idea_alpha, idea_beta) end - it 'filters by query matching title' do - results = StoryIdea.search_by_params(query: 'Art Healing') + it "filters by query matching title" do + results = StoryIdea.search_by_params(query: "Art Healing") expect(results).to include(idea_alpha) expect(results).not_to include(idea_beta) end - it 'returns empty for non-matching query' do - results = StoryIdea.search_by_params(query: 'nonexistent') + it "returns empty for non-matching query" do + results = StoryIdea.search_by_params(query: "nonexistent") expect(results).not_to include(idea_alpha, idea_beta) end diff --git a/spec/requests/story_ideas_spec.rb b/spec/requests/story_ideas_spec.rb index 9106dfaa7c..66eb9a09a7 100644 --- a/spec/requests/story_ideas_spec.rb +++ b/spec/requests/story_ideas_spec.rb @@ -139,6 +139,19 @@ }.to change(StoryIdea, :count).by(1) end + it "creates a StoryIdea with external_workshop_title when workshop_id is 'new'" do + attrs = valid_attributes.merge( + workshop_id: "new", + external_workshop_title: "My Custom Workshop" + ) + + post story_ideas_url, params: { story_idea: attrs } + + story_idea = StoryIdea.last + expect(story_idea.workshop_id).to be_nil + expect(story_idea.external_workshop_title).to eq("My Custom Workshop") + end + it "redirects to show after create" do post story_ideas_url, params: { story_idea: valid_attributes } expect(response).to redirect_to(story_idea_url(StoryIdea.last))