diff --git a/app/controllers/workshop_ideas_controller.rb b/app/controllers/workshop_ideas_controller.rb index f53015bce7..221e119e09 100644 --- a/app/controllers/workshop_ideas_controller.rb +++ b/app/controllers/workshop_ideas_controller.rb @@ -86,6 +86,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 @@ -109,7 +110,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 5d6c75dbdb..37f2ea9525 100644 --- a/app/controllers/workshops_controller.rb +++ b/app/controllers/workshops_controller.rb @@ -153,6 +153,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") @@ -205,7 +206,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 c0cbb49d43..341bfe00f9 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -31,6 +31,10 @@ def communications_email end has_many :reports has_many :workshop_logs + has_many :workshop_ideas + has_many :workshops + has_many :workshops_as_author, through: :people, source: :workshops_as_author + has_many :workshop_ideas_as_author, through: :people, source: :workshop_ideas_as_author has_many :grants, as: :funder, dependent: :destroy has_many :categorizable_items, dependent: :destroy, inverse_of: :categorizable, as: :categorizable @@ -38,7 +42,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| @@ -241,6 +244,24 @@ def affiliated_workshop_logs direct.or(legacy).distinct end + def affiliated_workshops + return Workshop.none unless persisted? + + direct = Workshop.where(organization_id: id) + indirect = Workshop.where(author_id: people.select(:id)) + direct.or(indirect).distinct + end + alias_method :all_workshops, :affiliated_workshops + + def affiliated_workshop_ideas + return WorkshopIdea.none unless persisted? + + direct = WorkshopIdea.where(organization_id: id) + indirect = WorkshopIdea.where(author_id: people.select(:id)) + direct.or(indirect).distinct + end + alias_method :all_workshop_ideas, :affiliated_workshop_ideas + # Display order, and what the attendees index filters on. FACILITATOR_PROGRAM_STATUSES = FacilitatorProgramStatus::STATUSES diff --git a/app/models/workshop.rb b/app/models/workshop.rb index 56e881233d..c729ff7ef1 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..d1081e0714 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 @@ -35,6 +36,10 @@ class WorkshopIdea < ApplicationRecord validates :title, presence: true, uniqueness: { case_sensitive: false }, length: { maximum: 255 } + def name + title + end + # Nested attributes accepts_nested_attributes_for :primary_asset, reject_if: :all_blank, allow_destroy: true accepts_nested_attributes_for :gallery_assets, reject_if: :all_blank, allow_destroy: true 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/organizations/show.html.erb b/app/views/organizations/show.html.erb index fe45c2e5b9..a4690562a2 100644 --- a/app/views/organizations/show.html.erb +++ b/app/views/organizations/show.html.erb @@ -227,14 +227,30 @@ <% if @organization.profile_show_workshops? %>

Workshops authored

- <% if @organization.workshops.any? %> - <% @organization.workshops.each do |workshop| %> - <%= link_to workshop.name, workshop_path(workshop), class: button_classes(:secondary_outline) %> - <% end %> + <% workshops = @organization.affiliated_workshops %> + <% if workshops.any? %> +
+ <% workshops.each do |workshop| %> + <%= link_to workshop.name, workshop_path(workshop), class: button_classes(:secondary_outline) %> + <% end %> +
<% else %>

No workshops authored yet.

<% end %>
+
+

Workshop ideas

+ <% workshop_ideas = @organization.affiliated_workshop_ideas %> + <% if workshop_ideas.any? %> +
+ <% workshop_ideas.each do |idea| %> + <%= link_to (idea.respond_to?(:name) ? idea.name : idea.title), workshop_idea_path(idea), class: button_classes(:secondary_outline) %> + <% end %> +
+ <% else %> +

No workshop ideas yet.

