From 3cc3aea15662454daa81ad53d4b77e6966a72005 Mon Sep 17 00:00:00 2001 From: 1steve78 Date: Sun, 18 Oct 2026 18:55:56 +0530 Subject: [PATCH 1/4] Add optional organization association to workshops and workshop ideas (#2695) - Add nullable organization_id references to workshop_ideas and workshops tables - Add belongs_to :organization, optional: true to WorkshopIdea and Workshop - Update Organization with direct has_many :workshops and has_many :workshop_ideas - Load scoped organizations in WorkshopIdeasController and WorkshopsController forms using currently_affiliated policy scope - Preserve organization_id when promoting WorkshopIdea to Workshop in WorkshopFromIdeaService - Update forms to follow #2693 pattern (single org hidden field vs multiple org select dropdown) - Display organization on index and show views for WorkshopIdea and Workshop - Add comprehensive unit, service, and request specs --- app/controllers/workshop_ideas_controller.rb | 3 +- app/controllers/workshops_controller.rb | 3 +- app/models/organization.rb | 3 +- app/models/workshop.rb | 1 + app/models/workshop_idea.rb | 1 + app/services/workshop_from_idea_service.rb | 3 +- app/views/workshop_ideas/_form.html.erb | 25 ++++ app/views/workshop_ideas/index.html.erb | 2 + app/views/workshop_ideas/show.html.erb | 11 ++ app/views/workshops/_form.html.erb | 19 +++ app/views/workshops/_index_row.html.erb | 3 + app/views/workshops/_show_header.html.erb | 9 +- ...3639_add_organization_to_workshop_ideas.rb | 5 + ...018123640_add_organization_to_workshops.rb | 5 + db/schema.rb | 8 +- spec/models/organization_spec.rb | 2 + spec/models/workshop_idea_spec.rb | 17 +++ spec/models/workshop_spec.rb | 12 ++ spec/requests/workshop_ideas_spec.rb | 132 ++++++++++++++++ spec/requests/workshops_spec.rb | 141 ++++++++++++++++++ .../workshop_from_idea_service_spec.rb | 27 ++++ 21 files changed, 426 insertions(+), 6 deletions(-) create mode 100644 db/migrate/20261018123639_add_organization_to_workshop_ideas.rb create mode 100644 db/migrate/20261018123640_add_organization_to_workshops.rb create mode 100644 spec/services/workshop_from_idea_service_spec.rb diff --git a/app/controllers/workshop_ideas_controller.rb b/app/controllers/workshop_ideas_controller.rb index 66bbbeaa23..95999dc93f 100644 --- a/app/controllers/workshop_ideas_controller.rb +++ b/app/controllers/workshop_ideas_controller.rb @@ -92,6 +92,7 @@ def set_form_variables @potential_series_workshops = authorized_scope(Workshop.published).includes(:windows_type).order(:title) @sectors = Sector.published @windows_types = WindowsType.all + @organizations = authorized_scope(Organization.all, as: :currently_affiliated).order(:name) @users = User.has_access.includes(:person).references(:person).order(Arel.sql("LOWER(people.first_name), LOWER(people.last_name), LOWER(users.email), LOWER(people.email_2), LOWER(people.email)")) @categories_grouped = Category @@ -115,7 +116,7 @@ def set_workshop_idea def workshop_idea_params params.require(:workshop_idea).permit( :title, :staff_notes, :author_credit_preference, - :created_by_id, :updated_by_id, :windows_type_id, + :created_by_id, :updated_by_id, :windows_type_id, :organization_id, :time_closing, :time_creation, :time_demonstration, :time_hours, :time_intro, :time_minutes, :time_opening, :time_opening_circle, :time_warm_up, diff --git a/app/controllers/workshops_controller.rb b/app/controllers/workshops_controller.rb index 698cb92d75..943f625fac 100644 --- a/app/controllers/workshops_controller.rb +++ b/app/controllers/workshops_controller.rb @@ -154,6 +154,7 @@ def set_form_variables potential_series = potential_series.where.not(id: @workshop.id) if @workshop.persisted? @potential_series_workshops = authorized_scope(potential_series).order(:title) @windows_types = WindowsType.all + @organizations = authorized_scope(Organization.all, as: :currently_affiliated).order(:name) @workshop_ideas = authorized_scope(WorkshopIdea.order(created_at: :desc)) .map { |wi| [ "#{wi.created_at.strftime("%Y-%m-%d") @@ -206,7 +207,7 @@ def dedupe_config def workshop_params params.require(:workshop).permit( :title, :featured, :published, - :full_name, :author_id, :windows_type_id, :workshop_idea_id, :author_credit_preference, + :full_name, :author_id, :windows_type_id, :workshop_idea_id, :organization_id, :author_credit_preference, :month, :year, :filemaker_code, :publicly_visible, :publicly_featured, diff --git a/app/models/organization.rb b/app/models/organization.rb index 76c5232c53..f3728cc489 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -31,6 +31,8 @@ def communications_email end has_many :reports has_many :workshop_logs + has_many :workshop_ideas + has_many :workshops has_many :grants, as: :funder, dependent: :destroy has_many :categorizable_items, dependent: :destroy, inverse_of: :categorizable, as: :categorizable @@ -38,7 +40,6 @@ def communications_email # has_many through has_many :categories, through: :categorizable_items has_many :sectors, through: :sectorable_items - has_many :workshops, through: :users # Asset associations has_one_attached :logo, dependent: :purge do |attachable| diff --git a/app/models/workshop.rb b/app/models/workshop.rb index 5001ee05f3..5323792b20 100644 --- a/app/models/workshop.rb +++ b/app/models/workshop.rb @@ -32,6 +32,7 @@ def self.mentionable_rich_text_fields belongs_to :updated_by, class_name: "User", optional: true belongs_to :author, class_name: "Person", optional: true belongs_to :workshop_idea, optional: true + belongs_to :organization, optional: true has_many :bookmarks, as: :bookmarkable, dependent: :destroy has_many :comments, -> { newest_first }, as: :commentable, dependent: :destroy diff --git a/app/models/workshop_idea.rb b/app/models/workshop_idea.rb index dacdc8aec7..89370a3f0f 100644 --- a/app/models/workshop_idea.rb +++ b/app/models/workshop_idea.rb @@ -7,6 +7,7 @@ class WorkshopIdea < ApplicationRecord belongs_to :created_by, class_name: "User" belongs_to :updated_by, class_name: "User" belongs_to :windows_type + belongs_to :organization, optional: true has_many :bookmarks, as: :bookmarkable, dependent: :destroy has_many :notifications, as: :noticeable, dependent: :nullify diff --git a/app/services/workshop_from_idea_service.rb b/app/services/workshop_from_idea_service.rb index 9799048de9..8ca333923a 100644 --- a/app/services/workshop_from_idea_service.rb +++ b/app/services/workshop_from_idea_service.rb @@ -18,11 +18,12 @@ def call def attributes_from_idea workshop_idea.attributes.slice( - "title", "windows_type_id", "author_credit_preference", + "title", "windows_type_id", "organization_id", "author_credit_preference", "time_intro", "time_closing", "time_creation", "time_demonstration", "time_warm_up", "time_opening", "time_opening_circle" ).merge( + organization_id: workshop_idea.organization_id, rhino_objective: workshop_idea.rhino_objective, rhino_materials: workshop_idea.rhino_materials, rhino_optional_materials: workshop_idea.rhino_optional_materials, diff --git a/app/views/workshop_ideas/_form.html.erb b/app/views/workshop_ideas/_form.html.erb index 0e7f4ef325..ded4afcdc5 100644 --- a/app/views/workshop_ideas/_form.html.erb +++ b/app/views/workshop_ideas/_form.html.erb @@ -10,6 +10,7 @@ <% promoted_to_workshop = f.object.workshops.any? %> <% if promoted_to_workshop %> + <%= f.hidden_field :organization_id, value: f.object.organization_id %>
This workshops idea has been promoted to a <% f.object.workshops.each do |workshop| %> @@ -62,6 +63,30 @@
+ +
+
+ <% if @organizations.size != 1 %> + <%= f.input :organization_id, + as: :select, + collection: @organizations, + label_method: :type_name, + value_method: :id, + label: "Organization", + required: false, + disabled: promoted_to_workshop, + prompt: "Select organization", + selected: (params[:organization_id].presence || f.object.organization_id), + hint: "Please contact AWBW if your organization is not listed.", + input_html: { class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm #{"readonly" if promoted_to_workshop}" }, + label_html: { class: "block text-sm font-medium text-gray-700 mb-1" } %> + <% else %> + <%= f.hidden_field :organization_id, + value: f.object.organization_id || @organizations.first.id %> + <% end %> +
+
+ <% if allowed_to?(:manage?, WorkshopIdea) %>
diff --git a/app/views/workshop_ideas/index.html.erb b/app/views/workshop_ideas/index.html.erb index 67b3a96177..e7caead71b 100644 --- a/app/views/workshop_ideas/index.html.erb +++ b/app/views/workshop_ideas/index.html.erb @@ -25,6 +25,7 @@ Title Timeframe Author + Organization Promoted? Actions @@ -46,6 +47,7 @@ <%= workshop_idea.title %> <%= workshop_idea.time_frame_total %> <%= credited_author_link(workshop_idea, class: eyebrow_link_class) %> + <%= workshop_idea.organization&.name %> <% if workshop_idea.workshops.any? %> diff --git a/app/views/workshop_ideas/show.html.erb b/app/views/workshop_ideas/show.html.erb index fd48418237..9ee290dffd 100644 --- a/app/views/workshop_ideas/show.html.erb +++ b/app/views/workshop_ideas/show.html.erb @@ -41,6 +41,17 @@ Timeframe: <%= @workshop_idea.timeframe.presence || "—" %>
+
+ Organization: + <% if @workshop_idea.organization && allowed_to?(:show?, @workshop_idea.organization) %> + <%= link_to @workshop_idea.organization.name, + organization_path(@workshop_idea.organization), + data: { turbo_frame: "_top" }, + class: "inline-block px-3 py-1 rounded-md text-sm font-medium #{DomainTheme.bg_class_for(:organizations, intensity: 100)} #{DomainTheme.text_class_for(:organizations)} #{DomainTheme.bg_class_for(:organizations, intensity: 100, hover: true)}" %> + <% else %> + <%= @workshop_idea.organization&.name || "—" %> + <% end %> +
Created by: <%= @workshop_idea.created_by&.name %> diff --git a/app/views/workshops/_form.html.erb b/app/views/workshops/_form.html.erb index 83bec3b2e9..bb966d6e93 100644 --- a/app/views/workshops/_form.html.erb +++ b/app/views/workshops/_form.html.erb @@ -85,6 +85,25 @@ hint: "If this workshop was created from a Workshop Idea, select it here.", input_html: { value: f.object.workshop_idea_id, class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %> +
+ <% if @organizations.size != 1 %> + <%= f.input :organization_id, + as: :select, + collection: @organizations, + label_method: :type_name, + value_method: :id, + label: "Organization", + required: false, + prompt: "Select organization", + selected: (params[:organization_id].presence || f.object.organization_id), + hint: "Please contact AWBW if your organization is not listed.", + input_html: { class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" }, + label_html: { class: "block text-sm font-medium text-gray-700 mb-1" } %> + <% else %> + <%= f.hidden_field :organization_id, + value: f.object.organization_id || @organizations.first.id %> + <% end %> +
diff --git a/app/views/workshops/_index_row.html.erb b/app/views/workshops/_index_row.html.erb index 72d9921e13..15bca80924 100644 --- a/app/views/workshops/_index_row.html.erb +++ b/app/views/workshops/_index_row.html.erb @@ -53,6 +53,9 @@
By: <%= credited_author_link(workshop, class: "hover:underline", data: { turbo: false }) %> + <% if workshop.organization.present? %> + | <%= workshop.organization.name %> + <% end %> | <%= workshop.date %>
diff --git a/app/views/workshops/_show_header.html.erb b/app/views/workshops/_show_header.html.erb index 2156fd95ec..b38465937c 100644 --- a/app/views/workshops/_show_header.html.erb +++ b/app/views/workshops/_show_header.html.erb @@ -20,7 +20,14 @@ (Unpublished) <% end %> -
Author: <%= credited_author_link(workshop, class: "hover:underline") %>Category: <%= workshop.windows_type&.short_name %>Added: <%= workshop.date %>
+
+ Author: <%= credited_author_link(workshop, class: "hover:underline") %> + <% if workshop.organization.present? %> + Organization: <%= link_to_if allowed_to?(:show?, workshop.organization), workshop.organization.name, organization_path(workshop.organization), class: "hover:underline" %> + <% end %> + Category: <%= workshop.windows_type&.short_name %> + Added: <%= workshop.date %> +
<%= render "assets/primary_image_picker", owner: workshop %>
diff --git a/db/migrate/20261018123639_add_organization_to_workshop_ideas.rb b/db/migrate/20261018123639_add_organization_to_workshop_ideas.rb new file mode 100644 index 0000000000..96e5e09484 --- /dev/null +++ b/db/migrate/20261018123639_add_organization_to_workshop_ideas.rb @@ -0,0 +1,5 @@ +class AddOrganizationToWorkshopIdeas < ActiveRecord::Migration[8.1] + def change + add_reference :workshop_ideas, :organization, type: :integer, null: true, foreign_key: true + end +end diff --git a/db/migrate/20261018123640_add_organization_to_workshops.rb b/db/migrate/20261018123640_add_organization_to_workshops.rb new file mode 100644 index 0000000000..d71a1b17b0 --- /dev/null +++ b/db/migrate/20261018123640_add_organization_to_workshops.rb @@ -0,0 +1,5 @@ +class AddOrganizationToWorkshops < ActiveRecord::Migration[8.1] + def change + add_reference :workshops, :organization, type: :integer, null: true, foreign_key: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 9991841d28..1c8721ffb8 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_10_17_183912) do +ActiveRecord::Schema[8.1].define(version: 2026_10_18_123640) 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 @@ -2426,8 +2426,10 @@ t.text "warm_up" t.text "warm_up_spanish" t.integer "windows_type_id", null: false + t.integer "organization_id" t.index ["author_id"], name: "index_workshop_ideas_on_author_id" t.index ["created_by_id"], name: "index_workshop_ideas_on_created_by_id" + t.index ["organization_id"], name: "index_workshop_ideas_on_organization_id" t.index ["updated_by_id"], name: "index_workshop_ideas_on_updated_by_id" t.index ["windows_type_id"], name: "index_workshop_ideas_on_windows_type_id" end @@ -2631,11 +2633,13 @@ t.integer "windows_type_id" t.bigint "workshop_idea_id" t.integer "year" + t.integer "organization_id" t.index ["author_id"], name: "index_workshops_on_author_id" t.index ["created_at"], name: "index_workshops_on_created_at" t.index ["created_by_id"], name: "index_workshops_on_created_by_id" t.index ["inactive", "led_count", "title"], name: "index_workshops_on_inactive_and_led_count_and_title" t.index ["led_count"], name: "index_workshops_on_led_count" + t.index ["organization_id"], name: "index_workshops_on_organization_id" t.index ["published"], name: "index_workshops_on_published" t.index ["title", "full_name", "objective", "materials", "introduction", "demonstration", "opening_circle", "warm_up", "creation", "closing", "notes", "tips", "misc1", "misc2"], name: "workshop_fullsearch", type: :fulltext t.index ["title"], name: "index_workshops_on_title", type: :fulltext @@ -2901,6 +2905,7 @@ add_foreign_key "windows_types", "users", column: "updated_by_id" add_foreign_key "workshop_age_ranges", "age_ranges" add_foreign_key "workshop_age_ranges", "workshops" + add_foreign_key "workshop_ideas", "organizations" add_foreign_key "workshop_ideas", "people", column: "author_id" add_foreign_key "workshop_ideas", "users", column: "created_by_id" add_foreign_key "workshop_ideas", "users", column: "updated_by_id" @@ -2932,6 +2937,7 @@ add_foreign_key "workshop_variations", "windows_types" add_foreign_key "workshop_variations", "workshop_variation_ideas" add_foreign_key "workshop_variations", "workshops" + add_foreign_key "workshops", "organizations" add_foreign_key "workshops", "people", column: "author_id" add_foreign_key "workshops", "users", column: "created_by_id" add_foreign_key "workshops", "users", column: "updated_by_id" diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index cdd680f628..001602160e 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -13,6 +13,8 @@ it { should have_many(:users).through(:people) } it { should have_many(:reports) } it { should have_many(:workshop_logs) } + it { should have_many(:workshop_ideas) } + it { should have_many(:workshops) } end describe 'validations' do diff --git a/spec/models/workshop_idea_spec.rb b/spec/models/workshop_idea_spec.rb index d93a96b609..c209e4528f 100644 --- a/spec/models/workshop_idea_spec.rb +++ b/spec/models/workshop_idea_spec.rb @@ -3,6 +3,23 @@ RSpec.describe WorkshopIdea, type: :model do it_behaves_like "author_creditable", factory: :workshop_idea, org_credited: false, credits_creator: true + describe "associations" do + it { should belong_to(:organization).optional } + end + + describe "validations" do + it "is valid with an organization" do + org = create(:organization) + idea = build(:workshop_idea, organization: org) + expect(idea).to be_valid + end + + it "is valid without an organization" do + idea = build(:workshop_idea, organization: nil) + expect(idea).to be_valid + end + end + # An idea nobody named an author on still credits its submitter, who filled the form in. describe "#author_credit" do it "credits the creating user's person, not the generic label" do diff --git a/spec/models/workshop_spec.rb b/spec/models/workshop_spec.rb index 12750e1353..b04ed0ac78 100644 --- a/spec/models/workshop_spec.rb +++ b/spec/models/workshop_spec.rb @@ -11,6 +11,7 @@ it { should belong_to(:created_by).optional } it { should belong_to(:author).class_name("Person").optional } it { should belong_to(:windows_type).optional } + it { should belong_to(:organization).optional } it { should have_many(:sectorable_items).dependent(:destroy).inverse_of(:sectorable) } it { should have_many(:sectors).through(:sectorable_items) } @@ -41,6 +42,17 @@ it { should validate_presence_of(:title) } + it "is valid with an organization" do + org = create(:organization) + subject.organization = org + expect(subject).to be_valid + end + + it "is valid without an organization" do + subject.organization = nil + expect(subject).to be_valid + end + # Conditional presence validation for legacy workshops (month, year) context 'when legacy is true' do before { allow(subject).to receive(:legacy).and_return(true) } diff --git a/spec/requests/workshop_ideas_spec.rb b/spec/requests/workshop_ideas_spec.rb index 754c012291..886b8c8ac1 100644 --- a/spec/requests/workshop_ideas_spec.rb +++ b/spec/requests/workshop_ideas_spec.rb @@ -45,6 +45,19 @@ get workshop_ideas_path expect(response).to have_http_status(:ok) end + + it "displays the organization name when present" do + org = create(:organization, name: "Sunset Arts") + create(:workshop_idea, valid_attributes.merge(organization: org)) + get workshop_ideas_path + expect(response.body).to include("Sunset Arts") + end + + it "does not error when organization is nil" do + create(:workshop_idea, valid_attributes.merge(organization: nil)) + get workshop_ideas_path + expect(response).to have_http_status(:ok) + end end describe "GET /show" do @@ -53,6 +66,19 @@ get workshop_idea_path(idea) expect(response).to have_http_status(:ok) end + + it "displays the organization name when present" do + org = create(:organization, name: "Sunset Arts") + idea = create(:workshop_idea, valid_attributes.merge(organization: org)) + get workshop_idea_path(idea) + expect(response.body).to include("Sunset Arts") + end + + it "does not error when organization is nil" do + idea = create(:workshop_idea, valid_attributes.merge(organization: nil)) + get workshop_idea_path(idea) + expect(response).to have_http_status(:ok) + end end describe "GET /new" do @@ -60,6 +86,18 @@ get new_workshop_idea_path expect(response).to have_http_status(:ok) end + + it "offers every organization in the dropdown, including ones the admin isn't affiliated with" do + first_org = create(:organization, name: "First Arts Org") + second_org = create(:organization, name: "Second Arts Org") + + get new_workshop_idea_path + + page = Capybara.string(response.body) + option_values = page.all("select[name='workshop_idea[organization_id]'] option").map(&:value) + expect(option_values).to include(first_org.id.to_s, second_org.id.to_s) + expect(option_values).to include("") + end end describe "GET /edit" do @@ -79,6 +117,23 @@ expect(response).to redirect_to(workshop_idea_path(WorkshopIdea.last)) end + it "creates a WorkshopIdea with an organization" do + org = create(:organization) + expect { + post workshop_ideas_path, params: { workshop_idea: valid_attributes.merge(organization_id: org.id) } + }.to change(WorkshopIdea, :count).by(1) + + expect(WorkshopIdea.last.organization).to eq(org) + end + + it "creates a WorkshopIdea without an organization" do + expect { + post workshop_ideas_path, params: { workshop_idea: valid_attributes.merge(organization_id: "") } + }.to change(WorkshopIdea, :count).by(1) + + expect(WorkshopIdea.last.organization).to be_nil + end + it "sends admin and submitter notifications" do expect(NotificationServices::CreateNotification).to receive(:call).with( hash_including( @@ -120,6 +175,26 @@ expect(response).to redirect_to(workshop_idea_path(idea)) end + it "updates the organization_id" do + idea = create(:workshop_idea, valid_attributes) + org = create(:organization) + + patch workshop_idea_path(idea), + params: { workshop_idea: { organization_id: org.id } } + + expect(idea.reload.organization_id).to eq(org.id) + end + + it "allows clearing the organization_id" do + org = create(:organization) + idea = create(:workshop_idea, valid_attributes.merge(organization: org)) + + patch workshop_idea_path(idea), + params: { workshop_idea: { organization_id: "" } } + + expect(idea.reload.organization_id).to be_nil + end + it "handles RecordNotUnique gracefully" do idea = create(:workshop_idea, valid_attributes) @@ -175,6 +250,44 @@ end end + describe "GET /new" do + before do + allow_any_instance_of(WorkshopIdeaPolicy).to receive(:new?).and_return(true) + end + + it "fills in the one organization the user is currently active at" do + person = create(:person, user: regular_user) + current_org = create(:organization, name: "Current Arts Org") + past_org = create(:organization, name: "Former Arts Org") + create(:affiliation, person: person, organization: current_org, start_date: 1.year.ago) + create(:affiliation, person: person, organization: current_org, start_date: 5.years.ago, end_date: 3.years.ago) + create(:affiliation, person: person, organization: past_org, start_date: 3.years.ago, end_date: 2.years.ago) + + get new_workshop_idea_path + + page = Capybara.string(response.body) + expect(page).to have_field("workshop_idea[organization_id]", type: :hidden, with: current_org.id.to_s) + expect(page).not_to have_select("workshop_idea[organization_id]") + expect(page).not_to have_text(past_org.name) + end + + it "lets the user choose among several current organizations" do + person = create(:person, user: regular_user) + first_org = create(:organization, name: "First Arts Org") + second_org = create(:organization, name: "Second Arts Org") + past_org = create(:organization, name: "Former Arts Org") + create(:affiliation, person: person, organization: first_org) + create(:affiliation, person: person, organization: second_org) + create(:affiliation, person: person, organization: past_org, inactive: true) + + get new_workshop_idea_path + + page = Capybara.string(response.body) + option_values = page.all("select[name='workshop_idea[organization_id]'] option").map(&:value) + expect(option_values).to eq([ "", first_org.id.to_s, second_org.id.to_s ]) + end + end + describe "POST /create" do context "with valid params" do it "creates a WorkshopIdea" do @@ -183,6 +296,25 @@ params: { workshop_idea: valid_attributes } }.to change(WorkshopIdea, :count).by(1) end + + it "creates a WorkshopIdea with an organization" do + org = create(:organization) + expect { + post workshop_ideas_path, + params: { workshop_idea: valid_attributes.merge(organization_id: org.id) } + }.to change(WorkshopIdea, :count).by(1) + + expect(WorkshopIdea.last.organization).to eq(org) + end + + it "creates a WorkshopIdea without an organization" do + expect { + post workshop_ideas_path, + params: { workshop_idea: valid_attributes.merge(organization_id: "") } + }.to change(WorkshopIdea, :count).by(1) + + expect(WorkshopIdea.last.organization).to be_nil + end end context "with invalid params" do diff --git a/spec/requests/workshops_spec.rb b/spec/requests/workshops_spec.rb index 1ca40e066a..95e481f6ff 100644 --- a/spec/requests/workshops_spec.rb +++ b/spec/requests/workshops_spec.rb @@ -308,4 +308,145 @@ end end end + + describe "organization handling" do + let(:admin) { create(:user, :admin) } + let(:windows_type) { create(:windows_type) } + let(:organization) { create(:organization, name: "Hope Harbor") } + + before { sign_in admin } + + describe "GET /new" do + it "offers all organizations in the dropdown with a blank prompt when multiple exist" do + first_org = create(:organization, name: "First Community") + second_org = create(:organization, name: "Second Community") + + get new_workshop_url + + page = Capybara.string(response.body) + option_values = page.all("select[name='workshop[organization_id]'] option").map(&:value) + expect(option_values).to include(first_org.id.to_s, second_org.id.to_s) + expect(option_values).to include("") + end + + it "prefills organization_id in select dropdown when multiple organizations exist" do + create(:organization, name: "Another Org") + idea = create(:workshop_idea, organization: organization, windows_type: windows_type) + + get new_workshop_url(workshop_idea_id: idea.id) + + expect(response).to have_http_status(:ok) + page = Capybara.string(response.body) + expect(page).to have_select("workshop[organization_id]", selected: organization.type_name) + end + + it "uses a hidden field for organization_id when only one organization exists" do + idea = create(:workshop_idea, organization: organization, windows_type: windows_type) + + get new_workshop_url(workshop_idea_id: idea.id) + + expect(response).to have_http_status(:ok) + page = Capybara.string(response.body) + expect(page).to have_field("workshop[organization_id]", type: :hidden, with: organization.id.to_s) + end + end + + describe "POST /create" do + it "creates a workshop with an organization" do + expect { + post workshops_url, params: { + workshop: { + title: "Org Workshop", + windows_type_id: windows_type.id, + organization_id: organization.id, + category_ids: [ "" ] + } + } + }.to change(Workshop, :count).by(1) + + expect(Workshop.last.organization).to eq(organization) + end + + it "creates a workshop without an organization" do + expect { + post workshops_url, params: { + workshop: { + title: "No Org Workshop", + windows_type_id: windows_type.id, + organization_id: "", + category_ids: [ "" ] + } + } + }.to change(Workshop, :count).by(1) + + expect(Workshop.last.organization).to be_nil + end + end + + describe "PATCH /update" do + it "updates the workshop's organization" do + workshop = create(:workshop, organization: nil) + + patch workshop_url(workshop), params: { + workshop: { + title: workshop.title, + organization_id: organization.id, + category_ids: [ "" ] + } + } + + expect(workshop.reload.organization).to eq(organization) + end + + it "allows clearing the organization" do + workshop = create(:workshop, organization: organization) + + patch workshop_url(workshop), params: { + workshop: { + title: workshop.title, + organization_id: "", + category_ids: [ "" ] + } + } + + expect(workshop.reload.organization).to be_nil + end + end + + describe "GET /show" do + it "displays the organization name when present" do + workshop = create(:workshop, organization: organization, published: true) + + get workshop_url(workshop) + + expect(response.body).to include(organization.name) + end + + it "does not error when organization is nil" do + workshop = create(:workshop, organization: nil, published: true) + + get workshop_url(workshop) + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /index" do + it "displays the organization name in results row when present" do + workshop = create(:workshop, organization: organization, published: true) + + get workshops_url, headers: { "Turbo-Frame" => "workshops_results" } + + expect(response.body).to include(organization.name) + end + + it "does not error when organization is nil" do + create(:workshop, organization: nil, published: true) + + get workshops_url, headers: { "Turbo-Frame" => "workshops_results" } + + expect(response).to have_http_status(:ok) + end + end + end end diff --git a/spec/services/workshop_from_idea_service_spec.rb b/spec/services/workshop_from_idea_service_spec.rb new file mode 100644 index 0000000000..f698482877 --- /dev/null +++ b/spec/services/workshop_from_idea_service_spec.rb @@ -0,0 +1,27 @@ +require "rails_helper" + +RSpec.describe WorkshopFromIdeaService do + let(:admin) { create(:user, :admin) } + let(:organization) { create(:organization) } + let(:windows_type) { create(:windows_type) } + + describe "#call" do + it "copies organization_id from the workshop_idea to the initialized workshop" do + idea = create(:workshop_idea, organization: organization, windows_type: windows_type) + + workshop = described_class.new(idea, user: admin).call + + expect(workshop.organization_id).to eq(organization.id) + expect(workshop.organization).to eq(organization) + expect(workshop.workshop_idea_id).to eq(idea.id) + end + + it "sets organization_id to nil when workshop_idea has no organization" do + idea = create(:workshop_idea, organization: nil, windows_type: windows_type) + + workshop = described_class.new(idea, user: admin).call + + expect(workshop.organization_id).to be_nil + end + end +end From b1f32ee8ac8dd24798330b5491b741dc87f155c2 Mon Sep 17 00:00:00 2001 From: 1steve78 Date: Sun, 18 Oct 2026 19:30:25 +0530 Subject: [PATCH 2/4] Safely handle nil @organizations in workshop and workshop idea forms - Guard @organizations with fallback to empty array to avoid NoMethodError when unassigned - Update view specs to assign :organizations and verify organization_id select --- app/views/workshop_ideas/_form.html.erb | 11 ++++++----- app/views/workshops/_form.html.erb | 11 ++++++----- spec/views/workshop_ideas/edit.html.erb_spec.rb | 4 +++- spec/views/workshop_ideas/new.html.erb_spec.rb | 3 +++ spec/views/workshops/edit.html.erb_spec.rb | 1 + 5 files changed, 19 insertions(+), 11 deletions(-) diff --git a/app/views/workshop_ideas/_form.html.erb b/app/views/workshop_ideas/_form.html.erb index ded4afcdc5..882d6fe4a1 100644 --- a/app/views/workshop_ideas/_form.html.erb +++ b/app/views/workshop_ideas/_form.html.erb @@ -66,10 +66,14 @@
- <% if @organizations.size != 1 %> + <% organizations = @organizations || [] %> + <% if organizations.size == 1 %> + <%= f.hidden_field :organization_id, + value: f.object.organization_id || organizations.first.id %> + <% else %> <%= f.input :organization_id, as: :select, - collection: @organizations, + collection: organizations, label_method: :type_name, value_method: :id, label: "Organization", @@ -80,9 +84,6 @@ hint: "Please contact AWBW if your organization is not listed.", input_html: { class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm #{"readonly" if promoted_to_workshop}" }, label_html: { class: "block text-sm font-medium text-gray-700 mb-1" } %> - <% else %> - <%= f.hidden_field :organization_id, - value: f.object.organization_id || @organizations.first.id %> <% end %>
diff --git a/app/views/workshops/_form.html.erb b/app/views/workshops/_form.html.erb index bb966d6e93..8ef1e47e1f 100644 --- a/app/views/workshops/_form.html.erb +++ b/app/views/workshops/_form.html.erb @@ -86,10 +86,14 @@ input_html: { value: f.object.workshop_idea_id, class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %>
- <% if @organizations.size != 1 %> + <% organizations = @organizations || [] %> + <% if organizations.size == 1 %> + <%= f.hidden_field :organization_id, + value: f.object.organization_id || organizations.first.id %> + <% else %> <%= f.input :organization_id, as: :select, - collection: @organizations, + collection: organizations, label_method: :type_name, value_method: :id, label: "Organization", @@ -99,9 +103,6 @@ hint: "Please contact AWBW if your organization is not listed.", input_html: { class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" }, label_html: { class: "block text-sm font-medium text-gray-700 mb-1" } %> - <% else %> - <%= f.hidden_field :organization_id, - value: f.object.organization_id || @organizations.first.id %> <% end %>
diff --git a/spec/views/workshop_ideas/edit.html.erb_spec.rb b/spec/views/workshop_ideas/edit.html.erb_spec.rb index 57db42f111..c14b35ba77 100644 --- a/spec/views/workshop_ideas/edit.html.erb_spec.rb +++ b/spec/views/workshop_ideas/edit.html.erb_spec.rb @@ -15,9 +15,9 @@ assign(:categories_grouped, []) assign(:potential_series_workshops, []) assign(:users, User.none) + assign(:organizations, []) end - it "renders the edit workshop_idea form" do render @@ -26,6 +26,8 @@ assert_select "select[name=?]", "workshop_idea[windows_type_id]" + assert_select "select[name=?]", "workshop_idea[organization_id]" + assert_select "input[type=hidden][name=?]", "workshop_idea[rhino_tips]" assert_select "input[type=hidden][name=?]", "workshop_idea[rhino_objective]" diff --git a/spec/views/workshop_ideas/new.html.erb_spec.rb b/spec/views/workshop_ideas/new.html.erb_spec.rb index 2ed237b264..f2ad8a6cd8 100644 --- a/spec/views/workshop_ideas/new.html.erb_spec.rb +++ b/spec/views/workshop_ideas/new.html.erb_spec.rb @@ -13,6 +13,7 @@ assign(:categories_grouped, []) assign(:potential_series_workshops, []) assign(:users, User.none) + assign(:organizations, []) end it "renders new workshop_idea form" do @@ -23,6 +24,8 @@ assert_select "select[name=?]", "workshop_idea[windows_type_id]" + assert_select "select[name=?]", "workshop_idea[organization_id]" + assert_select "input[type=hidden][name=?]", "workshop_idea[rhino_tips]" assert_select "input[type=hidden][name=?]", "workshop_idea[rhino_objective]" diff --git a/spec/views/workshops/edit.html.erb_spec.rb b/spec/views/workshops/edit.html.erb_spec.rb index 538e1821c2..76ca5da990 100644 --- a/spec/views/workshops/edit.html.erb_spec.rb +++ b/spec/views/workshops/edit.html.erb_spec.rb @@ -14,6 +14,7 @@ assign(:sectors, []) assign(:categories_grouped, []) assign(:age_range_comments, []) + assign(:organizations, []) end context "when user is an admin" do From 8d067a909d43d7f11e6bf571fd1ed669fea2cbac Mon Sep 17 00:00:00 2001 From: 1steve78 Date: Mon, 19 Oct 2026 16:09:41 +0530 Subject: [PATCH 3/4] Update schema version to include organization migrations - Set ActiveRecord::Schema version to 2026_10_18_123640 in db/schema.rb so parallel_rspec and test database setup recognize migrations as applied --- db/schema.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/schema.rb b/db/schema.rb index d14e662c81..be31246a75 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_10_18_112434) do +ActiveRecord::Schema[8.1].define(version: 2026_10_18_123640) 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 From 11e98b3fa05de6e7b8ac0b15640f52eca46c01a9 Mon Sep 17 00:00:00 2001 From: 1steve78 Date: Fri, 23 Oct 2026 17:00:52 +0530 Subject: [PATCH 4/4] Fix workshop idea title collision in organization spec --- spec/factories/workshop_ideas.rb | 2 +- spec/models/organization_spec.rb | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/spec/factories/workshop_ideas.rb b/spec/factories/workshop_ideas.rb index d612b33b58..a672e0c102 100644 --- a/spec/factories/workshop_ideas.rb +++ b/spec/factories/workshop_ideas.rb @@ -1,6 +1,6 @@ FactoryBot.define do factory :workshop_idea do - title { "MyString" } + sequence(:title) { |n| "Workshop Idea #{n}" } description { "MyText" } staff_notes { "MyText" } association :windows_type diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 9dcd5e02a6..77537d7664 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -537,10 +537,10 @@ def facilitator(primary:, additional: []) end it "includes directly associated workshops and workshops authored by affiliated people without duplicates" do - direct_workshop = create(:workshop, organization: organization, windows_type: windows_type) - authored_workshop = create(:workshop, author: affiliated_person, windows_type: windows_type) - both_workshop = create(:workshop, organization: organization, author: affiliated_person, windows_type: windows_type) - unrelated_workshop = create(:workshop, windows_type: windows_type) + direct_workshop = create(:workshop, title: "Direct Workshop", organization: organization, windows_type: windows_type) + authored_workshop = create(:workshop, title: "Authored Workshop", author: affiliated_person, windows_type: windows_type) + both_workshop = create(:workshop, title: "Both Workshop", organization: organization, author: affiliated_person, windows_type: windows_type) + unrelated_workshop = create(:workshop, title: "Unrelated Workshop", windows_type: windows_type) results = organization.affiliated_workshops @@ -561,10 +561,10 @@ def facilitator(primary:, additional: []) end it "includes directly associated ideas and ideas authored by affiliated people without duplicates" do - direct_idea = create(:workshop_idea, organization: organization, windows_type: windows_type, created_by: admin) - authored_idea = create(:workshop_idea, author: affiliated_person, windows_type: windows_type, created_by: admin) - both_idea = create(:workshop_idea, organization: organization, author: affiliated_person, windows_type: windows_type, created_by: admin) - unrelated_idea = create(:workshop_idea, windows_type: windows_type, created_by: admin) + direct_idea = create(:workshop_idea, title: "Direct Idea", organization: organization, windows_type: windows_type, created_by: admin) + authored_idea = create(:workshop_idea, title: "Authored Idea", author: affiliated_person, windows_type: windows_type, created_by: admin) + both_idea = create(:workshop_idea, title: "Both Idea", organization: organization, author: affiliated_person, windows_type: windows_type, created_by: admin) + unrelated_idea = create(:workshop_idea, title: "Unrelated Idea", windows_type: windows_type, created_by: admin) results = organization.affiliated_workshop_ideas