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 %>
+
+ <% 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? %>
+
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-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