diff --git a/AGENTS.md b/AGENTS.md index 8cf263471..cb42e92f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,7 @@ This codebase (Rails 8.1) | `Event` | Events with registrations, featured/published states | | `EventStaff` | Join model connecting `Person` to `Event` as staff (title, `expected_to_attend`); drives the "Meet the staff" roster and "My events" | | `EventRegistrationChecklistCompletion` | Audited completion row for one manual onboarding step on an `EventRegistration` (`step` from `EventRegistration::CHECKLIST_STEPS`, `completed_by` User, `completed_at`); row-exists = done. Powers the event Onboarding tab's checkbox matrix | -| `RegistrationTicketCallout` | Call-outs shown on an event's registration ticket (title, subtitle, HTML description, `callout_type` action/reference, icon/colour, `payment_access_gated` — only shown once the registrant has `payment_access_granted?`, draggable `position`, `hidden` draft/opt-out, `display_from` drip date, and `has_many :resources` through `RegistrationTicketCalloutResource`); each links to its own public detail page. A nil `magic_key` is an admin-authored callout; a set `magic_key` is a built-in card materialized by `DefaultTicketCallouts` (hidden instead of deleted, restorable to default) | +| `RegistrationTicketCallout` | Call-outs shown on an event's registration ticket (title, subtitle, HTML description, `callout_type` action/reference, icon/colour, `payment_access_gated` — only shown once the registrant has `payment_access_granted?`, draggable `position`, `hidden` draft/opt-out, `display_from` drip date, and `has_many :resources` through `RegistrationTicketCalloutResource`); each links to its own public detail page. A nil `builtin_key` is an admin-authored callout; a set `builtin_key` is a built-in card materialized by `BuiltinCallouts` (hidden instead of deleted, restorable to default) | | `RegistrationTicketCalloutResource` | Ordered join linking a `RegistrationTicketCallout` to the `Resource`s shown on its detail page | | `Story` | Editorial content with facilitators, primary/gallery assets | | `Resource` | Handouts, toolkits, templates with downloadable assets | @@ -207,9 +207,9 @@ end - `EventRegistrationServices::PublicRegistration` — Public registration handling - `EventRegistrationReadiness` — Computes a registration's lifecycle `status` (`:not_ready` → `:ready` → `:certificate_due` → `:completed`) from a pre-event "event ready" checklist, a post-event "completion work" checklist (attendance, scholarship tasks), and certificate delivery, returning the specific outstanding reasons. Reads payment/certificate state via `Registerable` (`paid_in_full?`, `certificate_sent?`) on both the registration and its `continuing_education_registrations`. Drives the registrants roster's single far-right Status badge column (with a short reason under "Not ready" and a cert-type note under "Certificate pending") and its matching filter - `ReminderRecipientFilter` — Decides which event registrations stay checked on the bulk reminder page given the admin's filters (matches in memory, returns matching ids) -- `MagicTicketCallouts` — Code-defined ("magic") ticket callout cards (payment, certificate, scholarship, CE hours, art supplies, handouts, portal, videoconference, FAQ), each with its own visibility rule; rendered through the same `_callout_card` partial as `RegistrationTicketCallout`s. Skips any card an event has materialized (see `DefaultTicketCallouts`) so the two paths never double-render, and serves as the fallback for events not yet seeded. Public show pages live under `app/views/events/callouts/` (`Events::CalloutsController`, slug-authorized) -- `DefaultTicketCallouts` — Materializes all built-in callouts into `RegistrationTicketCallout` rows in canonical ticket order, seeded on event create and lazily on edit, each gated by the event's config via `seed_if` (idempotent, no backfill). Built-ins are edited in the **same** callout-fields row as custom callouts (pre-filled title/subtitle/colour/icon/callout-page-text/resources; hidden instead of deleted; "Restore default" shown only when `.customized?`). "Content" cards (Handouts, FAQ) render their own copy/resources; "behavioral" cards render live status through `MagicTicketCallouts#card_for`, which overlays the app's badge/visibility/destination on the row's editable presentation. Behavioral pages show the row's callout-page-text as an intro (`@builtin_intro`) and any linked resources below it. Certificate is opt-in (default off except trainings); Videoconference drips a week before start via `display_from`. CE hours and Event details are edited like every other built-in — their title/text seed from the event's columns once (migrating existing content) then live on the row; the CE hours-offered/cost config still edits the event inline via `event_f` (`ce_config?`). The registrant CE/details pages read the row (falling back to the event columns pre-seed). Built-ins always seed and also materialize lazily on `edit`, so the editor shows the full set; the editor shows "Restore default" (or a static "Matches default") per row via `.customized?`. The visibility control is a `published` toggle (inverse of `hidden`) -- `CalloutContent` — Parses admin-authored callout HTML into ordered segments so **every** callout content page renders the same way: plain rich text, with each standard `
` disclosure (the markup any HTML generator/LLM produces; `` and a `title` attribute are accepted aliases; `
` starts expanded) rebuilt into a styled collapsible card. `
`/`` are also on the `form_label_html` allowlist (`FORM_LABEL_TAGS`, plus the `open` attribute), so a disclosure is never stripped on save — the parser only upgrades its styling. Rendered through the shared `app/views/events/callouts/_rich_content.html.erb` partial (which wraps each disclosure in `_toggle.html.erb`), used by the event-details ("Art supplies & what to bring"), CE hours, custom-callout, behavioural-card-intro, and FAQ pages. The FAQ page renders the editable `faq` callout `description` (each question a `
`), falling back to `DefaultTicketCallouts.faq_html` when the card isn't materialized. Content with no disclosure renders unchanged +- `BuiltinCalloutCards` — Renders the live, per-registration ticket callout cards (payment, certificate, scholarship, CE hours, event details, videoconference), overlaying dynamic status (badge, colour, visibility guard, destination) on each materialized built-in row via `#card_for`. Rendered through the same `_callout_card` partial as `RegistrationTicketCallout`s. Skips any card an event has materialized (see `BuiltinCallouts`) so the two paths never double-render, and `#cards` serves as the fallback for events not yet seeded; `.editor_cards` builds the editor's preview cards. Handouts/FAQ are pure content cards with no builder here — they render from their row. Public show pages live under `app/views/events/callouts/` (`Events::CalloutsController`, slug-authorized) +- `BuiltinCallouts` — Owns the built-in callout definitions and materializes them into `RegistrationTicketCallout` rows in canonical ticket order: `seed` persists (on create, and lazily on edit so older events heal with no backfill), `build` makes the same rows in memory for the new-event form (with `builtin_key` round-tripped through nested attributes), `reset`/`customized?` back the "Restore default" control. All eight seed **hidden** by default — admins publish the ones they want; there's no config-based auto-publish. Built-ins are edited in the **same** callout-fields row as custom callouts (pre-filled title/subtitle/colour/icon/callout-page-text/resources; hidden instead of deleted; "Restore default" shown only when `.customized?`). "Content" cards (Handouts, FAQ) render their own copy/resources; "behavioral" cards render live status through `BuiltinCalloutCards#card_for`, which overlays the app's badge/visibility/destination on the row's editable presentation. Behavioral pages show the row's callout-page-text as an intro (`@builtin_intro`) and any linked resources below it. Videoconference drips a week before start via `display_from`. CE hours and Event details are edited like every other built-in — their title/text seed from the event's columns once (migrating existing content) then live on the row; the CE hours-offered/cost config still edits the event inline via `event_f` (`ce_config?`). The registrant CE/details pages read the row (falling back to the event columns pre-seed). Built-ins always seed and also materialize lazily on `edit`, so the editor shows the full set; the editor shows "Restore default" (or a static "Matches default") per row via `.customized?`. The visibility control is a `published` toggle (inverse of `hidden`) +- `CalloutContent` — Parses admin-authored callout HTML into ordered segments so **every** callout content page renders the same way: plain rich text, with each standard `
` disclosure (the markup any HTML generator/LLM produces; `` and a `title` attribute are accepted aliases; `
` starts expanded) rebuilt into a styled collapsible card. `
`/`` are also on the `form_label_html` allowlist (`FORM_LABEL_TAGS`, plus the `open` attribute), so a disclosure is never stripped on save — the parser only upgrades its styling. Rendered through the shared `app/views/events/callouts/_rich_content.html.erb` partial (which wraps each disclosure in `_toggle.html.erb`), used by the event-details ("Art supplies & what to bring"), CE hours, custom-callout, behavioural-card-intro, and FAQ pages. The FAQ page renders the editable `faq` callout `description` (each question a `
`), falling back to `BuiltinCallouts.faq_html` when the card isn't materialized. Content with no disclosure renders unchanged ### Affiliations diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index be4ff8caf..51fcd776b 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -1,5 +1,5 @@ module Events - # Public show pages for a registration ticket's magic callouts (payment, CE, + # Public show pages for a registration ticket's built-in callouts (payment, CE, # scholarship, handouts, videoconference, FAQ, certificate). # Each is reachable by the registration slug — the slug is the authorization, # so no login is required (mirrors the public ticket/invoice pages). @@ -145,8 +145,8 @@ def request_ce # (PDF preview + download, with a back-to-handouts eyebrow). Cards read their # subtitle from the materialized handouts callout's join rows. def handouts - return redirect_to registration_ticket_path(@event_registration.slug) unless @event.show_handouts_callout? - callout = @event.registration_ticket_callouts.find_by(magic_key: "handouts") + return redirect_to registration_ticket_path(@event_registration.slug) unless builtin_published?("handouts") + callout = @event.registration_ticket_callouts.find_by(builtin_key: "handouts") @handout_cards = resource_cards_for(callout, icon: "fa-solid fa-file-pdf", return_to: "handouts") end @@ -171,13 +171,21 @@ def videoconference # question), falling back to the code-defined default for events that haven't # materialized the card yet. def faq - return redirect_to(registration_ticket_path(@event_registration.slug)) unless @event.show_faq_callout? - callout = @event.registration_ticket_callouts.find_by(magic_key: "faq") - @faq_content = callout&.description.presence || DefaultTicketCallouts.faq_html + return redirect_to(registration_ticket_path(@event_registration.slug)) unless builtin_published?("faq") + callout = @event.registration_ticket_callouts.find_by(builtin_key: "faq") + @faq_content = callout&.description.presence || BuiltinCallouts.faq_html end private + # Whether the event's built-in callout for this key is materialized and + # published (visible). These public pages gate on that alone now — the admin's + # published/hidden choice on the row decides whether the page is reachable, so + # a hidden or not-yet-materialized card can't surface via a stray link. + def builtin_published?(builtin_key) + @event.registration_ticket_callouts.exists?(builtin_key: builtin_key, hidden: false) + end + def set_event_registration @event_registration = EventRegistration.find_by!(slug: params[:slug]) end @@ -191,14 +199,14 @@ def set_event end # The editable intro and linked resources for a built-in page, from the - # materialized callout row for this action's magic_key. Nil/empty when the + # materialized callout row for this action's builtin_key. Nil/empty when the # event hasn't materialized the card. Payment renders its own document list # (W-9 + invoice/receipt) in its Documents section, so it skips the generic # resource list here. Resources render as callout cards linking to their own # registrant page (PDF preview + download), each returning to this callout — # never inline. def set_builtin_content - callout = @event.registration_ticket_callouts.find_by(magic_key: action_name) + callout = @event.registration_ticket_callouts.find_by(builtin_key: action_name) @builtin_intro = callout&.description.presence callout = nil if action_name == "payment" @builtin_resource_cards = resource_cards_for(callout, icon: "fa-solid fa-file-lines", return_to: action_name) @@ -224,8 +232,8 @@ def origin_callout case params[:return_to] when "callout" @event.registration_ticket_callouts.find_by(id: params[:callout_id]) - when *RegistrationTicketCallout::MAGIC_KEYS - @event.registration_ticket_callouts.find_by(magic_key: params[:return_to]) + when *RegistrationTicketCallout::BUILTIN_KEYS + @event.registration_ticket_callouts.find_by(builtin_key: params[:return_to]) end end @@ -282,7 +290,7 @@ def payment_resource_card(link, slug) # can add/remove them), else transient links for the W-9 on paid events not # yet materialized. def payment_document_resources - payment_callout = @event.registration_ticket_callouts.find_by(magic_key: "payment") + payment_callout = @event.registration_ticket_callouts.find_by(builtin_key: "payment") return payment_callout.registration_ticket_callout_resources.ordered.includes(:resource).to_a if payment_callout return [] unless @event.cost_cents.to_i.positive? Resource.where(title: "W-9").map { |resource| RegistrationTicketCalloutResource.new(resource:) } @@ -291,7 +299,7 @@ def payment_document_resources # A grey document card for a non-resource payment document (the dynamic # invoice/receipt). Opens in a new tab, matching the callout-card contract. def document_card(title:, subtitle:, icon:, href:) - MagicTicketCallouts::Card.new(icon_class: icon, color: "gray", title:, subtitle:, + BuiltinCalloutCards::Card.new(icon_class: icon, color: "gray", title:, subtitle:, href:, target: "_blank", trailing_icon: "fa-solid fa-arrow-right") end @@ -299,7 +307,7 @@ def document_card(title:, subtitle:, icon:, href:) # available yet; the payment view renders it as a plain div with a lock icon, # and the subtitle names what unlocks it. def locked_document_card(title:, subtitle:, icon:) - MagicTicketCallouts::Card.new(icon_class: icon, color: "gray", title:, subtitle:, + BuiltinCalloutCards::Card.new(icon_class: icon, color: "gray", title:, subtitle:, href: nil, target: nil, trailing_icon: "fa-solid fa-lock") end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 88a23e0fb..8ce7e6109 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -37,7 +37,7 @@ def edit authorize! @event # Materialize any missing built-in callouts so the editor shows them all # (idempotent; heals events created before a built-in existed). - DefaultTicketCallouts.seed(@event) + BuiltinCallouts.seed(@event) set_form_variables end @@ -191,7 +191,7 @@ def onboarding def details authorize! @event, to: :details? - callout = @event.registration_ticket_callouts.find_by(magic_key: "event_details") + callout = @event.registration_ticket_callouts.find_by(builtin_key: "event_details") @event_details_title = callout&.title.presence || @event.event_details_label @event_details_body = callout&.description.presence || @event.event_details @@ -452,7 +452,7 @@ def create if params.dig(:library_asset, :new_assets).present? update_asset_owner(@event) end - DefaultTicketCallouts.seed(@event) + BuiltinCallouts.seed(@event) success = true end rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e @@ -483,7 +483,7 @@ def update assign_associations(@event) # Lazily materialize built-in callouts for events created before this # existed — heals on first edit, no data backfill. Idempotent. - DefaultTicketCallouts.seed(@event) + BuiltinCallouts.seed(@event) success = true else raise ActiveRecord::Rollback @@ -727,6 +727,11 @@ def set_form_variables @event = @event.decorate @event.build_primary_asset if @event.primary_asset.blank? @event.gallery_assets.build + # Build any not-yet-present built-in callouts as in-memory rows so the editor + # shows them (editable, with builtin_key round-tripped) even before the event's + # first save. Idempotent, so it's a no-op once they exist (edit seeds real + # rows first; a failed create/update already carries the submitted rows). + BuiltinCallouts.build(@event) @locations = Location.order(:city, :state) @registration_forms = Form.standalone.where(role: "registration").order(:name) @scholarship_forms = Form.standalone.where(role: "scholarship").order(:name) diff --git a/app/decorators/registration_ticket_callout_resource_decorator.rb b/app/decorators/registration_ticket_callout_resource_decorator.rb index 15c46f282..098f446df 100644 --- a/app/decorators/registration_ticket_callout_resource_decorator.rb +++ b/app/decorators/registration_ticket_callout_resource_decorator.rb @@ -13,7 +13,7 @@ def card_subtitle # callout page and is dropped from the URL when nil. `color` themes the card — # blue on the callout pages, grey in the payment page's documents list. def to_card(registrant_slug:, return_to:, icon: "fa-solid fa-file-lines", color: "blue", callout_id: nil) - MagicTicketCallouts::Card.new( + BuiltinCalloutCards::Card.new( icon_class: icon, color:, title: resource.title, subtitle: card_subtitle, href: card_href(registrant_slug:, return_to:, callout_id:), target: nil, trailing_icon: "fa-solid fa-arrow-right" diff --git a/app/models/event.rb b/app/models/event.rb index 078d6c27a..b1b9e0147 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -48,7 +48,7 @@ class Event < ApplicationRecord reject_if: proc { |attrs| attrs["person_id"].blank? } accepts_nested_attributes_for :registration_ticket_callouts, allow_destroy: true, # Reject only blank *new* callouts; existing rows (with an id) must always - # process so a control-only magic row's hidden/position toggle persists even + # process so a control-only built-in row's hidden/position toggle persists even # though it submits no title. reject_if: proc { |attrs| attrs["id"].blank? && attrs["title"].blank? } @@ -124,24 +124,6 @@ def one_click_for_signed_in? signed_in_one_click_enabled? || registration_form.nil? end - # Whether the built-in "training" ticket callouts (Handouts, FAQ, Forms) appear - # on this event's registration tickets. Their content is written for the 2-Day - # AWBW Facilitator Training, so they only belong on those events — a public, - # non-training event (often with no registration process) should not surface - # training worksheets or "2-day training" FAQ on its ticket. Handouts and FAQ - # are pure training curriculum; Forms (W-9, invoice, receipt) also belongs on - # any paid event, whose registrants need their invoice and receipt. - # - # These are the default rules; a later change adds a per-event override so - # admins can force a callout on or off from the event's callouts section. - def show_handouts_callout? - facilitator_training? - end - - def show_faq_callout? - facilitator_training? - end - def scholarship_form forms.find_by(event_forms: { role: "scholarship" }) end @@ -182,7 +164,7 @@ def videoconference_window_open? def videoconference_details_available_from return @videoconference_details_available_from if defined?(@videoconference_details_available_from) @videoconference_details_available_from = - if (callout = registration_ticket_callouts.magic.find_by(magic_key: "videoconference")) + if (callout = registration_ticket_callouts.builtin.find_by(builtin_key: "videoconference")) callout.display_from elsif start_date start_date - VIDEOCONFERENCE_DETAILS_LEAD diff --git a/app/models/registration_ticket_callout.rb b/app/models/registration_ticket_callout.rb index 74e8546b6..017816496 100644 --- a/app/models/registration_ticket_callout.rb +++ b/app/models/registration_ticket_callout.rb @@ -5,31 +5,31 @@ class RegistrationTicketCallout < ApplicationRecord # group them on the ticket later. CALLOUT_TYPES = %w[ action reference ].freeze - # Hidden identifiers for the built-in ("magic") callouts. A row carrying one of - # these was seeded from a code-defined default (see DefaultTicketCallouts) and + # Hidden identifiers for the built-in callouts. A row carrying one of + # these was seeded from a code-defined default (see BuiltinCallouts) and # keeps its ticket behavior — badges, per-registration visibility — driven by - # that key. Admin-authored callouts have a nil magic_key. Magic callouts are + # that key. Admin-authored callouts have a nil builtin_key. Built-in callouts are # hidden rather than destroyed so they can be restored to their default. - MAGIC_KEYS = %w[ + BUILTIN_KEYS = %w[ payment certificate scholarship ce_hours event_details videoconference handouts faq ].freeze - # "Content" magic callouts render their own editable copy/resources (like custom - # callouts). "Behavioral" magic callouts (the rest) render live per-registration - # status through MagicTicketCallouts#card_for — the row still owns the editable + # "Content" built-in callouts render their own editable copy/resources (like custom + # callouts). "Behavioral" built-in callouts (the rest) render live per-registration + # status through BuiltinCalloutCards#card_for — the row still owns the editable # title/subtitle/text, order, visibility, and resources. - CONTENT_MAGIC_KEYS = %w[ handouts faq ].freeze + CONTENT_BUILTIN_KEYS = %w[ handouts faq ].freeze # Behavioral built-ins that also carry event-level config edited inline in their # row (CE hours offered / cost); their text lives on the row like everything else. - CONFIG_MAGIC_KEYS = %w[ ce_hours ].freeze + CONFIG_BUILTIN_KEYS = %w[ ce_hours ].freeze # Built-ins whose card colour the app sets from live status — Payment turns # orange while a balance is due, and Scholarship / CE hours turn amber while the # registrant has something outstanding. For these, the app colour overrides the - # selected one (see MagicTicketCallouts#card_for). - APP_COLORED_MAGIC_KEYS = %w[ payment scholarship ce_hours ].freeze + # selected one (see BuiltinCalloutCards#card_for). + APP_COLORED_BUILTIN_KEYS = %w[ payment scholarship ce_hours ].freeze # Per-type fallbacks for the icon and colour. These are callout-specific (unlike # the generic colour swatches and palette, which live in DomainTheme so the whole @@ -69,13 +69,13 @@ class RegistrationTicketCallout < ApplicationRecord validates :callout_type, inclusion: { in: CALLOUT_TYPES } validates :color_class, inclusion: { in: DomainTheme::SWATCH_COLORS.map(&:to_s) }, allow_blank: true validates :position, numericality: { only_integer: true, greater_than: 0, allow_nil: true } - validates :magic_key, inclusion: { in: MAGIC_KEYS }, allow_nil: true - validates :magic_key, uniqueness: { scope: :event_id }, allow_nil: true + validates :builtin_key, inclusion: { in: BUILTIN_KEYS }, allow_nil: true + validates :builtin_key, uniqueness: { scope: :event_id }, allow_nil: true scope :ordered, -> { order(:position, :id) } scope :visible, -> { where(hidden: false) } - scope :magic, -> { where.not(magic_key: nil) } - scope :custom, -> { where(magic_key: nil) } + scope :builtin, -> { where.not(builtin_key: nil) } + scope :custom, -> { where(builtin_key: nil) } # Reset flagged rows to their template after the save that set the flag. The # flag is cleared first so the reset's own update doesn't recurse. @@ -83,7 +83,7 @@ class RegistrationTicketCallout < ApplicationRecord def apply_reset_to_default self.reset_to_default = nil - DefaultTicketCallouts.reset(self) + BuiltinCallouts.reset(self) end def action? @@ -107,30 +107,30 @@ def published=(value) attr_accessor :reset_to_default def reset_to_default? - magic? && ActiveModel::Type::Boolean.new.cast(reset_to_default) + builtin? && ActiveModel::Type::Boolean.new.cast(reset_to_default) end # A seeded built-in callout (Handouts, FAQ, …) rather than an admin-authored - # one. Magic callouts hide instead of delete and can be reset to default. - def magic? - magic_key.present? + # one. Built-in callouts hide instead of delete and can be reset to default. + def builtin? + builtin_key.present? end - # A behavioral built-in callout whose card is rendered by MagicTicketCallouts + # A behavioral built-in callout whose card is rendered by BuiltinCalloutCards # (live status), as opposed to a content callout that renders from its own row. - def behavioral_magic? - magic? && CONTENT_MAGIC_KEYS.exclude?(magic_key) + def behavioral_builtin? + builtin? && CONTENT_BUILTIN_KEYS.exclude?(builtin_key) end # Whether the row carries the inline CE config fields (hours offered / cost). def ce_config? - CONFIG_MAGIC_KEYS.include?(magic_key.to_s) + CONFIG_BUILTIN_KEYS.include?(builtin_key.to_s) end # Whether the app sets this card's colour from live status, overriding the # selected colour (so the editor notes it under the colour picker). def app_colored? - APP_COLORED_MAGIC_KEYS.include?(magic_key.to_s) + APP_COLORED_BUILTIN_KEYS.include?(builtin_key.to_s) end # Whether the callout's own page has content to show — its editable text or any @@ -144,7 +144,7 @@ def page_content? # so the manual visibility controls (drip date, payment gate) don't apply — the # editor hides its Visibility section. def payment? - magic_key == "payment" + builtin_key == "payment" end # Whether the callout's page content is drip-scheduled to reveal only from a diff --git a/app/models/registration_ticket_callout_resource.rb b/app/models/registration_ticket_callout_resource.rb index b01afac64..fb3f5797a 100644 --- a/app/models/registration_ticket_callout_resource.rb +++ b/app/models/registration_ticket_callout_resource.rb @@ -6,7 +6,7 @@ class RegistrationTicketCalloutResource < ApplicationRecord # `subtitle` is the short line shown on the callout card for this resource; # `page_content` is the longer copy shown under the title on the resource's own # detail page. Both are editable per event (materialized from - # DefaultTicketCallouts for the built-in handouts). + # BuiltinCallouts for the built-in handouts). belongs_to :registration_ticket_callout belongs_to :resource diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index 4ee664e3d..a081a54f7 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -161,7 +161,7 @@ def google_analytics? sector_ids: [], primary_asset_attributes: [ :id, :file, :_destroy ], gallery_assets_attributes: [ :id, :file, :_destroy ], - registration_ticket_callouts_attributes: [ :id, :title, :subtitle, :description, :callout_type, :icon_class, :color_class, :display_from, :payment_access_gated, :published, :reset_to_default, :_destroy, + registration_ticket_callouts_attributes: [ :id, :builtin_key, :title, :subtitle, :description, :callout_type, :icon_class, :color_class, :display_from, :payment_access_gated, :published, :reset_to_default, :_destroy, { registration_ticket_callout_resources_attributes: [ :id, :resource_id, :subtitle, :page_content, :_destroy ] } ] ] diff --git a/app/services/magic_ticket_callouts.rb b/app/services/builtin_callout_cards.rb similarity index 82% rename from app/services/magic_ticket_callouts.rb rename to app/services/builtin_callout_cards.rb index 02c377948..88346c126 100644 --- a/app/services/magic_ticket_callouts.rb +++ b/app/services/builtin_callout_cards.rb @@ -1,13 +1,20 @@ -# "Magic" ticket callouts are code-defined cards on a registration ticket whose -# presence and content the app controls — unlike admin-configured -# RegistrationTicketCallouts, which admins create and reorder. Each magic card -# knows its own visibility rule (e.g. the payment card switches between an action -# and a reference card depending on whether a balance is due) and where it links. +# Renders the live, per-registration cards for a registrant's ticket. Given an +# EventRegistration, it turns each built-in callout into a display Card whose +# dynamic parts depend on that registration — the Payment card's balance and +# colour, the Certificate card only once it's unlocked, the Scholarship badge, and +# so on. Each card exposes the _callout_card partial's interface +# (#display_icon_class, #theme, #title, #subtitle) plus #href / #target / +# #trailing_icon. # -# They render through the same _callout_card partial as admin callouts, so each -# card exposes that partial's presentation interface (#display_icon_class, -# #theme, #title, #subtitle) plus its own #href / #target / #trailing_icon. -class MagicTicketCallouts +# `#card_for(row)` overlays this live status onto a materialized built-in row: the +# row supplies the admin-edited title/subtitle/colour, this service supplies the +# badge, status colour, visibility guard, and destination. `#cards` is the code +# fallback for events whose built-ins aren't materialized yet. `.editor_cards` +# builds the greyed preview cards shown in the event editor. +# +# The callouts' definitions and materialization live in BuiltinCallouts; this +# service is only about how they render on a ticket. +class BuiltinCalloutCards include Rails.application.routes.url_helpers # `badge` / `badge_classes` are an optional status chip shown inline in the @@ -26,12 +33,12 @@ def theme = DomainTheme.swatch(color) # A registration-free description of one built-in card, for the event editor's # callouts section. `key` is :ce_hours / :event_details for the two whose text # admins edit via event columns; nil for the cards the app fully controls (shown - # greyed out). `magic_key` ties the card to its ticket behavior — once an event + # greyed out). `builtin_key` ties the card to its ticket behavior — once an event # has materialized that key into an editable row, the preview is dropped here and # the row is edited in the callout list instead. `subtitle` mirrors the card's # ticket subtitle; `visibility` describes when the app shows it (rendered next to # the "Built in" chip); `note` is an optional hint on where content comes from. - EditorCard = Data.define(:key, :magic_key, :icon_class, :color, :title, :subtitle, :visibility, :note) do + EditorCard = Data.define(:key, :builtin_key, :icon_class, :color, :title, :subtitle, :visibility, :note) do def theme = DomainTheme.swatch(color) def editable? = key.present? end @@ -41,7 +48,9 @@ def editable? = key.present? # materialized are omitted — they're edited as real rows in the callout list. # Keep in sync with #cards: add a card method, add it here. def self.editor_cards(event) - materialized = event.registration_ticket_callouts.magic.pluck(:magic_key).to_set + # Read the loaded/in-memory rows (not a DB pluck) so a new event's just-built, + # not-yet-saved built-in rows count as materialized and aren't also previewed. + materialized = event.registration_ticket_callouts.reject(&:marked_for_destruction?).filter_map(&:builtin_key).to_set [ EditorCard.new(nil, "payment", "fa-solid fa-credit-card", "orange", "Payment", "Your balance and payment history", "When the event has a cost", nil), EditorCard.new(nil, "certificate", "fa-solid fa-certificate", "green", "Certificate of completion", "View and download your certificate", "Once the certificate is unlocked", nil), @@ -49,19 +58,20 @@ def self.editor_cards(event) EditorCard.new(nil, "videoconference", "fa-solid fa-video", "blue", "Videoconference", "Join link and how to add it to your calendar", "When the event has a videoconference link", "Details come from this event's videoconference settings."), EditorCard.new(nil, "handouts", "fa-solid fa-folder-open", "blue", "Handouts", "Worksheets and resources for the training", "On facilitator trainings", "Items link to their relevant resources."), EditorCard.new(nil, "faq", "fa-solid fa-circle-question", "blue", "Frequently asked questions", "Common questions about the 2-day training", "On facilitator trainings", nil) - ].reject { |card| card.key.nil? && materialized.include?(card.magic_key) } + ].reject { |card| card.key.nil? && materialized.include?(card.builtin_key) } end - # magic_key → builder method, in the default order cards appear on a ticket. + # builtin_key → builder method, in the default order cards appear on a ticket. + # Handouts and FAQ are pure content cards: they only ever render from their + # materialized row (the ticket's content branch), never from code, so they + # have no builder here. CARD_BUILDERS = { "payment" => :payment_card, "certificate" => :certificate_card, "scholarship" => :scholarship_status_card, "ce_hours" => :ce_hours_card, "event_details" => :event_details_card, - "videoconference" => :videoconference_card, - "handouts" => :handouts_card, - "faq" => :faq_card + "videoconference" => :videoconference_card }.freeze def initialize(event_registration) @@ -74,7 +84,7 @@ def initialize(event_registration) # those from the row (calling #card_for for behavioral ones), so this is both the # non-materialized set and the fallback for events not yet seeded. def cards - CARD_BUILDERS.reject { |magic_key, _| materialized?(magic_key) || skip_in_fallback?(magic_key) } + CARD_BUILDERS.reject { |builtin_key, _| materialized?(builtin_key) || skip_in_fallback?(builtin_key) } .filter_map { |_, builder| send(builder) } end @@ -84,11 +94,11 @@ def cards # destination link); the row supplies the editable presentation (title, # subtitle, colour, icon), so admin edits to those take effect on the ticket. def card_for(callout) - builder = CARD_BUILDERS[callout.magic_key] + builder = CARD_BUILDERS[callout.builtin_key] base = builder && send(builder) return unless base # Event details links to its page only when it has content to show. - return if callout.magic_key == "event_details" && callout.description.blank? + return if callout.builtin_key == "event_details" && callout.description.blank? base.with( title: callout.title, @@ -106,15 +116,15 @@ def card_for(callout) # A built-in card the event has materialized into an editable row renders from # that row, not from #cards, so we skip it here to avoid double-rendering. - def materialized?(magic_key) - @materialized_keys ||= event.registration_ticket_callouts.magic.pluck(:magic_key).to_set - @materialized_keys.include?(magic_key) + def materialized?(builtin_key) + @materialized_keys ||= event.registration_ticket_callouts.builtin.pluck(:builtin_key).to_set + @materialized_keys.include?(builtin_key) end # In the unseeded fallback, event-details content lives on the event column, so # hide the card when it's blank (the row path checks the row in #card_for). - def skip_in_fallback?(magic_key) - magic_key == "event_details" && event.event_details.blank? + def skip_in_fallback?(builtin_key) + builtin_key == "event_details" && event.event_details.blank? end # Top card: an action card while a balance is due, a reference card once paid @@ -261,26 +271,4 @@ def videoconference_card href: registration_videoconference_path(registration.slug), target: nil, trailing_icon: "fa-solid fa-arrow-right") end - - # Links to the 2-day training worksheets and handouts, so shown only on - # facilitator trainings — see Event#show_handouts_callout?. - def handouts_card - return unless event.show_handouts_callout? - Card.new(icon_class: "fa-solid fa-folder-open", color: "blue", - title: "Handouts", - subtitle: "Worksheets and resources for the training", - href: registration_handouts_path(registration.slug), - target: nil, trailing_icon: "fa-solid fa-arrow-right") - end - - # Reference card linking to the 2-day training FAQ page, so shown only on - # facilitator trainings — see Event#show_faq_callout?. - def faq_card - return unless event.show_faq_callout? - Card.new(icon_class: "fa-solid fa-circle-question", color: "blue", - title: "Frequently asked questions", - subtitle: "Common questions about the 2-day training", - href: registration_faq_path(registration.slug), - target: nil, trailing_icon: "fa-solid fa-arrow-right") - end end diff --git a/app/services/default_ticket_callouts.rb b/app/services/builtin_callouts.rb similarity index 80% rename from app/services/default_ticket_callouts.rb rename to app/services/builtin_callouts.rb index 90da5e16b..eb7dab097 100644 --- a/app/services/default_ticket_callouts.rb +++ b/app/services/builtin_callouts.rb @@ -1,19 +1,19 @@ -# Materializes the built-in ("magic") ticket callouts into editable rows for an -# event. Run on event create and, for events that predate this, lazily on edit — -# so existing events heal the first time they're saved, with no data backfill. +# The catalog of built-in ticket callouts (Payment, Certificate, Scholarship, CE +# hours, Event details, Videoconference, Handouts, FAQ) and the code that +# materializes them onto an event as editable RegistrationTicketCallout rows: +# `seed` persists them (on create, and lazily on edit so older events heal with no +# backfill); `build` makes the same rows in memory for the new-event form; `reset` +# restores a row to its default; `customized?` reports whether a row was edited. # -# All eight built-ins always seed. Their initial visibility (hidden/published) is -# derived from the event's config via each definition's `hidden` proc: published -# by default on facilitator trainings, hidden (unchecked) on everything else. -# Admins toggle visibility per row from the editor. Seeding is idempotent so a -# re-run never clobbers admin edits. +# All eight seed hidden (unchecked) by default — admins publish the ones they want +# per event, edit them per row, and can restore a row to default. Custom callouts +# live in the same list and can be reordered among the built-ins. Seeding is +# idempotent so a re-run never clobbers admin edits. # -# Behavioral built-ins (payment, CE, scholarship, …) render live per-registration -# status through MagicTicketCallouts#card_for, which has its own guards (e.g. the -# payment card returns nil on a free event) so publishing a row on an -# non-applicable event is harmless. MagicTicketCallouts skips any card an event -# has already materialized, so the two paths never double-render. -class DefaultTicketCallouts +# This service owns the callouts' *definitions and materialization*. How a callout +# then renders as a live card on a registrant's ticket (status badges, per- +# registration guards) is the job of BuiltinCalloutCards. +class BuiltinCallouts # Default FAQ content for the 2-day training, mirrored from the code-defined FAQ # page so a newly materialized card starts with the current copy. Admins edit it # per event afterward. @@ -82,15 +82,26 @@ class DefaultTicketCallouts } }.freeze - # magic_keys this service knows how to materialize. + # builtin_keys this service knows how to materialize. def self.seedable_keys - new(nil).send(:definitions).map { |definition| definition[:magic_key] } + new(nil).send(:definitions).map { |definition| definition[:builtin_key] } end def self.seed(event) new(event).seed end + # Build (not persist) any not-yet-present built-in callouts as in-memory rows on + # the event, so a brand-new event's form shows the built-ins as editable rows + # that save together with the event on first submit. Idempotent against rows + # already loaded/built (e.g. re-rendering a failed create), so it never + # duplicates. Persisting still routes each row's `builtin_key` back through nested + # attributes; the post-save `seed` is the idempotent safety net for non-form + # creation paths. Returns the built rows. + def self.build(event) + new(event).build + end + # Reset a materialized callout's content and default visibility back to its # built-in template, keeping its position. Used by the "Restore default" action. def self.reset(callout) @@ -107,19 +118,27 @@ def initialize(event) @event = event end - # Create any not-yet-present magic callouts for the event, appended in + # Create any not-yet-present built-in callouts for the event, appended in # definition order after whatever already exists. All built-ins always seed; # their initial visibility (hidden/published) is derived from the event's # config via each definition's `hidden` proc. Seeding is idempotent so a re-run # never clobbers admin edits. Returns the created rows. def seed - existing_keys = @event.registration_ticket_callouts.magic.pluck(:magic_key).to_set - definitions.reject { |definition| existing_keys.include?(definition[:magic_key]) } + existing_keys = @event.registration_ticket_callouts.builtin.pluck(:builtin_key).to_set + definitions.reject { |definition| existing_keys.include?(definition[:builtin_key]) } .map { |definition| create(definition) } end + # In-memory counterpart to #seed. Skips keys already present on the loaded + # association (built or persisted) so it's safe to call on every form render. + def build + existing_keys = @event.registration_ticket_callouts.reject(&:marked_for_destruction?).filter_map(&:builtin_key).to_set + definitions.reject { |definition| existing_keys.include?(definition[:builtin_key]) } + .map { |definition| build_row(definition) } + end + def reset(callout) - definition = definitions.find { |candidate| candidate[:magic_key] == callout.magic_key } + definition = definitions.find { |candidate| candidate[:builtin_key] == callout.builtin_key } return callout unless definition callout.update!( @@ -138,7 +157,7 @@ def reset(callout) end def customized?(callout) - definition = definitions.find { |candidate| candidate[:magic_key] == callout.magic_key } + definition = definitions.find { |candidate| candidate[:builtin_key] == callout.builtin_key } return false unless definition callout.title != resolve(definition[:title]) || @@ -165,18 +184,18 @@ def resolve(value) # each event derives its own defaults; `resources` resolves the linked records; # `seed_if` gates whether the card applies. Content cards (Handouts, FAQ) render # their own copy; "behavioral" cards (Certificate, Videoconference) render live - # per-registration status through MagicTicketCallouts#card_for — the row only + # per-registration status through BuiltinCalloutCards#card_for — the row only # governs visibility, drip date, and order. def definitions [ { - magic_key: "payment", + builtin_key: "payment", title: "Payment", subtitle: "Your balance and payment history", callout_type: "action", icon_class: "fa-solid fa-credit-card", color_class: "orange", - hidden: ->(event) { !event.facilitator_training? }, + hidden: ->(_event) { true }, # The W-9 is a removable linked resource, included by default only on paid # events (where a tax form applies); the invoice/receipt stay dynamic on # the payment page. Admins add/remove it per event. @@ -187,27 +206,27 @@ def definitions } }, { - magic_key: "certificate", + builtin_key: "certificate", title: "Certificate of completion", subtitle: "View and download your certificate", callout_type: "action", icon_class: "fa-solid fa-certificate", color_class: "green", # Off by default except on facilitator trainings. When shown, it still only - # appears once the certificate unlocks (MagicTicketCallouts guards this). - hidden: ->(event) { !event.facilitator_training? } + # appears once the certificate unlocks (BuiltinCalloutCards guards this). + hidden: ->(_event) { true } }, { - magic_key: "scholarship", + builtin_key: "scholarship", title: "Scholarship", subtitle: "Your scholarship request and award", callout_type: "action", icon_class: "fa-solid fa-award", color_class: "fuchsia", - hidden: ->(event) { !event.facilitator_training? } + hidden: ->(_event) { true } }, { - magic_key: "ce_hours", + builtin_key: "ce_hours", # Title/text seed from the event's CE columns (migrating existing content); # thereafter they live on the row like every other built-in. The row also # carries the CE hours-offered/cost config. @@ -217,10 +236,10 @@ def definitions callout_type: "action", icon_class: "fa-solid fa-graduation-cap", color_class: "teal", - hidden: ->(event) { !event.facilitator_training? } + hidden: ->(_event) { true } }, { - magic_key: "event_details", + builtin_key: "event_details", # Title/text seed from the event's details columns (migrating existing # content); thereafter they live on the row. title: ->(event) { event.event_details_label }, @@ -229,48 +248,62 @@ def definitions callout_type: "reference", icon_class: "fa-solid fa-palette", color_class: "blue", - hidden: ->(event) { !event.facilitator_training? } + hidden: ->(_event) { true } }, { - magic_key: "videoconference", + builtin_key: "videoconference", title: "Videoconference", subtitle: "Join link and how to add it to your calendar", callout_type: "action", icon_class: "fa-solid fa-video", color_class: "blue", - hidden: ->(event) { !event.facilitator_training? }, + hidden: ->(_event) { true }, # Drips onto the ticket a week before the event starts, replacing the old # hard-coded "one week prior" rule with a stored, editable date. display_from: ->(event) { event.start_date - 7.days if event.start_date } }, { - magic_key: "handouts", + builtin_key: "handouts", title: "Handouts", subtitle: "Worksheets and resources for the training", callout_type: "reference", icon_class: "fa-solid fa-folder-open", color_class: "blue", - hidden: ->(event) { !event.facilitator_training? }, + hidden: ->(_event) { true }, resources: -> { handout_resources }, # Per-link subtitle/page_content defaults, keyed by resource title. resource_content: HANDOUT_LINK_DEFAULTS }, { - magic_key: "faq", + builtin_key: "faq", title: "Frequently asked questions", subtitle: "Common questions about the 2-day training", callout_type: "reference", icon_class: "fa-solid fa-circle-question", color_class: "blue", description: self.class.faq_html, - hidden: ->(event) { !event.facilitator_training? } + hidden: ->(_event) { true } } ] end def create(definition) - callout = @event.registration_ticket_callouts.create!( - magic_key: definition[:magic_key], + callout = @event.registration_ticket_callouts.create!(attributes_for(definition)) + build_resource_links(callout, definition) + callout + end + + # In-memory sibling of #create: builds the row (and its resource links) without + # persisting, so they save as nested attributes when the event is saved. + def build_row(definition) + callout = @event.registration_ticket_callouts.build(attributes_for(definition)) + build_resource_links(callout, definition) + callout + end + + def attributes_for(definition) + { + builtin_key: definition[:builtin_key], title: resolve(definition[:title]), subtitle: resolve(definition[:subtitle]), description: resolve(definition[:description]), @@ -279,23 +312,24 @@ def create(definition) color_class: definition[:color_class], hidden: definition[:hidden].call(@event), display_from: definition[:display_from]&.call(@event) - ) - build_resource_links(callout, definition) - callout + } end # Link the definition's resources in order, materializing each join row's # subtitle/page_content from `resource_content` (keyed by resource title) when - # the definition supplies it. The positioning gem assigns each row's position. + # the definition supplies it. Links persist immediately for a saved callout, or + # stay in memory (saved with the event) when the callout is still unsaved. The + # positioning gem assigns each row's position. def build_resource_links(callout, definition) content = definition[:resource_content] || {} Array(definition[:resources]&.call).each do |resource| attrs = content[resource.title] || {} - callout.registration_ticket_callout_resources.create!( + link = callout.registration_ticket_callout_resources.build( resource: resource, subtitle: attrs[:subtitle], page_content: attrs[:page_content] ) + link.save! if callout.persisted? end end diff --git a/app/views/event_registrations/_callout_card.html.erb b/app/views/event_registrations/_callout_card.html.erb index e797d2987..5e69fe530 100644 --- a/app/views/event_registrations/_callout_card.html.erb +++ b/app/views/event_registrations/_callout_card.html.erb @@ -1,9 +1,9 @@ <%# Renders one registration ticket callout card. Used for both admin-configured - RegistrationTicketCallouts and code-defined MagicTicketCallouts::Card objects. + RegistrationTicketCallouts and code-defined BuiltinCalloutCards::Card objects. locals: callout (responds to #theme, #display_icon_class, #title, #subtitle). href / target / trailing_icon may be passed explicitly (admin callouts compute href from a route) or read off the callout when it carries them - (MagicTicketCallouts::Card). + (BuiltinCalloutCards::Card). linked (default true): when false the card renders as a non-navigating div — the sample-ticket preview has no real per-registration pages. %> <% theme = callout.theme %> @@ -14,7 +14,7 @@ <% badge = callout.try(:badge) %> <% badge_classes = callout.try(:badge_classes).presence || "bg-amber-100 text-amber-800 border border-amber-300" %> <%# A card leads to a page — so it links and shows the trailing "go to page" - arrow — always for magic cards (they carry their own page), and for admin + arrow — always for built-in cards (they carry their own page), and for admin callouts only when they have page content of their own. Content-less callouts render as a plain, non-navigating card. %> <% links_to_page = callout.respond_to?(:page_content?) ? callout.page_content? : true %> diff --git a/app/views/event_registrations/_ticket.html.erb b/app/views/event_registrations/_ticket.html.erb index 033cb9043..4d351e6c8 100644 --- a/app/views/event_registrations/_ticket.html.erb +++ b/app/views/event_registrations/_ticket.html.erb @@ -100,26 +100,26 @@ <% end %> - <% magic = MagicTicketCallouts.new(event_registration) %> + <% builtin_cards = BuiltinCalloutCards.new(event_registration) %> - - <% magic.cards.each do |card| %> + <% builtin_cards.cards.each do |card| %> <%= render "event_registrations/callout_card", callout: card, linked: !preview %> <% end %> <% payment_access = event_registration.payment_access_granted? || @checkout_payment_status == "paid" %> <% event_registration.event.registration_ticket_callouts.visible.each do |callout| %> <% next if callout.payment_access_gated && !payment_access %> - <% if callout.behavioral_magic? %> - <% card = magic.card_for(callout) %> + <% if callout.behavioral_builtin? %> + <% card = builtin_cards.card_for(callout) %> <% if card %> <%= render "event_registrations/callout_card", callout: card, linked: !preview %> <% end %> diff --git a/app/views/events/_builtin_callout_card.html.erb b/app/views/events/_builtin_callout_card.html.erb index 77b4e6f7d..ef198b877 100644 --- a/app/views/events/_builtin_callout_card.html.erb +++ b/app/views/events/_builtin_callout_card.html.erb @@ -1,7 +1,7 @@ <%# An editable built-in registration-ticket callout (CE hours, art supplies), rendered inside the event form's callouts section and tinted in the colour the card uses on the ticket. locals: f (event form builder), card - (MagicTicketCallouts::EditorCard), label_field/content_field (event attribute + (BuiltinCalloutCards::EditorCard), label_field/content_field (event attribute symbols), title_placeholder, content_help, content_placeholder. %> <% theme = card.theme %>
diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 2a2fa229e..6b526d84a 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -571,7 +571,7 @@ %>
- <% MagicTicketCallouts.editor_cards(@event).each do |card| %> + <% BuiltinCalloutCards.editor_cards(@event).each do |card| %> <% case card.key %> <% when :ce_hours %> <%= render "events/builtin_callout_card", f: f, card: card, diff --git a/app/views/events/_registration_ticket_callout_fields.html.erb b/app/views/events/_registration_ticket_callout_fields.html.erb index 9743f4580..7da76d907 100644 --- a/app/views/events/_registration_ticket_callout_fields.html.erb +++ b/app/views/events/_registration_ticket_callout_fields.html.erb @@ -21,6 +21,12 @@ <% end %>
+ <%# Round-trips the built-in identity so an unsaved (new-event) built-in row saves + as its built-in, not a custom callout; kept outside the collapsed content + so it submits even when the row is unpublished. %> + <% if f.object.builtin? %> + <%= f.hidden_field :builtin_key %> + <% end %> <%# Published toggle, pinned to the card's top-right corner. The checkbox is kept as a bare sibling *before* the content div below so the @@ -40,7 +46,7 @@ %>
- <% if f.object.magic? %> + <% if f.object.builtin? %> Built in <% end %> @@ -223,8 +229,8 @@ the same spot instead. %>
- <% if f.object.magic? %> - <% if f.object.persisted? && DefaultTicketCallouts.customized?(f.object) %> + <% if f.object.builtin? %> + <% if f.object.persisted? && BuiltinCallouts.customized?(f.object) %> <% else %> Matches default diff --git a/db/migrate/20260715041845_rename_magic_key_to_builtin_key.rb b/db/migrate/20260715041845_rename_magic_key_to_builtin_key.rb new file mode 100644 index 000000000..ec0bd2440 --- /dev/null +++ b/db/migrate/20260715041845_rename_magic_key_to_builtin_key.rb @@ -0,0 +1,17 @@ +class RenameMagicKeyToBuiltinKey < ActiveRecord::Migration[8.1] + # "magic_key" was the hidden identifier tying a materialized row back to its + # built-in. Renamed to "builtin_key" so the whole built-in callout stack reads + # consistently (BuiltinCallouts / BuiltinCalloutCards / builtin_key). + OLD_INDEX = "index_registration_ticket_callouts_on_event_id_and_magic_key" + NEW_INDEX = "index_registration_ticket_callouts_on_event_id_and_builtin_key" + + def up + rename_column :registration_ticket_callouts, :magic_key, :builtin_key + rename_index :registration_ticket_callouts, OLD_INDEX, NEW_INDEX if index_name_exists?(:registration_ticket_callouts, OLD_INDEX) + end + + def down + rename_index :registration_ticket_callouts, NEW_INDEX, OLD_INDEX if index_name_exists?(:registration_ticket_callouts, NEW_INDEX) + rename_column :registration_ticket_callouts, :builtin_key, :magic_key + end +end diff --git a/db/schema.rb b/db/schema.rb index 367f5f508..39ed76ab0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_15_000553) do +ActiveRecord::Schema[8.1].define(version: 2026_07_15_041845) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -1111,6 +1111,7 @@ end create_table "registration_ticket_callouts", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.string "builtin_key" t.string "callout_type", default: "reference", null: false t.string "color_class" t.datetime "created_at", null: false @@ -1119,13 +1120,12 @@ t.bigint "event_id", null: false t.boolean "hidden", default: false, null: false t.string "icon_class" - t.string "magic_key" t.boolean "payment_access_gated", default: false, null: false t.integer "position", null: false t.string "subtitle" t.string "title", null: false t.datetime "updated_at", null: false - t.index ["event_id", "magic_key"], name: "index_registration_ticket_callouts_on_event_id_and_magic_key", unique: true + t.index ["event_id", "builtin_key"], name: "index_registration_ticket_callouts_on_event_id_and_builtin_key", unique: true t.index ["event_id", "position"], name: "index_registration_ticket_callouts_on_event_id_and_position" t.index ["event_id"], name: "index_registration_ticket_callouts_on_event_id" end diff --git a/spec/factories/registration_ticket_callouts.rb b/spec/factories/registration_ticket_callouts.rb index e6e64b61a..9111952aa 100644 --- a/spec/factories/registration_ticket_callouts.rb +++ b/spec/factories/registration_ticket_callouts.rb @@ -33,8 +33,8 @@ hidden { true } end - trait :magic do - magic_key { "faq" } + trait :builtin do + builtin_key { "faq" } title { "Frequently asked questions" } end end diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index f77363e4e..156082173 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -664,7 +664,7 @@ def registration_with_scholarship it "is visible immediately once paid when the callout has no drip date" do event = create(:event, cost_cents: 1099, start_date: 30.days.from_now, end_date: 30.days.from_now + 2.hours) - create(:registration_ticket_callout, event:, magic_key: "videoconference", display_from: nil) + create(:registration_ticket_callout, event:, builtin_key: "videoconference", display_from: nil) reg = create(:event_registration, event: event, registrant: user.person, intends_to_pay: true) expect(reg.videoconference_details_visible?).to be true end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index e84b3a459..f5a71f6e8 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -111,7 +111,7 @@ it "defers to the videoconference callout's drip date over the week-before default" do event = create(:event, start_date: 6.days.from_now, end_date: 6.days.from_now + 2.hours) - create(:registration_ticket_callout, event:, magic_key: "videoconference", + create(:registration_ticket_callout, event:, builtin_key: "videoconference", display_from: 2.days.from_now) # Within the week-before default (which would be true), but before the @@ -121,7 +121,7 @@ it "is visible immediately when the callout's drip date has been cleared" do event = create(:event, start_date: 6.days.from_now, end_date: 6.days.from_now + 2.hours) - create(:registration_ticket_callout, event:, magic_key: "videoconference", display_from: nil) + create(:registration_ticket_callout, event:, builtin_key: "videoconference", display_from: nil) expect(event.videoconference_details_visible?).to be true end @@ -323,18 +323,6 @@ end end - describe "built-in ticket callout visibility" do - it "shows Handouts and FAQ only for facilitator trainings" do - training = build(:event, facilitator_training: true) - other = build(:event, facilitator_training: false) - - expect(training.show_handouts_callout?).to be true - expect(training.show_faq_callout?).to be true - expect(other.show_handouts_callout?).to be false - expect(other.show_faq_callout?).to be false - end - end - describe '.search_by_params' do let!(:art_event) { create(:event, title: 'Art Workshop Showcase', description: 'Annual art exhibition') } let!(:music_event) { create(:event, title: 'Music Therapy Session', description: 'Healing through music') } diff --git a/spec/models/registration_ticket_callout_spec.rb b/spec/models/registration_ticket_callout_spec.rb index 3e62bf495..24cad46d8 100644 --- a/spec/models/registration_ticket_callout_spec.rb +++ b/spec/models/registration_ticket_callout_spec.rb @@ -28,38 +28,38 @@ expect(callout).not_to be_valid end - it "rejects an unknown magic_key" do - callout.magic_key = "bogus" + it "rejects an unknown builtin_key" do + callout.builtin_key = "bogus" expect(callout).not_to be_valid - expect(callout.errors[:magic_key]).to be_present + expect(callout.errors[:builtin_key]).to be_present end - it "allows only one callout per magic_key within an event" do + it "allows only one callout per builtin_key within an event" do event = create(:event) - create(:registration_ticket_callout, event:, magic_key: "faq") - duplicate = build(:registration_ticket_callout, event:, magic_key: "faq") + create(:registration_ticket_callout, event:, builtin_key: "faq") + duplicate = build(:registration_ticket_callout, event:, builtin_key: "faq") expect(duplicate).not_to be_valid - expect(duplicate.errors[:magic_key]).to be_present + expect(duplicate.errors[:builtin_key]).to be_present end - it "allows many custom callouts with a nil magic_key" do + it "allows many custom callouts with a nil builtin_key" do event = create(:event) - create(:registration_ticket_callout, event:, magic_key: nil) - expect(build(:registration_ticket_callout, event:, magic_key: nil)).to be_valid + create(:registration_ticket_callout, event:, builtin_key: nil) + expect(build(:registration_ticket_callout, event:, builtin_key: nil)).to be_valid end end describe "scopes and predicates" do - it "partitions magic and custom callouts and reports #magic?" do + it "partitions built-in and custom callouts and reports #builtin?" do event = create(:event) - magic = create(:registration_ticket_callout, event:, magic_key: "faq") - custom = create(:registration_ticket_callout, event:, magic_key: nil) + builtin = create(:registration_ticket_callout, event:, builtin_key: "faq") + custom = create(:registration_ticket_callout, event:, builtin_key: nil) - expect(event.registration_ticket_callouts.magic).to eq([ magic ]) + expect(event.registration_ticket_callouts.builtin).to eq([ builtin ]) expect(event.registration_ticket_callouts.custom).to eq([ custom ]) - expect(magic.magic?).to be(true) - expect(custom.magic?).to be(false) + expect(builtin.builtin?).to be(true) + expect(custom.builtin?).to be(false) end it "#visible excludes hidden callouts" do @@ -72,9 +72,9 @@ it "#payment? is true only for the payment built-in" do event = create(:event) - payment = create(:registration_ticket_callout, event:, magic_key: "payment") - other = create(:registration_ticket_callout, event:, magic_key: "faq") - custom = create(:registration_ticket_callout, event:, magic_key: nil) + payment = create(:registration_ticket_callout, event:, builtin_key: "payment") + other = create(:registration_ticket_callout, event:, builtin_key: "faq") + custom = create(:registration_ticket_callout, event:, builtin_key: nil) expect(payment.payment?).to be(true) expect(other.payment?).to be(false) diff --git a/spec/requests/events/callouts_spec.rb b/spec/requests/events/callouts_spec.rb index b5cdf2ace..359afa6f7 100644 --- a/spec/requests/events/callouts_spec.rb +++ b/spec/requests/events/callouts_spec.rb @@ -3,72 +3,62 @@ RSpec.describe "Events::Callouts", type: :request do let(:registration) { create(:event_registration, event: event) } - # These pages are reachable by slug (no login), so the built-in callout - # visibility rules are the only gate. Hidden callouts redirect back to the - # ticket so a stray link can't surface training content on a non-training event. - describe "GET /registration/:slug/faq" do - context "on a facilitator training" do - let(:event) { create(:event, facilitator_training: true) } + # These pages are reachable by slug (no login), so the built-in callout's + # published state is the only gate. A hidden (or not-yet-materialized) callout + # redirects back to the ticket so a stray link can't surface it. + let(:event) { create(:event) } - it "renders the FAQ as collapsible toggles from the default content" do - get registration_faq_path(registration.slug) - expect(response).to have_http_status(:success) - expect(response.body).to include("Custom question