+ <% end %> +
<% end %> <% if @organization.profile_show_stories? %> diff --git a/app/views/workshop_ideas/_form.html.erb b/app/views/workshop_ideas/_form.html.erb index 0e7f4ef325..77069dfe2e 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,31 @@
+ +
+
+ <% 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, + label_method: :type_name, + value_method: :id, + label: "Organization", + required: false, + disabled: promoted_to_workshop, + include_blank: "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" } %> + <% 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 84196fb89d..27edd6b3ed 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 779556a09f..02a4a28f8a 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: <%= person_name(@workshop_idea.created_by) %> diff --git a/app/views/workshops/_form.html.erb b/app/views/workshops/_form.html.erb index 83bec3b2e9..5137232a01 100644 --- a/app/views/workshops/_form.html.erb +++ b/app/views/workshops/_form.html.erb @@ -85,6 +85,26 @@ 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" } %> +
+ <% 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, + label_method: :type_name, + value_method: :id, + label: "Organization", + required: false, + include_blank: "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" } %> + <% 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 2c3d578b62..e55ad05c98 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -2486,8 +2486,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 @@ -2678,11 +2680,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 @@ -2954,6 +2958,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" @@ -2981,6 +2986,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/db/seeds/features/2026-10-22-organization-workshops-and-ideas.yml b/db/seeds/features/2026-10-22-organization-workshops-and-ideas.yml new file mode 100644 index 0000000000..e092810db2 --- /dev/null +++ b/db/seeds/features/2026-10-22-organization-workshops-and-ideas.yml @@ -0,0 +1,8 @@ +name: "Workshops and ideas on organization profiles" +area: content +display_status: user_facing +released_on: 2026-10-22 +action_path: "/organizations" +summary: >- + Workshops and workshop ideas now link to organizations directly and display on + organization profiles, including content authored by affiliated facilitators. 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 374dfd477e..77537d7664 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -13,6 +13,10 @@ 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) } + it { should have_many(:workshops_as_author).through(:people) } + it { should have_many(:workshop_ideas_as_author).through(:people) } end describe 'validations' do @@ -522,6 +526,53 @@ def facilitator(primary:, additional: []) expect(organization.active_facilitator_age_groups).to eq([ teen, young, adult ]) end end + + describe "#affiliated_workshops" do + let(:organization) { create(:organization) } + let(:affiliated_person) { create(:person) } + let(:windows_type) { create(:windows_type, :adult) } + + before do + create(:affiliation, organization: organization, person: affiliated_person) + end + + it "includes directly associated workshops and workshops authored by affiliated people without duplicates" do + 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 + + expect(results).to include(direct_workshop, authored_workshop, both_workshop) + expect(results).not_to include(unrelated_workshop) + expect(results.where(id: both_workshop.id).count).to eq(1) + end + end + + describe "#affiliated_workshop_ideas" do + let(:organization) { create(:organization) } + let(:affiliated_person) { create(:person) } + let(:windows_type) { create(:windows_type, :adult) } + let(:admin) { create(:user, :admin) } + + before do + create(:affiliation, organization: organization, person: affiliated_person) + end + + it "includes directly associated ideas and ideas authored by affiliated people without duplicates" do + 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 + + expect(results).to include(direct_idea, authored_idea, both_idea) + expect(results).not_to include(unrelated_idea) + expect(results.where(id: both_idea.id).count).to eq(1) + end + end end RSpec.describe Organization, "scholarship index helpers" 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 1981d72098..2a48ec6d17 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) } @@ -39,6 +40,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/organizations_spec.rb b/spec/requests/organizations_spec.rb index ef0e0b42fb..64abb70223 100644 --- a/spec/requests/organizations_spec.rb +++ b/spec/requests/organizations_spec.rb @@ -96,6 +96,46 @@ expect(response).to be_successful end + it "shows directly associated workshops and ideas on the profile" do + organization = Organization.create!(valid_attributes) + windows_type = create(:windows_type, :adult) + workshop = create(:workshop, organization: organization, title: "Org Pottery Workshop", windows_type: windows_type) + idea = create(:workshop_idea, organization: organization, title: "Org Pottery Idea", windows_type: windows_type, created_by: admin) + + get organization_url(organization) + + expect(response.body).to include(workshop.title) + expect(response.body).to include(idea.title) + end + + it "shows workshops and ideas authored by affiliated people on the profile" do + organization = Organization.create!(valid_attributes) + affiliated_person = create(:person) + create(:affiliation, organization: organization, person: affiliated_person) + windows_type = create(:windows_type, :adult) + workshop = create(:workshop, author: affiliated_person, title: "Affiliated Weaving Workshop", windows_type: windows_type) + idea = create(:workshop_idea, author: affiliated_person, title: "Affiliated Weaving Idea", windows_type: windows_type, created_by: admin) + + get organization_url(organization) + + expect(response.body).to include(workshop.title) + expect(response.body).to include(idea.title) + end + + it "does not duplicate workshops or ideas that are both directly and indirectly associated" do + organization = Organization.create!(valid_attributes) + affiliated_person = create(:person) + create(:affiliation, organization: organization, person: affiliated_person) + windows_type = create(:windows_type, :adult) + workshop = create(:workshop, organization: organization, author: affiliated_person, title: "Dual Mosaic Workshop", windows_type: windows_type) + idea = create(:workshop_idea, organization: organization, author: affiliated_person, title: "Dual Mosaic Idea", windows_type: windows_type, created_by: admin) + + get organization_url(organization) + + expect(response.body.scan(workshop.title).count).to eq(1) + expect(response.body.scan(idea.title).count).to eq(1) + end + it "hides the Monthly reports row when there are no monthly reports" do organization = Organization.create!(valid_attributes) get organization_url(organization) diff --git a/spec/requests/workshop_ideas_spec.rb b/spec/requests/workshop_ideas_spec.rb index e11ba382da..abddcbfb68 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,10 +117,26 @@ 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 the author's confirmation and the admin FYI" do submitter = create(:user, :with_person, super_user: true) sign_in submitter - expect(NotificationServices::CreateNotification).to receive(:call).with( hash_including( kind: :idea_submitted, @@ -125,6 +179,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) @@ -180,6 +254,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 @@ -188,6 +300,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 5c84aaae7b..d51e4772ae 100644 --- a/spec/requests/workshops_spec.rb +++ b/spec/requests/workshops_spec.rb @@ -345,4 +345,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 diff --git a/spec/system/organization_workshops_and_ideas_spec.rb b/spec/system/organization_workshops_and_ideas_spec.rb new file mode 100644 index 0000000000..c6885e2720 --- /dev/null +++ b/spec/system/organization_workshops_and_ideas_spec.rb @@ -0,0 +1,69 @@ +require "rails_helper" + +RSpec.describe "Organization workshops and ideas", type: :system do + let(:admin) { create(:user, :admin) } + let(:windows_type) { create(:windows_type, :adult) } + let(:organization) { create(:organization, name: "Community Arts Center") } + let(:affiliated_person) { create(:person, first_name: "Jane", last_name: "Artist") } + + before do + driven_by(:selenium_chrome_headless) + create(:affiliation, organization: organization, person: affiliated_person) + end + + scenario "Organization profile displays directly associated workshops and ideas" do + sign_in(admin) + + direct_workshop = create(:workshop, :published, + title: "Direct Pottery Workshop", + organization: organization, + windows_type: windows_type) + direct_idea = create(:workshop_idea, + title: "Direct Clay Idea", + organization: organization, + windows_type: windows_type, + created_by: admin) + + visit organization_path(organization) + + expect(page).to have_content("Workshops authored") + expect(page).to have_content(direct_workshop.title) + expect(page).to have_content("Workshop ideas") + expect(page).to have_content(direct_idea.title) + end + + scenario "Organization profile displays workshops and ideas authored by affiliated people" do + sign_in(admin) + + affiliated_workshop = create(:workshop, :published, + title: "Affiliated Painting Workshop", + author: affiliated_person, + organization: nil, + windows_type: windows_type) + affiliated_idea = create(:workshop_idea, + title: "Affiliated Canvas Idea", + author: affiliated_person, + organization: nil, + windows_type: windows_type, + created_by: admin) + + visit organization_path(organization) + + expect(page).to have_content(affiliated_workshop.title) + expect(page).to have_content(affiliated_idea.title) + end + + scenario "Avoids duplicates when a workshop is both directly and indirectly associated" do + sign_in(admin) + + create(:workshop, :published, + title: "Dual Linked Workshop", + author: affiliated_person, + organization: organization, + windows_type: windows_type) + + visit organization_path(organization) + + expect(page.all("a", text: "Dual Linked Workshop").count).to eq(1) + end +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