-
Notifications
You must be signed in to change notification settings - Fork 24
WAIT: Add a sectors-style age-range editor to the person form #1872
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6ee92c3
00ace8b
494acbe
551ce4f
53bf7be
2dbe5a3
03103e0
a49441d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)) | ||
| }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.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). | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| -> { 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 | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| 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 | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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). |
||
| 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) | ||
|
|
||
There was a problem hiding this comment.
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_primaryandlegacy_idsurvive. Org keeps the old full-replace path since it doesn't send the key.