-
Notifications
You must be signed in to change notification settings - Fork 24
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
Changes from all commits
3d86157
094e80c
4284700
65d91e0
b132fef
89d563a
2f388f6
b5acf7c
76f0dc6
da688a4
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 |
|---|---|---|
|
|
@@ -57,6 +57,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 | ||
|
|
@@ -67,6 +72,19 @@ 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? } | ||
| # The picker can submit the same age range twice (two new rows), which the | ||
| # CategorizableItem uniqueness validation can't catch β both are unsaved, so | ||
| # both INSERT and hit the DB unique index. Collapse duplicates before validation. | ||
| before_validation :dedupe_age_range_items | ||
| accepts_nested_attributes_for :user, update_only: true | ||
| accepts_nested_attributes_for :affiliations, allow_destroy: true, | ||
| reject_if: proc { |attrs| attrs["organization_id"].blank? } | ||
|
|
@@ -268,8 +286,41 @@ 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 | ||
|
|
||
| # Keep one tagging per age-range category. Prefer the persisted row, fold any | ||
| # duplicate's primary flag onto the keeper, and drop the extras (destroy if | ||
| # persisted, otherwise remove from the unsaved set). | ||
| def dedupe_age_range_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: Fixes RecordNotUnique on the categorizable_items unique index. Two new rows for the same age range both pass CategorizableItem's uniqueness validation (neither is persisted yet at validation time) and then collide on INSERT. This collapses them before validation β keeper prefers the persisted row and inherits any duplicate's primary flag. Note: sectors have the same theoretical in-memory-dup gap (separate SectorableItem uniqueness); not seen in practice, left as-is unless it surfaces. |
||
| live = age_range_categorizable_items.reject(&:marked_for_destruction?) | ||
| live.group_by(&:category_id).each_value do |items| | ||
| next if items.size <= 1 | ||
|
|
||
| keeper = items.find(&:persisted?) || items.first | ||
| keeper.is_primary = true if items.any?(&:is_primary?) | ||
| (items - [ keeper ]).each do |dup| | ||
| dup.persisted? ? dup.mark_for_destruction : age_range_categorizable_items.delete(dup) | ||
| end | ||
| end | ||
| end | ||
|
Comment on lines
+311
to
+322
|
||
|
|
||
| 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.