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? %> + + + + + + + + + + <% @groups.each do |group| %> + + + + + + <% end %> + +
ResponsePeoplePromote to a sector
+ <%= 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?" } } %> +
+
+ <% 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",