From e30c24c5c4b65aa05cea49f78f4462b439e46462 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 22 Jun 2026 02:25:47 -0400 Subject: [PATCH] Show combined primary + additional sectors on the recipients header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recipients "Serves" strip read only the additional-sector answer (and a single normalized "sector" key), so a registrant's primary sector and the rest of their additional sectors weren't shown together. EventDashboard now exposes #header_sectors_for, which combines the single-select primary and multi-select additional sector answers for the event — uniqued, primary first — read from the form answers themselves (independent of the registration-derived is_primary profile tags, which legacy data marks all-primary), falling back to profile tags when no sector answers are on file. The header renders them as chips with the primary starred, then the additional sectors alphabetically. Co-Authored-By: Claude Opus 4.8 --- app/services/event_dashboard.rb | 83 ++++++++++++++++++++------- app/views/events/recipients.html.erb | 23 +++----- spec/services/event_dashboard_spec.rb | 52 +++++++++++++++-- 3 files changed, 118 insertions(+), 40 deletions(-) diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index f27aac74bc..51a4493332 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -41,15 +41,11 @@ def scholarship_recipient_count # standalone scholarship form, or both). SCHOLARSHIP_ANSWER_IDENTIFIERS = FormBuilderService::SECTION_FIELD_IDENTIFIERS[:scholarship].freeze - # Professional-info answers shown in each recipient's header. The view falls - # back to the person's profile (sectors / age-range tags) when these aren't on - # file as form answers. Sector answers are normalized under the "sector" key - # (see #header_answers_by_applicant) so the view reads one key regardless of - # which sector field identifier the answer was stored under. - HEADER_ANSWER_IDENTIFIERS = (FormField::ADDITIONAL_SECTOR_FIELD_IDENTIFIERS + %w[primary_age_group]).freeze - - # The key sector answers are filed under in the per-applicant header hash. - HEADER_SECTOR_KEY = "sector".freeze + # Non-sector professional-info answers shown in each recipient's header (just + # the primary age group today). Sectors are handled separately by + # #header_sectors_for, which combines the primary + additional sector answers. + # The view falls back to the person's profile tags when these aren't on file. + HEADER_ANSWER_IDENTIFIERS = %w[primary_age_group].freeze # Active registrants who requested a scholarship for this event, as Person # records sorted by display name. Sectors, age-range tags, and affiliations are @@ -58,7 +54,7 @@ def scholarship_recipient_count def scholarship_applicants @scholarship_applicants ||= Person .where(id: scholarship_applicant_ids) - .includes(:sectors, { categories: :category_type }, + .includes(:sectors, { sectorable_items: :sector }, { categories: :category_type }, { categorizable_items: { category: :category_type } }, { affiliations: :organization }) .sort_by(&:name) @@ -75,20 +71,30 @@ def scholarship_answers_by_applicant .transform_values { |answers| dedupe_answers(answers) } end - # Sector / primary_age_group answers for the recipients page header, keyed by - # Person id then by header key (one answer each). Sector answers (whichever - # sector field identifier they used) are filed under HEADER_SECTOR_KEY so the - # view reads a single, stable key. Same cross-submission gathering as - # scholarship_answers_by_applicant. + # Non-sector header answers (primary age group) for the recipients page, keyed + # by Person id then by field identifier (one answer each). Same cross-submission + # gathering as scholarship_answers_by_applicant. def header_answers_by_applicant @header_answers_by_applicant ||= applicant_answers_for(HEADER_ANSWER_IDENTIFIERS) - .transform_values { |answers| answers.index_by { |answer| header_answer_key(answer.form_field&.field_identifier) } } + .transform_values { |answers| answers.index_by { |answer| answer.form_field&.field_identifier } } end - # Normalizes a header answer's field identifier to its lookup key: every sector - # field collapses to HEADER_SECTOR_KEY; other identifiers pass through. - def header_answer_key(identifier) - identifier.in?(FormField::SECTOR_FIELD_IDENTIFIERS) ? HEADER_SECTOR_KEY : identifier + # A recipient's sectors for the "Serves" header, as [ Sector, is_primary ] pairs + # with their primary sector first (starred) and additional sectors following + # (alphabetical), all uniqued. Read straight from this event's form answers — + # combining the single-select primary and multi-select additional sector fields + # — so it reflects what the registrant chose, independent of the profile's + # is_primary tags. Falls back to the person's profile sector tags when they + # named no sectors on a form. + def header_sectors_for(person) + ids = header_sector_ids_by_applicant[person.id] + return profile_sector_pairs(person) unless ids && (ids[:primary].any? || ids[:additional].any?) + + primary = ids[:primary].filter_map { |id| [ header_sector_lookup[id], true ] if header_sector_lookup[id] } + additional = ids[:additional] + .filter_map { |id| [ header_sector_lookup[id], false ] if header_sector_lookup[id] } + .sort_by { |sector, _| sector.name.to_s.downcase } + primary + additional end # A "shout out": a registrant the admin opted in (shoutout on their @@ -709,6 +715,43 @@ def applicant_submission_ids .pluck(:id) end + # Sector ids each applicant named on this event's forms, split into their + # single primary (the dropdown answer) and additional ones (the multi-select + # answer), keyed by Person id. A sector named in both collapses to the primary. + def header_sector_ids_by_applicant + @header_sector_ids_by_applicant ||= + applicant_answers_for(FormField::SECTOR_FIELD_IDENTIFIERS).transform_values do |answers| + primary = sector_ids_in(answers, FormField::PRIMARY_SECTOR_FIELD_IDENTIFIERS) + { primary: primary, additional: sector_ids_in(answers, FormField::ADDITIONAL_SECTOR_FIELD_IDENTIFIERS) - primary } + end + end + + # Sector records for every id named across all applicants, fetched in one query + # and keyed by id, so #header_sectors_for resolves names without an N+1. + def header_sector_lookup + @header_sector_lookup ||= begin + ids = header_sector_ids_by_applicant.values.flat_map { |split| split[:primary] + split[:additional] }.uniq + Sector.where(id: ids).index_by(&:id) + end + end + + # The integer Sector ids in the given answers for the given field identifiers, + # de-duplicated. Submitted values are ", "-joined Sector ids; non-numeric + # tokens (e.g. an "Other: " free-text answer) are dropped. + def sector_ids_in(answers, identifiers) + answers + .select { |answer| answer.form_field&.field_identifier.in?(identifiers) } + .flat_map { |answer| answer.submitted_answer.to_s.split(", ") } + .filter_map { |token| token.to_i if token.match?(/\A\d+\z/) } + .uniq + end + + # The person's profile sector tags as [ Sector, is_primary ] pairs, primary + # first — the fallback when they named no sectors on a form. + def profile_sector_pairs(person) + person.sectorable_items_primary_first.filter_map { |item| [ item.sector, item.is_primary? ] if item.sector } + end + # Non-blank answers for the given field identifiers across the applicants' # event submissions, grouped by Person id, with fields and submissions # preloaded. diff --git a/app/views/events/recipients.html.erb b/app/views/events/recipients.html.erb index ca2d82b4be..b1a104548a 100644 --- a/app/views/events/recipients.html.erb +++ b/app/views/events/recipients.html.erb @@ -56,10 +56,8 @@ <% participant_slug = @dashboard.registration_slug_by_registrant[person.id] %> <% answers = (answers_by_applicant[person.id] || []).reject { |a| a.form_field&.field_identifier == "scholarship_eligibility" } %> <% header = header_answers[person.id] || {} %> - <% sector_answer = header[EventDashboard::HEADER_SECTOR_KEY] %> <% age_group_answer = header["primary_age_group"] %> <%# Prefer the registrant's form answers; fall back to their profile data. %> - <% sector_text = resolve_answer_text(sector_answer&.form_field, sector_answer&.submitted_answer).presence || person.sectors.map(&:name).join(", ").presence %> <% age_group_text = resolve_answer_text(age_group_answer&.form_field, age_group_answer&.submitted_answer).presence || person.categories.select { |c| c.category_type&.name == "AgeRange" }.map(&:name).join(", ").presence %> <%# The affiliation active at the time of the event, excluding facilitator roles. %> <% recipient_affiliations = person.affiliations.select { |a| @@ -76,10 +74,12 @@ the avatar stays blank). Calm palette: neutral name/org links, with the sector chips as the single accent. %> <% dp = person.decorate %> - <%# Primary sector first (rendered as a darker-green crowned chip), - then the additional sectors alphabetically. %> - <% sectorable_items = person.sectorable_items_primary_first %> - <% has_sectors = sectorable_items.any? %> + <%# The registrant's sectors as [ sector, is_primary ] pairs — primary + first (starred chip), then the additional sectors alphabetically — + combined and uniqued from their form answers (profile tags as a + fallback). %> + <% header_sectors = @dashboard.header_sectors_for(person) %> + <% has_sectors = header_sectors.any? %> <%# Primary age groups first (starred amber chips), then the additional ones — matching the sector chips above. Falls back to the form answer / profile text only when the person carries no tagged age @@ -125,20 +125,15 @@ <%# Serves / Ages — parallel with the name %> - <% if has_sectors || sector_text.present? || has_age_groups || age_group_text.present? %> + <% if has_sectors || has_age_groups || age_group_text.present? %>
<% if has_sectors %>
Serves - <% sectorable_items.each do |si| %> - <%= render "sectors/tagging_label", sector: si.sector, is_primary: si.is_primary? %> + <% header_sectors.each do |sector, is_primary| %> + <%= render "sectors/tagging_label", sector: sector, is_primary: is_primary %> <% end %>
- <% elsif sector_text.present? %> -
- Serves - <%= sector_text %> -
<% end %> <% if has_age_groups %>
diff --git a/spec/services/event_dashboard_spec.rb b/spec/services/event_dashboard_spec.rb index 1c3d2a03e6..3423bdf643 100644 --- a/spec/services/event_dashboard_spec.rb +++ b/spec/services/event_dashboard_spec.rb @@ -456,19 +456,59 @@ expect(dashboard.scholarship_answers_by_applicant[embedded_applicant.id].size).to eq(1) end - it "gathers header (sector / age group) answers keyed by applicant, sector answers under the normalized sector key" do - # Use a legacy "service area" identifier to confirm it still resolves under - # the normalized sector key alongside the current "sector" identifiers. - service_field = create(:form_field, form: registration_form, name: "Primary sector", field_identifier: "primary_service_area") + it "gathers the primary age group header answer keyed by applicant" do + age_field = create(:form_field, form: registration_form, name: "Primary age group", field_identifier: "primary_age_group") reg_submission = FormSubmission.find_by(person: embedded_applicant, form: registration_form) - create(:form_answer, form_submission: reg_submission, form_field: service_field, submitted_answer: "5") + create(:form_answer, form_submission: reg_submission, form_field: age_field, submitted_answer: "5") header = dashboard.header_answers_by_applicant - expect(header[embedded_applicant.id][EventDashboard::HEADER_SECTOR_KEY].submitted_answer).to eq("5") + expect(header[embedded_applicant.id]["primary_age_group"].submitted_answer).to eq("5") expect(header).not_to have_key(non_applicant.id) end + describe "#header_sectors_for" do + let(:health) { create(:sector, name: "Healthcare") } + let(:education) { create(:sector, name: "Education") } + let(:housing) { create(:sector, name: "Housing") } + + def answer_sectors(primary_identifier:, additional_identifier:, primary:, additional:) + primary_field = create(:form_field, form: registration_form, name: "Primary sector", + field_identifier: primary_identifier, answer_type: :single_select_dropdown) + additional_field = create(:form_field, form: registration_form, name: "Additional sectors", + field_identifier: additional_identifier, answer_type: :multi_select_checkbox) + reg_submission = FormSubmission.find_by(person: embedded_applicant, form: registration_form) + create(:form_answer, form_submission: reg_submission, form_field: primary_field, submitted_answer: primary.id.to_s) + create(:form_answer, form_submission: reg_submission, form_field: additional_field, + submitted_answer: additional.map { |s| s.id }.join(", ")) + end + + it "returns the primary sector first (starred) then additional sectors, uniqued" do + # health is named both primary and additional; it must appear once, as primary. + answer_sectors(primary_identifier: "primary_sector_single", additional_identifier: "additional_sectors", + primary: health, additional: [ education, housing, health ]) + + expect(dashboard.header_sectors_for(embedded_applicant)) + .to eq([ [ health, true ], [ education, false ], [ housing, false ] ]) + end + + it "still resolves the legacy service-area identifiers" do + answer_sectors(primary_identifier: "primary_service_area_single", additional_identifier: "primary_service_area", + primary: health, additional: [ education ]) + + expect(dashboard.header_sectors_for(embedded_applicant)) + .to eq([ [ health, true ], [ education, false ] ]) + end + + it "falls back to the person's profile sector tags when no sector answers exist" do + embedded_applicant.sectorable_items.create!(sector: health, is_primary: true) + embedded_applicant.sectorable_items.create!(sector: education, is_primary: false) + + expect(dashboard.header_sectors_for(embedded_applicant)) + .to eq([ [ health, true ], [ education, false ] ]) + end + end + describe "shout outs" do let(:org) { create(:organization, name: "New Economics for Women") }