diff --git a/AGENTS.md b/AGENTS.md index 12d579d53..4ee4ac50b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,6 +102,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" typed on a form question, captured at submission time (registration, scholarship, bulk payment). Polymorphic `owner`: a **sector** "Other" is owned by the `Person` (promotable into a `Sector`, shown on their profile/edit chip); an **organization_type** "Other" is owned by the `Organization` (stored now, not promotable until `OrganizationType` is a model). `generic` questions aren't captured — that stays searchable in the form answers. `field_identifier` records the question; `kind` is derived. Curated at `/other_responses` (grouped by kind/question): `promote` (sectors only), `keep`, `dismiss`. Admins deep-link there from a person's chip. | | `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` | @@ -212,6 +213,14 @@ end - `AffiliationServices::CreateFromRegistration` — On registration / org linking, creates a "job affiliation" with the typed title (when present) plus a standing "Facilitator" affiliation, in one transaction. Skips the facilitator one only when the person already has an active-or-pending affiliation titled exactly "Facilitator" with that org (a current one or one dated to a future training); an ended facilitator affiliation gets a fresh second one. Dedupe is by title + org + dates, so a job title like "Lead Facilitator" still gets its own Facilitator affiliation. Accepts an optional `organization_address:` and sets it on every affiliation it creates (the registrant's typed agency address, upserted onto the org); when an affiliation already exists and is skipped, it backfills that address onto the existing one only if it has none (an admin-set address is never overwritten) +### Sectors + +- `SectorTagging` — `.apply(person:, organizations:, primary_ids:, additional_ids:)` tags a person's sectors (primary + additional) and mirrors them onto the given organizations as additional-only (orgs aggregate members' sectors and have no primary). Shared by registration (person's selections onto the org they registered with) and "Other" sector-response promotion (additional only). Promotion passes `OtherResponse#registration_organizations`, which derives the org from the response's `source_form_answer` → submission → the person's registration for that event — so it tags exactly the org registration would have, without storing one on the response + +### Other responses + +- `OtherResponses::CaptureFromSubmission` — Materializes a form submission's **person-owned** "Other" answers (sectors) as `OtherResponse` records; org-type "Other" is captured separately in `PublicRegistration#sync_agency_type` (owned by the org). Uses `OtherOption.texts`, which keys strictly on the `Other:` prefix, so named specify options and the CE `Yes: N` box are ignored; de-dupes per owner + question. Shared by the registration, scholarship, and bulk-payment submission paths + ### Organizations - `OrganizationServices::UpsertAddress` — Find-or-create an organization's "work" address from a registrant's submitted agency fields (street/city/state/zip/country). Updates the matching city/state address in place, else adds a new one; never demotes the org's existing primary (a registrant's address becomes primary only when the org has none yet). Returns nil when no city is given. Shared by `PublicRegistration` and the admin org-linking actions so both build the org address identically before linking the affiliation to it diff --git a/app/controllers/other_responses_controller.rb b/app/controllers/other_responses_controller.rb new file mode 100644 index 000000000..bed803d66 --- /dev/null +++ b/app/controllers/other_responses_controller.rb @@ -0,0 +1,127 @@ +class OtherResponsesController < ApplicationController + before_action :set_other_response, only: :update + + # Review page: the same free-text "Other" value typed across many people, + # grouped so a curator can decide what to do. Sector "Other"s are bucketed + # together (they all promote into the one Sector catalog); every other question + # is grouped on its own, since its "Other"s are unrelated auxiliary data. + def index + authorize! + @status_filter = params[:status].presence_in(OtherResponse::VISIBLE_STATUSES) + statuses = @status_filter ? [ @status_filter ] : OtherResponse::VISIBLE_STATUSES + responses = OtherResponse.where(status: statuses).includes(:owner, :source_form_answer) + + @groups = responses + .group_by { |response| [ response.group_key, response.normalized_text ] } + .map { |_key, rows| build_group(rows) } + .sort_by { |group| [ group[:promotable] ? 0 : 1, group[:question_label].downcase, -group[:count], group[:display_text].downcase ] } + + @sectors = Sector.excluding_other.order(:name) + end + + # Bulk keep/dismiss every visible person in a group, from the review queue. + # Keep leaves the value in place; dismiss hides it. + def curate + authorize! + status = params[:status] + unless %w[kept dismissed].include?(status) + return redirect_to other_responses_path, alert: "Choose keep or dismiss." + end + + scope = group_scope.where(status: OtherResponse::VISIBLE_STATUSES) + 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.owner), status: :see_other + else + redirect_to other_responses_path, status: :see_other + end + end + + # Promote every non-dismissed person in a sector group 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. Only sector + # groups are promotable, enforced by the .sectors scope. + def promote + authorize! + sector = target_sector + return redirect_to other_responses_path, alert: "Pick or name a sector to promote to." unless sector + + responses = group_scope.sectors.promotable_now + responses.includes(:owner).find_each do |response| + # Reuse registration's tagging so the sector lands on the person AND the + # org(s) they registered with, always as an additional tag (never primary). + SectorTagging.apply(person: response.owner, organizations: response.registration_organizations, + 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 + + def build_group(rows) + first = rows.first + { + kind: first.kind, + field_identifier: first.field_identifier, + promotable: first.promotable?, + question_label: question_label_for(first), + display_text: first.text, + normalized_text: first.normalized_text, + anchor: first.review_anchor, + count: rows.size, + status_counts: rows.each_with_object(Hash.new(0)) { |r, h| h[r.status] += 1 } + } + end + + def question_label_for(response) + case response.kind + when "sector" then "Sectors" + when "organization_type" then "Organization type" + else response.source_form_answer&.question_name_when_answered.presence || response.field_identifier.humanize + end + end + + # The set of responses a curate/promote action targets, matching how the group + # was bucketed: captured kinds by kind, generic questions by field. + def group_scope + scope = OtherResponse.where(normalized_text: OtherResponse.normalize(params[:normalized_text])) + if params[:kind].present? && params[:kind] != "generic" + scope.where(kind: params[:kind]) + else + scope.where(field_identifier: params[:field_identifier]) + end + 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/controllers/people_controller.rb b/app/controllers/people_controller.rb index a314ef04a..192878248 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -296,7 +296,10 @@ def set_form_variables end @person.affiliations.build if @person.affiliations.empty? - @all_sectors = Sector.published.order(:name) + # "Other" is the free-text catch-all, not a real tag (see Sector), so it's + # never offered in the sector picker — a typed "Other" is captured as an + # OtherResponse, not saved as the literal "Other" sector. + @all_sectors = Sector.published.excluding_other.order(:name) @sectors_collection = @all_sectors.pluck(:name, :id) @current_sector_ids = @person.sectorable_items.map(&:sector_id) diff --git a/app/models/concerns/sectors_taggable.rb b/app/models/concerns/sectors_taggable.rb index 14b6bad2e..dde5e2ca7 100644 --- a/app/models/concerns/sectors_taggable.rb +++ b/app/models/concerns/sectors_taggable.rb @@ -53,7 +53,10 @@ def demote_unselected_primary_sectors(primary_ids) end def upsert_sector_items(sector_ids, is_primary:) - Sector.where(id: sector_ids).find_each do |sector| + # "Other" is the free-text catch-all, never a real tag — a typed "Other" is + # captured as an OtherResponse, so it must not become a SectorableItem even + # if its id slips into the submitted set. + Sector.where(id: sector_ids).excluding_other.find_each do |sector| item = sectorable_items.find_or_initialize_by(sector: sector) item.is_primary = is_primary item.save! diff --git a/app/models/organization.rb b/app/models/organization.rb index fb579164c..007a9c03c 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -6,6 +6,7 @@ class Organization < ApplicationRecord belongs_to :windows_type, optional: true has_many :addresses, as: :addressable, dependent: :destroy has_many :bookmarks, as: :bookmarkable, dependent: :destroy + has_many :other_responses, as: :owner, dependent: :destroy has_many :affiliations, dependent: :restrict_with_error, inverse_of: :organization has_many :event_registration_organizations, dependent: :restrict_with_error has_many :event_registrations, through: :event_registration_organizations diff --git a/app/models/other_response.rb b/app/models/other_response.rb new file mode 100644 index 000000000..7b0fe75ad --- /dev/null +++ b/app/models/other_response.rb @@ -0,0 +1,123 @@ +class OtherResponse < ApplicationRecord + # A free-text "Other" someone typed on a form question whose "Other" can (or + # will) become a real tag. Captured at submission time so a curator can + # promote / keep / dismiss it. The `owner` is polymorphic because the answer's + # subject differs by question: a sector "Other" is about the person, an + # organization-type "Other" is about their organization. + # + # - `sector` → owned by a Person; promotable into a `Sector`, shown + # on the person's profile beside the sector tags. + # - `organization_type` → owned by an Organization; stored now, but not yet + # promotable (its promote button is hidden until + # `OrganizationType` is a model). + # + # Questions whose "Other" will never become a tag (`generic`) are not captured + # at all — that data stays searchable in the form answers. + KINDS = %w[sector organization_type generic].freeze + + # Kinds we materialize as records (the rest stay in the form answers). + CAPTURED_KINDS = %w[sector organization_type].freeze + + # Kinds captured against a Person, via their form submission. + PERSON_KINDS = %w[sector].freeze + + # Kinds that can be promoted into a real tag today. + PROMOTABLE_KINDS = %w[sector].freeze + + # The organization-type question. Its "Other" is org-owned (see + # PublicRegistration#sync_agency_type). + ORGANIZATION_TYPE_FIELD_IDENTIFIER = "agency_type" + + # A response starts life as `pending` (awaiting a curator's decision) and is + # then either promoted into a real tag, kept as auxiliary data, or dismissed. + STATUSES = %w[pending kept promoted dismissed].freeze + + # Statuses that still surface (as an "(other)" chip for sectors, or a live row + # in the review queue). + VISIBLE_STATUSES = %w[pending kept].freeze + + belongs_to :owner, polymorphic: true + belongs_to :promotable, polymorphic: true, optional: true + belongs_to :source_form_answer, class_name: "FormAnswer", optional: true + + before_validation :set_kind + before_validation :set_normalized_text + + validates :field_identifier, presence: true + validates :text, presence: true + validates :kind, inclusion: { in: KINDS } + validates :status, inclusion: { in: STATUSES } + validates :normalized_text, uniqueness: { scope: [ :owner_type, :owner_id, :field_identifier ] } + + scope :sectors, -> { where(kind: "sector") } + scope :visible, -> { where(status: VISIBLE_STATUSES) } + scope :pending, -> { where(status: "pending") } + scope :promotable_now, -> { where.not(status: "dismissed") } + + # The coarse category (and thus owner + promotability) for a question. + def self.kind_for(field_identifier) + identifier = field_identifier.to_s + if identifier.in?(FormField::SECTOR_FIELD_IDENTIFIERS) + "sector" + elsif identifier == ORGANIZATION_TYPE_FIELD_IDENTIFIER + "organization_type" + else + "generic" + end + end + + # Case/whitespace-insensitive key used both for the unique index and for + # grouping the same typed value across owners on the review page. + def self.normalize(value) + value.to_s.strip.downcase + end + + # Stable DOM id for a review-page group, so a chip can deep-link to its row. + def self.review_anchor(bucket, normalized_text) + "other-#{bucket}-#{normalized_text}".parameterize + end + + def promotable? + kind.in?(PROMOTABLE_KINDS) + end + + # The organization(s) the person registered with when they typed this response, + # derived from the source form answer's submission + the person's registration + # for that event. Lets promotion mirror a sector onto exactly the orgs a + # registration would have — no need to store the org here. Empty when there's no + # registration context (no source answer, or a non-person owner). + def registration_organizations + event = source_form_answer&.form_submission&.event + return Organization.none unless event && owner.is_a?(Person) + + owner.event_registrations.find_by(event: event)&.organizations || Organization.none + end + + # How the review page buckets this response: captured kinds group by kind (all + # sector "Other"s together, all org-type together); generic groups by question. + def group_key + kind == "generic" ? field_identifier : kind + end + + def review_anchor + self.class.review_anchor(group_key, normalized_text) + end + + def dismiss! + update!(status: "dismissed") + end + + def keep! + update!(status: "kept") + end + + private + + def set_kind + self.kind = self.class.kind_for(field_identifier) if field_identifier.present? + end + + 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 66c166e3a..0d2032be5 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, as: :owner, 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, @@ -305,10 +306,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..4b82329b4 --- /dev/null +++ b/app/policies/other_response_policy.rb @@ -0,0 +1,21 @@ +class OtherResponsePolicy < ApplicationPolicy + # Reviewing and curating free-text "Other" responses (promote/keep/dismiss) is + # an admin-only task. Defined explicitly rather than leaning on the inherited + # manage? fallback, matching how the other policies spell out their rules. + + def index? + admin? + end + + def update? + admin? + end + + def curate? + admin? + end + + def promote? + admin? + end +end diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index 89c731de6..87b88e8c8 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -434,6 +434,37 @@ def additional_sector_count additional_sector_ids.size end + # Free-text "Other" sectors registrants typed, kept as OtherResponse records + # (never real sector tags). These back two things on the background report: a + # single aggregate "Other" bucket in the "All sectors" chart (#..._count / + # #..._registrant_ids), and a separate "Other responses" detail list of the + # actual typed values (#..._rows). Only visible (pending/kept) responses count + # — dismissed/promoted ones drop off. "Other" is an additional-sector answer + # only (the primary dropdown omits it), so it never touches the primary chart. + def other_sector_response_count + other_sector_response_registrant_ids.size + end + + # Distinct registrant ids with at least one visible other-sector response. + def other_sector_response_registrant_ids + @other_sector_response_registrant_ids ||= visible_other_sector_responses + .map(&:owner_id).uniq + end + + # The distinct typed values, each as [ text, registrant_count, registrant_ids ], + # for the "Other responses" detail card. Grouped by normalized text so the same + # answer typed with different casing/whitespace collapses into one row (the + # display text is the first spelling on file). Ordered by count desc, then text. + def other_sector_response_rows + @other_sector_response_rows ||= visible_other_sector_responses + .group_by(&:normalized_text) + .map do |_normalized, responses| + ids = responses.map(&:owner_id).uniq + [ responses.first.text, ids.size, ids ] + end + .sort_by { |text, count, _ids| [ -count, text.downcase ] } + end + # Primary age group(s) served, read from registrants' answers to the # registration form's "primary_age_group" question (each answer stores the # chosen AgeRange category ids, ", "-joined). Resolved to AgeRange categories, @@ -895,6 +926,18 @@ def additional_sector_ids end end + # Visible (pending/kept) sector "Other" responses owned by the registrants, + # loaded once and reused by the aggregate + detail methods above. Ordered by id + # so the display spelling picked per group is stable (first one on file). + def visible_other_sector_responses + @visible_other_sector_responses ||= OtherResponse + .sectors + .visible + .where(owner_type: "Person", owner_id: registrant_ids) + .order(:id) + .to_a + end + # [ person_id, sector_id ] pairs for every sector tag on the registrants. def registrant_sector_pairs @registrant_sector_pairs ||= SectorableItem diff --git a/app/services/event_registration_services/bulk_payment.rb b/app/services/event_registration_services/bulk_payment.rb index cd6041ea2..34bce257c 100644 --- a/app/services/event_registration_services/bulk_payment.rb +++ b/app/services/event_registration_services/bulk_payment.rb @@ -133,6 +133,7 @@ def create_phone_contact(person) def create_form_submission(person) submission = FormSubmission.create!(person: person, form: @form, event: @event, role: "bulk_payment") save_form_answers(submission) + OtherResponses::CaptureFromSubmission.call(submission) submission end diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index 1719e172d..a644da14b 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -217,6 +217,19 @@ def sync_agency_type(organization) return if label.blank? other_text = FormField.other_option?(label) ? specified.strip.presence : nil organization.update!(agency_type: label, agency_type_other: other_text) + capture_organization_type_other(organization, other_text) + end + + # Materialize the org-type "Other" as an OtherResponse owned by the org, so it + # joins the curation queue alongside sector "Other"s. Not promotable yet (no + # OrganizationType model), but stored now so nothing is lost; de-duped per org. + def capture_organization_type_other(organization, text) + return if text.blank? + + organization.other_responses.find_or_create_by!( + field_identifier: OtherResponse::ORGANIZATION_TYPE_FIELD_IDENTIFIER, + normalized_text: OtherResponse.normalize(text) + ) { |response| response.text = text } end # Write value onto attribute when a non-blank value was submitted, overwriting @@ -359,11 +372,8 @@ def assign_tags(person, organization) additional_age_ids = collect_ids_from_checkboxes("additional_age_group") if primary_sector_ids.any? || additional_sector_ids.any? - person.tag_sectors(primary_ids: primary_sector_ids, additional_ids: additional_sector_ids) - # Organizations aggregate sectors across many people and have no single - # "primary", so union everyone's selections in as additional tags rather - # than churning the org's primary on each registration. - organization&.tag_sectors(primary_ids: [], additional_ids: primary_sector_ids + additional_sector_ids) + SectorTagging.apply(person: person, organizations: [ organization ], + primary_ids: primary_sector_ids, additional_ids: additional_sector_ids) end if primary_age_ids.any? || additional_age_ids.any? @@ -442,6 +452,7 @@ def invoice_requested? def create_form_submission(person) submission = FormSubmission.create!(person: person, form: @registration_form, event: @event, role: "registration") save_form_answers(submission) + OtherResponses::CaptureFromSubmission.call(submission) submission end @@ -450,6 +461,7 @@ def update_form_submission(person) record.event = @event end save_form_answers(submission) + OtherResponses::CaptureFromSubmission.call(submission) submission end @@ -496,6 +508,8 @@ def save_scholarship_submission(person) record = submission.form_answers.find_or_initialize_by(form_field: field) record.update!(submitted_answer: text, question_name_when_answered: field.name) end + + OtherResponses::CaptureFromSubmission.call(submission) end def save_continuing_education_submission(person) diff --git a/app/services/other_responses/capture_from_submission.rb b/app/services/other_responses/capture_from_submission.rb new file mode 100644 index 000000000..26f4f480a --- /dev/null +++ b/app/services/other_responses/capture_from_submission.rb @@ -0,0 +1,58 @@ +module OtherResponses + # Materializes the free-text "Other" answers on a form submission as + # OtherResponse records so they can be curated (promoted/kept/dismissed). + # + # Captures the person-owned "Other" answers (sectors today). Organization-type + # "Other" is owned by the org and captured separately, where the org is known + # (PublicRegistration#sync_agency_type). Questions whose "Other" will never + # become a tag are left in the form answers, which stay searchable, rather than + # stored here — but the record still carries `field_identifier`, so switching a + # new question on later is a one-line change. OtherOption.texts keys strictly + # on the "Other:" prefix, so named specify options ("Word of Mouth: …") and the + # CE "Yes: 3" box are ignored. De-dupes per person + question. + # + # Shared by the registration, scholarship, and bulk-payment submission paths. + class CaptureFromSubmission + def self.call(submission) + new(submission).call + end + + def initialize(submission) + @submission = submission + end + + def call + answers.each do |answer| + field_identifier = answer.form_field&.field_identifier + next unless capturable?(field_identifier) + + OtherOption.texts(answer.submitted_answer).each do |text| + capture(field_identifier, text, answer) + end + end + end + + private + + # Capture only the person-owned "Other" questions here — the rest stay + # searchable in the form answers (or, for org-type, are captured elsewhere). + def capturable?(field_identifier) + field_identifier.present? && + OtherResponse.kind_for(field_identifier).in?(OtherResponse::PERSON_KINDS) + end + + def answers + @submission.form_answers.includes(:form_field) + end + + def capture(field_identifier, text, answer) + @submission.person.other_responses.find_or_create_by!( + field_identifier: field_identifier, + normalized_text: OtherResponse.normalize(text) + ) do |response| + response.text = text + response.source_form_answer = answer + end + end + end +end diff --git a/app/services/sector_tagging.rb b/app/services/sector_tagging.rb new file mode 100644 index 000000000..2ac4d7db0 --- /dev/null +++ b/app/services/sector_tagging.rb @@ -0,0 +1,21 @@ +module SectorTagging + # Tags a person's sectors and mirrors them onto the organizations they're + # tied to. Organizations aggregate sectors across many members and have no + # single "primary", so a person's primary + additional sectors are all unioned + # onto each org as additional tags rather than churning the org's primary. + # + # Shared by registration (a person's primary + additional selections onto the + # org they registered with) and "Other" sector-response promotion (an existing + # response's sector onto the person and their orgs — always additional only). + def self.apply(person:, organizations:, primary_ids: [], additional_ids: []) + primary_ids = Array(primary_ids) + additional_ids = Array(additional_ids) + return if primary_ids.empty? && additional_ids.empty? + + person.tag_sectors(primary_ids: primary_ids, additional_ids: additional_ids) + + Array(organizations).compact.each do |organization| + organization.tag_sectors(primary_ids: [], additional_ids: primary_ids + additional_ids) + end + end +end diff --git a/app/views/events/background.html.erb b/app/views/events/background.html.erb index 42bbb8062..896cae5fd 100644 --- a/app/views/events/background.html.erb +++ b/app/views/events/background.html.erb @@ -2,6 +2,13 @@ <% primary_sector_data = @dashboard.primary_sectors.map { |s| [ s.name, @dashboard.primary_sector_counts.fetch(s.id, 0) ] }.sort_by { |_, count| -count } %> <% sector_data = @dashboard.sectors.map { |s| [ s.name, @dashboard.sector_counts.fetch(s.id, 0) ] }.sort_by { |_, count| -count } %> +<%# Free-text "Other" sectors ride along as one aggregate slice, pinned last (the + actual typed values get their own detail card below). The primary chart has no + such slice — "Other" is an additional-sector answer only. %> +<% sector_data += [ [ "Other", @dashboard.other_sector_response_count ] ] if @dashboard.other_sector_response_count.positive? %> +<%# Detail list of the actual typed values, each drilling into the registrants who typed it. %> +<% other_response_data = @dashboard.other_sector_response_rows.map { |text, count, _ids| [ text, count ] } %> +<% other_response_paths = @dashboard.other_sector_response_rows.to_h { |text, _count, ids| [ text, registrants_event_path(@event, registrant_ids: ids.join("-")) ] } %> <% age_group_data = @dashboard.age_groups.map { |c| [ c.name, @dashboard.age_group_counts.fetch(c.id, 0) ] }.sort_by { |_, count| -count } %> <% state_data = @dashboard.state_counts.sort_by { |_, count| -count } %> <% country_data = @dashboard.country_counts.sort_by { |_, count| -count } %> @@ -198,6 +205,7 @@ the matching registrant_ids set). %> <% primary_sector_paths = @dashboard.primary_sectors.to_h { |s| [ s.name, registrants_event_path(@event, registrant_ids: @dashboard.primary_sector_registrant_ids_by_sector.fetch(s.id, []).join("-")) ] } %> <% sector_paths = @dashboard.sectors.to_h { |s| [ s.name, registrants_event_path(@event, sector: s.id) ] } %> + <% sector_paths["Other"] = registrants_event_path(@event, registrant_ids: @dashboard.other_sector_response_registrant_ids.join("-")) if @dashboard.other_sector_response_count.positive? %> <% age_group_paths = @dashboard.age_groups.to_h { |c| [ c.name, registrants_event_path(@event, registrant_ids: @dashboard.age_group_registrant_ids_by_category.fetch(c.id, []).join("-")) ] } %> <% state_paths = @dashboard.states.index_with { |state| registrants_event_path(@event, state: state) } %> <% country_paths = @dashboard.country_registrant_ids_by_country.transform_values { |ids| registrants_event_path(@event, registrant_ids: ids.join("-")) } %> @@ -212,6 +220,11 @@ <% if sector_data.any? %> <%= render "breakdown_card", title: "All sectors", data: sector_data, chart: :pie, palette: palette, row_paths: sector_paths %> <% end %> + <%# The free-text values behind the "Other" sector slice — a list, since these + are un-curated answers a curator later promotes into real sectors. %> + <% if other_response_data.any? %> + <%= render "breakdown_card", title: "Other sector responses", data: other_response_data, chart: nil, palette: palette, row_paths: other_response_paths %> + <% end %> <%# States and countries use the addresses theme color (slate) for their map ramps. %> <% addresses_color = "#475569" %> <%# Age group and Countries are both short; stack them in one column so the pair diff --git a/app/views/other_responses/index.html.erb b/app/views/other_responses/index.html.erb new file mode 100644 index 000000000..dda4b82ea --- /dev/null +++ b/app/views/other_responses/index.html.erb @@ -0,0 +1,115 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<%# Eyebrow returns to wherever the admin came from — a person's profile/edit + when they clicked a chip (return_to + person_id), otherwise the Sectors list. %> +<% back_path, back_label = if params[:return_to] == "person_edit" && params[:person_id].present? + [ edit_person_path(params[:person_id]), "Back to person" ] + elsif params[:return_to] == "person_show" && params[:person_id].present? + [ person_path(params[:person_id]), "Back to person" ] + else + [ sectors_path, "Sectors" ] + end %> +<%= link_to back_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 %> + <%= back_label %> +<% end %> +
+ Free-text “Other” answers people typed on forms, grouped by value. Turn a sector into a + real tag — create a new one or + merge it into an existing sector — or + keep / dismiss the value. +
+| Question | +Response | +People | +Action | +
|---|---|---|---|
| <%= group[:question_label] %> | ++ <%= group[:display_text] %> + <% if group[:status_counts]["kept"].to_i.positive? %> + (<%= group[:status_counts]["kept"] %> kept) + <% end %> + | +<%= group[:count] %> | +
+ <% if group[:promotable] %>
+ <%# Two distinct promote paths, each with its own verb: create a
+ brand-new sector from the typed value, or merge the value into
+ an existing sector (the dedupe path). %>
+
+ <%= form_with url: promote_other_responses_path, method: :post,
+ class: "flex items-center gap-2" do %>
+ <%= hidden_field_tag :kind, group[:kind] %>
+ <%= hidden_field_tag :field_identifier, group[:field_identifier] %>
+ <%= hidden_field_tag :normalized_text, group[:normalized_text] %>
+ New
+ <%= text_field_tag :new_sector_name, group[:display_text],
+ "aria-label": "New sector name",
+ class: "flex-1 min-w-0 rounded-md border-gray-300 text-sm" %>
+ <%= submit_tag "Create sector", class: "btn btn-primary-outline text-sm whitespace-nowrap",
+ data: { turbo_confirm: "Create the sector “#{group[:display_text]}” and tag all #{group[:count]} people?" } %>
+ <% end %>
+ <%= form_with url: promote_other_responses_path, method: :post,
+ class: "flex items-center gap-2" do %>
+ <%= hidden_field_tag :kind, group[:kind] %>
+ <%= hidden_field_tag :field_identifier, group[:field_identifier] %>
+ <%= hidden_field_tag :normalized_text, group[:normalized_text] %>
+ Existing
+ <%= select_tag :sector_id,
+ options_from_collection_for_select(@sectors, :id, :name),
+ include_blank: "Select a sector…",
+ "aria-label": "Existing sector to merge into",
+ class: "flex-1 min-w-0 rounded-md border-gray-300 text-sm" %>
+ <%= submit_tag "Merge", class: "btn btn-secondary-outline text-sm whitespace-nowrap",
+ data: { turbo_confirm: "Map “#{group[:display_text]}” into the chosen sector for all #{group[:count]} people?" } %>
+ <% end %>
+
+ <% end %>
+
+ <%= button_to "Keep all",
+ curate_other_responses_path(kind: group[:kind], field_identifier: group[:field_identifier], normalized_text: group[:normalized_text], status: "kept", return_status: @status_filter),
+ class: "text-gray-500 hover:text-gray-700 whitespace-nowrap" %>
+ <%= button_to "Dismiss all",
+ curate_other_responses_path(kind: group[:kind], field_identifier: group[:field_identifier], normalized_text: group[:normalized_text], status: "dismissed", return_status: @status_filter),
+ class: "text-gray-500 hover:text-red-600 whitespace-nowrap",
+ form: { data: { turbo_confirm: "Dismiss “#{group[:display_text]}” for all #{group[:count]} people?" } } %>
+
+ |
+
No “Other” responses to review.
+ <% end %> +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 %>)