Skip to content

Add a sectors-style age-range editor to the person form#1872

Draft
maebeale wants to merge 8 commits into
mainfrom
maebeale/person-age-ranges-edit
Draft

Add a sectors-style age-range editor to the person form#1872
maebeale wants to merge 8 commits into
mainfrom
maebeale/person-age-ranges-edit

Conversation

@maebeale

@maebeale maebeale commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

🤖 PR, suggested 👤 review level: 🔬 Inspect — new editor UI, a category-assignment scoping fix, and a shared cocoon mechanism

Why

The person edit form never had an age-range editor — AgeRange isn't a profile_specific category type, so it was absent from the categories section. Age groups could only be set via registration. This adds an editor, and unifies it with the sector editor.

What

  • Age ranges edit via cocoon, the same mechanism as sectors: a scoped Person#age_range_categorizable_items association + accepts_nested_attributes_for, rendered by shared/_age_range_item_fields with link_to_add_association / link_to_remove_association. The is_primary join-row flag is the primary marker — no separate param.
  • One shared primary_tag controller (renamed from primary_sector) drives the single-select primary star for both pickers; highlight colors come from the Stimulus Classes API. Lighting one star clears the others; no reorder — chips keep alphabetical (sectors) / position (age) order. The bespoke age_range_picker controller + chip partials are gone.
  • Single-primary validation: Person#at_most_one_primary_age_range mirrors SectorsTaggable — posting two primaries is a form error. Person-only, because orgs aggregate several members' primary age groups.
  • Survives a failed save: the form reads the same association the nested attributes build into, so a range picked before a validation error (its chip + primary star) is retained on re-render.
  • Displays keep primary-first for both sectors and age ranges (profile, recipients, dashboard).
  • Layout: equal-height Sectors/Age boxes splitting 50/50 (flex-wrap + min-w-[20rem]), stacking as the viewport narrows; on the profile they're two columns with age ranges thinner.
  • Scoping fix (TagAssignable): saving used to categories = submitted_ids, wiping every type the form doesn't show. The form sends managed_category_type_ids (workshop settings only — age ranges go through nested attributes); only those are replaced, so other taggings survive. category_ids dropped from strong params.

Tests

  • spec/requests/people_age_ranges_spec.rb — cocoon render, tag/primary via nested attributes, _destroy, position order, single-primary validation (request + model), retention after a validation error, and non-AgeRange preservation.
  • spec/models/concerns/sectors_taggable_spec.rbsectorable_items_ordered vs sectorable_items_primary_first.
  • spec/requests/events/professional_field_identifiers_spec.rb — updated to assert the cocoon chips instead of the old age checkboxes.

Age ranges previously sat in the generic profile-specific categories
block, separated from sectors. Pull the AgeRange type out so it edits as
a column next to sectors, and on the profile render the two as side-by-
side columns with age ranges as the thinner one — reclaiming the empty
space age groups left when it wrapped to its own full-width row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread app/views/people/_form.html.erb Outdated
<%= render "shared/sectorable_item_fields", f: sfi, show_admin_flags: true %>
<% end %>
<!-- Sectors + Age ranges (side by side; age ranges is the thinner column) -->
<% age_type, age_cats = (@person_categories_grouped || {}).find { |type, _| type.name == "AgeRange" } %>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: AgeRange is pulled out of the shared categories loop here and rejected from the other_category_types block below (line 174), so the primary-age hidden field and "mark the primary ones" hint move into this column with it. Other profile-specific types (Workshop Settings) keep their original full-width rendering.

AgeRange isn't a profile_specific category type, so the person edit form
never actually showed an age-range editor — age groups could only be set
via registration. Add one that mirrors the sector chip UI: add/remove
chips with a primary star, but multi-select primaries (a person serves
several primary age groups) and no leader flag. On the profile, sectors
and age ranges render as two columns; on the form they split 50/50 and
wrap as the viewport narrows.

Saving the form previously did `categories = submitted ids`, which would
wipe every category type the form doesn't show. Scope replacement to the
types the form actually edits (via managed_category_type_ids) so a
person's non-AgeRange taggings — and their is_primary/legacy_id — survive
untouched. Drop category_ids from strong params so assign_attributes no
longer pre-wipes categories before assign_associations runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@maebeale maebeale changed the title Place age ranges beside sectors on person edit and show Add a sectors-style age-range editor to the person form Jun 22, 2026
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: This is the core safety fix: the form sends managed_category_type_ids (age ranges + workshop settings), so we only replace those types and union-preserve every other type the form never shows. Because preserved categories stay in the assigned set, their join rows aren't destroyed/recreated — is_primary and legacy_id survive. Org keeps the old full-replace path since it doesn't send the key.

