diff --git a/AGENTS.md b/AGENTS.md
index b95413ced..cc4da6e1e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -101,6 +101,7 @@ This codebase (Rails 8.1)
| `Story` | Editorial content with facilitators, primary/gallery assets |
| `Resource` | Handouts, toolkits, templates with downloadable assets |
| `Person` | Organization affiliates with contacts, addresses, sectors |
+| `OtherResponse` | A free-text "Other" a person typed on a tag-backed form question (`kind: "sector"` today), captured at registration so a curator can `promote` it into a real `Sector` tag, `keep` it as a chip, or `dismiss` it (hide from profile/edit). Reviewed at `/other_responses` |
| `Organization` | Groups with affiliations, addresses, logos via ActiveStorage |
| `Grant` | Donated funds (polymorphic `donor`: Organization or Person) with eligibility criteria, tasks, deadlines; parent of `Scholarship`. Scholarship totals cannot exceed the grant amount |
| `Scholarship` | Award to a `Person`; optionally drawn from a `Grant`, syncs to event registration `Allocation` |
diff --git a/app/controllers/other_responses_controller.rb b/app/controllers/other_responses_controller.rb
new file mode 100644
index 000000000..bac63e263
--- /dev/null
+++ b/app/controllers/other_responses_controller.rb
@@ -0,0 +1,96 @@
+class OtherResponsesController < ApplicationController
+ before_action :set_other_response, only: :update
+
+ # Review page: the same free-text "Other" sector value typed across many
+ # people, grouped with a count so a curator can decide what to promote.
+ def index
+ authorize!
+ @status_filter = params[:status].presence_in(OtherResponse::VISIBLE_STATUSES)
+ statuses = @status_filter ? [ @status_filter ] : OtherResponse::VISIBLE_STATUSES
+ responses = OtherResponse
+ .sectors.where(status: statuses)
+ .includes(:person)
+
+ @groups = responses.group_by(&:normalized_text).map do |_normalized, rows|
+ {
+ display_text: rows.first.text,
+ normalized_text: rows.first.normalized_text,
+ count: rows.size,
+ status_counts: rows.each_with_object(Hash.new(0)) { |r, h| h[r.status] += 1 }
+ }
+ end.sort_by { |group| [ -group[:count], group[:display_text].downcase ] }
+
+ @sectors = Sector.excluding_other.order(:name)
+ end
+
+ # Bulk keep/dismiss every visible person who typed this value, from the review
+ # queue. Keep leaves it as a free-text chip; dismiss hides it from profiles.
+ def curate
+ authorize! to: :update?
+ status = params[:status]
+ unless %w[kept dismissed].include?(status)
+ return redirect_to other_responses_path, alert: "Choose keep or dismiss."
+ end
+
+ scope = OtherResponse.sectors.where(status: OtherResponse::VISIBLE_STATUSES)
+ .where(normalized_text: OtherResponse.normalize(params[:normalized_text]))
+ count = scope.count
+ scope.find_each { |response| response.update!(status: status) }
+
+ verb = status == "kept" ? "Kept" : "Dismissed"
+ redirect_to other_responses_path(status: params[:return_status].presence),
+ status: :see_other, notice: "#{verb} #{count} response(s)."
+ end
+
+ # Curate a single response — the profile-edit "×" dismisses, and the review
+ # page can keep an individual person's response.
+ def update
+ authorize! @other_response
+ status = params.dig(:other_response, :status)
+ @other_response.update!(status: status) if OtherResponse::STATUSES.include?(status)
+
+ if params[:return_to] == "person_edit"
+ redirect_to edit_person_path(@other_response.person), status: :see_other
+ else
+ redirect_to other_responses_path, status: :see_other
+ end
+ end
+
+ # Promote every non-dismissed person who typed this value into a real Sector
+ # tag — mapping to an existing sector or minting a new (published) one — and
+ # mark those responses promoted so they stop showing as free-text chips.
+ def promote
+ authorize! to: :promote?
+ sector = target_sector
+ return redirect_to other_responses_path, alert: "Pick or name a sector to promote to." unless sector
+
+ responses = OtherResponse.sectors.promotable_now
+ .where(normalized_text: OtherResponse.normalize(params[:normalized_text]))
+
+ responses.includes(:person).find_each do |response|
+ response.person.tag_sectors(primary_ids: [], additional_ids: [ sector.id ])
+ response.update!(status: "promoted", promotable: sector)
+ end
+
+ redirect_to other_responses_path, status: :see_other,
+ notice: "Promoted #{responses.size} response(s) to “#{sector.name}”."
+ end
+
+ private
+
+ def set_other_response
+ @other_response = OtherResponse.find(params[:id])
+ end
+
+ # The promote target: an existing sector by id, or a newly minted published one
+ # from a typed name. Returns nil when neither was supplied.
+ def target_sector
+ if params[:sector_id].present?
+ Sector.find_by(id: params[:sector_id])
+ elsif params[:new_sector_name].present?
+ Sector.find_or_create_by!(name: params[:new_sector_name].strip) do |sector|
+ sector.published = true
+ end
+ end
+ end
+end
diff --git a/app/models/other_response.rb b/app/models/other_response.rb
new file mode 100644
index 000000000..b4607aa2f
--- /dev/null
+++ b/app/models/other_response.rb
@@ -0,0 +1,50 @@
+class OtherResponse < ApplicationRecord
+ # The free-text "Other" a person typed on a tag-backed form question. Only
+ # sectors are wired up today; the `kind` column leaves room for the identical
+ # workshop-setting responses to move here later.
+ KINDS = %w[sector].freeze
+
+ # A response starts life as `pending` (awaiting a curator's decision) and is
+ # then either promoted into a real tag, kept as a free-text chip, or dismissed
+ # (hidden from the person's profile and edit form).
+ STATUSES = %w[pending kept promoted dismissed].freeze
+
+ # Statuses that still surface as an "(other)" chip on the person's pages.
+ VISIBLE_STATUSES = %w[pending kept].freeze
+
+ belongs_to :person
+ belongs_to :promotable, polymorphic: true, optional: true
+ belongs_to :source_form_answer, class_name: "FormAnswer", optional: true
+
+ before_validation :set_normalized_text
+
+ validates :text, presence: true
+ validates :kind, inclusion: { in: KINDS }
+ validates :status, inclusion: { in: STATUSES }
+ validates :normalized_text, uniqueness: { scope: [ :person_id, :kind ] }
+
+ scope :sectors, -> { where(kind: "sector") }
+ scope :visible, -> { where(status: VISIBLE_STATUSES) }
+ scope :pending, -> { where(status: "pending") }
+ scope :promotable_now, -> { where.not(status: "dismissed") }
+
+ # Case/whitespace-insensitive key used both for the unique index and for
+ # grouping the same typed value across many people on the review page.
+ def self.normalize(value)
+ value.to_s.strip.downcase
+ end
+
+ def dismiss!
+ update!(status: "dismissed")
+ end
+
+ def keep!
+ update!(status: "kept")
+ end
+
+ private
+
+ def set_normalized_text
+ self.normalized_text = self.class.normalize(text)
+ end
+end
diff --git a/app/models/person.rb b/app/models/person.rb
index 261d41482..8f0fce29f 100644
--- a/app/models/person.rb
+++ b/app/models/person.rb
@@ -20,6 +20,7 @@ class Person < ApplicationRecord
has_many :categorizable_items, inverse_of: :categorizable, as: :categorizable, dependent: :destroy
has_many :notifications, as: :noticeable, dependent: :destroy
has_many :sectorable_items, as: :sectorable, dependent: :destroy
+ has_many :other_responses, dependent: :destroy
has_many :stories_as_spotlighted_facilitator, inverse_of: :spotlighted_facilitator, class_name: "Story",
dependent: :restrict_with_error
has_many :stories_as_author, inverse_of: :author, class_name: "Story", foreign_key: :author_id,
@@ -285,10 +286,12 @@ def remote_search_label
# profile fields shown on the edit page.
OTHER_WORKSHOP_SETTING_IDENTIFIERS = %w[primary_age_group additional_age_group].freeze
- # Free-text "Other" sectors the person typed on registration forms.
- # They can't be Sector records, so they're surfaced beside the sector tags.
+ # Free-text "Other" sectors the person typed on registration forms, captured
+ # as OtherResponse records (see EventRegistrationServices::PublicRegistration).
+ # They can't be Sector records, so they're surfaced beside the sector tags —
+ # only while pending or explicitly kept (dismissed/promoted ones drop off).
def other_sector_responses
- other_form_responses(FormField::SECTOR_FIELD_IDENTIFIERS)
+ other_responses.sectors.visible.order(:text)
end
# Free-text "Other" workshop settings (category-backed fields) from forms.
diff --git a/app/policies/other_response_policy.rb b/app/policies/other_response_policy.rb
new file mode 100644
index 000000000..fb48adc03
--- /dev/null
+++ b/app/policies/other_response_policy.rb
@@ -0,0 +1,8 @@
+class OtherResponsePolicy < ApplicationPolicy
+ # Curating free-text "Other" responses (reviewing, promoting, keeping,
+ # dismissing) is an admin-only task. index?/update? fall back to manage?.
+
+ def promote?
+ admin?
+ end
+end
diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb
index 0930c1b8e..f2c8dbbe5 100644
--- a/app/services/event_registration_services/public_registration.rb
+++ b/app/services/event_registration_services/public_registration.rb
@@ -422,6 +422,7 @@ def invoice_requested?
def create_form_submission(person)
submission = FormSubmission.create!(person: person, form: @form, event: @event)
save_form_answers(submission)
+ capture_other_sector_responses(submission)
submission
end
@@ -430,9 +431,30 @@ def update_form_submission(person)
record.event = @event
end
save_form_answers(submission)
+ capture_other_sector_responses(submission)
submission
end
+ # Materialize the free-text "Other" sector answers as OtherResponse records so
+ # they can be curated (promoted/kept/dismissed). Reuses OtherOption.texts, the
+ # same extraction used to display them, and de-dupes on the person's normalized
+ # value so a repeat registration of the same text doesn't create a second row.
+ def capture_other_sector_responses(submission)
+ submission.form_answers
+ .joins(:form_field)
+ .where(form_fields: { field_identifier: FormField::SECTOR_FIELD_IDENTIFIERS })
+ .each do |answer|
+ OtherOption.texts(answer.submitted_answer).each do |text|
+ submission.person.other_responses.find_or_create_by!(
+ kind: "sector", normalized_text: OtherResponse.normalize(text)
+ ) do |response|
+ response.text = text
+ response.source_form_answer = answer
+ end
+ end
+ end
+ end
+
def save_form_answers(submission)
@form_params.each do |field_id, raw_value|
field = @form.form_fields.find_by(id: field_id)
diff --git a/app/views/other_responses/index.html.erb b/app/views/other_responses/index.html.erb
new file mode 100644
index 000000000..b66994c0e
--- /dev/null
+++ b/app/views/other_responses/index.html.erb
@@ -0,0 +1,79 @@
+<% content_for(:page_bg_class, "admin-only bg-blue-100") %>
+<%= link_to sectors_path, class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 px-2 py-1 mb-2" do %>
+ Sectors
+<% end %>
+
+
+
+
+
+
Review “Other” sectors
+
+ Free-text sectors people typed on registration forms, grouped by value.
+ Promote a recurring one into a real sector tag for everyone who typed it.
+
+
+
+
+
+
+ <% [ [ "All", nil ], [ "Pending", "pending" ], [ "Kept", "kept" ] ].each do |label, value| %>
+ <% active = @status_filter == value %>
+ <%= link_to label, other_responses_path(status: value),
+ class: "px-3 py-1 rounded-full #{active ? "bg-gray-800 text-white" : "bg-white text-gray-600 border border-gray-300 hover:bg-gray-100"}" %>
+ <% end %>
+
+
+
+ <% if @groups.any? %>
+
+
+
+ | Response |
+ People |
+ Promote to a sector |
+
+
+
+ <% @groups.each do |group| %>
+
+ |
+ <%= group[:display_text] %>
+ <% if group[:status_counts]["kept"].to_i.positive? %>
+ (<%= group[:status_counts]["kept"] %> kept)
+ <% end %>
+ |
+ <%= group[:count] %> |
+
+ <%= form_with url: promote_other_responses_path, method: :post,
+ class: "flex flex-wrap items-center gap-2" do %>
+ <%= hidden_field_tag :normalized_text, group[:normalized_text] %>
+ <%= select_tag :sector_id,
+ options_from_collection_for_select(@sectors, :id, :name),
+ include_blank: "Existing sector…",
+ class: "rounded-md border-gray-300 text-sm" %>
+ or
+ <%= text_field_tag :new_sector_name, group[:display_text],
+ placeholder: "New sector name",
+ class: "rounded-md border-gray-300 text-sm" %>
+ <%= submit_tag "Promote", class: "btn btn-primary-outline text-sm",
+ data: { turbo_confirm: "Promote “#{group[:display_text]}” for all #{group[:count]} people?" } %>
+ <% end %>
+
+ <%= button_to "Keep all", curate_other_responses_path(normalized_text: group[:normalized_text], status: "kept", return_status: @status_filter),
+ class: "text-gray-500 hover:text-gray-700" %>
+ <%= button_to "Dismiss all", curate_other_responses_path(normalized_text: group[:normalized_text], status: "dismissed", return_status: @status_filter),
+ class: "text-gray-500 hover:text-red-600",
+ form: { data: { turbo_confirm: "Hide “#{group[:display_text]}” from all #{group[:count]} profiles?" } } %>
+
+ |
+
+ <% end %>
+
+
+ <% else %>
+
No “Other” sector responses to review.
+ <% end %>
+
+
+
diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb
index a27721042..31fbb989e 100644
--- a/app/views/people/_form.html.erb
+++ b/app/views/people/_form.html.erb
@@ -160,7 +160,7 @@
.reject { |_, id| (@current_sector_ids || []).include?(id) },
show_admin_flags: true } },
class: "btn btn-secondary-outline" %>
- <%= render "people/other_responses", responses: @person.other_sector_responses %>
+ <%= render "people/other_sector_responses", responses: @person.other_sector_responses, dismissable: true %>
diff --git a/app/views/people/_other_sector_responses.html.erb b/app/views/people/_other_sector_responses.html.erb
new file mode 100644
index 000000000..3a1e27c4d
--- /dev/null
+++ b/app/views/people/_other_sector_responses.html.erb
@@ -0,0 +1,20 @@
+<%# Chips for a person's free-text "Other" sector responses (OtherResponse
+ records). Read-only on the profile; on the edit form (dismissable: true)
+ each chip carries an × that dismisses it (hides it from the profile/edit).
+ Renders bare chips — the caller supplies the surrounding flex container. %>
+<% responses ||= [] %>
+<% dismissable = local_assigns.fetch(:dismissable, false) %>
+<% responses.each do |response| %>
+
+ <%= response.text %>
+ (other)
+ <% if dismissable %>
+ <%= link_to "×",
+ other_response_path(response, other_response: { status: "dismissed" }, return_to: "person_edit"),
+ data: { turbo_method: :patch },
+ class: "ml-2 -mr-1 px-1 leading-none text-gray-400 hover:text-red-600",
+ title: "Hide this response from the profile" %>
+ <% end %>
+
+<% end %>
diff --git a/app/views/people/show.html.erb b/app/views/people/show.html.erb
index 4857fe4d0..d2626fb58 100644
--- a/app/views/people/show.html.erb
+++ b/app/views/people/show.html.erb
@@ -167,7 +167,7 @@
display_leader: true,
is_leader: si.is_leader %>
<% end %>
- <%= render "people/other_responses", responses: other_sectors %>
+ <%= render "people/other_sector_responses", responses: other_sectors %>
<% else %>
None selected.
diff --git a/app/views/sectors/index.html.erb b/app/views/sectors/index.html.erb
index e72ce0e70..d078db12a 100644
--- a/app/views/sectors/index.html.erb
+++ b/app/views/sectors/index.html.erb
@@ -8,6 +8,9 @@
Sectors (<%= @count_display %>)
+ <%= link_to "Review “Other”",
+ other_responses_path,
+ class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
<%= link_to "Dedupe",
dedupe_index_sectors_path,
class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
diff --git a/config/routes.rb b/config/routes.rb
index c984800d0..8fd6110f2 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -165,6 +165,12 @@
resources :comments, only: [ :index, :create, :update ]
end
resources :faqs
+ resources :other_responses, only: [ :index, :update ] do
+ collection do
+ post :promote
+ post :curate
+ end
+ end
resources :notifications, only: [ :index, :show, :update ] do
member do
post :resend
diff --git a/db/migrate/20260705014639_create_other_responses.rb b/db/migrate/20260705014639_create_other_responses.rb
new file mode 100644
index 000000000..84272654f
--- /dev/null
+++ b/db/migrate/20260705014639_create_other_responses.rb
@@ -0,0 +1,28 @@
+class CreateOtherResponses < ActiveRecord::Migration[8.1]
+ # Materializes the free-text "Other" answers people type on tag-backed form
+ # questions (sectors today; workshop settings could follow via `kind`). These
+ # can't be Sector/Category records, so they were previously derived on the fly
+ # from form answers. Capturing them as records lets a curator promote a
+ # recurring value into a real tag, keep it as a free-text chip, or dismiss it.
+ def up
+ return if table_exists?(:other_responses)
+
+ create_table :other_responses do |t|
+ t.references :person, null: false, foreign_key: true
+ t.string :kind, null: false
+ t.string :text, null: false
+ t.string :normalized_text, null: false
+ t.string :status, null: false, default: "pending"
+ t.references :promotable, polymorphic: true, null: true
+ t.references :source_form_answer, null: true, foreign_key: { to_table: :form_answers }
+ t.timestamps
+ end
+
+ add_index :other_responses, [ :person_id, :kind, :normalized_text ],
+ unique: true, name: "index_other_responses_on_person_kind_text"
+ end
+
+ def down
+ drop_table :other_responses, if_exists: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 344a94cbd..8cfe0533a 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_07_05_014631) do
+ActiveRecord::Schema[8.1].define(version: 2026_07_05_014639) 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
@@ -839,6 +839,23 @@
t.index ["windows_type_id"], name: "index_organizations_on_windows_type_id"
end
+ create_table "other_responses", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "kind", null: false
+ t.string "normalized_text", null: false
+ t.bigint "person_id", null: false
+ t.bigint "promotable_id"
+ t.string "promotable_type"
+ t.bigint "source_form_answer_id"
+ t.string "status", default: "pending", null: false
+ t.string "text", null: false
+ t.datetime "updated_at", null: false
+ t.index ["person_id", "kind", "normalized_text"], name: "index_other_responses_on_person_kind_text", unique: true
+ t.index ["person_id"], name: "index_other_responses_on_person_id"
+ t.index ["promotable_type", "promotable_id"], name: "index_other_responses_on_promotable"
+ t.index ["source_form_answer_id"], name: "index_other_responses_on_source_form_answer_id"
+ end
+
create_table "pay_charges", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.integer "amount", null: false
t.integer "amount_refunded"
@@ -1732,6 +1749,8 @@
add_foreign_key "organizations", "locations"
add_foreign_key "organizations", "organization_statuses"
add_foreign_key "organizations", "windows_types"
+ add_foreign_key "other_responses", "form_answers", column: "source_form_answer_id"
+ add_foreign_key "other_responses", "people"
add_foreign_key "pay_charges", "pay_customers", column: "customer_id"
add_foreign_key "pay_charges", "pay_subscriptions", column: "subscription_id"
add_foreign_key "pay_payment_methods", "pay_customers", column: "customer_id"
diff --git a/spec/factories/other_responses.rb b/spec/factories/other_responses.rb
new file mode 100644
index 000000000..1693fd49d
--- /dev/null
+++ b/spec/factories/other_responses.rb
@@ -0,0 +1,21 @@
+FactoryBot.define do
+ factory :other_response do
+ person
+ kind { "sector" }
+ sequence(:text) { |n| "Equine therapy #{n}" }
+ status { "pending" }
+
+ trait :kept do
+ status { "kept" }
+ end
+
+ trait :dismissed do
+ status { "dismissed" }
+ end
+
+ trait :promoted do
+ status { "promoted" }
+ association :promotable, factory: :sector
+ end
+ end
+end
diff --git a/spec/models/other_response_spec.rb b/spec/models/other_response_spec.rb
new file mode 100644
index 000000000..5f3c9182a
--- /dev/null
+++ b/spec/models/other_response_spec.rb
@@ -0,0 +1,62 @@
+require "rails_helper"
+
+RSpec.describe OtherResponse, type: :model do
+ describe "validations" do
+ it "has a valid factory" do
+ expect(build(:other_response)).to be_valid
+ end
+
+ it "requires text" do
+ expect(build(:other_response, text: "")).not_to be_valid
+ end
+
+ it "rejects an unknown kind" do
+ expect(build(:other_response, kind: "nonsense")).not_to be_valid
+ end
+
+ it "rejects an unknown status" do
+ expect(build(:other_response, status: "nonsense")).not_to be_valid
+ end
+
+ it "is unique per person + kind + normalized text" do
+ person = create(:person)
+ create(:other_response, person: person, kind: "sector", text: "Equine therapy")
+ dup = build(:other_response, person: person, kind: "sector", text: " equine therapy ")
+
+ expect(dup).not_to be_valid
+ end
+
+ it "allows the same text for a different person" do
+ create(:other_response, kind: "sector", text: "Equine therapy")
+ expect(build(:other_response, kind: "sector", text: "Equine therapy")).to be_valid
+ end
+ end
+
+ describe "normalization" do
+ it "derives normalized_text from text on save" do
+ response = create(:other_response, text: " Equine Therapy ")
+ expect(response.normalized_text).to eq("equine therapy")
+ end
+ end
+
+ describe "scopes" do
+ it ".visible returns only pending and kept" do
+ pending = create(:other_response)
+ kept = create(:other_response, :kept)
+ create(:other_response, :dismissed)
+ create(:other_response, :promoted)
+
+ expect(OtherResponse.visible).to contain_exactly(pending, kept)
+ end
+ end
+
+ describe "#dismiss! / #keep!" do
+ it "transitions status" do
+ response = create(:other_response)
+ response.dismiss!
+ expect(response.reload.status).to eq("dismissed")
+ response.keep!
+ expect(response.reload.status).to eq("kept")
+ end
+ end
+end
diff --git a/spec/models/person_spec.rb b/spec/models/person_spec.rb
index 29849ef87..049ba6cf3 100644
--- a/spec/models/person_spec.rb
+++ b/spec/models/person_spec.rb
@@ -395,23 +395,17 @@ def answer(identifier, value)
end
describe "#other_sector_responses" do
- # Exercises the legacy "service area" field identifiers on purpose — they
- # must still resolve via FormField::SECTOR_FIELD_IDENTIFIERS.
- it "returns free-text Other values from primary sector fields" do
- answer("primary_service_area", "5, Other: Equine therapy")
- answer("primary_service_area_single", "Other: Music therapy")
+ it "returns the person's visible sector OtherResponses" do
+ create(:other_response, person: person, kind: "sector", text: "Equine therapy")
+ create(:other_response, :kept, person: person, kind: "sector", text: "Music therapy")
- expect(person.other_sector_responses).to contain_exactly("Equine therapy", "Music therapy")
+ expect(person.other_sector_responses.map(&:text))
+ .to contain_exactly("Equine therapy", "Music therapy")
end
- it "ignores answers without an Other value" do
- answer("primary_service_area", "5, 12")
-
- expect(person.other_sector_responses).to be_empty
- end
-
- it "does not pull from unrelated fields" do
- answer("primary_age_group", "Other: School")
+ it "omits dismissed and promoted responses" do
+ create(:other_response, :dismissed, person: person, kind: "sector", text: "Hidden")
+ create(:other_response, :promoted, person: person, kind: "sector", text: "Promoted")
expect(person.other_sector_responses).to be_empty
end
diff --git a/spec/requests/events/professional_field_identifiers_spec.rb b/spec/requests/events/professional_field_identifiers_spec.rb
index c5d832613..47dab03b1 100644
--- a/spec/requests/events/professional_field_identifiers_spec.rb
+++ b/spec/requests/events/professional_field_identifiers_spec.rb
@@ -105,7 +105,7 @@
it "tags the person with the primary/additional split and captures the Other free text" do
expect(primary_sector_of(registrant)).to eq(sector_education)
expect(additional_sectors_of(registrant)).to contain_exactly(sector_mh)
- expect(registrant.other_sector_responses).to include("Equine therapy")
+ expect(registrant.other_sector_responses.map(&:text)).to include("Equine therapy")
expect(registrant.primary_age_groups).to contain_exactly(age_adults)
expect(registrant.additional_age_groups).to contain_exactly(age_teens, age_children)
end
diff --git a/spec/requests/other_responses_spec.rb b/spec/requests/other_responses_spec.rb
new file mode 100644
index 000000000..265eca60b
--- /dev/null
+++ b/spec/requests/other_responses_spec.rb
@@ -0,0 +1,118 @@
+require "rails_helper"
+
+RSpec.describe "OtherResponses", type: :request do
+ let(:admin) { create(:user, :admin) }
+
+ describe "GET /other_responses" do
+ it "requires an admin" do
+ get other_responses_path
+ expect(response).not_to have_http_status(:ok)
+ end
+
+ it "groups the same value across people with a count" do
+ sign_in admin
+ alice = create(:person)
+ bob = create(:person)
+ create(:other_response, person: alice, kind: "sector", text: "Equine therapy")
+ create(:other_response, person: bob, kind: "sector", text: "equine therapy")
+ create(:other_response, :dismissed, person: bob, kind: "sector", text: "Hidden one")
+
+ get other_responses_path
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Equine therapy")
+ expect(response.body).not_to include("Hidden one")
+ end
+
+ it "filters to a single status" do
+ sign_in admin
+ create(:other_response, kind: "sector", text: "Pending value")
+ create(:other_response, :kept, kind: "sector", text: "Kept value")
+
+ get other_responses_path(status: "kept")
+
+ expect(response.body).to include("Kept value")
+ expect(response.body).not_to include("Pending value")
+ end
+ end
+
+ describe "POST /other_responses/curate (bulk)" do
+ it "keeps every visible person who typed the value" do
+ sign_in admin
+ one = create(:other_response, kind: "sector", text: "Equine therapy")
+ two = create(:other_response, kind: "sector", text: "equine therapy")
+
+ post curate_other_responses_path,
+ params: { normalized_text: "equine therapy", status: "kept" }
+
+ expect(one.reload.status).to eq("kept")
+ expect(two.reload.status).to eq("kept")
+ end
+
+ it "dismisses every visible person who typed the value" do
+ sign_in admin
+ response_record = create(:other_response, :kept, kind: "sector", text: "Equine therapy")
+
+ post curate_other_responses_path,
+ params: { normalized_text: "equine therapy", status: "dismissed" }
+
+ expect(response_record.reload.status).to eq("dismissed")
+ end
+
+ it "rejects an unsupported status" do
+ sign_in admin
+ response_record = create(:other_response, kind: "sector", text: "Equine therapy")
+
+ post curate_other_responses_path,
+ params: { normalized_text: "equine therapy", status: "promoted" }
+
+ expect(response).to redirect_to(other_responses_path)
+ expect(response_record.reload.status).to eq("pending")
+ end
+ end
+
+ describe "PATCH /other_responses/:id (dismiss)" do
+ it "dismisses the response and returns to the person edit page" do
+ sign_in admin
+ response_record = create(:other_response, kind: "sector", text: "Equine therapy")
+
+ patch other_response_path(response_record),
+ params: { other_response: { status: "dismissed" }, return_to: "person_edit" }
+
+ expect(response).to redirect_to(edit_person_path(response_record.person))
+ expect(response_record.reload.status).to eq("dismissed")
+ end
+ end
+
+ describe "POST /other_responses/promote" do
+ it "tags every non-dismissed person and marks the responses promoted" do
+ sign_in admin
+ sector = create(:sector, name: "Equine Therapy")
+ kept = create(:other_response, person: create(:person), kind: "sector", text: "Equine therapy")
+ dismissed = create(:other_response, :dismissed, person: create(:person), kind: "sector", text: "Equine therapy")
+
+ post promote_other_responses_path,
+ params: { normalized_text: "equine therapy", sector_id: sector.id }
+
+ expect(kept.reload.status).to eq("promoted")
+ expect(kept.person.sectors).to include(sector)
+ expect(dismissed.reload.status).to eq("dismissed")
+ expect(dismissed.person.sectors).not_to include(sector)
+ end
+
+ it "mints a new published sector when given a name" do
+ sign_in admin
+ person = create(:person)
+ create(:other_response, person: person, kind: "sector", text: "Equine therapy")
+
+ expect {
+ post promote_other_responses_path,
+ params: { normalized_text: "equine therapy", new_sector_name: "Equine therapy" }
+ }.to change(Sector, :count).by(1)
+
+ sector = Sector.find_by(name: "Equine therapy")
+ expect(sector.published).to be(true)
+ expect(person.sectors).to include(sector)
+ end
+ end
+end
diff --git a/spec/requests/people_other_responses_spec.rb b/spec/requests/people_other_responses_spec.rb
index e7480c9af..c29d1a2ef 100644
--- a/spec/requests/people_other_responses_spec.rb
+++ b/spec/requests/people_other_responses_spec.rb
@@ -14,9 +14,9 @@ def answer(identifier, value)
before { sign_in admin }
describe "profile page" do
- it "shows the Other service area as a free-text chip, not the primary sector" do
+ it "shows the Other sector as a free-text chip, not the primary sector" do
person.update!(profile_show_sectors: true)
- answer("primary_service_area", "Other: Equine therapy")
+ create(:other_response, person: person, kind: "sector", text: "Equine therapy")
get person_path(person)
@@ -26,15 +26,25 @@ def answer(identifier, value)
equine_chip = response.body[/Equine therapy.{0,80}/m]
expect(equine_chip).to include("(other)")
end
+
+ it "hides a dismissed Other sector" do
+ person.update!(profile_show_sectors: true)
+ create(:other_response, :dismissed, person: person, kind: "sector", text: "Equine therapy")
+
+ get person_path(person)
+
+ expect(response.body).not_to include("Equine therapy")
+ end
end
describe "edit page" do
- it "shows the Other service area in the sectors section" do
- answer("primary_service_area_single", "Other: Music therapy")
+ it "shows the Other sector in the sectors section with a dismiss control" do
+ create(:other_response, person: person, kind: "sector", text: "Music therapy")
get edit_person_path(person)
expect(response.body).to include("Music therapy")
+ expect(response.body).to include("Hide this response from the profile")
end
it "shows the Other workshop setting near the category checkboxes" do
diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb
index ff0bbd301..8e35d72ed 100644
--- a/spec/views/page_bg_class_alignment_spec.rb
+++ b/spec/views/page_bg_class_alignment_spec.rb
@@ -99,6 +99,7 @@
"app/views/taggings/matrix.html.erb" => "admin-only bg-blue-100",
# index
"app/views/allocations/index.html.erb" => "admin-only bg-blue-100",
+ "app/views/other_responses/index.html.erb" => "admin-only bg-blue-100",
"app/views/banners/index.html.erb" => "admin-only bg-blue-100",
"app/views/comments/index.html.erb" => "admin-only bg-blue-100",
"app/views/bookmarks/index.html.erb" => "admin-only bg-blue-100",