Custom answer

") + it "renders the editable FAQ callout copy when the card is materialized" do + BuiltinCallouts.seed(event) + faq = event.registration_ticket_callouts.find_by(builtin_key: "faq") + faq.update!(description: "
Custom question

Custom answer

", hidden: false) - get registration_faq_path(registration.slug) - expect(response.body).to include("Custom question") - expect(response.body).to include("Custom answer") - expect(response.body).not_to include("Who is this training designed for?") - end + get registration_faq_path(registration.slug) + expect(response.body).to include("Custom question") + expect(response.body).to include("Custom answer") + expect(response.body).not_to include("Who is this training designed for?") end - context "on a non-training event" do - let(:event) { create(:event, facilitator_training: false) } - - it "redirects to the ticket" do - get registration_faq_path(registration.slug) - expect(response).to redirect_to(registration_ticket_path(registration.slug)) - end + it "redirects to the ticket when the callout is hidden or absent" do + get registration_faq_path(registration.slug) + expect(response).to redirect_to(registration_ticket_path(registration.slug)) end end describe "GET /registration/:slug/handouts" do - context "on a facilitator training" do - let(:event) { create(:event, facilitator_training: true) } - - it "renders the handouts page" do - get registration_handouts_path(registration.slug) - expect(response).to have_http_status(:success) - end + it "renders the handouts page when its callout is published" do + create(:registration_ticket_callout, event:, builtin_key: "handouts", hidden: false) + get registration_handouts_path(registration.slug) + expect(response).to have_http_status(:success) + end - it "shows each linked resource card with its join-row subtitle" do - callout = create(:registration_ticket_callout, event:, magic_key: "handouts") - resource = create(:resource, title: "Aha Moments") - create(:registration_ticket_callout_resource, registration_ticket_callout: callout, - resource:, subtitle: "Reflect on the workshop", page_content: "Long detail copy.") + it "shows each linked resource card with its join-row subtitle" do + callout = create(:registration_ticket_callout, event:, builtin_key: "handouts", hidden: false) + resource = create(:resource, title: "Aha Moments") + create(:registration_ticket_callout_resource, registration_ticket_callout: callout, + resource:, subtitle: "Reflect on the workshop", page_content: "Long detail copy.") - get registration_handouts_path(registration.slug) + get registration_handouts_path(registration.slug) - expect(response.body).to include("Reflect on the workshop") - # Page content is detail-page only, never on the card. - expect(response.body).not_to include("Long detail copy.") - end + expect(response.body).to include("Reflect on the workshop") + # Page content is detail-page only, never on the card. + expect(response.body).not_to include("Long detail copy.") end - context "on a non-training event" do - let(:event) { create(:event, facilitator_training: false) } - - it "redirects to the ticket" do - get registration_handouts_path(registration.slug) - expect(response).to redirect_to(registration_ticket_path(registration.slug)) - end + it "redirects to the ticket when the callout is hidden" do + create(:registration_ticket_callout, event:, builtin_key: "handouts", hidden: true) + get registration_handouts_path(registration.slug) + expect(response).to redirect_to(registration_ticket_path(registration.slug)) end end @@ -76,7 +66,7 @@ let(:event) { create(:event, cost_cents: 10_000) } it "shows the W-9 document's materialized join-row subtitle, not hard-coded copy" do - callout = create(:registration_ticket_callout, event:, magic_key: "payment") + callout = create(:registration_ticket_callout, event:, builtin_key: "payment") w9 = create(:resource, title: "W-9") create(:registration_ticket_callout_resource, registration_ticket_callout: callout, resource: w9, subtitle: "Custom W-9 line") @@ -97,7 +87,7 @@ it "links resources on the built-in callout to their own page, not inline" do resource = create(:resource) create(:downloadable_asset, owner: resource) - create(:registration_ticket_callout, event:, magic_key: "videoconference", + create(:registration_ticket_callout, event:, builtin_key: "videoconference", title: "Videoconference", resources: [ resource ]) get registration_videoconference_path(registration.slug) @@ -189,7 +179,7 @@ let(:resource) { create(:resource) } it "shows the join row's page content below the title" do - callout = create(:registration_ticket_callout, event:, magic_key: "handouts") + callout = create(:registration_ticket_callout, event:, builtin_key: "handouts") create(:registration_ticket_callout_resource, registration_ticket_callout: callout, resource:, subtitle: "Short card line", page_content: "The longer page content copy.") @@ -201,7 +191,7 @@ end it "shows no page content when the resource isn't linked to the origin callout" do - create(:registration_ticket_callout, :magic, event:, magic_key: "handouts") + create(:registration_ticket_callout, :builtin, event:, builtin_key: "handouts") get registration_resource_path(registration.slug, resource, return_to: "handouts") diff --git a/spec/requests/events/registration_ticket_callouts_spec.rb b/spec/requests/events/registration_ticket_callouts_spec.rb index 3b48b3016..ac5aabacc 100644 --- a/spec/requests/events/registration_ticket_callouts_spec.rb +++ b/spec/requests/events/registration_ticket_callouts_spec.rb @@ -218,7 +218,7 @@ it "resets a callout flagged reset_to_default on the main save" do training = create(:event, :publicly_visible, facilitator_training: true) - callout = create(:registration_ticket_callout, event: training, magic_key: "faq", title: "Edited", hidden: true) + callout = create(:registration_ticket_callout, event: training, builtin_key: "faq", title: "Edited", hidden: false) patch event_path(training), params: { event: { @@ -230,7 +230,7 @@ } expect(callout.reload.title).to eq("Frequently asked questions") - expect(callout.hidden).to be(false) + expect(callout.hidden).to be(true) # restored to the built-in default (hidden) end end @@ -242,7 +242,7 @@ event: { title: event.title, start_date: event.start_date, end_date: event.end_date } } - expect(event.registration_ticket_callouts.magic.pluck(:magic_key)).to contain_exactly( + expect(event.registration_ticket_callouts.builtin.pluck(:builtin_key)).to contain_exactly( "payment", "certificate", "scholarship", "ce_hours", "event_details", "videoconference", "handouts", "faq" ) @@ -253,7 +253,7 @@ before { sign_in admin } it "renders a materialized content callout as an editable field with a restore checkbox" do - create(:registration_ticket_callout, event:, magic_key: "faq", + create(:registration_ticket_callout, event:, builtin_key: "faq", title: "Frequently asked questions") get edit_event_path(event) @@ -263,8 +263,8 @@ expect(response.body).to include("name=\"event[registration_ticket_callouts_attributes][0][reset_to_default]\"") end - it "renders a behavioral magic callout with the same editable fields as a custom one" do - create(:registration_ticket_callout, event:, magic_key: "certificate", + it "renders a behavioral built-in callout with the same editable fields as a custom one" do + create(:registration_ticket_callout, event:, builtin_key: "certificate", title: "Certificate of completion") get edit_event_path(event) @@ -279,8 +279,8 @@ it "shows the restore checkbox only once the built-in has been customized" do # Seed unedited built-ins, then load the editor. - DefaultTicketCallouts.seed(event) - faq = event.registration_ticket_callouts.find_by(magic_key: "faq") + BuiltinCallouts.seed(event) + faq = event.registration_ticket_callouts.find_by(builtin_key: "faq") get edit_event_path(event) expect(response.body).to include("Matches default") @@ -293,7 +293,7 @@ it "gives the Payment card an add-another linked-resource picker" do resource = create(:resource, title: "W-9") - create(:registration_ticket_callout, event:, magic_key: "payment", title: "Payment", resources: [ resource ]) + create(:registration_ticket_callout, event:, builtin_key: "payment", title: "Payment", resources: [ resource ]) get edit_event_path(event) diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb index 873211ea6..a2b85ea59 100644 --- a/spec/requests/events/registrations_spec.rb +++ b/spec/requests/events/registrations_spec.rb @@ -47,22 +47,24 @@ end end - context "magic callouts" do - # A facilitator training shows the full built-in set, including the - # training-only Handouts and FAQ cards. - let(:event) { create(:event, facilitator_training: true) } + context "built-in callouts" do + # The ticket renders whatever built-in rows are published — no + # facilitator-training gate. Seed them all, then publish them. + before do + BuiltinCallouts.seed(event) + event.registration_ticket_callouts.builtin.each { |callout| callout.update!(hidden: false) } + end - it "renders the consolidated magic callout cards" do + it "renders the consolidated built-in callout cards for the published built-ins" do get registration_ticket_path(registration.slug) - expect(response.body).to include("view your balance") + expect(response.body).to include("Your balance and payment history") expect(response.body).to include("Worksheets and resources for the training") expect(response.body).to include("Frequently asked questions") end - it "omits the training-only Handouts and FAQ cards on a non-training event" do - non_training = create(:event, facilitator_training: false) - reg = create(:event_registration, event: non_training, registrant: user.person) - get registration_ticket_path(reg.slug) + it "omits a built-in card once its row is hidden" do + event.registration_ticket_callouts.where(builtin_key: %w[handouts faq]).each { |callout| callout.update!(hidden: true) } + get registration_ticket_path(registration.slug) expect(response.body).not_to include("Worksheets and resources for the training") expect(response.body).not_to include("Frequently asked questions") end @@ -261,8 +263,11 @@ end describe "GET /registration/:slug/faq" do - let(:event) { create(:event, facilitator_training: true) } let!(:registration) { create(:event_registration, event: event, registrant: user.person) } + before do + BuiltinCallouts.seed(event) + event.registration_ticket_callouts.find_by(builtin_key: "faq").update!(hidden: false) + end it "renders the training FAQ with the folded-in contact link" do get registration_faq_path(registration.slug) @@ -303,7 +308,7 @@ it "links the W-9 to its resource page once a payment is on file, returning to payment" do w9 = create(:resource, title: "W-9", kind: "Form") - DefaultTicketCallouts.seed(event) + BuiltinCallouts.seed(event) payment = create(:payment, type: "CashPayment", amount_cents: event.cost_cents, amount_cents_remaining: nil) create(:allocation, source: payment, allocatable: registration, amount: event.cost_cents) @@ -314,7 +319,7 @@ it "locks the W-9 (no link) until a payment is on file" do w9 = create(:resource, title: "W-9", kind: "Form") - DefaultTicketCallouts.seed(event) + BuiltinCallouts.seed(event) get registration_payment_path(registration.slug) expect(response.body).not_to include(registration_resource_path(registration.slug, w9, return_to: "payment")) @@ -328,8 +333,8 @@ it "drops the W-9 when the payment callout's resource is removed" do w9 = create(:resource, title: "W-9", kind: "Form") - DefaultTicketCallouts.seed(event) - event.registration_ticket_callouts.find_by(magic_key: "payment").resources.destroy_all + BuiltinCallouts.seed(event) + event.registration_ticket_callouts.find_by(builtin_key: "payment").resources.destroy_all get registration_payment_path(registration.slug) expect(response.body).not_to include(registration_resource_path(registration.slug, w9, return_to: "payment")) end @@ -504,18 +509,19 @@ end describe "GET /registration/:slug/handouts" do - let(:event) { create(:event, facilitator_training: true) } let!(:registration) { create(:event_registration, event: event, registrant: user.person) } it "links each handout to its registrant resource page, returning to handouts" do handout = create(:resource, title: "Aha Moments", kind: "Handout") - DefaultTicketCallouts.seed(event) # materialize the handouts callout, linking the resource + BuiltinCallouts.seed(event) # materialize the handouts callout, linking the resource + event.registration_ticket_callouts.find_by(builtin_key: "handouts").update!(hidden: false) get registration_handouts_path(registration.slug) expect(response).to have_http_status(:success) expect(response.body).to include(registration_resource_path(registration.slug, handout, return_to: "handouts")) end it "shows a placeholder when no handouts are present" do + create(:registration_ticket_callout, event:, builtin_key: "handouts", hidden: false) get registration_handouts_path(registration.slug) expect(response.body).to include("Training handouts will be available here soon.") end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 7c6a2e6b5..bf08395bd 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -148,7 +148,7 @@ def offer_ce!(target_event) expect(response).to have_http_status(:ok) expect(response.body).to include("Sample ticket preview") expect(response.body).to include("Sample Registrant") - # Magic callout cards render (the sample event is paid, so the payment + # Built-in callout cards render (the sample event is paid, so the payment # card shows) without raising on the unsaved sample's sentinel slug. expect(response.body).to include("Make your payment") end @@ -246,6 +246,15 @@ def offer_ce!(target_event) get new_event_path expect(response).to have_http_status(:ok) end + + it "renders the built-in callouts as editable rows before the first save" do + sign_in admin + get new_event_path + # Built-in rows are built in memory, so their title is editable and the + # builtin_key round-trips as a hidden field. + expect(response.body).to include("Frequently asked questions") + expect(response.body).to include('name="event[registration_ticket_callouts_attributes][0][builtin_key]"') + end end context "as non-admin" do @@ -323,6 +332,24 @@ def offer_ce!(target_event) expect(response).to redirect_to(event_url(Event.last)) end + it "persists edited built-in callouts from the new form without duplicating them" do + params = valid_params.deep_dup + params[:event][:registration_ticket_callouts_attributes] = { + "0" => { builtin_key: "payment", title: "Pay your balance", subtitle: "view balance", callout_type: "action", color_class: "orange", published: "1" }, + "1" => { builtin_key: "faq", title: "Frequently asked questions", callout_type: "reference", color_class: "blue", published: "0" } + } + + expect { post events_path, params: params }.to change(Event, :count).by(1) + + created = Event.order(created_at: :desc).first + # The two submitted built-ins persist their edits, and the post-save seed + # fills the remaining six — every built-in key present exactly once. + expect(created.registration_ticket_callouts.builtin.pluck(:builtin_key).sort).to eq(RegistrationTicketCallout::BUILTIN_KEYS.sort) + payment = created.registration_ticket_callouts.find_by(builtin_key: "payment") + expect(payment.title).to eq("Pay your balance") + expect(payment.published?).to be true + end + it "stores start_date/end_date in UTC when created by user in Pacific time zone" do admin_pt = create(:user, :admin, time_zone: "Pacific Time (US & Canada)") sign_in admin_pt diff --git a/spec/services/magic_ticket_callouts_spec.rb b/spec/services/builtin_callout_cards_spec.rb similarity index 90% rename from spec/services/magic_ticket_callouts_spec.rb rename to spec/services/builtin_callout_cards_spec.rb index ee24ef2ce..8a7ff97ae 100644 --- a/spec/services/magic_ticket_callouts_spec.rb +++ b/spec/services/builtin_callout_cards_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -RSpec.describe MagicTicketCallouts do +RSpec.describe BuiltinCalloutCards do let(:event) { create(:event) } let(:registration) { create(:event_registration, event:) } @@ -22,21 +22,19 @@ def card(reg, title) expect(card_titles(registration)).not_to include("Payment") end - it "shows Handouts and FAQ only for facilitator trainings" do - expect(card_titles(registration)).not_to include("Handouts", "Frequently asked questions") - + it "never surfaces the row-driven Handouts or FAQ cards in the code fallback" do + # These are admin-published now — there's no code fallback for them, even on + # a facilitator training. event.update!(facilitator_training: true) - expect(card_titles(registration)).to include("Handouts", "Frequently asked questions") + expect(card_titles(registration)).not_to include("Handouts", "Frequently asked questions") end it "skips a built-in card the event has materialized (it renders from the row instead)" do - event.update!(facilitator_training: true) - create(:registration_ticket_callout, event:, magic_key: "faq", title: "Frequently asked questions") + event.update!(event_details: "Bring supplies") + create(:registration_ticket_callout, event:, builtin_key: "event_details", title: "Before you attend") - # No duplicate FAQ from the code path; Handouts (not materialized) still renders here. - titles = card_titles(registration) - expect(titles.count("Frequently asked questions")).to eq(0) - expect(titles).to include("Handouts") + # Materialized, so the code fallback omits it — the row renders it instead. + expect(card_titles(registration)).not_to include(event.event_details_label) end it "makes the payment card an action card while a balance is due, reference once paid" do @@ -169,7 +167,8 @@ def card(reg, title) expect(scholarship_card.badge_classes).to include("fuchsia") end - it "places payment first and FAQ last in the full ordering" do + it "orders the code-fallback cards from payment downward" do + # Handouts/FAQ are row-driven, so they never appear in this fallback. event.update!(facilitator_training: true, event_details: "Bring supplies", ce_hours_details: "6 hours", ce_hours_offered: 6, videoconference_url: "https://example.zoom.us/j/123", @@ -183,9 +182,7 @@ def card(reg, title) "Scholarship", event.ce_hours_details_label, event.event_details_label, - "Videoconference", - "Handouts", - "Frequently asked questions" + "Videoconference" ]) end end @@ -194,7 +191,7 @@ def card(reg, title) it "uses the row's editable presentation (title/subtitle/colour) but the app's live badge/link" do # Videoconference isn't app-coloured, so the row's colour is honoured. event.update!(videoconference_url: "https://example.com/z") - callout = create(:registration_ticket_callout, event:, magic_key: "videoconference", + callout = create(:registration_ticket_callout, event:, builtin_key: "videoconference", title: "Your documents", subtitle: "Downloads", color_class: "green") card = described_class.new(registration).card_for(callout) @@ -205,7 +202,7 @@ def card(reg, title) it "keeps Payment's live-status colour, overriding the selected colour" do event.update!(cost_cents: 5_000) - callout = create(:registration_ticket_callout, event:, magic_key: "payment", + callout = create(:registration_ticket_callout, event:, builtin_key: "payment", title: "Pay your balance", color_class: "green") card = described_class.new(registration).card_for(callout) @@ -215,7 +212,7 @@ def card(reg, title) end it "returns nil when the card shouldn't show for this registration" do - callout = create(:registration_ticket_callout, event:, magic_key: "certificate", + callout = create(:registration_ticket_callout, event:, builtin_key: "certificate", title: "Certificate of completion") # Certificate isn't unlocked (event not ended, not attended). @@ -226,7 +223,7 @@ def card(reg, title) event.update!(ce_hours_cost_cents: 15_000, ce_payment_due_deadline: Date.new(2026, 8, 15)) license = create(:professional_license, person: registration.registrant, number: "LIC123") create(:continuing_education_registration, event_registration: registration, professional_license: license) - callout = create(:registration_ticket_callout, event:, magic_key: "ce_hours", title: "CE credit") + callout = create(:registration_ticket_callout, event:, builtin_key: "ce_hours", title: "CE credit") card = described_class.new(registration.reload).card_for(callout) expect(card.title).to eq("CE credit") # row owns the text diff --git a/spec/services/default_ticket_callouts_spec.rb b/spec/services/builtin_callouts_spec.rb similarity index 67% rename from spec/services/default_ticket_callouts_spec.rb rename to spec/services/builtin_callouts_spec.rb index dbf04e85f..4730de0ff 100644 --- a/spec/services/default_ticket_callouts_spec.rb +++ b/spec/services/builtin_callouts_spec.rb @@ -1,13 +1,50 @@ require "rails_helper" -RSpec.describe DefaultTicketCallouts do +RSpec.describe BuiltinCallouts do + describe "#build" do + it "builds all eight built-ins as unsaved in-memory rows on a new event" do + event = Event.new + + built = described_class.build(event) + + expect(built.map(&:builtin_key)).to contain_exactly( + "payment", "certificate", "scholarship", "ce_hours", "event_details", + "videoconference", "handouts", "faq" + ) + expect(built).to all(be_new_record) + expect(event.registration_ticket_callouts).to match_array(built) + end + + it "is idempotent — skips keys already present on the association" do + event = create(:event) + described_class.seed(event) + + built = described_class.build(event) + + expect(built).to be_empty + expect(event.registration_ticket_callouts.builtin.count).to eq(8) + end + + it "builds a paid event's Payment card with the W-9 link (subtitle) in memory" do + w9 = create(:resource, title: "W-9") + event = Event.new(cost_cents: 5_000) + + described_class.build(event) + + payment = event.registration_ticket_callouts.find { |c| c.builtin_key == "payment" } + link = payment.registration_ticket_callout_resources.find { |r| r.resource == w9 } + expect(link).to be_present + expect(link.subtitle).to eq("AWBW's W-9 tax form for your records") + end + end + describe "#seed" do it "materializes all eight built-in callouts for every event" do event = create(:event, cost_cents: 0) # free, no scholarship form, no VC link described_class.seed(event) - keys = event.registration_ticket_callouts.magic.pluck(:magic_key) + keys = event.registration_ticket_callouts.builtin.pluck(:builtin_key) expect(keys).to contain_exactly( "payment", "certificate", "scholarship", "ce_hours", "event_details", "videoconference", "handouts", "faq" @@ -22,7 +59,7 @@ described_class.seed(event) - expect(event.registration_ticket_callouts.ordered.map(&:magic_key)).to eq( + expect(event.registration_ticket_callouts.ordered.map(&:builtin_key)).to eq( %w[payment certificate scholarship ce_hours event_details videoconference handouts faq] ) end @@ -31,49 +68,34 @@ event = create(:event, start_date: Date.new(2026, 9, 10)) described_class.seed(event) - vc = event.registration_ticket_callouts.find_by(magic_key: "videoconference") + vc = event.registration_ticket_callouts.find_by(builtin_key: "videoconference") expect(vc).to be_present expect(vc.display_from.to_date).to eq(Date.new(2026, 9, 3)) expect(vc.hidden).to be(true) # non-training: hidden by default end - it "hides all callouts by default on a non-training event, shows all on a facilitator training" do - form = create(:form) + it "hides every callout by default, regardless of facilitator training" do non_training = create(:event, cost_cents: 0) - non_training.event_forms.create!(form:, role: "scholarship") training = create(:event, :publicly_visible, facilitator_training: true, cost_cents: 100, videoconference_url: "https://example.com/vc") - training.event_forms.create!(form:, role: "scholarship") described_class.seed(non_training) described_class.seed(training) - non_training.registration_ticket_callouts.magic.each do |callout| - expect(callout.hidden).to be(true), "expected #{callout.magic_key} hidden on non-training" - end - training.registration_ticket_callouts.magic.each do |callout| - expect(callout.hidden).to be(false), "expected #{callout.magic_key} visible on training" + [ non_training, training ].each do |event| + event.registration_ticket_callouts.builtin.each do |callout| + expect(callout.hidden).to be(true), "expected #{callout.builtin_key} hidden by default" + end end end - it "defaults Certificate off for a non-training event and on for a facilitator training" do - non_training = create(:event, facilitator_training: false) - training = create(:event, facilitator_training: true) - - described_class.seed(non_training) - described_class.seed(training) - - expect(non_training.registration_ticket_callouts.find_by(magic_key: "certificate").hidden).to be(true) - expect(training.registration_ticket_callouts.find_by(magic_key: "certificate").hidden).to be(false) - end - it "links the W-9 to the Payment card as a removable resource on paid events" do w9 = create(:resource, title: "W-9") event = create(:event) # paid by factory, so the W-9 seeds described_class.seed(event) - payment = event.registration_ticket_callouts.find_by(magic_key: "payment") + payment = event.registration_ticket_callouts.find_by(builtin_key: "payment") expect(payment.resources).to eq([ w9 ]) end @@ -83,7 +105,7 @@ described_class.seed(event) - payment = event.registration_ticket_callouts.find_by(magic_key: "payment") + payment = event.registration_ticket_callouts.find_by(builtin_key: "payment") expect(payment).to be_present expect(payment.resources).to be_empty # no W-9 on a free event end @@ -95,8 +117,8 @@ described_class.seed(event) - ce = event.registration_ticket_callouts.find_by(magic_key: "ce_hours") - details = event.registration_ticket_callouts.find_by(magic_key: "event_details") + ce = event.registration_ticket_callouts.find_by(builtin_key: "ce_hours") + details = event.registration_ticket_callouts.find_by(builtin_key: "event_details") expect(ce.title).to eq("Continuing education") expect(ce.description).to eq("

CAMFT approved.

") expect(details.title).to eq("Art supplies") @@ -108,7 +130,7 @@ it "reports whether a materialized callout has been customized" do event = create(:event, facilitator_training: true) described_class.seed(event) - faq = event.registration_ticket_callouts.find_by(magic_key: "faq") + faq = event.registration_ticket_callouts.find_by(builtin_key: "faq") expect(described_class.customized?(faq)).to be(false) @@ -122,7 +144,7 @@ it "treats a changed drip display date as customized" do event = create(:event, facilitator_training: true) described_class.seed(event) - faq = event.registration_ticket_callouts.find_by(magic_key: "faq") + faq = event.registration_ticket_callouts.find_by(builtin_key: "faq") expect(described_class.customized?(faq)).to be(false) @@ -138,7 +160,7 @@ described_class.seed(event) - faq = event.registration_ticket_callouts.find_by(magic_key: "faq") + faq = event.registration_ticket_callouts.find_by(builtin_key: "faq") expect(faq.description).to include("Who is this training designed for?") expect(faq.callout_type).to eq("reference") end @@ -150,7 +172,7 @@ described_class.seed(event) - handouts = event.registration_ticket_callouts.find_by(magic_key: "handouts") + handouts = event.registration_ticket_callouts.find_by(builtin_key: "handouts") expect(handouts.resources).to eq([ first, second ]) end @@ -161,9 +183,9 @@ described_class.seed(event) - handouts = event.registration_ticket_callouts.find_by(magic_key: "handouts") + handouts = event.registration_ticket_callouts.find_by(builtin_key: "handouts") link = handouts.registration_ticket_callout_resources.joins(:resource).find_by(resources: { title: }) - defaults = DefaultTicketCallouts::HANDOUT_LINK_DEFAULTS[title] + defaults = BuiltinCallouts::HANDOUT_LINK_DEFAULTS[title] expect(link.subtitle).to eq(defaults[:subtitle]) expect(link.page_content).to eq(defaults[:page_content]) end @@ -172,7 +194,7 @@ create(:resource, title: "Aha Moments") event = create(:event) described_class.seed(event) - handouts = event.registration_ticket_callouts.find_by(magic_key: "handouts") + handouts = event.registration_ticket_callouts.find_by(builtin_key: "handouts") expect(described_class.customized?(handouts)).to be(false) handouts.registration_ticket_callout_resources.first.update!(subtitle: "Edited") @@ -182,26 +204,18 @@ expect(described_class.customized?(handouts.reload)).to be(false) end - it "shows Handouts and FAQ by default on a facilitator training" do + it "hides Handouts and FAQ by default (no config signal auto-publishes them)" do event = create(:event, facilitator_training: true) described_class.seed(event) - expect(event.registration_ticket_callouts.where(magic_key: %w[handouts faq]).pluck(:hidden)).to all(be(false)) - end - - it "hides Handouts and FAQ by default on a non-training event" do - event = create(:event, facilitator_training: false) - - described_class.seed(event) - - expect(event.registration_ticket_callouts.where(magic_key: %w[handouts faq]).pluck(:hidden)).to all(be(true)) + expect(event.registration_ticket_callouts.where(builtin_key: %w[handouts faq]).pluck(:hidden)).to all(be(true)) end - it "is idempotent and never clobbers an existing magic callout" do + it "is idempotent and never clobbers an existing built-in callout" do event = create(:event, facilitator_training: true) described_class.seed(event) - faq = event.registration_ticket_callouts.find_by(magic_key: "faq") + faq = event.registration_ticket_callouts.find_by(builtin_key: "faq") faq.update!(description: "

Custom answer.

", hidden: true) expect { described_class.seed(event) }.not_to change { event.registration_ticket_callouts.count } @@ -209,40 +223,40 @@ expect(faq.hidden).to be(true) end - it "appends the magic callouts after existing custom ones" do + it "appends the built-in callouts after existing custom ones" do event = create(:event) custom = create(:registration_ticket_callout, event:, title: "Parking") described_class.seed(event) expect(event.registration_ticket_callouts.ordered.first).to eq(custom) - expect(event.registration_ticket_callouts.ordered.map(&:magic_key).compact).to eq( + expect(event.registration_ticket_callouts.ordered.map(&:builtin_key).compact).to eq( %w[payment certificate scholarship ce_hours event_details videoconference handouts faq] ) end end describe ".reset" do - it "restores an edited magic callout's content, resources, and visibility to default" do + it "restores an edited built-in callout's content, resources, and visibility to default" do resource = create(:resource, title: "AWBW Training Workshop Worksheets") event = create(:event, facilitator_training: true) described_class.seed(event) - faq = event.registration_ticket_callouts.find_by(magic_key: "faq") - faq.update!(title: "Custom", description: "

Edited

", hidden: true) + faq = event.registration_ticket_callouts.find_by(builtin_key: "faq") + faq.update!(title: "Custom", description: "

Edited

", hidden: false) faq.resources << resource described_class.reset(faq) expect(faq.reload.title).to eq("Frequently asked questions") expect(faq.description).to include("Who is this training designed for?") - expect(faq.hidden).to be(false) + expect(faq.hidden).to be(true) expect(faq.resources).to be_empty end it "keeps the callout's position when restoring" do event = create(:event, facilitator_training: true) described_class.seed(event) - faq = event.registration_ticket_callouts.find_by(magic_key: "faq") + faq = event.registration_ticket_callouts.find_by(builtin_key: "faq") original_position = faq.position described_class.reset(faq) @@ -251,7 +265,7 @@ end it "leaves a custom callout untouched" do - callout = create(:registration_ticket_callout, title: "Parking", magic_key: nil) + callout = create(:registration_ticket_callout, title: "Parking", builtin_key: nil) expect { described_class.reset(callout) }.not_to change { callout.reload.title } end