diff --git a/AGENTS.md b/AGENTS.md index df764e7b9f..34fbb47364 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -291,6 +291,7 @@ end - `paginated_fields` — Client-side pagination of nested fields - `password_toggle` — Show/hide password fields - `prefetch_lazy` — Prefetch lazy-loaded content +- `primary_tag` — Shared single-primary star for the sector and age-range cocoon chip editors (clears other stars, highlights via configurable classes, no reorder) - `print_options` — Print options toggle for analytics - `reminder_preview` — Live-preview a custom message in the reminder email as the admin types it on the bulk-reminder page - `remote_select` — AJAX-powered select dropdown diff --git a/app/controllers/concerns/tag_assignable.rb b/app/controllers/concerns/tag_assignable.rb index ed50437548..8b4a932750 100644 --- a/app/controllers/concerns/tag_assignable.rb +++ b/app/controllers/concerns/tag_assignable.rb @@ -7,7 +7,19 @@ def assign_associations(record, param_key: nil) key = param_key || record.model_name.param_key selected_category_ids = Array(params[key][:category_ids]).reject(&:blank?).map(&:to_i) - record.categories = Category.where(id: selected_category_ids) + selected = Category.where(id: selected_category_ids).to_a + + if params[key].key?(:managed_category_type_ids) + # The form only edits certain category types (e.g. age ranges + workshop + # settings). Preserve taggings of every other type the form never shows so + # saving can't silently drop them — and assign the union so the join rows + # for preserved categories stay intact (is_primary/legacy_id untouched). + managed_type_ids = Array(params[key][:managed_category_type_ids]).reject(&:blank?).map(&:to_i) + preserved = record.categories.reject { |category| managed_type_ids.include?(category.category_type_id) } + record.categories = (preserved + selected).uniq + else + record.categories = selected + end if params[key].key?(:sector_ids) selected_sector_ids = Array(params[key][:sector_ids]).reject(&:blank?).map(&:to_i) diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index e698aa54eb..efa4de8050 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -280,6 +280,21 @@ def set_form_variables .group_by(&:category_type) .select { |type, _| type&.profile_specific? } .sort_by { |type, _| type&.name.to_s.downcase } + + # Age ranges edit as their own sectors-style cocoon chip picker (AgeRange + # isn't a profile_specific type, so it never appears in + # @person_categories_grouped). Tagged via age_range_categorizable_items nested + # attributes, not category_ids. + @age_range_type = CategoryType.find_by(name: AgeGroupTaggable::AGE_RANGE_CATEGORY_TYPE) + age_ranges = @age_range_type ? @age_range_type.categories.published.order(:position, :name) : Category.none + @age_ranges_collection = age_ranges.pluck(:name, :id) + @current_age_range_category_ids = @person.age_range_categorizable_items.map(&:category_id) + + # The category types this form edits via category_ids — the profile-specific + # types shown below (workshop settings). assign_associations preserves taggings + # of any other type (age ranges included, handled by nested attributes), so + # saving the form can't drop a person's other category connections. + @managed_category_type_ids = @person_categories_grouped.map { |type, _| type.id } end def find_duplicate_people(first_name, last_name, email) @@ -423,8 +438,8 @@ def person_params :youtube_url, :twitter_url, :created_by_id, :updated_by_id, - category_ids: [], sectorable_items_attributes: [ :id, :sector_id, :is_leader, :is_primary, :_destroy ], + age_range_categorizable_items_attributes: [ :id, :category_id, :is_primary, :_destroy ], addresses_attributes: [ :id, :address_type, diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 62b3cebdea..13e2909844 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -159,8 +159,8 @@ application.register("searchable-select", SearchableSelectController) import SectionFilterController from "./section_filter_controller" application.register("section-filter", SectionFilterController) -import PrimarySectorController from "./primary_sector_controller" -application.register("primary-sector", PrimarySectorController) +import PrimaryTagController from "./primary_tag_controller" +application.register("primary-tag", PrimaryTagController) import FormSectionToggleController from "./form_section_toggle_controller" application.register("form-section-toggle", FormSectionToggleController) diff --git a/app/frontend/javascript/controllers/primary_sector_controller.js b/app/frontend/javascript/controllers/primary_sector_controller.js deleted file mode 100644 index 9231509e5f..0000000000 --- a/app/frontend/javascript/controllers/primary_sector_controller.js +++ /dev/null @@ -1,43 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -// Drives the sector chip editor on the person/organization form. Enforces a -// single primary sector — lighting one star clears the others — and darkens -// the starred chip. The leader (crown) flag is independent and multi-select, -// so it needs no JS and is styled purely via CSS (peer-checked). -export default class extends Controller { - static targets = ["chip", "primary"] - - connect() { - this.style() - } - - selectPrimary(event) { - if (event.target.checked) { - this.primaryTargets.forEach((checkbox) => { - if (checkbox !== event.target) checkbox.checked = false - }) - this.moveToFront(event.target) - } - this.style() - } - - // Jump the newly-primary chip to the front, mirroring the primary-first order - // the server renders on save. - moveToFront(checkbox) { - const chip = checkbox.closest("[data-primary-sector-target='chip']") - if (chip) this.element.prepend(chip) - } - - // Reflect each chip's primary state: darker fill and stronger border when set. - style() { - this.primaryTargets.forEach((checkbox) => { - const chip = checkbox.closest("[data-primary-sector-target='chip']") - if (!chip) return - const primary = checkbox.checked - chip.classList.toggle("bg-lime-200", primary) - chip.classList.toggle("border-lime-500", primary) - chip.classList.toggle("bg-white", !primary) - chip.classList.toggle("border-gray-300", !primary) - }) - } -} diff --git a/app/frontend/javascript/controllers/primary_tag_controller.js b/app/frontend/javascript/controllers/primary_tag_controller.js new file mode 100644 index 0000000000..2d1d34a76f --- /dev/null +++ b/app/frontend/javascript/controllers/primary_tag_controller.js @@ -0,0 +1,37 @@ +import { Controller } from "@hotwired/stimulus" + +// Drives a chip editor with a single-select "primary" star, shared by the sector +// and age-range pickers on the person/organization form. Lighting one star clears +// the others; the configurable primary/default classes highlight the starred chip. +// Chips are NOT reordered — they keep their rendered (alphabetical / position) +// order, so starring doesn't reshuffle them. Profile/recipients/dashboard views +// still lead with the primary on display. The sector chip's leader (crown) flag is +// independent and CSS-only, so it needs no JS here. +export default class extends Controller { + static targets = ["chip", "primary"] + static classes = ["primary", "default"] + + connect() { + this.style() + } + + selectPrimary(event) { + if (event.target.checked) { + this.primaryTargets.forEach((checkbox) => { + if (checkbox !== event.target) checkbox.checked = false + }) + } + this.style() + } + + // Reflect each chip's primary state: highlight the starred chip, reset the rest. + style() { + this.primaryTargets.forEach((checkbox) => { + const chip = checkbox.closest("[data-primary-tag-target='chip']") + if (!chip) return + const primary = checkbox.checked + this.primaryClasses.forEach((klass) => chip.classList.toggle(klass, primary)) + this.defaultClasses.forEach((klass) => chip.classList.toggle(klass, !primary)) + }) + } +} diff --git a/app/models/concerns/age_group_taggable.rb b/app/models/concerns/age_group_taggable.rb index b9770aecc5..95c7083b3d 100644 --- a/app/models/concerns/age_group_taggable.rb +++ b/app/models/concerns/age_group_taggable.rb @@ -32,7 +32,7 @@ def primary_age_category_ids # only updates existing items rather than creating them. def apply_primary_age_groups!(primary_category_ids) primary = sanitize_age_ids(primary_category_ids).to_set - age_range_categorizable_items.includes(:category).find_each do |item| + age_range_items_relation.includes(:category).find_each do |item| desired = primary.include?(item.category_id) item.update!(is_primary: desired) if item.is_primary? != desired end @@ -64,7 +64,9 @@ def age_range_item?(item) item.category&.category_type&.name == AGE_RANGE_CATEGORY_TYPE end - def age_range_categorizable_items + # Query relation of this record's AgeRange categorizable_items. Named to avoid + # colliding with Person's age_range_categorizable_items nested association. + def age_range_items_relation categorizable_items .joins(category: :category_type) .where(category_types: { name: AGE_RANGE_CATEGORY_TYPE }) diff --git a/app/models/concerns/sectors_taggable.rb b/app/models/concerns/sectors_taggable.rb index eb6affbf04..14b6bad2e6 100644 --- a/app/models/concerns/sectors_taggable.rb +++ b/app/models/concerns/sectors_taggable.rb @@ -12,11 +12,20 @@ module SectorsTaggable # Sectorable items ordered for display: the primary sector first, then the # rest alphabetically by sector name. Sorts the in-memory association rather # than issuing a query, so it stays correct when a form re-renders its - # unsaved items after a failed save. + # unsaved items after a failed save. Used by profile, recipients, and dashboard + # views, where the primary should always lead. def sectorable_items_primary_first sectorable_items.sort_by { |item| [ item.is_primary? ? 0 : 1, item.sector&.name.to_s.downcase ] } end + # Sectorable items in stable order for the edit form: alphabetically by sector + # name (sectors have no position column, so name is the position-equivalent). + # Unlike sectorable_items_primary_first, the primary is NOT floated to the top, + # so starring a sector on the form doesn't reshuffle the chips. + def sectorable_items_ordered + sectorable_items.sort_by { |item| item.sector&.name.to_s.downcase } + end + # Additively tag sectors as primary/additional without disturbing other # taggings — used by registration, where a respondent names a single primary # sector (the dropdown) plus any number of additional sectors (the checkboxes). diff --git a/app/models/person.rb b/app/models/person.rb index 424dd07d15..4934334313 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -55,6 +55,11 @@ class Person < ApplicationRecord CONTACT_TYPES = [ "work", "personal" ].freeze validates :email_type, inclusion: { in: %w[work personal] }, allow_blank: true validates :email_2_type, inclusion: { in: %w[work personal] }, allow_blank: true + # Mirrors SectorsTaggable's single-primary rule for age ranges — the chip + # editor's single-star JS is the first line of defense, this guards imports, + # the console, and bad form posts. Person-only: organizations aggregate + # several members' primary age groups, so they legitimately have more than one. + validate :at_most_one_primary_age_range # TODO: add validation for zip code containing only numbers # TODO: add validation on STATE # TODO: add validation on phone number type @@ -65,6 +70,15 @@ class Person < ApplicationRecord accepts_nested_attributes_for :contact_methods, allow_destroy: true, reject_if: :all_blank accepts_nested_attributes_for :sectorable_items, allow_destroy: true, reject_if: proc { |attrs| attrs["sector_id"].blank? } + # Age ranges edit through cocoon nested fields like sectors. A scoped view of + # categorizable_items (AgeRange categories only) so the form's add/remove and + # primary toggle round-trip as nested attributes — the is_primary flag splits + # primary vs additional, no separate primary_age_category_ids param needed. + has_many :age_range_categorizable_items, + -> { joins(category: :category_type).where(category_types: { name: AgeGroupTaggable::AGE_RANGE_CATEGORY_TYPE }) }, + class_name: "CategorizableItem", as: :categorizable, inverse_of: :categorizable + accepts_nested_attributes_for :age_range_categorizable_items, allow_destroy: true, + reject_if: proc { |attrs| attrs["category_id"].blank? } accepts_nested_attributes_for :user, update_only: true accepts_nested_attributes_for :affiliations, allow_destroy: true, reject_if: proc { |attrs| attrs["organization_id"].blank? } @@ -243,8 +257,25 @@ def other_workshop_setting_responses other_form_responses(OTHER_WORKSHOP_SETTING_IDENTIFIERS) end + # The age-range nested items in category position order for the cocoon chip + # editor. Reads the same association the form's nested attributes build into, so + # unsaved picks survive a failed save (and aren't primary-first — starring + # shouldn't reshuffle them). Display surfaces lead with the primary instead. + def age_range_items_ordered + age_range_categorizable_items.sort_by { |item| [ item.category&.position || 0, item.category&.name.to_s ] } + end + private + # Count the in-memory set (not a DB query): nested attributes build the items in + # one transaction, so a row-level check would see none persisted yet. + def at_most_one_primary_age_range + primary_count = age_range_categorizable_items.reject(&:marked_for_destruction?).count(&:is_primary?) + return if primary_count <= 1 + + errors.add(:base, "Only one age range can be marked as primary") + end + def other_form_responses(identifiers) form_submissions .joins(form_answers: :form_field) diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index f3f31acacb..91d182a607 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -116,52 +116,85 @@ - -
-
- Sectors -
-
- <% sectors_owner = f.object.respond_to?(:object) ? f.object.object : f.object %> - <%= f.simple_fields_for :sectorable_items, sectors_owner.sectorable_items_primary_first do |sfi| %> - <%= render "shared/sectorable_item_fields", f: sfi, show_admin_flags: true %> - <% end %> + + <%# Tells the controller which category types this form edits via category_ids + (workshop settings); saving replaces only those and preserves any other type + the form never shows. Age ranges are edited as nested attributes, separately. %> + <%= hidden_field_tag "person[category_ids][]", "" %> + <% (@managed_category_type_ids || []).each do |type_id| %> + <%= hidden_field_tag "person[managed_category_type_ids][]", type_id %> + <% end %> +
+ +
+
+ Sectors +
+
+ <% sectors_owner = f.object.respond_to?(:object) ? f.object.object : f.object %> + <%= f.simple_fields_for :sectorable_items, sectors_owner.sectorable_items_ordered do |sfi| %> + <%= render "shared/sectorable_item_fields", f: sfi, show_admin_flags: true %> + <% end %> - <%= link_to_add_association "➕ Add Sector", - f, - :sectorable_items, - partial: "shared/sectorable_item_fields", - render_options: { - locals: { collection: (@sectors_collection || []) - .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 %> + <%= link_to_add_association "➕ Add Sector", + f, + :sectorable_items, + partial: "shared/sectorable_item_fields", + render_options: { + locals: { collection: (@sectors_collection || []) + .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 %> +
+ + + <% if @age_ranges_collection.present? %> +
+
+ Age ranges +
+
+ <% age_owner = f.object.respond_to?(:object) ? f.object.object : f.object %> + <%= f.simple_fields_for :age_range_categorizable_items, age_owner.age_range_items_ordered do |afi| %> + <%= render "shared/age_range_item_fields", f: afi %> + <% end %> + + <%= link_to_add_association "➕ Add age range", + f, + :age_range_categorizable_items, + partial: "shared/age_range_item_fields", + render_options: { + locals: { collection: (@age_ranges_collection || []) + .reject { |_, id| (@current_age_range_category_ids || []).include?(id) } } }, + class: "btn btn-secondary-outline" %> +
+
+ <% end %>
- - <% if @person_categories_grouped.present? %> - <% primary_age_ids = @person.primary_age_category_ids %> + + <% other_category_types = (@person_categories_grouped || {}).reject { |type, _| type.name == "AgeRange" } %> + <% if other_category_types.present? %>
- <%# Ensures the primary-age param is always submitted so unchecking every - toggle clears the primary flags. %> - <%= hidden_field_tag "person[primary_age_category_ids][]", "" %> - <% @person_categories_grouped.each do |type, cats| %> - <% is_age = type.name == "AgeRange" %> + <% other_category_types.each do |type, cats| %>

<%= type.display_label %>

- <% if is_age %> -

Check every age group served, then mark the primary ones.

- <% end %>
<% cats.each do |category| %> <%= render "shared/category_checkbox", param_key: "person", category: category, checked: @person.category_ids.include?(category.id), - is_age: is_age, - primary_checked: is_age && primary_age_ids.include?(category.id) %> + is_age: false, + primary_checked: false %> <% end %>
diff --git a/app/views/people/show.html.erb b/app/views/people/show.html.erb index d8fdc59d3b..473a22a8b6 100644 --- a/app/views/people/show.html.erb +++ b/app/views/people/show.html.erb @@ -131,38 +131,45 @@ <% end %>
<% end %> - - <% if @person.profile_show_sectors? %> - <%# One combined list: the primary sector leads as a darker-green starred - chip, followed by the additional sectors alphabetically and any - free-text "Other" responses. %> - <% sectorable_items = @person.sectorable_items.includes(:sector).sort_by { |si| [ si.is_primary? ? 0 : 1, si.sector&.name.to_s.downcase ] } %> - <% other_sectors = @person.other_sector_responses %> -
-

Sectors

- <% if sectorable_items.any? || other_sectors.any? %> -
- <% sectorable_items.each do |si| %> - <%= render "sectors/tagging_label", - sector: si.sector, - is_primary: si.is_primary?, - display_leader: true, - is_leader: si.is_leader %> - <% end %> - <%= render "people/other_responses", responses: other_sectors %> -
- <% else %> -

None selected.

- <% end %> -
- <% end %> - + + <% sectorable_items = @person.sectorable_items.includes(:sector).sort_by { |si| [ si.is_primary? ? 0 : 1, si.sector&.name.to_s.downcase ] } %> + <% other_sectors = @person.other_sector_responses %> + <% show_sectors = @person.profile_show_sectors? %> <% primary_age_groups = @person.primary_age_groups.to_a %> <% additional_age_groups = @person.additional_age_groups.to_a %> - <% if primary_age_groups.any? || additional_age_groups.any? %> + <% show_age = primary_age_groups.any? || additional_age_groups.any? %> + <% if show_sectors || show_age %>
-

Age groups served

- <%= render "shared/age_group_tags", primary: primary_age_groups, additional: additional_age_groups %> +
+ <% if show_sectors %> + <%# One combined list: the primary sector leads as a darker-green + starred chip, then additional sectors alphabetically and any + free-text "Other" responses. %> +
+

Sectors

+ <% if sectorable_items.any? || other_sectors.any? %> +
+ <% sectorable_items.each do |si| %> + <%= render "sectors/tagging_label", + sector: si.sector, + is_primary: si.is_primary?, + display_leader: true, + is_leader: si.is_leader %> + <% end %> + <%= render "people/other_responses", responses: other_sectors %> +
+ <% else %> +

None selected.

+ <% end %> +
+ <% end %> + <% if show_age %> +
+

Age ranges

+ <%= render "shared/age_group_tags", primary: primary_age_groups, additional: additional_age_groups, compact: true %> +
+ <% end %> +
<% end %>
diff --git a/app/views/shared/_age_range_item_fields.html.erb b/app/views/shared/_age_range_item_fields.html.erb new file mode 100644 index 0000000000..58d36fa43a --- /dev/null +++ b/app/views/shared/_age_range_item_fields.html.erb @@ -0,0 +1,37 @@ +<%# A cocoon nested field for one age-range tagging on the person form — the + age-range counterpart of shared/_sectorable_item_fields. A persisted item + shows its name; a new one (added via "Add age range") shows a select. The + single-select primary star is driven by the shared primary-tag controller. + There is no leader/crown flag for age ranges. %> +<% collection ||= @age_ranges_collection || Category.age_ranges.published.order(:position, :name).pluck(:name, :id) %> +<% is_primary = f.object&.is_primary? %> +
+ text-gray-700 px-3 py-1 text-sm font-medium transition" + data-primary-tag-target="chip"> + <% if f.object&.category_id.present? %> + <%= f.hidden_field :category_id %> + <%= f.object.category&.name %> + <% else %> + <%= f.input :category_id, + as: :select, + label: false, + collection: collection, + selected: f.object&.category_id, + include_blank: "Select age range…", + wrapper: false, + input_html: { + class: "bg-transparent border-none focus:ring-0 text-sm text-gray-500 cursor-pointer py-0" + } %> + <% end %> + + <%= link_to_remove_association "✖", + f, + class: "ml-0.5 text-gray-400 hover:text-gray-600 font-bold text-xs transition" %> +
diff --git a/app/views/shared/_sectorable_item_fields.html.erb b/app/views/shared/_sectorable_item_fields.html.erb index fb25a3b304..51dff6159e 100644 --- a/app/views/shared/_sectorable_item_fields.html.erb +++ b/app/views/shared/_sectorable_item_fields.html.erb @@ -5,7 +5,7 @@
text-gray-700 px-3 py-1 text-sm font-medium transition" - data-primary-sector-target="chip"> + data-primary-tag-target="chip"> <% if f.object&.sector_id.present? %> <%= f.hidden_field :sector_id %> <%= f.object.sector&.name %> @@ -25,7 +25,7 @@ diff --git a/spec/models/concerns/sectors_taggable_spec.rb b/spec/models/concerns/sectors_taggable_spec.rb index 959ac53ae9..ec6e114afe 100644 --- a/spec/models/concerns/sectors_taggable_spec.rb +++ b/spec/models/concerns/sectors_taggable_spec.rb @@ -51,4 +51,25 @@ expect(person.sectorable_items.find_by(sector: health).is_primary).to be true end end + + describe "ordering" do + before do + # Healthcare is the primary but sorts last alphabetically. + person.sectorable_items.create!(sector: education, is_primary: false) + person.sectorable_items.create!(sector: housing, is_primary: false) + person.sectorable_items.create!(sector: health, is_primary: true) + end + + it "#sectorable_items_primary_first leads with the primary (for display)" do + names = person.sectorable_items_primary_first.map { |item| item.sector.name } + + expect(names).to eq([ "Healthcare", "Education", "Housing" ]) + end + + it "#sectorable_items_ordered keeps alphabetical order, not primary first (for the edit form)" do + names = person.sectorable_items_ordered.map { |item| item.sector.name } + + expect(names).to eq([ "Education", "Healthcare", "Housing" ]) + end + end end diff --git a/spec/requests/events/professional_field_identifiers_spec.rb b/spec/requests/events/professional_field_identifiers_spec.rb index d9581741f7..c5d832613b 100644 --- a/spec/requests/events/professional_field_identifiers_spec.rb +++ b/spec/requests/events/professional_field_identifiers_spec.rb @@ -125,11 +125,11 @@ # Sector chips render only for tagged sectors, so their presence proves the tag. expect(page.text).to include("Education", "Mental Health", "Equine therapy") - # Age categories all render as checkboxes; the tagged ones are checked, with - # the primary toggle set only on the primary age group. - expect(membership_checked?(page, age_adults)).to be(true) - expect(membership_checked?(page, age_teens)).to be(true) - expect(membership_checked?(page, age_children)).to be(true) + # Age ranges render as cocoon chips; a chip exists only for a tagged range, + # with the primary star checked only on the primary age group. + expect(age_tagged?(page, age_adults)).to be(true) + expect(age_tagged?(page, age_teens)).to be(true) + expect(age_tagged?(page, age_children)).to be(true) expect(primary_age_checked?(page, age_adults)).to be(true) expect(primary_age_checked?(page, age_teens)).to be(false) end @@ -187,13 +187,22 @@ def checkbox_values(field) dom.css("input[type=checkbox][name='public_registration[form_fields][#{field.id}][]']").map { |node| node["value"] } end - def membership_checked?(page, category) - box = page.at_css("input[name='person[category_ids][]'][value='#{category.id}']") - box&.key?("checked") || false + # The age cocoon nested-attributes index whose category_id field holds this + # category, or nil when the range isn't tagged (no chip rendered). + def age_field_index(page, category) + field = page.at_css("input[name^='person[age_range_categorizable_items_attributes]'][name$='[category_id]'][value='#{category.id}']") + field && field["name"][/attributes\]\[(\d+)\]/, 1] + end + + def age_tagged?(page, category) + age_field_index(page, category).present? end def primary_age_checked?(page, category) - box = page.at_css("input[name='person[primary_age_category_ids][]'][value='#{category.id}']") + index = age_field_index(page, category) + return false unless index + + box = page.at_css("input[type=checkbox][name='person[age_range_categorizable_items_attributes][#{index}][is_primary]']") box&.key?("checked") || false end diff --git a/spec/requests/people_age_ranges_spec.rb b/spec/requests/people_age_ranges_spec.rb new file mode 100644 index 0000000000..92c7b03dac --- /dev/null +++ b/spec/requests/people_age_ranges_spec.rb @@ -0,0 +1,154 @@ +require "rails_helper" + +RSpec.describe "Person age ranges", type: :request do + let(:admin) { create(:user, :admin) } + let(:person) { create(:person) } + + # AgeGroupTaggable matches the type by the exact name "AgeRange". + let(:age_type) { create(:category_type, :published, name: "AgeRange") } + # Created in order so the positioned gem assigns positions children < teens < adults. + let!(:children) { create(:category, :published, category_type: age_type, name: "Children (0-12)") } + let!(:teens) { create(:category, :published, category_type: age_type, name: "Teens (13-17)") } + let!(:adults) { create(:category, :published, category_type: age_type, name: "Adults (18+)") } + + # A category of some other type that the person form never shows. + let(:art_type) { create(:category_type, :published, name: "ArtType") } + let(:clay) { create(:category, :published, category_type: art_type, name: "Clay") } + + before { sign_in admin } + + # Age ranges are saved as age_range_categorizable_items nested attributes (the + # cocoon picker), not category_ids. category_ids only carries workshop settings, + # of which there are none here. + def update_person(age_items:) + patch person_path(person), params: { + person: { + first_name: person.first_name, + category_ids: [ "" ], + managed_category_type_ids: [], + age_range_categorizable_items_attributes: age_items + } + } + end + + describe "edit form" do + it "renders the cocoon age-range editor with every published age range" do + get edit_person_path(person) + + expect(response.body).to include("primary-tag") + expect(response.body).to include("Add age range") + expect(response.body).to include("Children (0-12)") + expect(response.body).to include("Adults (18+)") + end + + it "renders selected age ranges in category position order, not primary first" do + person.categories << children << teens << adults + # Mark the last-positioned range primary; it must NOT float to the front. + person.categorizable_items.find_by(category: adults).update!(is_primary: true) + + get edit_person_path(person) + + body = response.body + expect(body.index("Children (0-12)")).to be < body.index("Teens (13-17)") + expect(body.index("Teens (13-17)")).to be < body.index("Adults (18+)") + end + end + + describe "saving age ranges" do + it "tags the selected age ranges and marks the chosen one primary" do + update_person(age_items: [ + { category_id: children.id, is_primary: "1" }, + { category_id: adults.id, is_primary: "0" } + ]) + + person.reload + expect(person.primary_age_groups).to contain_exactly(children) + expect(person.additional_age_groups).to contain_exactly(adults) + end + + it "removes an age range via _destroy" do + person.categories << children + item = person.categorizable_items.find_by(category: children) + + update_person(age_items: [ { id: item.id, category_id: children.id, _destroy: "1" } ]) + + person.reload + expect(person.primary_age_groups).to be_empty + expect(person.additional_age_groups).to be_empty + end + end + + describe "re-rendering after a validation error" do + it "retains a newly chosen age range and its primary flag, with options available" do + patch person_path(person), params: { + person: { + first_name: person.first_name, + last_name: "", + category_ids: [ "" ], + managed_category_type_ids: [], + age_range_categorizable_items_attributes: [ + { category_id: children.id, is_primary: "1" } + ] + } + } + + expect(response).to have_http_status(:unprocessable_content) + # The chosen age range comes back as a rendered chip, not dropped, with its + # category_id retained in a nested-attributes field. + expect(response.body).to include("Children (0-12)") + expect(response.body).to match(/value="#{children.id}"\s+name="person\[age_range_categorizable_items_attributes\]\[\d+\]\[category_id\]"/) + # Its primary star comes back checked. + expect(response.body).to match(/primary-tag#selectPrimary[^>]*checked="checked"/) + # The add picker still offers the other ranges. + expect(response.body).to include("Teens (13-17)") + end + end + + describe "more than one primary age range" do + it "fails validation with the same single-primary rule as sectors" do + update_person(age_items: [ + { category_id: children.id, is_primary: "1" }, + { category_id: adults.id, is_primary: "1" } + ]) + + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("Only one age range can be marked as primary") + + person.reload + expect(person.primary_age_groups).to be_empty + end + + it "is enforced at the model level" do + person.age_range_categorizable_items.build(category: children, is_primary: true) + person.age_range_categorizable_items.build(category: adults, is_primary: true) + + expect(person).not_to be_valid + expect(person.errors[:base]).to include("Only one age range can be marked as primary") + end + end + + describe "preserving non-AgeRange category connections" do + before do + person.categories << clay + # Give the Clay tagging a primary flag so we can prove it is left untouched. + person.categorizable_items.find_by(category: clay).update!(is_primary: true) + end + + it "does not delete or edit the person's non-AgeRange category connections" do + original_item = person.categorizable_items.find_by(category: clay) + + update_person(age_items: [ { category_id: children.id, is_primary: "1" } ]) + + person.reload + expect(person.categories).to include(clay) + + surviving_item = person.categorizable_items.find_by(category: clay) + # Same row (not destroyed + recreated) and its primary flag is unchanged. + expect(surviving_item.id).to eq(original_item.id) + expect(surviving_item.is_primary).to be(true) + + # And the age ranges were still applied alongside the preserved tagging. + expect(person.primary_age_groups).to contain_exactly(children) + end + end +end