From 6ee92c3853daa2c1c6441edbd586fdb9a7466dd5 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 22 Jun 2026 10:44:50 -0400 Subject: [PATCH 1/8] Place age ranges beside sectors on person edit and show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/views/people/_form.html.erb | 91 ++++++++++++++++++++------------- app/views/people/show.html.erb | 65 ++++++++++++----------- 2 files changed, 92 insertions(+), 64 deletions(-) diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index f3f31acacb..0c42b8ce02 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -116,52 +116,73 @@ - -
-
- 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 %> + + <% age_type, age_cats = (@person_categories_grouped || {}).find { |type, _| type.name == "AgeRange" } %> + <% primary_age_ids = @person.primary_age_category_ids %> +
+ +
+
+ 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 %> - <%= 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_type %> +
+
+ <%= age_type.display_label %> +
+
+ <%# Ensures the primary-age param is always submitted so unchecking every + toggle clears the primary flags. %> + <%= hidden_field_tag "person[primary_age_category_ids][]", "" %> +

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

+
+ <% age_cats.each do |category| %> + <%= render "shared/category_checkbox", param_key: "person", category: category, + checked: @person.category_ids.include?(category.id), + is_age: true, + primary_checked: primary_age_ids.include?(category.id) %> + <% end %> +
+
+
+ <% 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 %>
From 00ace8b3b059c61545f9c0be8d6c2f65524e9fbe Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 22 Jun 2026 11:02:38 -0400 Subject: [PATCH 2/8] Add a sectors-style age-range chip editor to the person form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AGENTS.md | 3 +- app/controllers/concerns/tag_assignable.rb | 14 +++- app/controllers/people_controller.rb | 13 ++- .../age_range_picker_controller.js | 54 ++++++++++++ app/frontend/javascript/controllers/index.js | 3 + app/views/people/_age_range_chip.html.erb | 30 +++++++ app/views/people/_form.html.erb | 54 +++++++----- spec/requests/people_age_ranges_spec.rb | 84 +++++++++++++++++++ 8 files changed, 233 insertions(+), 22 deletions(-) create mode 100644 app/frontend/javascript/controllers/age_range_picker_controller.js create mode 100644 app/views/people/_age_range_chip.html.erb create mode 100644 spec/requests/people_age_ranges_spec.rb diff --git a/AGENTS.md b/AGENTS.md index df764e7b9f..69be191590 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ This codebase (Rails 8.1) | Directory | Purpose | |---|---| | `app/frontend/entrypoints/` | Vite entry points (application.js, application.css) | -| `app/frontend/javascript/controllers/` | Stimulus controllers (74) | +| `app/frontend/javascript/controllers/` | Stimulus controllers (75) | | `app/frontend/javascript/rhino/` | Rich text editor customizations (mentions, grid) | | `app/frontend/stylesheets/` | Tailwind CSS and component styles | @@ -268,6 +268,7 @@ end - `address_select` — Compact numbered picker linking an affiliation to an org address - `affiliation_dates` — Recalculate affiliation date ranges +- `age_range_picker` — Sectors-style chip editor for a person's age ranges (add/remove + multi-primary star, no leader) - `anchor_highlight` — Highlight anchored elements - `asset_picker` — Asset selection UI - `autosave` — Auto-save form state 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..f975e0cb8c 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -280,6 +280,18 @@ 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 chip picker (AgeRange isn't a + # profile_specific type, so it never appears in @person_categories_grouped). + @age_range_type = CategoryType.find_by(name: AgeGroupTaggable::AGE_RANGE_CATEGORY_TYPE) + @age_ranges_collection = @age_range_type ? @age_range_type.categories.published.order(:position, :name) : Category.none + + # The category types this form actually edits — age ranges plus every + # 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 ] + + @person_categories_grouped.map { |type, _| type.id }).compact.uniq end def find_duplicate_people(first_name, last_name, email) @@ -423,7 +435,6 @@ 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 ], addresses_attributes: [ :id, diff --git a/app/frontend/javascript/controllers/age_range_picker_controller.js b/app/frontend/javascript/controllers/age_range_picker_controller.js new file mode 100644 index 0000000000..f1408277d2 --- /dev/null +++ b/app/frontend/javascript/controllers/age_range_picker_controller.js @@ -0,0 +1,54 @@ +import { Controller } from "@hotwired/stimulus" + +// Drives the age-range chip editor on the person form. Mirrors the sector chip +// UI (add/remove + a primary star) but a person can serve several primary age +// groups, so the star toggles are independent — unlike primary_sector, which is +// single-select — and there is no leader/crown flag. New chips are cloned from a +//