Comment thread app/controllers/people_controller.rb Outdated
# profile-specific type shown above. assign_associations preserves taggings
# of any other type, so saving the form can't drop a person's non-AgeRange
# category connections (e.g. art types tagged elsewhere).
@managed_category_type_ids = ([ @age_range_type&.id ] +

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: Note: category_ids was also dropped from person_params. Otherwise #update's assign_attributes(person_params) would full-replace categories= and save before assign_associations runs its scoped/preserving assignment, destroying the non-AgeRange join rows. assign_associations reads category_ids raw from params, so the contract is unchanged.

Make the two boxes equal height by stretching each column and letting the
card grow (flex-col + flex-1 + content-start). Replace the age-range add
<select> with an "Add age range" button styled and behaving like cocoon's
"Add Sector": it inserts a chip containing a picker, the controller keys
the chip's primary star to the chosen id and stops a range being added
twice, and disables the button once every range is in use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
choose(event) {
const select = event.target
const chip = select.closest("[data-age-range-picker-target='chip']")
chip.dataset.categoryId = select.value

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: A new chip uses a select for its id, but the primary star submits person[primary_age_category_ids][] keyed by id — so on select we copy the chosen id onto the star's value. That keeps the multi-primary contract identical to persisted chips (which render the id directly) without changing the controller params.

Match primary_sector: lighting one star clears the others and prepends
that chip, so a person has at most one primary age range. Registration's
"primary age group" is itself a single-select dropdown, so the data never
has more than one primary — the edit UI now reflects that.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

// Single-select primary: lighting one star clears the others and floats that
// chip to the front, mirroring primary_sector#selectPrimary.
togglePrimary(event) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: Single-select to match sectors. The server (apply_primary_age_groups!) still accepts a set, but registration's primary age group is a single-select dropdown so the data never has >1 primary — the UI now enforces the same. Persisted chips' stars carry the primaryToggle target too, so clearing-others sees them.

maebeale and others added 2 commits June 22, 2026 11:19
Drop the move-to-front on starring a primary age range and render the
selected ranges merged in category position order instead of primary-first,
so chips don't jump around as you star/unstar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror the age-range change for sectors: the edit form now renders sector
chips alphabetically (sectors have no position column) via a new
sectorable_items_ordered, and the primary-sector controller no longer
moves the starred chip to the front — so starring doesn't reshuffle them.
Profile, recipients, and dashboard views keep leading with the primary via
sectorable_items_primary_first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: Edit-form ordering is intentionally split from display ordering: the form uses this (alpha, primary not floated) so starring doesn't reshuffle chips, while profile/recipients/dashboard keep sectorable_items_primary_first. Sectors have no position column, so name is the position-equivalent — same idea as the age-range chips, which do have positions.

Age ranges now edit through cocoon nested fields like sectors, via a
scoped age_range_categorizable_items association with accepts_nested_
attributes_for, instead of the bespoke category_ids/template Stimulus
picker. Both pickers share one primary_tag controller (renamed from
primary_sector) whose highlight colors come from the Classes API, so the
single-primary star behaves identically. Drop the age-range-specific JS
controller and partials.

Because age ranges no longer ride category_ids, they're removed from
managed_category_type_ids — category_ids now carries only workshop
settings, and nested attributes manage the age taggings directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread app/models/person.rb
# 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,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: Scoped association so age ranges edit via cocoon nested attributes (is_primary on the join row is the primary flag — no separate param). Heads-up: AgeGroupTaggable had a private helper of the same name; I renamed it to age_range_items_relation to avoid the concern shadowing this reader (it was being included below the generated association methods, so the private one won).

Two fixes for the cocoon age picker:

- age_range_items_ordered now reads the age_range_categorizable_items
  association the nested attributes build into (not the general
  categorizable_items), so a range picked before a failed save survives
  the re-render — its chip and primary star come back. Mirrors how
  sectors read sectorable_items.

- Add a Person at_most_one_primary_age_range validation mirroring
  SectorsTaggable, so posting two primary age ranges is a form error.
  Person-only: organizations aggregate several members' primary age
  groups, so they legitimately have more than one.

Update professional_field_identifiers_spec to assert the cocoon chips
instead of the old age checkboxes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread app/models/person.rb
# 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: The retention fix: read the same age_range_categorizable_items association the form's nested attributes build into. The old version read the general categorizable_items, whose in-memory target doesn't include the freshly-built (unsaved) items after a failed save — so a picked range vanished on re-render. Sectors never had this because sectorable_items is a single association.

Comment thread app/models/person.rb

# 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: Person-only on purpose (not in the shared concern like sectors): organizations aggregate several affiliated members' primary age groups via tag_age_groups, so an org legitimately has >1 primary. Sectors avoid this because registration tags orgs with primary_ids: [] (orgs never get a primary sector).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant