From 0f807a87a346e79d118d264105d6c9f3b21bf025 Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 8 Jul 2026 10:43:58 -0400 Subject: [PATCH 01/32] Materialize ticket callouts: schema, seeder, multi-resource links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for turning the code-defined "magic" ticket callouts into editable per-event rows so admins can edit, hide, restore, and reorder them. - Add magic_key (hidden built-in identifier), hidden (draft/opt-out), and display_from (drip date) to registration_ticket_callouts. - Replace the single resource_id belongs_to with an ordered join table so a callout can link many resources (Handouts, custom callouts); migrate existing links and update the editor/detail consumers. - DefaultTicketCallouts seeds the content cards (Handouts, FAQ) with their current copy/resources, deriving default visibility from the same rules as Event#show_*_callout?; idempotent so it never clobbers admin edits. Not yet wired to create/edit or the renderer — MagicTicketCallouts still drives the ticket, and will skip any card an event has materialized to avoid double-rendering. Follow-ups add the seeder hook, the row-backed renderer, and the editor UI. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...registration_ticket_callouts_controller.rb | 2 +- app/models/registration_ticket_callout.rb | 36 ++++- .../registration_ticket_callout_resource.rb | 14 ++ app/policies/event_policy.rb | 2 +- app/services/default_ticket_callouts.rb | 138 ++++++++++++++++++ ...egistration_ticket_callout_fields.html.erb | 9 +- .../show.html.erb | 5 +- ...60708143408_materialize_ticket_callouts.rb | 73 +++++++++ db/schema.rb | 20 ++- .../factories/registration_ticket_callouts.rb | 22 +++ .../registration_ticket_callout_spec.rb | 68 +++++++++ .../registration_ticket_callouts_spec.rb | 6 +- spec/services/default_ticket_callouts_spec.rb | 72 +++++++++ 13 files changed, 448 insertions(+), 19 deletions(-) create mode 100644 app/models/registration_ticket_callout_resource.rb create mode 100644 app/services/default_ticket_callouts.rb create mode 100644 db/migrate/20260708143408_materialize_ticket_callouts.rb create mode 100644 spec/services/default_ticket_callouts_spec.rb diff --git a/app/controllers/registration_ticket_callouts_controller.rb b/app/controllers/registration_ticket_callouts_controller.rb index acd4f9100d..d06fed5825 100644 --- a/app/controllers/registration_ticket_callouts_controller.rb +++ b/app/controllers/registration_ticket_callouts_controller.rb @@ -10,7 +10,7 @@ def show @callout = @event.registration_ticket_callouts.find(params[:id]) authorize! @callout, to: :show? - if @callout.description.blank? && @callout.resource.nil? + if @callout.description.blank? && @callout.resources.empty? redirect_to event_path(@event, reg: params[:reg].presence) return end diff --git a/app/models/registration_ticket_callout.rb b/app/models/registration_ticket_callout.rb index 1ed9edc765..b736405b73 100644 --- a/app/models/registration_ticket_callout.rb +++ b/app/models/registration_ticket_callout.rb @@ -5,6 +5,16 @@ 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 + # keeps its ticket behavior — badges, per-registration visibility — driven by + # that key. Admin-authored callouts have a nil magic_key. Magic callouts are + # hidden rather than destroyed so they can be restored to their default. + MAGIC_KEYS = %w[ + payment certificate scholarship ce_hours event_details + videoconference forms handouts faq + ].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 # app can reuse them for tinted boxes — amount-due, scholarship box, etc.). @@ -18,10 +28,12 @@ class RegistrationTicketCallout < ApplicationRecord belongs_to :event - # Optionally links the callout to a Resource. When present, the callout's - # detail page renders the resource's display (PDF first-page preview, etc.) - # and a download button beneath the callout's own title/subtitle/content. - belongs_to :resource, optional: true + # A callout can link many resources, shown in order on its detail page (PDF + # previews + download buttons) beneath its own title/subtitle/content — e.g. + # the Handouts card's worksheets, or a custom callout's supporting documents. + has_many :registration_ticket_callout_resources, -> { ordered }, dependent: :destroy, + inverse_of: :registration_ticket_callout + has_many :resources, through: :registration_ticket_callout_resources # Per-event ordering, drag-reordered after save via the shared `sortable` # Stimulus controller (a per-row PUT to #update). The gem reflows the other @@ -33,13 +45,29 @@ 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 scope :ordered, -> { order(:position, :id) } + scope :visible, -> { where(hidden: false) } + scope :magic, -> { where.not(magic_key: nil) } + scope :custom, -> { where(magic_key: nil) } def action? callout_type == "action" 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? + end + + # Whether the callout is drip-scheduled to appear only from a future date. + def dripping?(now = Time.current) + display_from.present? && display_from > now + end + # Font Awesome class for the leading icon, falling back to a sensible default # per callout type so a callout never renders without an icon. def display_icon_class diff --git a/app/models/registration_ticket_callout_resource.rb b/app/models/registration_ticket_callout_resource.rb new file mode 100644 index 0000000000..f3f2a12f01 --- /dev/null +++ b/app/models/registration_ticket_callout_resource.rb @@ -0,0 +1,14 @@ +class RegistrationTicketCalloutResource < ApplicationRecord + # Ordered join between a callout and the resources it links to (e.g. the + # Handouts card's worksheets, or a custom callout's supporting documents). + # Reordered like the callouts themselves via the positioning gem. + belongs_to :registration_ticket_callout + belongs_to :resource + + positioned on: :registration_ticket_callout_id + + validates :resource_id, uniqueness: { scope: :registration_ticket_callout_id } + validates :position, numericality: { only_integer: true, greater_than: 0, allow_nil: true } + + scope :ordered, -> { order(:position, :id) } +end diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index c9d2c0a5b9..88a0efb713 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -159,7 +159,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, :payment_access_gated, :resource_id, :_destroy ] + registration_ticket_callouts_attributes: [ :id, :title, :subtitle, :description, :callout_type, :icon_class, :color_class, :payment_access_gated, :hidden, :_destroy, { resource_ids: [] } ] ] permitted.prepend(:ga4_snippet, :gtm_head_snippet, :gtm_body_snippet) if admin? diff --git a/app/services/default_ticket_callouts.rb b/app/services/default_ticket_callouts.rb new file mode 100644 index 0000000000..9fbc937bb1 --- /dev/null +++ b/app/services/default_ticket_callouts.rb @@ -0,0 +1,138 @@ +# 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. +# +# Each definition carries the callout's default content plus how its initial +# visibility (`hidden`) is derived from the event's config, using the same rules +# the code-defined cards used (see Event#show_*_callout?). Seeding is idempotent: +# a magic_key already present on the event is left untouched, so a re-run never +# clobbers admin edits. +# +# Only the content-and-resource cards (Handouts, FAQ) are materialized today; the +# stateful cards (payment, CE, scholarship, …) still render through +# MagicTicketCallouts until their per-registration behavior is keyed off the row. +# MagicTicketCallouts skips any card an event has already materialized, so the two +# paths never double-render. +class DefaultTicketCallouts + # 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. + FAQS = [ + { q: "Who is this training designed for?", + a: [ "This training is designed for anyone interested in incorporating healing arts into the work they do with individuals, groups, or communities. Participants come from a wide range of backgrounds, including education, mental health, social services, healthcare, community organizing, advocacy, and nonprofit work." ] }, + { q: "Do I need to be an artist or have art experience to participate?", + a: [ "No prior art experience or artistic background is required to participate in the training. The AWBW approach is centered on the creative process rather than artistic skill or technique." ] }, + { q: "Is the training trauma-informed?", + a: [ "Yes. The AWBW model, philosophy, and facilitation approach are rooted in trauma-informed practices. The training includes dedicated content focused on trauma-informed facilitation and creating supportive, inclusive environments for participants." ] }, + { q: "Do I need to work for an organization to participate in the training or become a facilitator?", + a: [ "No. You do not need to be affiliated with an organization or agency to participate in the training or become an AWBW Facilitator. While many facilitators implement the workshops within organizations, schools, or community programs, others use the workshops in private practice, community spaces, support groups, creative gatherings, or personal healing work. The workshops are designed to be flexible and adaptable across a variety of settings and populations." ] }, + { q: "Can I use the workshops with youth? Adults? Families?", + a: [ "Yes. AWBW workshops are designed to be flexible and adaptable for individuals of all ages, including youth, adults, families, and intergenerational groups.", + "Within the facilitator portal, workshops can be searched and filtered by focus area, population, theme, and other categories to help facilitators find workshops that best fit the communities they serve." ] }, + { q: "How many workshops are included in the curriculum library?", + a: [ "The AWBW facilitator portal includes access to a library of more than 600 art workshops and facilitation resources." ] }, + { q: "Are workshops available in Spanish or other languages?", + a: [ "Yes. A number of workshops have been translated into Spanish. Within the facilitator portal, facilitators can use the search filters to locate workshops that include Spanish translations.", + "AWBW continues to work toward increasing language accessibility and expanding translated resources over time." ] }, + { q: "What materials do I need for the art workshops?", + a: [ "There are no required art materials for participation. Workshops can be completed using any materials you already have available, such as paper, pencils, pens, crayons, markers, colored pencils, paint, collage materials, or other creative supplies. The focus of the AWBW approach is on the creative process rather than the materials themselves.", + "Prior to the training, we will also send participants a list of optional art supplies that may be helpful to have on hand, as well as printable worksheets for each of the art workshops that will be facilitated during the training. While neither the supplies nor the worksheets are required, many participants find them helpful for engaging more fully in the workshop experience.", + "We will walk through the art supplies suggested for each workshop so that, if trainees choose to use those materials in their own facilitation, they feel familiar and comfortable incorporating those materials into their own facilitation practice." ] }, + { q: "What ongoing support does AWBW provide after completing the on-demand training?", + a: [ "Once you complete the On-Demand Training and receive your certification, you become part of a thriving community of over 1,500 Windows Facilitators across the country and abroad, all learning from and supporting one another.", + "As a certified facilitator, you'll gain access to:" ], + list: [ "The AWBW Facilitator Portal, which houses a curriculum of over 600 art workshops, downloadable resources, toolkits, handouts, the Facilitator Manual, and tools for tracking attendance and gathering feedback", + "Ongoing professional development, including live art workshops, Q&As, and recorded presentations by subject matter experts", + "Virtual Community of Practice events, where you can connect with fellow facilitators and experience new workshops", + "AWBW staff support for workshop selection, facilitation questions, art supply tips, and more", + "A bi-monthly newsletter with new workshops, stories, and resources" ] }, + { q: "Can multiple staff members from one organization participate?", + a: [ "Yes. Multiple staff members from the same organization are welcome and encouraged to participate in the training.", + "Many organizations choose to train teams of staff members in order to integrate trauma-informed healing arts practices more broadly across their programs, services, and communities." ] }, + { q: "Are there any fees, other than the training fee, associated with becoming a facilitator?", + a: [ "AWBW has an annual membership fee of $100 per program to support the sustainability of the organization and continued facilitator resources and support.", + "The membership fee covers all facilitators connected to the same program or organization for the calendar year, regardless of the number of facilitators participating. Only one membership fee payment is required per program annually." ] } + ].freeze + + def self.seed(event) + new(event).seed + end + + def initialize(event) + @event = event + end + + # Create any not-yet-present magic callouts for the event, appended in + # definition order after whatever already exists. 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]) } + .map { |definition| create(definition) } + end + + private + + # Ordered built-in callout definitions. `hidden` is a proc so each event + # derives its own default; `resources` resolves the linked records. + def definitions + [ + { + magic_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.show_handouts_callout? }, + resources: -> { handout_resources } + }, + { + magic_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: faq_html, + hidden: ->(event) { !event.show_faq_callout? } + } + ] + end + + def create(definition) + callout = @event.registration_ticket_callouts.create!( + magic_key: definition[:magic_key], + title: definition[:title], + subtitle: definition[:subtitle], + description: definition[:description], + callout_type: definition[:callout_type], + icon_class: definition[:icon_class], + color_class: definition[:color_class], + hidden: definition[:hidden].call(@event) + ) + definition[:resources]&.call&.each { |resource| callout.resources << resource } + callout + end + + # The training worksheet resources, by title, in the display order the code + # card used. Missing ones (not seeded in an environment) are simply skipped. + def handout_resources + by_title = Resource.where(title: Events::CalloutsController::HANDOUT_RESOURCE_TITLES).index_by(&:title) + Events::CalloutsController::HANDOUT_RESOURCE_TITLES.filter_map { |title| by_title[title] } + end + + # Renders FAQS to the basic HTML the callout description accepts (headings, + # paragraphs, lists) so the materialized card reads like the code-defined page. + def faq_html + FAQS.map do |faq| + parts = [ "

#{h(faq[:q])}

" ] + parts += faq[:a].map { |paragraph| "

#{h(paragraph)}

" } + parts << "" if faq[:list] + parts.join + end.join + end + + def h(text) + ERB::Util.html_escape(text) + end +end diff --git a/app/views/events/_registration_ticket_callout_fields.html.erb b/app/views/events/_registration_ticket_callout_fields.html.erb index 0c9795d347..0ae7c82bff 100644 --- a/app/views/events/_registration_ticket_callout_fields.html.erb +++ b/app/views/events/_registration_ticket_callout_fields.html.erb @@ -73,10 +73,11 @@
- <%= f.label :resource_id, "Linked resource", class: "block text-xs font-medium text-gray-600 mb-0.5" %> -

Optional. Shows the resource below the content on the call-out's page (PDF first-page preview, etc.), with a download button when the resource has a downloadable file.

- <%= f.collection_select :resource_id, Resource.order(:title), :id, :title, - { include_blank: "None" }, + <%= f.label :resource_ids, "Linked resources", class: "block text-xs font-medium text-gray-600 mb-0.5" %> +

Optional. Shows each resource below the content on the call-out's page (PDF first-page preview, etc.), with a download button when the resource has a downloadable file. Hold ⌘/Ctrl to select more than one.

+ <%= f.collection_select :resource_ids, Resource.order(:title), :id, :title, + {}, + multiple: true, size: 4, class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %>
diff --git a/app/views/registration_ticket_callouts/show.html.erb b/app/views/registration_ticket_callouts/show.html.erb index bb0bf64e49..d3ee20fa43 100644 --- a/app/views/registration_ticket_callouts/show.html.erb +++ b/app/views/registration_ticket_callouts/show.html.erb @@ -2,7 +2,6 @@ <% content_for(:page_title, "#{@callout.title} — #{@event.title}") %> <% reg_slug = params[:reg].presence %> <% back_path = reg_slug ? registration_ticket_path(reg_slug) : nil %> -<% resource = @callout.resource&.decorate %> <%= render layout: "events/callouts/callout_page", locals: { title: @callout.title, back_path: back_path } do %> <% if @callout.subtitle.present? %>

<%= @callout.subtitle %>

@@ -14,9 +13,9 @@ <% end %> - <% if resource %> + <% @callout.resources.each do |resource| %>
- <%= render "events/callouts/resource_body", resource: resource %> + <%= render "events/callouts/resource_body", resource: resource.decorate %>
<% end %> <% end %> diff --git a/db/migrate/20260708143408_materialize_ticket_callouts.rb b/db/migrate/20260708143408_materialize_ticket_callouts.rb new file mode 100644 index 0000000000..077635dc94 --- /dev/null +++ b/db/migrate/20260708143408_materialize_ticket_callouts.rb @@ -0,0 +1,73 @@ +class MaterializeTicketCallouts < ActiveRecord::Migration[8.1] + # Turns the code-defined "magic" ticket callouts into editable rows and lets a + # callout link many resources instead of one: + # * magic_key — hidden identifier tying a seeded row back to its built-in + # behavior (badges, per-registration visibility). nil for + # admin-authored custom callouts. + # * hidden — draft/opt-out flag. Custom callouts prep hidden; magic + # callouts are hidden rather than destroyed so they can be + # restored to their default later. + # * display_from — drip date; the callout only appears on/after it (e.g. the + # videoconference card, seeded to a week before the event). + # The single resource_id belongs_to is replaced by an ordered join table. + def up + add_column :registration_ticket_callouts, :magic_key, :string + add_index :registration_ticket_callouts, [ :event_id, :magic_key ] + add_column :registration_ticket_callouts, :hidden, :boolean, null: false, default: false + add_column :registration_ticket_callouts, :display_from, :datetime + + create_table :registration_ticket_callout_resources do |t| + t.references :registration_ticket_callout, null: false, foreign_key: { on_delete: :cascade }, + index: { name: "index_callout_resources_on_callout_id" } + # resources.id is a legacy `int` (not bigint), so the FK column must match. + t.integer :resource_id, null: false + t.integer :position, null: false + t.timestamps + end + add_foreign_key :registration_ticket_callout_resources, :resources, on_delete: :cascade + add_index :registration_ticket_callout_resources, [ :registration_ticket_callout_id, :resource_id ], + unique: true, name: "index_callout_resources_on_callout_and_resource" + add_index :registration_ticket_callout_resources, :resource_id, name: "index_callout_resources_on_resource_id" + + # Carry each existing single link over to the join table as the first item. + execute <<~SQL.squish + INSERT INTO registration_ticket_callout_resources + (registration_ticket_callout_id, resource_id, position, created_at, updated_at) + SELECT id, resource_id, 1, NOW(), NOW() + FROM registration_ticket_callouts + WHERE resource_id IS NOT NULL + SQL + + if foreign_key_exists?(:registration_ticket_callouts, :resources) + remove_foreign_key :registration_ticket_callouts, :resources + end + remove_column :registration_ticket_callouts, :resource_id + end + + def down + add_reference :registration_ticket_callouts, :resource, type: :integer, null: true, index: true + add_foreign_key :registration_ticket_callouts, :resources, on_delete: :nullify + + # Restore the lowest-positioned resource as the single link. + execute <<~SQL.squish + UPDATE registration_ticket_callouts c + JOIN ( + SELECT registration_ticket_callout_id, MIN(position) AS min_position + FROM registration_ticket_callout_resources + GROUP BY registration_ticket_callout_id + ) firsts ON firsts.registration_ticket_callout_id = c.id + JOIN registration_ticket_callout_resources r + ON r.registration_ticket_callout_id = c.id AND r.position = firsts.min_position + SET c.resource_id = r.resource_id + SQL + + drop_table :registration_ticket_callout_resources, if_exists: true + + remove_column :registration_ticket_callouts, :display_from, if_exists: true + remove_column :registration_ticket_callouts, :hidden, if_exists: true + if index_exists?(:registration_ticket_callouts, [ :event_id, :magic_key ]) + remove_index :registration_ticket_callouts, [ :event_id, :magic_key ] + end + remove_column :registration_ticket_callouts, :magic_key, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 28fba433c2..c5724d68b8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1080,22 +1080,35 @@ t.index ["stripe_refund_id"], name: "index_refunds_on_stripe_refund_id", unique: true end + create_table "registration_ticket_callout_resources", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "position", null: false + t.bigint "registration_ticket_callout_id", null: false + t.integer "resource_id", null: false + t.datetime "updated_at", null: false + t.index ["registration_ticket_callout_id", "resource_id"], name: "index_callout_resources_on_callout_and_resource", unique: true + t.index ["registration_ticket_callout_id"], name: "index_callout_resources_on_callout_id" + t.index ["resource_id"], name: "index_callout_resources_on_resource_id" + end + create_table "registration_ticket_callouts", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.string "callout_type", default: "reference", null: false t.string "color_class" t.datetime "created_at", null: false t.text "description" + t.datetime "display_from" 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.integer "resource_id" 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" 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" - t.index ["resource_id"], name: "index_registration_ticket_callouts_on_resource_id" end create_table "report_form_field_answers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| @@ -1741,8 +1754,9 @@ add_foreign_key "professional_licenses", "people" add_foreign_key "quotable_item_quotes", "quotes" add_foreign_key "quotes", "workshops" + add_foreign_key "registration_ticket_callout_resources", "registration_ticket_callouts", on_delete: :cascade + add_foreign_key "registration_ticket_callout_resources", "resources", on_delete: :cascade add_foreign_key "registration_ticket_callouts", "events" - add_foreign_key "registration_ticket_callouts", "resources", on_delete: :nullify add_foreign_key "report_form_field_answers", "answer_options" add_foreign_key "report_form_field_answers", "form_fields" add_foreign_key "report_form_field_answers", "reports" diff --git a/spec/factories/registration_ticket_callouts.rb b/spec/factories/registration_ticket_callouts.rb index a9fb2e9999..e6e64b61ad 100644 --- a/spec/factories/registration_ticket_callouts.rb +++ b/spec/factories/registration_ticket_callouts.rb @@ -6,8 +6,21 @@ description { "

Details for this callout.

" } callout_type { "reference" } payment_access_gated { false } + hidden { false } # position is assigned by the positioning gem on save (appended within the event) + # Convenience: `create(:registration_ticket_callout, resource:)` links one + # resource through the join, and `resources: [a, b]` links several. + transient do + resource { nil } + resources { [] } + end + + after(:create) do |callout, evaluator| + Array(evaluator.resource).each { |r| callout.resources << r } + evaluator.resources.each { |r| callout.resources << r } + end + trait :action do callout_type { "action" } end @@ -15,5 +28,14 @@ trait :payment_access_gated do payment_access_gated { true } end + + trait :hidden do + hidden { true } + end + + trait :magic do + magic_key { "faq" } + title { "Frequently asked questions" } + end end end diff --git a/spec/models/registration_ticket_callout_spec.rb b/spec/models/registration_ticket_callout_spec.rb index 7482db0f24..4a91372fa2 100644 --- a/spec/models/registration_ticket_callout_spec.rb +++ b/spec/models/registration_ticket_callout_spec.rb @@ -27,6 +27,74 @@ callout.color_class = "chartreuse" expect(callout).not_to be_valid end + + it "rejects an unknown magic_key" do + callout.magic_key = "bogus" + expect(callout).not_to be_valid + expect(callout.errors[:magic_key]).to be_present + end + + it "allows only one callout per magic_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") + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:magic_key]).to be_present + end + + it "allows many custom callouts with a nil magic_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 + end + end + + describe "scopes and predicates" do + it "partitions magic and custom callouts and reports #magic?" do + event = create(:event) + magic = create(:registration_ticket_callout, event:, magic_key: "faq") + custom = create(:registration_ticket_callout, event:, magic_key: nil) + + expect(event.registration_ticket_callouts.magic).to eq([ magic ]) + expect(event.registration_ticket_callouts.custom).to eq([ custom ]) + expect(magic.magic?).to be(true) + expect(custom.magic?).to be(false) + end + + it "#visible excludes hidden callouts" do + event = create(:event) + shown = create(:registration_ticket_callout, event:) + create(:registration_ticket_callout, :hidden, event:) + + expect(event.registration_ticket_callouts.visible).to eq([ shown ]) + end + end + + describe "#dripping?" do + it "is true only while display_from is in the future" do + callout.display_from = 1.day.from_now + expect(callout.dripping?).to be(true) + + callout.display_from = 1.day.ago + expect(callout.dripping?).to be(false) + + callout.display_from = nil + expect(callout.dripping?).to be(false) + end + end + + describe "linked resources" do + it "links many resources in order and removes them with the callout" do + callout = create(:registration_ticket_callout) + a = create(:resource) + b = create(:resource) + callout.resources << a + callout.resources << b + + expect(callout.reload.resources).to eq([ a, b ]) + expect { callout.destroy }.to change(RegistrationTicketCalloutResource, :count).by(-2) + end end describe "positioning" do diff --git a/spec/requests/events/registration_ticket_callouts_spec.rb b/spec/requests/events/registration_ticket_callouts_spec.rb index 7a81040211..37bdaa47f1 100644 --- a/spec/requests/events/registration_ticket_callouts_spec.rb +++ b/spec/requests/events/registration_ticket_callouts_spec.rb @@ -110,7 +110,7 @@ expect(ordered.map(&:position)).to eq([ 1, 2, 3 ]) end - it "links a callout to a resource through nested attributes" do + it "links a callout to resources through nested attributes" do resource = create(:resource) patch event_path(event), params: { @@ -119,13 +119,13 @@ start_date: event.start_date, end_date: event.end_date, registration_ticket_callouts_attributes: { - "0" => { title: "Workbook", callout_type: "reference", resource_id: resource.id } + "0" => { title: "Workbook", callout_type: "reference", resource_ids: [ resource.id ] } } } } callout = event.registration_ticket_callouts.reload.find_by(title: "Workbook") - expect(callout.resource).to eq(resource) + expect(callout.resources).to eq([ resource ]) end end diff --git a/spec/services/default_ticket_callouts_spec.rb b/spec/services/default_ticket_callouts_spec.rb new file mode 100644 index 0000000000..03f2074de2 --- /dev/null +++ b/spec/services/default_ticket_callouts_spec.rb @@ -0,0 +1,72 @@ +require "rails_helper" + +RSpec.describe DefaultTicketCallouts do + describe "#seed" do + it "materializes the Handouts and FAQ magic callouts" do + event = create(:event) + + described_class.seed(event) + + keys = event.registration_ticket_callouts.magic.pluck(:magic_key) + expect(keys).to contain_exactly("handouts", "faq") + end + + it "seeds the FAQ card with the default training content" do + event = create(:event) + + described_class.seed(event) + + faq = event.registration_ticket_callouts.find_by(magic_key: "faq") + expect(faq.description).to include("Who is this training designed for?") + expect(faq.callout_type).to eq("reference") + end + + it "links the handout worksheet resources in display order" do + first = create(:resource, title: "2-Day AWBW Facilitator Training Worksheets & Handouts") + second = create(:resource, title: "AWBW Training Workshop Worksheets") + event = create(:event) + + described_class.seed(event) + + handouts = event.registration_ticket_callouts.find_by(magic_key: "handouts") + expect(handouts.resources).to eq([ first, second ]) + end + + it "shows Handouts and FAQ by default on a facilitator training" 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)) + end + + it "is idempotent and never clobbers an existing magic callout" do + event = create(:event, facilitator_training: true) + described_class.seed(event) + faq = event.registration_ticket_callouts.find_by(magic_key: "faq") + faq.update!(description: "

Custom answer.

", hidden: true) + + expect { described_class.seed(event) }.not_to change { event.registration_ticket_callouts.count } + expect(faq.reload.description).to eq("

Custom answer.

") + expect(faq.hidden).to be(true) + end + + it "appends the magic 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(%w[handouts faq]) + end + end +end From cea9f5e49773c481bc9fb6bb2d5ee2eac04ff365 Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 8 Jul 2026 11:04:48 -0400 Subject: [PATCH 02/32] Wire callout seeding into create/edit, render + edit materialized rows Makes the materialized magic callouts actually work end-to-end for Handouts and FAQ: - Seed on event create and lazily on edit (idempotent, no backfill), so existing events heal on first save. - Ticket renders visible, non-dripped rows; MagicTicketCallouts skips any card the event has materialized (no double-render) and stays the fallback for unseeded events. The public callout page redirects when hidden/dripping. - Editor: materialized cards render as editable rows (title, subtitle, page text, multi-resource) with a hide toggle and "Restore default", interleaved with custom callouts in the one sortable/position-ordered list; custom callouts gain a hidden (draft) checkbox. editor_cards drops any card the event has materialized so it isn't also previewed. - DefaultTicketCallouts.reset restores a magic callout to its template, keeping position; new restore route/action drives it. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 6 +- app/controllers/events_controller.rb | 4 ++ ...registration_ticket_callouts_controller.rb | 17 +++++ app/services/default_ticket_callouts.rb | 28 ++++++++ app/services/magic_ticket_callouts.rb | 48 ++++++++----- .../event_registrations/_ticket.html.erb | 7 +- app/views/events/_form.html.erb | 6 +- ...egistration_ticket_callout_fields.html.erb | 14 ++-- ...ation_ticket_magic_callout_fields.html.erb | 63 +++++++++++++++++ config/routes.rb | 4 +- .../registration_ticket_callouts_spec.rb | 70 ++++++++++++++++++- spec/services/default_ticket_callouts_spec.rb | 35 ++++++++++ spec/services/magic_ticket_callouts_spec.rb | 10 +++ 13 files changed, 284 insertions(+), 28 deletions(-) create mode 100644 app/views/events/_registration_ticket_magic_callout_fields.html.erb diff --git a/AGENTS.md b/AGENTS.md index d4fc65ab46..c9fee8e3a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,8 @@ 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` | Admin-configured 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?` (paid or intends to pay), draggable `position` via the positioning gem); each links to its own public detail page | +| `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) | +| `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 | | `Person` | Organization affiliates with contacts, addresses, sectors | @@ -204,7 +205,8 @@ 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, forms, handouts, portal, videoconference, FAQ), each with its own visibility rule; rendered through the same `_callout_card` partial as admin-configured `RegistrationTicketCallout`s. Their public show pages live under `app/views/events/callouts/` and are served by `Events::CalloutsController` (slug-authorized, no login) +- `MagicTicketCallouts` — Code-defined ("magic") ticket callout cards (payment, certificate, scholarship, CE hours, art supplies, forms, 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 built-in callouts (currently Handouts, FAQ) into editable `RegistrationTicketCallout` rows, seeded on event create and lazily on edit (idempotent, no backfill). Derives each card's default visibility from `Event#show_*_callout?`; `.reset` restores a materialized callout's content/resources/visibility to its template ### Affiliations diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 5ff0874e67..f9d2d4db0b 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -447,6 +447,7 @@ def create if params.dig(:library_asset, :new_assets).present? update_asset_owner(@event) end + DefaultTicketCallouts.seed(@event) success = true end rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e @@ -475,6 +476,9 @@ def update @event.event_forms.reset if @event.update(event_params) 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) success = true else raise ActiveRecord::Rollback diff --git a/app/controllers/registration_ticket_callouts_controller.rb b/app/controllers/registration_ticket_callouts_controller.rb index d06fed5825..c36f8d52f1 100644 --- a/app/controllers/registration_ticket_callouts_controller.rb +++ b/app/controllers/registration_ticket_callouts_controller.rb @@ -10,6 +10,12 @@ def show @callout = @event.registration_ticket_callouts.find(params[:id]) authorize! @callout, to: :show? + # A hidden (draft/opted-out) or not-yet-dripped callout has no public page. + if @callout.hidden? || @callout.dripping? + redirect_to event_path(@event, reg: params[:reg].presence) + return + end + if @callout.description.blank? && @callout.resources.empty? redirect_to event_path(@event, reg: params[:reg].presence) return @@ -32,6 +38,17 @@ def update end end + # Reset a materialized built-in callout's content and default visibility back to + # its template, keeping its position. Only magic callouts have a default. + def restore + @callout = @event.registration_ticket_callouts.find(params[:id]) + authorize! @callout, to: :update? + + DefaultTicketCallouts.reset(@callout) if @callout.magic? + redirect_to edit_event_path(@event, expand: "callouts", anchor: "registration_ticket_callouts"), + notice: "Restored the #{@callout.title} callout to its default." + end + private def set_event diff --git a/app/services/default_ticket_callouts.rb b/app/services/default_ticket_callouts.rb index 9fbc937bb1..37e7f4bf48 100644 --- a/app/services/default_ticket_callouts.rb +++ b/app/services/default_ticket_callouts.rb @@ -54,10 +54,21 @@ class DefaultTicketCallouts "The membership fee covers all facilitators connected to the same program or organization for the calendar year, regardless of the number of facilitators participating. Only one membership fee payment is required per program annually." ] } ].freeze + # magic_keys this service knows how to materialize. + def self.seedable_keys + new(nil).send(:definitions).map { |definition| definition[:magic_key] } + end + def self.seed(event) new(event).seed 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) + new(callout.event).reset(callout) + end + def initialize(event) @event = event end @@ -70,6 +81,23 @@ def seed .map { |definition| create(definition) } end + def reset(callout) + definition = definitions.find { |candidate| candidate[:magic_key] == callout.magic_key } + return callout unless definition + + callout.update!( + title: definition[:title], + subtitle: definition[:subtitle], + description: definition[:description], + callout_type: definition[:callout_type], + icon_class: definition[:icon_class], + color_class: definition[:color_class], + hidden: definition[:hidden].call(@event) + ) + callout.resources = definition[:resources]&.call || [] + callout + end + private # Ordered built-in callout definitions. `hidden` is a proc so each event diff --git a/app/services/magic_ticket_callouts.rb b/app/services/magic_ticket_callouts.rb index e6f2a1811c..b968d2d639 100644 --- a/app/services/magic_ticket_callouts.rb +++ b/app/services/magic_ticket_callouts.rb @@ -25,30 +25,34 @@ 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; nil for the cards the app fully controls (shown greyed out). - # `subtitle` mirrors the card's ticket subtitle; `visibility` describes when the - # app shows it (rendered next to the "Built in" chip in the editor); `note` is an - # optional extra hint on where the card's content comes from. - EditorCard = Data.define(:key, :icon_class, :color, :title, :subtitle, :visibility, :note) do + # 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 + # 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 def theme = DomainTheme.swatch(color) def editable? = key.present? end # Every built-in card in the order it appears on a ticket, for the editor to - # preview the full ticket context. Keep in sync with #cards: add a card method, - # add it here. + # preview the full ticket context. Cards whose content the event has already + # 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 [ - EditorCard.new(nil, "fa-solid fa-credit-card", "orange", "Payment", "Your balance and payment history", "When the event has a cost", nil), - EditorCard.new(nil, "fa-solid fa-certificate", "green", "Certificate of completion", "View and download your certificate", "Once the certificate is unlocked", nil), - EditorCard.new(nil, "fa-solid fa-award", "fuchsia", "Scholarship", "Your scholarship request and award", "When the registrant requested a scholarship", nil), - EditorCard.new(:ce_hours, "fa-solid fa-graduation-cap", "teal", event.ce_hours_details_label, "Continuing education — requirements & how to request", "When a registrant requests CE credit", nil), - EditorCard.new(:event_details, "fa-solid fa-palette", "blue", event.event_details_label, "Important info for this event — please read", "When the content below is filled in", nil), - EditorCard.new(nil, "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, "fa-solid fa-file-lines", "blue", "Forms", "W-9, invoice, and receipt", "On facilitator trainings and paid events", "Items link to their relevant resources."), - EditorCard.new(nil, "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, "fa-solid fa-circle-question", "blue", "Frequently asked questions", "Common questions about the 2-day training", "On facilitator trainings", nil) - ] + 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), + EditorCard.new(nil, "scholarship", "fa-solid fa-award", "fuchsia", "Scholarship", "Your scholarship request and award", "When the registrant requested a scholarship", nil), + EditorCard.new(:ce_hours, "ce_hours", "fa-solid fa-graduation-cap", "teal", event.ce_hours_details_label, "Continuing education — requirements & how to request", "When a registrant requests CE credit", nil), + EditorCard.new(:event_details, "event_details", "fa-solid fa-palette", "blue", event.event_details_label, "Important info for this event — please read", "When the content below is filled in", nil), + 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, "forms", "fa-solid fa-file-lines", "blue", "Forms", "W-9, invoice, and receipt", "On facilitator trainings and paid events", "Items link to their relevant resources."), + 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| materialized.include?(card.magic_key) } end def initialize(event_registration) @@ -67,6 +71,14 @@ def cards attr_reader :registration, :event + # A built-in card whose content has been materialized into an editable row for + # this event renders from that row (through the custom-callout loop) instead of + # here, so we skip it to avoid double-rendering. See DefaultTicketCallouts. + def materialized?(magic_key) + @materialized_keys ||= event.registration_ticket_callouts.magic.pluck(:magic_key).to_set + @materialized_keys.include?(magic_key) + end + # Top card: an action card while a balance is due, a reference card once paid # in full. Its page lists every allocation with the running balance. def payment_card @@ -196,6 +208,7 @@ def forms_card # Links to the 2-day training worksheets and handouts, so shown only on # facilitator trainings — see Event#show_handouts_callout?. def handouts_card + return if materialized?("handouts") return unless event.show_handouts_callout? Card.new(icon_class: "fa-solid fa-folder-open", color: "blue", title: "Handouts", @@ -207,6 +220,7 @@ def handouts_card # 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 if materialized?("faq") return unless event.show_faq_callout? Card.new(icon_class: "fa-solid fa-circle-question", color: "blue", title: "Frequently asked questions", diff --git a/app/views/event_registrations/_ticket.html.erb b/app/views/event_registrations/_ticket.html.erb index 0e1f0c1816..01a23c3db1 100644 --- a/app/views/event_registrations/_ticket.html.erb +++ b/app/views/event_registrations/_ticket.html.erb @@ -107,9 +107,12 @@ <%= 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.each do |callout| %> + <% event_registration.event.registration_ticket_callouts.visible.each do |callout| %> + <% next if callout.dripping? %> <% next if callout.payment_access_gated && !payment_access %> <%= render "event_registrations/callout_card", callout: callout, diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 96f08bc12d..3291dab87c 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -582,7 +582,11 @@ data-controller="sortable" data-sortable-url-value="<%= @event.persisted? ? event_registration_ticket_callout_path(@event, ":id") : "" %>"> <%= f.fields_for :registration_ticket_callouts do |rts| %> - <%= render "events/registration_ticket_callout_fields", f: rts %> + <% if rts.object.magic? %> + <%= render "events/registration_ticket_magic_callout_fields", f: rts %> + <% else %> + <%= render "events/registration_ticket_callout_fields", f: rts %> + <% end %> <% end %> diff --git a/app/views/events/_registration_ticket_callout_fields.html.erb b/app/views/events/_registration_ticket_callout_fields.html.erb index 0ae7c82bff..b0e955ba64 100644 --- a/app/views/events/_registration_ticket_callout_fields.html.erb +++ b/app/views/events/_registration_ticket_callout_fields.html.erb @@ -84,10 +84,16 @@
- +
+ + +
<%= link_to_remove_association "Remove", f, class: "text-sm text-gray-400 hover:text-red-600 underline" %>
diff --git a/app/views/events/_registration_ticket_magic_callout_fields.html.erb b/app/views/events/_registration_ticket_magic_callout_fields.html.erb new file mode 100644 index 0000000000..22ed097a02 --- /dev/null +++ b/app/views/events/_registration_ticket_magic_callout_fields.html.erb @@ -0,0 +1,63 @@ +<%# Editor row for a materialized built-in ("magic") callout. Its title, subtitle, + page text, and linked resources are editable, and it can be hidden or restored + to default — but never removed (magic callouts hide instead of delete). %> +
+
+ +
+ +
+
+ Built in + Editable — this app-provided callout also shows live status on the ticket. +
+ +
+
+
+
+ <%= f.label :title, "Title", class: "block text-xs font-medium text-gray-600 mb-0.5" %> + <%= f.text_field :title, required: true, + class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %> +
+
+ <%= f.label :subtitle, "Subtitle", class: "block text-xs font-medium text-gray-600 mb-0.5" %> + <%= f.text_field :subtitle, + class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %> +
+
+
+ +
+
+ <%= f.label :description, "Callout page text", class: "block text-xs font-medium text-gray-600 mb-0.5" %> +

Shown on its own page. Accepts basic HTML — bold, italics, links, lists, headings, and line breaks.

+ <%= f.text_area :description, rows: 4, + class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm font-mono" %> +
+ +
+ <%= f.label :resource_ids, "Linked resources", class: "block text-xs font-medium text-gray-600 mb-0.5" %> +

Optional. Hold ⌘/Ctrl to select more than one.

+ <%= f.collection_select :resource_ids, Resource.order(:title), :id, :title, + {}, multiple: true, size: 4, + class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %> +
+
+
+ +
+ + <% if f.object.persisted? %> + <%# link (not button_to) so we don't nest a
inside the event form %> + <%= link_to "Restore default", restore_event_registration_ticket_callout_path(@event, f.object), + data: { turbo_method: :post, turbo_confirm: "Reset this callout's text and links to the default? Save any other changes first — this reloads the form." }, + class: "text-sm text-gray-400 hover:text-purple-700 underline" %> + <% end %> +
+
+
diff --git a/config/routes.rb b/config/routes.rb index b107a16572..55b850f5c0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -147,7 +147,9 @@ post :allocate_bulk_payment post :create_bulk_payment end - resources :registration_ticket_callouts, only: [ :show, :update ] + resources :registration_ticket_callouts, only: [ :show, :update ] do + member { post :restore } + end resource :registrations, only: %i[ create ], module: :events, as: :registrant_registration resource :public_registration, only: [ :new, :create, :show ], module: :events resource :bulk_payment, only: [ :new, :create, :show ], module: :events diff --git a/spec/requests/events/registration_ticket_callouts_spec.rb b/spec/requests/events/registration_ticket_callouts_spec.rb index 37bdaa47f1..3c7cc2af91 100644 --- a/spec/requests/events/registration_ticket_callouts_spec.rb +++ b/spec/requests/events/registration_ticket_callouts_spec.rb @@ -54,6 +54,23 @@ expect(response).to have_http_status(:not_found) end + it "redirects a hidden callout's page back to the event" do + callout = create(:registration_ticket_callout, :hidden, event:, description: "

Draft.

") + + get event_registration_ticket_callout_path(event, callout) + + expect(response).to redirect_to(event_path(event)) + end + + it "redirects a not-yet-dripped callout's page back to the event" do + callout = create(:registration_ticket_callout, event:, description: "

Later.

", + display_from: 1.day.from_now) + + get event_registration_ticket_callout_path(event, callout) + + expect(response).to redirect_to(event_path(event)) + end + context "when linked to a resource with a downloadable file" do let(:resource) { create(:resource) } let(:callout) do @@ -105,7 +122,7 @@ } } - ordered = event.registration_ticket_callouts.reload.ordered + ordered = event.registration_ticket_callouts.custom.reload.ordered expect(ordered.map(&:title)).to eq(%w[First Second Third]) expect(ordered.map(&:position)).to eq([ 1, 2, 3 ]) end @@ -152,4 +169,55 @@ expect(callout.reload.position).to eq(2) end end + + describe "POST /events/:event_id/registration_ticket_callouts/:id/restore" do + it "restores a magic callout to its default for a manager" do + sign_in admin + training = create(:event, :publicly_visible, facilitator_training: true) + callout = create(:registration_ticket_callout, event: training, magic_key: "faq", title: "Edited", hidden: true) + + post restore_event_registration_ticket_callout_path(training, callout) + + expect(response).to redirect_to(edit_event_path(training, expand: "callouts", anchor: "registration_ticket_callouts")) + expect(callout.reload.title).to eq("Frequently asked questions") + expect(callout.hidden).to be(false) + end + + it "is not permitted for a non-manager" do + sign_in create(:user) + callout = create(:registration_ticket_callout, event:, magic_key: "faq", title: "Edited") + + post restore_event_registration_ticket_callout_path(event, callout) + + expect(response).to redirect_to(root_path) + expect(callout.reload.title).to eq("Edited") + end + end + + describe "seeding built-in callouts on save" do + before { sign_in admin } + + it "materializes Handouts and FAQ when an event is updated" do + patch event_path(event), params: { + 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("handouts", "faq") + end + end + + describe "the event editor" do + before { sign_in admin } + + it "renders a materialized callout as an editable field with hide and restore controls" do + callout = create(:registration_ticket_callout, event:, magic_key: "faq", + title: "Frequently asked questions") + + get edit_event_path(event) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("name=\"event[registration_ticket_callouts_attributes][0][title]\"") + expect(response.body).to include(restore_event_registration_ticket_callout_path(event, callout)) + end + end end diff --git a/spec/services/default_ticket_callouts_spec.rb b/spec/services/default_ticket_callouts_spec.rb index 03f2074de2..73f0603d60 100644 --- a/spec/services/default_ticket_callouts_spec.rb +++ b/spec/services/default_ticket_callouts_spec.rb @@ -69,4 +69,39 @@ expect(event.registration_ticket_callouts.ordered.map(&:magic_key).compact).to eq(%w[handouts faq]) end end + + describe ".reset" do + it "restores an edited magic 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.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.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") + original_position = faq.position + + described_class.reset(faq) + + expect(faq.reload.position).to eq(original_position) + end + + it "leaves a custom callout untouched" do + callout = create(:registration_ticket_callout, title: "Parking", magic_key: nil) + + expect { described_class.reset(callout) }.not_to change { callout.reload.title } + end + end end diff --git a/spec/services/magic_ticket_callouts_spec.rb b/spec/services/magic_ticket_callouts_spec.rb index 1498d8cc69..5baaab8edb 100644 --- a/spec/services/magic_ticket_callouts_spec.rb +++ b/spec/services/magic_ticket_callouts_spec.rb @@ -29,6 +29,16 @@ def card(reg, title) expect(card_titles(registration)).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") + + # 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") + end + it "shows the Forms card for facilitator trainings and paid events, but not free non-trainings" do expect(card_titles(registration)).to include("Forms") From 21a6935c28e8d55d68a63a997ba36adda683af99 Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 8 Jul 2026 11:25:54 -0400 Subject: [PATCH 03/32] Materialize behavioral magic callouts (Certificate, Videoconference) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends materialization to app-controlled ("behavioral") cards, whose live status stays code-driven while the row governs visibility, drip date, and order. - MagicTicketCallouts#card_for(magic_key) builds one card's live state on demand; #cards now dispatches through the same key→builder map and skips materialized keys. The ticket renders a behavioral magic row via card_for (nil when it shouldn't show for the registration), so opting out / reordering works while the app supplies the dynamic badge and destination. - Seed Certificate (opt-in: hidden by default except on facilitator trainings) and Videoconference (only once the event has a link; display_from = a week before start, replacing the hard-coded "one week prior" rule with stored data). - Editor: behavioral magic rows render a control-only partial (hide toggle, drag handle, Restore default — no content fields); reject_if now guards only new callouts so a control row's hidden toggle persists without a title. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- app/models/event.rb | 5 ++- app/models/registration_ticket_callout.rb | 12 ++++++ app/services/default_ticket_callouts.rb | 43 ++++++++++++++++--- app/services/magic_ticket_callouts.rb | 39 +++++++++++++---- .../event_registrations/_ticket.html.erb | 33 +++++++++----- app/views/events/_form.html.erb | 4 +- ...ion_ticket_control_callout_fields.html.erb | 36 ++++++++++++++++ .../registration_ticket_callouts_spec.rb | 18 ++++++-- spec/services/default_ticket_callouts_spec.rb | 30 +++++++++++-- spec/services/magic_ticket_callouts_spec.rb | 18 ++++++++ 11 files changed, 206 insertions(+), 34 deletions(-) create mode 100644 app/views/events/_registration_ticket_control_callout_fields.html.erb diff --git a/AGENTS.md b/AGENTS.md index c9fee8e3a4..aed7cee7ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,7 +206,7 @@ end - `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, forms, 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 built-in callouts (currently Handouts, FAQ) into editable `RegistrationTicketCallout` rows, seeded on event create and lazily on edit (idempotent, no backfill). Derives each card's default visibility from `Event#show_*_callout?`; `.reset` restores a materialized callout's content/resources/visibility to its template +- `DefaultTicketCallouts` — Materializes built-in callouts into `RegistrationTicketCallout` rows, seeded on event create and lazily on edit (idempotent, no backfill). "Content" cards (Handouts, FAQ) render their own editable copy/resources; "behavioral" cards (Certificate — opt-in, default off except trainings; Videoconference — drips a week before start via `display_from`) render live status through `MagicTicketCallouts#card_for`, the row governing only visibility/drip/order. `.reset` restores a materialized callout to its template ### Affiliations diff --git a/app/models/event.rb b/app/models/event.rb index 604acd7a5c..44480dead4 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -47,7 +47,10 @@ class Event < ApplicationRecord accepts_nested_attributes_for :event_staffs, allow_destroy: true, reject_if: proc { |attrs| attrs["person_id"].blank? } accepts_nested_attributes_for :registration_ticket_callouts, allow_destroy: true, - reject_if: proc { |attrs| attrs["title"].blank? } + # 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 + # though it submits no title. + reject_if: proc { |attrs| attrs["id"].blank? && attrs["title"].blank? } # Callbacks after_commit :build_public_registration_form, if: :public_registration_just_enabled? diff --git a/app/models/registration_ticket_callout.rb b/app/models/registration_ticket_callout.rb index b736405b73..258a66fe40 100644 --- a/app/models/registration_ticket_callout.rb +++ b/app/models/registration_ticket_callout.rb @@ -15,6 +15,12 @@ class RegistrationTicketCallout < ApplicationRecord videoconference forms 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 only governs visibility, + # drip date, and order. + CONTENT_MAGIC_KEYS = %w[ handouts faq ].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 # app can reuse them for tinted boxes — amount-due, scholarship box, etc.). @@ -63,6 +69,12 @@ def magic? magic_key.present? end + # A behavioral built-in callout whose card is rendered by MagicTicketCallouts + # (live status), as opposed to a content callout that renders from its own row. + def behavioral_magic? + magic? && CONTENT_MAGIC_KEYS.exclude?(magic_key) + end + # Whether the callout is drip-scheduled to appear only from a future date. def dripping?(now = Time.current) display_from.present? && display_from > now diff --git a/app/services/default_ticket_callouts.rb b/app/services/default_ticket_callouts.rb index 37e7f4bf48..4f5befd492 100644 --- a/app/services/default_ticket_callouts.rb +++ b/app/services/default_ticket_callouts.rb @@ -74,10 +74,13 @@ def initialize(event) end # Create any not-yet-present magic callouts for the event, appended in - # definition order after whatever already exists. Returns the created rows. + # definition order after whatever already exists. A definition's optional + # `seed_if` gates whether it applies to this event (e.g. skip the + # videoconference card until the event has a link). 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]) } + .select { |definition| definition[:seed_if].nil? || definition[:seed_if].call(@event) } .map { |definition| create(definition) } end @@ -92,7 +95,8 @@ def reset(callout) callout_type: definition[:callout_type], icon_class: definition[:icon_class], color_class: definition[:color_class], - hidden: definition[:hidden].call(@event) + hidden: definition[:hidden].call(@event), + display_from: definition[:display_from]&.call(@event) ) callout.resources = definition[:resources]&.call || [] callout @@ -100,8 +104,12 @@ def reset(callout) private - # Ordered built-in callout definitions. `hidden` is a proc so each event - # derives its own default; `resources` resolves the linked records. + # Ordered built-in callout definitions. `hidden` / `display_from` are procs so + # 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 + # governs visibility, drip date, and order. def definitions [ { @@ -123,6 +131,30 @@ def definitions color_class: "blue", description: faq_html, hidden: ->(event) { !event.show_faq_callout? } + }, + { + magic_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; admins opt other events + # in. When shown, it still only appears once the certificate unlocks. + hidden: ->(event) { !event.facilitator_training? } + }, + { + magic_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) { false }, + # 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 }, + seed_if: ->(event) { event.videoconference_url.present? } } ] end @@ -136,7 +168,8 @@ def create(definition) callout_type: definition[:callout_type], icon_class: definition[:icon_class], color_class: definition[:color_class], - hidden: definition[:hidden].call(@event) + hidden: definition[:hidden].call(@event), + display_from: definition[:display_from]&.call(@event) ) definition[:resources]&.call&.each { |resource| callout.resources << resource } callout diff --git a/app/services/magic_ticket_callouts.rb b/app/services/magic_ticket_callouts.rb index b968d2d639..9fefa63dfa 100644 --- a/app/services/magic_ticket_callouts.rb +++ b/app/services/magic_ticket_callouts.rb @@ -55,25 +55,48 @@ def self.editor_cards(event) ].reject { |card| materialized.include?(card.magic_key) } end + # magic_key → builder method, in the default order cards appear on a ticket. + 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, + "forms" => :forms_card, + "handouts" => :handouts_card, + "faq" => :faq_card + }.freeze + def initialize(event_registration) @registration = event_registration @event = event_registration.event end - # The visible cards for this registration, in display order. + # The visible built-in cards for this registration, in default order. Cards the + # event has materialized into editable rows are omitted here — the ticket renders + # 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 - [ payment_card, certificate_card, scholarship_status_card, ce_hours_card, - event_details_card, videoconference_card, forms_card, handouts_card, - faq_card ].compact + CARD_BUILDERS.reject { |magic_key, _| materialized?(magic_key) } + .filter_map { |_, builder| send(builder) } + end + + # The live Card for one behavioral magic_key (payment, certificate, …), or nil + # when it shouldn't show for this registration (e.g. certificate not yet + # unlocked). The ticket calls this for a materialized behavioral row so the row + # governs visibility/order while the app still supplies the dynamic status. + def card_for(magic_key) + builder = CARD_BUILDERS[magic_key] + builder && send(builder) end private attr_reader :registration, :event - # A built-in card whose content has been materialized into an editable row for - # this event renders from that row (through the custom-callout loop) instead of - # here, so we skip it to avoid double-rendering. See DefaultTicketCallouts. + # 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) @@ -208,7 +231,6 @@ def forms_card # Links to the 2-day training worksheets and handouts, so shown only on # facilitator trainings — see Event#show_handouts_callout?. def handouts_card - return if materialized?("handouts") return unless event.show_handouts_callout? Card.new(icon_class: "fa-solid fa-folder-open", color: "blue", title: "Handouts", @@ -220,7 +242,6 @@ def handouts_card # 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 if materialized?("faq") return unless event.show_faq_callout? Card.new(icon_class: "fa-solid fa-circle-question", color: "blue", title: "Frequently asked questions", diff --git a/app/views/event_registrations/_ticket.html.erb b/app/views/event_registrations/_ticket.html.erb index 01a23c3db1..652981abec 100644 --- a/app/views/event_registrations/_ticket.html.erb +++ b/app/views/event_registrations/_ticket.html.erb @@ -100,24 +100,35 @@ <% end %> - - <% MagicTicketCallouts.new(event_registration).cards.each do |card| %> + <% magic = MagicTicketCallouts.new(event_registration) %> + + + <% magic.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.dripping? %> <% next if callout.payment_access_gated && !payment_access %> - <%= render "event_registrations/callout_card", - callout: callout, - href: event_registration_ticket_callout_path(event_registration.event, callout, reg: event_registration.slug), - linked: !preview %> + <% if callout.behavioral_magic? %> + <% card = magic.card_for(callout.magic_key) %> + <% if card %> + <%= render "event_registrations/callout_card", callout: card, linked: !preview %> + <% end %> + <% else %> + <%= render "event_registrations/callout_card", + callout: callout, + href: event_registration_ticket_callout_path(event_registration.event, callout, reg: event_registration.slug), + linked: !preview %> + <% end %> <% end %> diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 3291dab87c..9bf26ca5e4 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -582,7 +582,9 @@ data-controller="sortable" data-sortable-url-value="<%= @event.persisted? ? event_registration_ticket_callout_path(@event, ":id") : "" %>"> <%= f.fields_for :registration_ticket_callouts do |rts| %> - <% if rts.object.magic? %> + <% if rts.object.behavioral_magic? %> + <%= render "events/registration_ticket_control_callout_fields", f: rts %> + <% elsif rts.object.magic? %> <%= render "events/registration_ticket_magic_callout_fields", f: rts %> <% else %> <%= render "events/registration_ticket_callout_fields", f: rts %> diff --git a/app/views/events/_registration_ticket_control_callout_fields.html.erb b/app/views/events/_registration_ticket_control_callout_fields.html.erb new file mode 100644 index 0000000000..53ee78628b --- /dev/null +++ b/app/views/events/_registration_ticket_control_callout_fields.html.erb @@ -0,0 +1,36 @@ +<%# Editor row for a behavioral built-in ("magic") callout — payment, certificate, + videoconference, etc. Its card text and live status are app-controlled, so only + its visibility, drip date, and order are editable here: a hide toggle, a drag + handle, and "Restore default". Never removable. %> +
+
+ +
+ + + +
+
+ Built in + App-controlled — shows live status on the ticket. +
+

<%= f.object.title %>

+

<%= f.object.subtitle %>

+ <% if f.object.display_from.present? %> +

Appears from <%= f.object.display_from.to_fs(:long) %>.

+ <% end %> + +
+ + <% if f.object.persisted? %> + <%= link_to "Restore default", restore_event_registration_ticket_callout_path(@event, f.object), + data: { turbo_method: :post, turbo_confirm: "Reset this callout to its default visibility? Save any other changes first — this reloads the form." }, + class: "text-sm text-gray-400 hover:text-purple-700 underline" %> + <% end %> +
+
+
diff --git a/spec/requests/events/registration_ticket_callouts_spec.rb b/spec/requests/events/registration_ticket_callouts_spec.rb index 3c7cc2af91..bf4708017d 100644 --- a/spec/requests/events/registration_ticket_callouts_spec.rb +++ b/spec/requests/events/registration_ticket_callouts_spec.rb @@ -197,19 +197,19 @@ describe "seeding built-in callouts on save" do before { sign_in admin } - it "materializes Handouts and FAQ when an event is updated" do + it "materializes the built-in callouts when an event is updated" do patch event_path(event), params: { 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("handouts", "faq") + expect(event.registration_ticket_callouts.magic.pluck(:magic_key)).to contain_exactly("handouts", "faq", "certificate") end end describe "the event editor" do before { sign_in admin } - it "renders a materialized callout as an editable field with hide and restore controls" do + it "renders a materialized content callout as an editable field with hide and restore controls" do callout = create(:registration_ticket_callout, event:, magic_key: "faq", title: "Frequently asked questions") @@ -219,5 +219,17 @@ expect(response.body).to include("name=\"event[registration_ticket_callouts_attributes][0][title]\"") expect(response.body).to include(restore_event_registration_ticket_callout_path(event, callout)) end + + it "renders a behavioral magic callout as a control-only row (hide + restore, no content fields)" do + callout = create(:registration_ticket_callout, event:, magic_key: "certificate", + title: "Certificate of completion") + + get edit_event_path(event) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("name=\"event[registration_ticket_callouts_attributes][0][hidden]\"") + expect(response.body).not_to include("name=\"event[registration_ticket_callouts_attributes][0][description]\"") + expect(response.body).to include(restore_event_registration_ticket_callout_path(event, callout)) + end end end diff --git a/spec/services/default_ticket_callouts_spec.rb b/spec/services/default_ticket_callouts_spec.rb index 73f0603d60..b507f77476 100644 --- a/spec/services/default_ticket_callouts_spec.rb +++ b/spec/services/default_ticket_callouts_spec.rb @@ -2,13 +2,37 @@ RSpec.describe DefaultTicketCallouts do describe "#seed" do - it "materializes the Handouts and FAQ magic callouts" do + it "materializes the Handouts, FAQ, and Certificate magic callouts" do event = create(:event) described_class.seed(event) keys = event.registration_ticket_callouts.magic.pluck(:magic_key) - expect(keys).to contain_exactly("handouts", "faq") + expect(keys).to contain_exactly("handouts", "faq", "certificate") + end + + it "seeds the Videoconference card, dripping a week before start, only once the event has a link" do + event = create(:event, start_date: Date.new(2026, 9, 10)) + described_class.seed(event) + expect(event.registration_ticket_callouts.find_by(magic_key: "videoconference")).to be_nil + + event.update!(videoconference_url: "https://example.com/zoom") + described_class.seed(event) + + vc = event.registration_ticket_callouts.find_by(magic_key: "videoconference") + expect(vc.display_from.to_date).to eq(Date.new(2026, 9, 3)) + expect(vc.hidden).to be(false) + 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 "seeds the FAQ card with the default training content" do @@ -66,7 +90,7 @@ 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(%w[handouts faq]) + expect(event.registration_ticket_callouts.ordered.map(&:magic_key).compact).to eq(%w[handouts faq certificate]) end end diff --git a/spec/services/magic_ticket_callouts_spec.rb b/spec/services/magic_ticket_callouts_spec.rb index 5baaab8edb..39210e1dcb 100644 --- a/spec/services/magic_ticket_callouts_spec.rb +++ b/spec/services/magic_ticket_callouts_spec.rb @@ -160,4 +160,22 @@ def card(reg, title) ]) end end + + describe "#card_for" do + it "builds the live behavioral card for a materialized magic_key" do + event.update!(cost_cents: 5_000) + create(:registration_ticket_callout, event:, magic_key: "payment", title: "Payment") + + card = described_class.new(registration).card_for("payment") + expect(card.title).to eq("Make your payment") + expect(card.badge).to end_with("due") + end + + it "returns nil when the card shouldn't show for this registration" do + create(:registration_ticket_callout, event:, magic_key: "certificate", title: "Certificate of completion") + + # Certificate isn't unlocked (event not ended, not attended). + expect(described_class.new(registration).card_for("certificate")).to be_nil + end + end end From 78c3307b048c50dca5031dc9232160e5f6f1a12c Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 8 Jul 2026 14:05:46 -0400 Subject: [PATCH 04/32] Materialize the remaining behavioral callouts (Payment, Scholarship, CE, Forms) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the built-in set: DefaultTicketCallouts now seeds all nine callouts in canonical ticket order, each gated by the event's config (payment when it has a cost, scholarship when it has a scholarship form, CE when hours are offered, event details when present, forms on trainings/paid events). The ticket renders each behavioral row through MagicTicketCallouts#card_for, so the whole set is one admin-ordered, hideable list. CE hours and Event details are behavioral but keep their text on event columns (edited via the built-in preview card in the callouts section), so they render via card_for but get no duplicate editor control row — placement stays fixed. Their colours (teal/fuchsia) come from the live card, not a stored swatch. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- app/models/registration_ticket_callout.rb | 11 +++ app/services/default_ticket_callouts.rb | 90 +++++++++++++++---- app/services/magic_ticket_callouts.rb | 2 +- app/views/events/_form.html.erb | 4 +- .../registration_ticket_callouts_spec.rb | 4 +- spec/services/default_ticket_callouts_spec.rb | 34 ++++++- 7 files changed, 122 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aed7cee7ce..5466ebe572 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,7 +206,7 @@ end - `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, forms, 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 built-in callouts into `RegistrationTicketCallout` rows, seeded on event create and lazily on edit (idempotent, no backfill). "Content" cards (Handouts, FAQ) render their own editable copy/resources; "behavioral" cards (Certificate — opt-in, default off except trainings; Videoconference — drips a week before start via `display_from`) render live status through `MagicTicketCallouts#card_for`, the row governing only visibility/drip/order. `.reset` restores a materialized callout to its template +- `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). "Content" cards (Handouts, FAQ) render their own editable copy/resources; "behavioral" cards render live status through `MagicTicketCallouts#card_for`, the row governing only visibility/drip/order. Certificate is opt-in (default off except trainings); Videoconference drips a week before start via `display_from`. CE hours and Event details are behavioral but keep their text on event columns (edited via the built-in preview card), so they get no editor control row. `.reset` restores a materialized callout to its template ### Affiliations diff --git a/app/models/registration_ticket_callout.rb b/app/models/registration_ticket_callout.rb index 258a66fe40..5aad479174 100644 --- a/app/models/registration_ticket_callout.rb +++ b/app/models/registration_ticket_callout.rb @@ -21,6 +21,11 @@ class RegistrationTicketCallout < ApplicationRecord # drip date, and order. CONTENT_MAGIC_KEYS = %w[ handouts faq ].freeze + # Behavioral magic callouts whose text is edited via the event's built-in + # preview card (their content lives on event columns, e.g. ce_hours_details), + # so the editor shows no separate control row for them. + PREVIEW_EDITED_MAGIC_KEYS = %w[ ce_hours event_details ].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 # app can reuse them for tinted boxes — amount-due, scholarship box, etc.). @@ -75,6 +80,12 @@ def behavioral_magic? magic? && CONTENT_MAGIC_KEYS.exclude?(magic_key) end + # A behavioral magic callout that gets an editor control row (hide/reorder/ + # restore). Excludes the preview-edited ones, whose placement stays fixed. + def control_row? + behavioral_magic? && PREVIEW_EDITED_MAGIC_KEYS.exclude?(magic_key) + end + # Whether the callout is drip-scheduled to appear only from a future date. def dripping?(now = Time.current) display_from.present? && display_from > now diff --git a/app/services/default_ticket_callouts.rb b/app/services/default_ticket_callouts.rb index 4f5befd492..97a20f7276 100644 --- a/app/services/default_ticket_callouts.rb +++ b/app/services/default_ticket_callouts.rb @@ -113,24 +113,14 @@ def reset(callout) def definitions [ { - magic_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.show_handouts_callout? }, - resources: -> { handout_resources } - }, - { - magic_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: faq_html, - hidden: ->(event) { !event.show_faq_callout? } + magic_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) { false }, + seed_if: ->(event) { event.cost_cents.to_i.positive? } }, { magic_key: "certificate", @@ -143,6 +133,40 @@ def definitions # in. When shown, it still only appears once the certificate unlocks. hidden: ->(event) { !event.facilitator_training? } }, + { + magic_key: "scholarship", + title: "Scholarship", + subtitle: "Your scholarship request and award", + callout_type: "action", + icon_class: "fa-solid fa-award", + # Colour (fuchsia) comes from the live card via #card_for, not a swatch. + hidden: ->(_event) { false }, + seed_if: ->(event) { event.scholarship_form.present? } + }, + { + magic_key: "ce_hours", + title: "CE hours", + subtitle: "Continuing education — requirements & how to request", + callout_type: "action", + icon_class: "fa-solid fa-graduation-cap", + # Colour (teal) comes from the live card via #card_for, not a swatch. + # Content (label + details) stays on the event and is edited in the + # callouts section's built-in card; the row only governs order/drip. + hidden: ->(_event) { false }, + seed_if: ->(event) { event.ce_hours_offered.present? } + }, + { + magic_key: "event_details", + title: "Before you attend", + subtitle: "Important info for this event — please read", + callout_type: "reference", + icon_class: "fa-solid fa-palette", + color_class: "blue", + # Content (label + body) stays on the event and is edited in the built-in + # card; the row only governs order/drip. + hidden: ->(_event) { false }, + seed_if: ->(event) { event.event_details.present? } + }, { magic_key: "videoconference", title: "Videoconference", @@ -155,6 +179,36 @@ def definitions # hard-coded "one week prior" rule with a stored, editable date. display_from: ->(event) { event.start_date - 7.days if event.start_date }, seed_if: ->(event) { event.videoconference_url.present? } + }, + { + magic_key: "forms", + title: "Forms", + subtitle: "W-9, invoice, and receipt", + callout_type: "action", + icon_class: "fa-solid fa-file-lines", + color_class: "blue", + hidden: ->(_event) { false }, + seed_if: ->(event) { event.show_forms_callout? } + }, + { + magic_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.show_handouts_callout? }, + resources: -> { handout_resources } + }, + { + magic_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: faq_html, + hidden: ->(event) { !event.show_faq_callout? } } ] end diff --git a/app/services/magic_ticket_callouts.rb b/app/services/magic_ticket_callouts.rb index 9fefa63dfa..fbcd98bfd5 100644 --- a/app/services/magic_ticket_callouts.rb +++ b/app/services/magic_ticket_callouts.rb @@ -52,7 +52,7 @@ def self.editor_cards(event) EditorCard.new(nil, "forms", "fa-solid fa-file-lines", "blue", "Forms", "W-9, invoice, and receipt", "On facilitator trainings and paid events", "Items link to their relevant resources."), 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| materialized.include?(card.magic_key) } + ].reject { |card| card.key.nil? && materialized.include?(card.magic_key) } end # magic_key → builder method, in the default order cards appear on a ticket. diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 9bf26ca5e4..a5d48fd240 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -582,8 +582,10 @@ data-controller="sortable" data-sortable-url-value="<%= @event.persisted? ? event_registration_ticket_callout_path(@event, ":id") : "" %>"> <%= f.fields_for :registration_ticket_callouts do |rts| %> - <% if rts.object.behavioral_magic? %> + <% if rts.object.control_row? %> <%= render "events/registration_ticket_control_callout_fields", f: rts %> + <% elsif rts.object.behavioral_magic? %> + <%# CE hours / event details: text is edited in the built-in card above %> <% elsif rts.object.magic? %> <%= render "events/registration_ticket_magic_callout_fields", f: rts %> <% else %> diff --git a/spec/requests/events/registration_ticket_callouts_spec.rb b/spec/requests/events/registration_ticket_callouts_spec.rb index bf4708017d..b5cca22fa6 100644 --- a/spec/requests/events/registration_ticket_callouts_spec.rb +++ b/spec/requests/events/registration_ticket_callouts_spec.rb @@ -202,7 +202,9 @@ 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("handouts", "faq", "certificate") + expect(event.registration_ticket_callouts.magic.pluck(:magic_key)).to contain_exactly( + "payment", "certificate", "forms", "handouts", "faq" + ) end end diff --git a/spec/services/default_ticket_callouts_spec.rb b/spec/services/default_ticket_callouts_spec.rb index b507f77476..2018282a1e 100644 --- a/spec/services/default_ticket_callouts_spec.rb +++ b/spec/services/default_ticket_callouts_spec.rb @@ -2,13 +2,39 @@ RSpec.describe DefaultTicketCallouts do describe "#seed" do - it "materializes the Handouts, FAQ, and Certificate magic callouts" do + it "materializes the built-in callouts the event's config warrants" do + # Factory event: has a cost (payment + forms), no scholarship/CE/details/VC. event = create(:event) described_class.seed(event) keys = event.registration_ticket_callouts.magic.pluck(:magic_key) - expect(keys).to contain_exactly("handouts", "faq", "certificate") + expect(keys).to contain_exactly("payment", "certificate", "forms", "handouts", "faq") + end + + it "seeds Payment, Scholarship, CE, and Event details only when their config is present" do + form = create(:form) + event = create(:event, cost_cents: 0, ce_hours_offered: 6, event_details: "

Bring supplies.

") + event.event_forms.create!(form:, role: "scholarship") + + described_class.seed(event) + + keys = event.registration_ticket_callouts.magic.pluck(:magic_key) + expect(keys).to include("scholarship", "ce_hours", "event_details") + expect(keys).not_to include("payment") # free event + end + + it "seeds callouts in canonical ticket order" do + form = create(:form) + event = create(:event, facilitator_training: true, ce_hours_offered: 6, + event_details: "

x

", videoconference_url: "https://example.com/z") + event.event_forms.create!(form:, role: "scholarship") + + described_class.seed(event) + + expect(event.registration_ticket_callouts.ordered.map(&:magic_key)).to eq( + %w[payment certificate scholarship ce_hours event_details videoconference forms handouts faq] + ) end it "seeds the Videoconference card, dripping a week before start, only once the event has a link" do @@ -90,7 +116,9 @@ 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(%w[handouts faq certificate]) + expect(event.registration_ticket_callouts.ordered.map(&:magic_key).compact).to eq( + %w[payment certificate forms handouts faq] + ) end end From 8eb5ba0673f3c79b3a61d0208dbd481624c0751a Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 8 Jul 2026 18:28:39 -0400 Subject: [PATCH 05/32] Make the W-9 a removable resource on the materialized Forms card The W-9 was hard-coded onto every forms page. Seed it instead as a linked resource on the Forms callout, editable/removable per event from the callouts section; the forms page now renders the callout's resources (falling back to the W-9 for events not yet materialized) alongside the still-dynamic invoice/receipt. - DefaultTicketCallouts links the W-9 resource to the seeded Forms row. - The Forms control row gains a linked-documents picker (RESOURCE_EDITABLE_MAGIC_KEYS). - build_form_cards reads the Forms row's resources. The W-9 resource is already hidden_from_search in the dev seeds. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/events/callouts_controller.rb | 32 +++++++++++++------ app/models/registration_ticket_callout.rb | 9 ++++++ app/services/default_ticket_callouts.rb | 3 ++ ...ion_ticket_control_callout_fields.html.erb | 11 +++++++ spec/requests/events/callouts_spec.rb | 16 ++++++++++ .../registration_ticket_callouts_spec.rb | 9 ++++++ spec/services/default_ticket_callouts_spec.rb | 10 ++++++ 7 files changed, 81 insertions(+), 9 deletions(-) diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index 865e9b1f79..e677e8b1ec 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -118,16 +118,16 @@ def set_event @event = @event_registration.event end - # Builds the callout-card links shown on the forms page. The W-9 opens in its - # own resource page (preview + download) when seeded; the invoice and the - # paid-in-full receipt (once settled) show for paid events. Each returns to forms. + # Builds the callout-card links shown on the forms page. The document links + # come from the materialized Forms callout's resources (the W-9 by default, + # editable/removable per event) — or the hard-coded W-9 for events not yet + # materialized. The invoice and paid-in-full receipt (once settled) show for + # paid events. Each returns to forms. def build_form_cards - cards = [] - w9 = Resource.find_by(title: "W-9") - if w9 - cards << resource_card(icon: "fa-solid fa-file-pdf", title: "W-9", - subtitle: "AWBW's W-9 tax form for your records", - href: registration_resource_path(@event_registration.slug, w9, return_to: "forms"), target: nil) + cards = form_document_resources.map do |resource| + resource_card(icon: "fa-solid fa-file-pdf", title: resource.title, + subtitle: form_resource_subtitle(resource), + href: registration_resource_path(@event_registration.slug, resource, return_to: "forms"), target: nil) end if @event_registration.invoice_available? cards << resource_card(icon: "fa-solid fa-file-invoice-dollar", title: "View invoice", @@ -142,6 +142,20 @@ def build_form_cards cards end + # The forms callout's linked documents. Uses the materialized Forms row's + # resources when present (so admins can add/remove them), else the W-9 for + # events not yet materialized. + def form_document_resources + forms_callout = @event.registration_ticket_callouts.find_by(magic_key: "forms") + return forms_callout.resources.to_a if forms_callout + Resource.where(title: "W-9").to_a + end + + def form_resource_subtitle(resource) + return "AWBW's W-9 tax form for your records" if resource.title == "W-9" + "Open this document" + end + # A blue callout card linking to a document. External/static links open in a # new tab (target: "_blank"); registrant resource pages stay in-tab so the # back-to-ticket eyebrow works (pass target: nil). diff --git a/app/models/registration_ticket_callout.rb b/app/models/registration_ticket_callout.rb index 5aad479174..a25c41e073 100644 --- a/app/models/registration_ticket_callout.rb +++ b/app/models/registration_ticket_callout.rb @@ -26,6 +26,10 @@ class RegistrationTicketCallout < ApplicationRecord # so the editor shows no separate control row for them. PREVIEW_EDITED_MAGIC_KEYS = %w[ ce_hours event_details ].freeze + # Behavioral magic callouts that carry admin-editable linked resources on their + # page (e.g. the Forms card's W-9), edited from the control row. + RESOURCE_EDITABLE_MAGIC_KEYS = %w[ forms ].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 # app can reuse them for tinted boxes — amount-due, scholarship box, etc.). @@ -86,6 +90,11 @@ def control_row? behavioral_magic? && PREVIEW_EDITED_MAGIC_KEYS.exclude?(magic_key) end + # Whether the control row also exposes an editable linked-resource picker. + def links_editable_resources? + RESOURCE_EDITABLE_MAGIC_KEYS.include?(magic_key) + end + # Whether the callout is drip-scheduled to appear only from a future date. def dripping?(now = Time.current) display_from.present? && display_from > now diff --git a/app/services/default_ticket_callouts.rb b/app/services/default_ticket_callouts.rb index 97a20f7276..08b3ece871 100644 --- a/app/services/default_ticket_callouts.rb +++ b/app/services/default_ticket_callouts.rb @@ -188,6 +188,9 @@ def definitions icon_class: "fa-solid fa-file-lines", color_class: "blue", hidden: ->(_event) { false }, + # The W-9 is a removable linked resource; invoice/receipt stay dynamic on + # the forms page. Admins remove the W-9 for events where it doesn't apply. + resources: -> { [ Resource.find_by(title: "W-9") ].compact }, seed_if: ->(event) { event.show_forms_callout? } }, { diff --git a/app/views/events/_registration_ticket_control_callout_fields.html.erb b/app/views/events/_registration_ticket_control_callout_fields.html.erb index 53ee78628b..6613862355 100644 --- a/app/views/events/_registration_ticket_control_callout_fields.html.erb +++ b/app/views/events/_registration_ticket_control_callout_fields.html.erb @@ -21,6 +21,17 @@

Appears from <%= f.object.display_from.to_fs(:long) %>.

<% end %> + <% if f.object.links_editable_resources? %> +
+ <%= f.label :resource_ids, "Linked documents", class: "block text-xs font-medium text-gray-600 mb-0.5" %> +

Shown on the forms page alongside the invoice and receipt. The W-9 is included by default — remove it for events where it doesn't apply. Hold ⌘/Ctrl to select more than one.

+ <%= f.collection_select :resource_ids, Resource.order(:title), :id, :title, + {}, + multiple: true, size: 3, + class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %> +
+ <% end %> +
diff --git a/app/views/events/_registration_ticket_control_callout_fields.html.erb b/app/views/events/_registration_ticket_control_callout_fields.html.erb deleted file mode 100644 index 6613862355..0000000000 --- a/app/views/events/_registration_ticket_control_callout_fields.html.erb +++ /dev/null @@ -1,47 +0,0 @@ -<%# Editor row for a behavioral built-in ("magic") callout — payment, certificate, - videoconference, etc. Its card text and live status are app-controlled, so only - its visibility, drip date, and order are editable here: a hide toggle, a drag - handle, and "Restore default". Never removable. %> -
-
- -
- - - -
-
- Built in - App-controlled — shows live status on the ticket. -
-

<%= f.object.title %>

-

<%= f.object.subtitle %>

- <% if f.object.display_from.present? %> -

Appears from <%= f.object.display_from.to_fs(:long) %>.

- <% end %> - - <% if f.object.links_editable_resources? %> -
- <%= f.label :resource_ids, "Linked documents", class: "block text-xs font-medium text-gray-600 mb-0.5" %> -

Shown on the forms page alongside the invoice and receipt. The W-9 is included by default — remove it for events where it doesn't apply. Hold ⌘/Ctrl to select more than one.

- <%= f.collection_select :resource_ids, Resource.order(:title), :id, :title, - {}, - multiple: true, size: 3, - class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %> -
- <% end %> - -
- - <% if f.object.persisted? %> - <%= link_to "Restore default", restore_event_registration_ticket_callout_path(@event, f.object), - data: { turbo_method: :post, turbo_confirm: "Reset this callout to its default visibility? Save any other changes first — this reloads the form." }, - class: "text-sm text-gray-400 hover:text-purple-700 underline" %> - <% end %> -
-
-
diff --git a/app/views/events/_registration_ticket_magic_callout_fields.html.erb b/app/views/events/_registration_ticket_magic_callout_fields.html.erb deleted file mode 100644 index 22ed097a02..0000000000 --- a/app/views/events/_registration_ticket_magic_callout_fields.html.erb +++ /dev/null @@ -1,63 +0,0 @@ -<%# Editor row for a materialized built-in ("magic") callout. Its title, subtitle, - page text, and linked resources are editable, and it can be hidden or restored - to default — but never removed (magic callouts hide instead of delete). %> -
-
- -
- -
-
- Built in - Editable — this app-provided callout also shows live status on the ticket. -
- -
-
-
-
- <%= f.label :title, "Title", class: "block text-xs font-medium text-gray-600 mb-0.5" %> - <%= f.text_field :title, required: true, - class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %> -
-
- <%= f.label :subtitle, "Subtitle", class: "block text-xs font-medium text-gray-600 mb-0.5" %> - <%= f.text_field :subtitle, - class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %> -
-
-
- -
-
- <%= f.label :description, "Callout page text", class: "block text-xs font-medium text-gray-600 mb-0.5" %> -

Shown on its own page. Accepts basic HTML — bold, italics, links, lists, headings, and line breaks.

- <%= f.text_area :description, rows: 4, - class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm font-mono" %> -
- -
- <%= f.label :resource_ids, "Linked resources", class: "block text-xs font-medium text-gray-600 mb-0.5" %> -

Optional. Hold ⌘/Ctrl to select more than one.

- <%= f.collection_select :resource_ids, Resource.order(:title), :id, :title, - {}, multiple: true, size: 4, - class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %> -
-
-
- -
- - <% if f.object.persisted? %> - <%# link (not button_to) so we don't nest a inside the event form %> - <%= link_to "Restore default", restore_event_registration_ticket_callout_path(@event, f.object), - data: { turbo_method: :post, turbo_confirm: "Reset this callout's text and links to the default? Save any other changes first — this reloads the form." }, - class: "text-sm text-gray-400 hover:text-purple-700 underline" %> - <% end %> -
-
-
diff --git a/app/views/events/callouts/_callout_page.html.erb b/app/views/events/callouts/_callout_page.html.erb index d36de874fd..85c81fae24 100644 --- a/app/views/events/callouts/_callout_page.html.erb +++ b/app/views/events/callouts/_callout_page.html.erb @@ -32,6 +32,11 @@
+ <% if @builtin_intro.present? %> +
+ <%= form_label_html(@builtin_intro) %> +
+ <% end %> <%= yield %>
diff --git a/db/schema.rb b/db/schema.rb index c5724d68b8..05981cec33 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1381,7 +1381,7 @@ t.bigint "item_id", null: false t.string "item_type", null: false t.text "object", size: :long - t.text "object_changes", size: :long + t.text "object_changes" t.string "whodunnit" t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id" end diff --git a/spec/requests/events/registration_ticket_callouts_spec.rb b/spec/requests/events/registration_ticket_callouts_spec.rb index 6875246a13..2af0c4c3be 100644 --- a/spec/requests/events/registration_ticket_callouts_spec.rb +++ b/spec/requests/events/registration_ticket_callouts_spec.rb @@ -222,19 +222,33 @@ expect(response.body).to include(restore_event_registration_ticket_callout_path(event, callout)) end - it "renders a behavioral magic callout as a control-only row (hide + restore, no content fields)" do + it "renders a behavioral magic callout with the same editable fields as a custom one" do callout = create(:registration_ticket_callout, event:, magic_key: "certificate", title: "Certificate of completion") get edit_event_path(event) expect(response).to have_http_status(:ok) + expect(response.body).to include("name=\"event[registration_ticket_callouts_attributes][0][title]\"") + expect(response.body).to include("name=\"event[registration_ticket_callouts_attributes][0][description]\"") expect(response.body).to include("name=\"event[registration_ticket_callouts_attributes][0][hidden]\"") - expect(response.body).not_to include("name=\"event[registration_ticket_callouts_attributes][0][description]\"") expect(response.body).to include(restore_event_registration_ticket_callout_path(event, callout)) end - it "gives the Forms control row an editable linked-resource picker" do + it "hides Restore default until 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") + + get edit_event_path(event) + expect(response.body).not_to include(restore_event_registration_ticket_callout_path(event, faq)) + + faq.update!(title: "Our FAQ") + get edit_event_path(event) + expect(response.body).to include(restore_event_registration_ticket_callout_path(event, faq)) + end + + it "gives the Forms card an editable linked-resource picker" do create(:registration_ticket_callout, event:, magic_key: "forms", title: "Forms") get edit_event_path(event) diff --git a/spec/services/default_ticket_callouts_spec.rb b/spec/services/default_ticket_callouts_spec.rb index 047c28efc6..c2ff241e4f 100644 --- a/spec/services/default_ticket_callouts_spec.rb +++ b/spec/services/default_ticket_callouts_spec.rb @@ -82,6 +82,20 @@ expect(forms.resources).to be_empty end + 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") + + expect(described_class.customized?(faq)).to be(false) + + faq.update!(title: "Our questions") + expect(described_class.customized?(faq)).to be(true) + + described_class.reset(faq) + expect(described_class.customized?(faq.reload)).to be(false) + end + it "seeds the FAQ card with the default training content" do event = create(:event) diff --git a/spec/services/magic_ticket_callouts_spec.rb b/spec/services/magic_ticket_callouts_spec.rb index 39210e1dcb..06150e8fac 100644 --- a/spec/services/magic_ticket_callouts_spec.rb +++ b/spec/services/magic_ticket_callouts_spec.rb @@ -162,20 +162,25 @@ def card(reg, title) end describe "#card_for" do - it "builds the live behavioral card for a materialized magic_key" do + it "uses the row's editable presentation but the app's live badge/link" do event.update!(cost_cents: 5_000) - create(:registration_ticket_callout, event:, magic_key: "payment", title: "Payment") + # Admin renamed and recoloured the payment card. + callout = create(:registration_ticket_callout, event:, magic_key: "payment", + title: "Pay your balance", subtitle: "See what you owe", color_class: "green") - card = described_class.new(registration).card_for("payment") - expect(card.title).to eq("Make your payment") - expect(card.badge).to end_with("due") + card = described_class.new(registration).card_for(callout) + expect(card.title).to eq("Pay your balance") # from the row + expect(card.subtitle).to eq("See what you owe") # from the row + expect(card.theme).to eq(DomainTheme.swatch("green")) # from the row + expect(card.badge).to end_with("due") # app-supplied live status end it "returns nil when the card shouldn't show for this registration" do - create(:registration_ticket_callout, event:, magic_key: "certificate", title: "Certificate of completion") + callout = create(:registration_ticket_callout, event:, magic_key: "certificate", + title: "Certificate of completion") # Certificate isn't unlocked (event not ended, not attended). - expect(described_class.new(registration).card_for("certificate")).to be_nil + expect(described_class.new(registration).card_for(callout)).to be_nil end end end From 83dd53efc17ab06a1c9089f57ea9709031470a99 Mon Sep 17 00:00:00 2001 From: maebeale Date: Fri, 10 Jul 2026 07:20:58 -0400 Subject: [PATCH 08/32] Edit callout resources one dropdown at a time (add-another) Replace the clunky multi-select of all resources with the same add/remove pattern used for Sectors on Person edit: one document dropdown per linked resource, plus an "Add resource" link (cocoon nested attributes on the ordered join). Applies to custom callouts and the built-ins that link resources (Handouts, FAQ, Forms). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/registration_ticket_callout.rb | 5 +++++ app/policies/event_policy.rb | 3 ++- .../_registration_ticket_callout_fields.html.erb | 16 ++++++++++------ ...ation_ticket_callout_resource_fields.html.erb | 9 +++++++++ .../events/registration_ticket_callouts_spec.rb | 12 ++++++++---- 5 files changed, 34 insertions(+), 11 deletions(-) create mode 100644 app/views/events/_registration_ticket_callout_resource_fields.html.erb diff --git a/app/models/registration_ticket_callout.rb b/app/models/registration_ticket_callout.rb index 81697d1d34..1e644c9860 100644 --- a/app/models/registration_ticket_callout.rb +++ b/app/models/registration_ticket_callout.rb @@ -50,6 +50,11 @@ class RegistrationTicketCallout < ApplicationRecord inverse_of: :registration_ticket_callout has_many :resources, through: :registration_ticket_callout_resources + # Linked resources are added one dropdown at a time in the editor (cocoon + # add/remove), like Sectors on a Person. Blank picks are dropped. + accepts_nested_attributes_for :registration_ticket_callout_resources, allow_destroy: true, + reject_if: proc { |attrs| attrs["resource_id"].blank? } + # Per-event ordering, drag-reordered after save via the shared `sortable` # Stimulus controller (a per-row PUT to #update). The gem reflows the other # callouts' positions on each move, exactly like Category. It assigns position diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index 88a0efb713..f6dabac35d 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -159,7 +159,8 @@ 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, :payment_access_gated, :hidden, :_destroy, { resource_ids: [] } ] + registration_ticket_callouts_attributes: [ :id, :title, :subtitle, :description, :callout_type, :icon_class, :color_class, :payment_access_gated, :hidden, :_destroy, + { registration_ticket_callout_resources_attributes: [ :id, :resource_id, :_destroy ] } ] ] permitted.prepend(:ga4_snippet, :gtm_head_snippet, :gtm_body_snippet) if admin? diff --git a/app/views/events/_registration_ticket_callout_fields.html.erb b/app/views/events/_registration_ticket_callout_fields.html.erb index 56852a3984..8ac255ba5c 100644 --- a/app/views/events/_registration_ticket_callout_fields.html.erb +++ b/app/views/events/_registration_ticket_callout_fields.html.erb @@ -81,12 +81,16 @@ <% if f.object.renders_resources? %>
- <%= f.label :resource_ids, "Linked resources", class: "block text-xs font-medium text-gray-600 mb-0.5" %> -

Optional. Shows each resource below the content on the call-out's page (PDF first-page preview, etc.), with a download button when the resource has a downloadable file. Hold ⌘/Ctrl to select more than one.

- <%= f.collection_select :resource_ids, Resource.order(:title), :id, :title, - {}, - multiple: true, size: 4, - class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %> + Linked resources +

Optional. Shows each resource below the content on the call-out's page (PDF first-page preview, etc.), with a download button when the resource has a downloadable file.

+
+ <%= f.fields_for :registration_ticket_callout_resources do |rf| %> + <%= render "events/registration_ticket_callout_resource_fields", f: rf %> + <% end %> +
+ <%= link_to_add_association "➕ Add resource", f, :registration_ticket_callout_resources, + partial: "events/registration_ticket_callout_resource_fields", + class: "mt-1 inline-block text-sm font-medium text-purple-600 hover:text-purple-800" %>
<% end %> diff --git a/app/views/events/_registration_ticket_callout_resource_fields.html.erb b/app/views/events/_registration_ticket_callout_resource_fields.html.erb new file mode 100644 index 0000000000..2dc9a1d2a8 --- /dev/null +++ b/app/views/events/_registration_ticket_callout_resource_fields.html.erb @@ -0,0 +1,9 @@ +<%# One linked-resource row: a single document dropdown + remove, added another + at a time (cocoon), mirroring the Sectors picker on Person edit. %> +
+ <%= f.collection_select :resource_id, Resource.order(:title), :id, :title, + { include_blank: "Select a document…" }, + class: "flex-1 min-w-0 rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %> + <%= link_to_remove_association "✖", f, + class: "shrink-0 text-gray-400 hover:text-red-600 font-bold text-xs" %> +
diff --git a/spec/requests/events/registration_ticket_callouts_spec.rb b/spec/requests/events/registration_ticket_callouts_spec.rb index 2af0c4c3be..549b332c18 100644 --- a/spec/requests/events/registration_ticket_callouts_spec.rb +++ b/spec/requests/events/registration_ticket_callouts_spec.rb @@ -136,7 +136,8 @@ start_date: event.start_date, end_date: event.end_date, registration_ticket_callouts_attributes: { - "0" => { title: "Workbook", callout_type: "reference", resource_ids: [ resource.id ] } + "0" => { title: "Workbook", callout_type: "reference", + registration_ticket_callout_resources_attributes: { "0" => { resource_id: resource.id } } } } } } @@ -248,13 +249,16 @@ expect(response.body).to include(restore_event_registration_ticket_callout_path(event, faq)) end - it "gives the Forms card an editable linked-resource picker" do - create(:registration_ticket_callout, event:, magic_key: "forms", title: "Forms") + it "gives the Forms card an add-another linked-resource picker" do + resource = create(:resource, title: "W-9") + create(:registration_ticket_callout, event:, magic_key: "forms", title: "Forms", resources: [ resource ]) get edit_event_path(event) expect(response).to have_http_status(:ok) - expect(response.body).to include("name=\"event[registration_ticket_callouts_attributes][0][resource_ids][]\"") + # One dropdown per linked resource, plus an "Add resource" link (cocoon). + expect(response.body).to match(/registration_ticket_callout_resources_attributes\]\[\d+\]\[resource_id\]/) + expect(response.body).to include("Add resource") end end end From 3774be3b52204b11bebcd29fd1f4ed46bec49865 Mon Sep 17 00:00:00 2001 From: maebeale Date: Fri, 10 Jul 2026 07:34:29 -0400 Subject: [PATCH 09/32] Polish callout page-text editor: hints, copy, collapsible resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the "Accepts basic HTML…" note to a small hint under the textarea (shared callout row + the CE/event-details built-in card). - Callout page text help now reads "The callout card only opens a page if this is filled out." - Remove the example placeholder text from the callout editor fields. - Linked resources collapse behind a chevron toggle, opened on load only when the callout already has saved resources. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../events/_builtin_callout_card.html.erb | 7 ++-- app/views/events/_form.html.erb | 8 +--- ...egistration_ticket_callout_fields.html.erb | 38 +++++++++++-------- 3 files changed, 28 insertions(+), 25 deletions(-) diff --git a/app/views/events/_builtin_callout_card.html.erb b/app/views/events/_builtin_callout_card.html.erb index 77b4e6f7d6..d51d5b0e42 100644 --- a/app/views/events/_builtin_callout_card.html.erb +++ b/app/views/events/_builtin_callout_card.html.erb @@ -2,7 +2,7 @@ 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 - symbols), title_placeholder, content_help, content_placeholder. %> + symbols), content_help. %> <% theme = card.theme %>
@@ -14,14 +14,15 @@
<%= f.label label_field, "Title", class: "block text-xs font-medium text-gray-600 mb-0.5" %> - <%= f.text_area label_field, rows: 2, placeholder: title_placeholder, + <%= f.text_area label_field, rows: 2, class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %>
<%= f.label content_field, "Callout page text", class: "block text-xs font-medium text-gray-600 mb-0.5" %>

<%= content_help %>

- <%= f.text_area content_field, rows: 3, placeholder: content_placeholder, + <%= f.text_area content_field, rows: 3, class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm font-mono" %> +

Accepts basic HTML — bold, italics, links, lists, headings, and line breaks.

diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index ae68ee465d..a66175e899 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -510,9 +510,7 @@ <% when :ce_hours %> <%= render "events/builtin_callout_card", f: f, card: card, label_field: :ce_hours_details_label, content_field: :ce_hours_details, - title_placeholder: "CE hours", - content_help: "CE requirements, payment, sign-in rules, and the post-training evaluation — shown on its own page linked from the ticket. Accepts basic HTML — bold, italics, links, lists, headings, and line breaks.", - content_placeholder: "e.g.

AWBW is approved by CAMFT…

Before the training

  • Email your license number
" %> + content_help: "CE requirements, payment, sign-in rules, and the post-training evaluation — shown on its own page linked from the ticket." %>
<% end %> <%= yield %> + + <% if @builtin_resources.present? %> +
+ <% @builtin_resources.each do |resource| %> + <%= render "events/callouts/resource_body", resource: resource.decorate %> + <% end %> +
+ <% end %> diff --git a/spec/requests/events/callouts_spec.rb b/spec/requests/events/callouts_spec.rb index c2a97c1bfc..9ad496aa83 100644 --- a/spec/requests/events/callouts_spec.rb +++ b/spec/requests/events/callouts_spec.rb @@ -81,4 +81,20 @@ end end end + + describe "GET /registration/:slug/videoconference" do + let(:event) { create(:event, videoconference_url: "https://example.com/zoom") } + + it "renders resources linked to the built-in callout below the content" do + resource = create(:resource) + create(:downloadable_asset, owner: resource) + create(:registration_ticket_callout, event:, magic_key: "videoconference", + title: "Videoconference", resources: [ resource ]) + + get registration_videoconference_path(registration.slug) + + expect(response).to have_http_status(:success) + expect(response.body).to include(rails_blob_path(resource.downloadable_asset.file, only_path: true)) + end + end end diff --git a/spec/requests/events/registration_ticket_callouts_spec.rb b/spec/requests/events/registration_ticket_callouts_spec.rb index 549b332c18..8e9192e6fc 100644 --- a/spec/requests/events/registration_ticket_callouts_spec.rb +++ b/spec/requests/events/registration_ticket_callouts_spec.rb @@ -233,6 +233,7 @@ expect(response.body).to include("name=\"event[registration_ticket_callouts_attributes][0][title]\"") expect(response.body).to include("name=\"event[registration_ticket_callouts_attributes][0][description]\"") expect(response.body).to include("name=\"event[registration_ticket_callouts_attributes][0][hidden]\"") + expect(response.body).to include("Add resource") # every built-in can link resources now expect(response.body).to include(restore_event_registration_ticket_callout_path(event, callout)) end From c4ec7d0c5b9655993efbe95ffbc328e0c9ddba25 Mon Sep 17 00:00:00 2001 From: maebeale Date: Fri, 10 Jul 2026 08:52:56 -0400 Subject: [PATCH 11/32] Fold CE hours and Event details into the sortable callout list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CE hours and art supplies (event details) now edit inline in the same reorderable row as every other callout, so they can be positioned and can link resources like the rest. Their text still lives on the event (shared with the standalone CE / details pages), edited via the passed-in event form builder, so those registrant pages are unchanged; the callout row owns their order, hidden flag, and linked resources. - Shared partial branches on content_on_event? — CE/event-details render the event-column label/text (+ CE hours/cost) via event_f; all others use the row. - card_for keeps their app-supplied, event-driven title (fixes a case where renaming them no longer reflected on the ticket). - Both always seed so the editor row is available; built-ins also materialize lazily on edit so the editor shows the full set. Removed the separate built-in preview card partial. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- app/controllers/events_controller.rb | 3 + app/models/registration_ticket_callout.rb | 17 ++- app/services/default_ticket_callouts.rb | 16 +-- app/services/magic_ticket_callouts.rb | 5 +- .../events/_builtin_callout_card.html.erb | 29 ---- app/views/events/_form.html.erb | 92 +++---------- ...egistration_ticket_callout_fields.html.erb | 127 ++++++++++++------ .../registration_ticket_callouts_spec.rb | 2 +- spec/services/default_ticket_callouts_spec.rb | 15 ++- 10 files changed, 139 insertions(+), 169 deletions(-) delete mode 100644 app/views/events/_builtin_callout_card.html.erb diff --git a/AGENTS.md b/AGENTS.md index c0e37753c8..cd0773dfb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,7 +206,7 @@ end - `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, forms, 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`). Certificate is opt-in (default off except trainings); Videoconference drips a week before start via `display_from`. CE hours and Event details keep their text on event columns (edited via their built-in preview card above the list). `.reset` restores a materialized callout to its template +- `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 (`content_on_event?`) keep their text on event columns (edited inline in the shared row via `event_f`, so registrant CE/details pages are unchanged) but the row still owns their order/hidden/resources; they always seed. Built-ins materialize lazily on `edit` too, so the editor always shows the full set. `.reset` restores a materialized callout to its template (`restorable?` excludes the event-column pair) ### Affiliations diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index f9d2d4db0b..cef05f262b 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -35,6 +35,9 @@ def new 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) set_form_variables end diff --git a/app/models/registration_ticket_callout.rb b/app/models/registration_ticket_callout.rb index bf1c640cca..94a34c3153 100644 --- a/app/models/registration_ticket_callout.rb +++ b/app/models/registration_ticket_callout.rb @@ -89,11 +89,18 @@ def behavioral_magic? magic? && CONTENT_MAGIC_KEYS.exclude?(magic_key) end - # Whether this callout is edited in the shared callout-fields editor row (all - # callouts except CE hours / Event details, whose text lives on the event and is - # edited via their built-in preview card above the list). - def editable_in_list? - PREVIEW_EDITED_MAGIC_KEYS.exclude?(magic_key.to_s) + # CE hours / Event details keep their text on the event (shared with their + # standalone CE / details pages), so the editor row edits those event columns + # instead of the callout row's title/description — but the row still owns their + # order, hidden flag, and linked resources. + def content_on_event? + PREVIEW_EDITED_MAGIC_KEYS.include?(magic_key.to_s) + end + + # Whether the editor offers "Restore default" — a built-in whose content lives + # on the row (so it has a template to restore to). + def restorable? + magic? && !content_on_event? end # Every callout can link resources; they render on the callout's page (its own diff --git a/app/services/default_ticket_callouts.rb b/app/services/default_ticket_callouts.rb index 8f4b7a569b..d0bc3fb715 100644 --- a/app/services/default_ticket_callouts.rb +++ b/app/services/default_ticket_callouts.rb @@ -170,10 +170,10 @@ def definitions callout_type: "action", icon_class: "fa-solid fa-graduation-cap", color_class: "teal", - # Content (label + details) stays on the event and is edited in the - # callouts section's built-in card; the row only governs order/drip. - hidden: ->(_event) { false }, - seed_if: ->(event) { event.ce_hours_offered.present? } + # Content (label + details) stays on the event and is edited inline in the + # callout row; the row governs order, hidden, and linked resources. Always + # seeded so the row is available to edit even before hours are set. + hidden: ->(_event) { false } }, { magic_key: "event_details", @@ -182,10 +182,10 @@ def definitions callout_type: "reference", icon_class: "fa-solid fa-palette", color_class: "blue", - # Content (label + body) stays on the event and is edited in the built-in - # card; the row only governs order/drip. - hidden: ->(_event) { false }, - seed_if: ->(event) { event.event_details.present? } + # Content (label + body) stays on the event and is edited inline in the + # callout row; the row governs order, hidden, and linked resources. Always + # seeded so the row is available to edit even before content is filled in. + hidden: ->(_event) { false } }, { magic_key: "videoconference", diff --git a/app/services/magic_ticket_callouts.rb b/app/services/magic_ticket_callouts.rb index a159d22652..3e80bb0ebc 100644 --- a/app/services/magic_ticket_callouts.rb +++ b/app/services/magic_ticket_callouts.rb @@ -46,8 +46,6 @@ def self.editor_cards(event) 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), EditorCard.new(nil, "scholarship", "fa-solid fa-award", "fuchsia", "Scholarship", "Your scholarship request and award", "When the registrant requested a scholarship", nil), - EditorCard.new(:ce_hours, "ce_hours", "fa-solid fa-graduation-cap", "teal", event.ce_hours_details_label, "Continuing education — requirements & how to request", "When a registrant requests CE credit", nil), - EditorCard.new(:event_details, "event_details", "fa-solid fa-palette", "blue", event.event_details_label, "Important info for this event — please read", "When the content below is filled in", nil), 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, "forms", "fa-solid fa-file-lines", "blue", "Forms", "W-9, invoice, and receipt", "On facilitator trainings and paid events", "Items link to their relevant resources."), 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."), @@ -91,6 +89,9 @@ def card_for(callout) builder = CARD_BUILDERS[callout.magic_key] base = builder && send(builder) return unless base + # CE hours / event details keep their app-supplied, event-driven title and + # subtitle (their text lives on the event, not the row). + return base if callout.content_on_event? base.with( title: callout.title, diff --git a/app/views/events/_builtin_callout_card.html.erb b/app/views/events/_builtin_callout_card.html.erb deleted file mode 100644 index d51d5b0e42..0000000000 --- a/app/views/events/_builtin_callout_card.html.erb +++ /dev/null @@ -1,29 +0,0 @@ -<%# 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 - symbols), content_help. %> -<% theme = card.theme %> -
- -
-
- Built in - <%= card.visibility %> -
-
-
- <%= f.label label_field, "Title", class: "block text-xs font-medium text-gray-600 mb-0.5" %> - <%= f.text_area label_field, rows: 2, - class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %> -
-
- <%= f.label content_field, "Callout page text", class: "block text-xs font-medium text-gray-600 mb-0.5" %> -

<%= content_help %>

- <%= f.text_area content_field, rows: 3, - class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm font-mono" %> -

Accepts basic HTML — bold, italics, links, lists, headings, and line breaks.

-
-
-
-
diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index a66175e899..6d13f25b4e 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -500,90 +500,36 @@ <% end %> + <%# Live preview of how this callout appears on the ticket — the same card + registrants see (icon, title, subtitle, themed colour). Seeded from the + record and repainted by the callout-preview controller as the admin edits + the title, subtitle, type, colour, or icon. The badge and live status the + app supplies at render time aren't shown here. %> + <% preview_theme = f.object.theme %> +
+ +
+

<%= f.object.title %>

+

" + data-callout-preview-target="capperSubtitle"><%= f.object.subtitle %>

+
+ +
+ <%# Two columns that collapse to one as the viewport narrows: the call-out's identity (title + presentation) on the left, its page content on the right. %>
-
- - +
@@ -102,7 +119,7 @@ class: "w-full rounded border-gray-300 bg-white shadow-sm px-2 py-1 text-sm", data: { callout_preview_target: "colorSelect", action: "callout-preview#update" } %> <% if f.object.app_colored? %> -

The app overrides this with a live-status colour when action is needed (e.g. orange while a balance is due).

+

The app overrides this with a live-status colour when the registrant has action to take (e.g. amber while something's outstanding, orange while a balance is due).

<% end %>
diff --git a/spec/services/magic_ticket_callouts_spec.rb b/spec/services/magic_ticket_callouts_spec.rb index 167f7071b1..ea005e6fbe 100644 --- a/spec/services/magic_ticket_callouts_spec.rb +++ b/spec/services/magic_ticket_callouts_spec.rb @@ -94,7 +94,7 @@ def card(reg, title) it "shows an amber 'what's needed' CE badge until complete, then a teal amount due" do registration.update!(ce_credit_requested: true, ce_hours_requested: nil, ce_license_number: nil) both = card(registration, event.ce_hours_details_label) - expect(both.theme).to eq(DomainTheme.swatch("teal")) + expect(both.theme).to eq(DomainTheme.swatch("amber")) # action needed expect(both.subtitle).to eq("Continuing education credit") expect(both.badge).to eq("Hours & license number needed") expect(both.badge_classes).to be_nil @@ -110,6 +110,7 @@ def card(reg, title) registration.update!(ce_hours_requested: 6, ce_license_number: "LIC123") complete = card(registration, event.ce_hours_details_label) + expect(complete.theme).to eq(DomainTheme.swatch("teal")) # no longer action needed expect(complete.subtitle).to eq("6 hours") expect(complete.badge).to eq("$150 due") expect(complete.badge_classes).to include("teal") @@ -121,6 +122,15 @@ def card(reg, title) scholarship_card = card(registration, "Scholarship") expect(scholarship_card.subtitle).to eq("Your scholarship request status") expect(scholarship_card.badge).to be_nil + expect(scholarship_card.theme).to eq(DomainTheme.swatch(DomainTheme.color_for(:scholarships))) + end + + it "turns the scholarship card amber while award tasks are outstanding" do + registration.update!(scholarship_requested: true) + scholarship = create(:scholarship, recipient: registration.registrant, tasks_completed: false) + create(:allocation, source: scholarship, allocatable: registration, amount: 1000) + + expect(card(registration, "Scholarship").theme).to eq(DomainTheme.swatch("amber")) end it "flags an awarded scholarship with outstanding tasks in an amber chip" do From 08ab050b55df5e6bfb6659603b24f9651edbb2fd Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:32:14 -0400 Subject: [PATCH 25/32] make index unique --- db/migrate/20260712183112_unique_magic_key_event_index.rb | 6 ++++++ db/schema.rb | 6 +++--- 2 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20260712183112_unique_magic_key_event_index.rb diff --git a/db/migrate/20260712183112_unique_magic_key_event_index.rb b/db/migrate/20260712183112_unique_magic_key_event_index.rb new file mode 100644 index 0000000000..1bae2c0ff2 --- /dev/null +++ b/db/migrate/20260712183112_unique_magic_key_event_index.rb @@ -0,0 +1,6 @@ +class UniqueMagicKeyEventIndex < ActiveRecord::Migration[8.1] + def change + remove_index :registration_ticket_callouts, column: [:event_id, :magic_key] + add_index :registration_ticket_callouts, [:event_id, :magic_key], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 239ca2a9c4..ee633774d4 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_11_193528) do +ActiveRecord::Schema[8.1].define(version: 2026_07_12_183112) 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 @@ -1102,7 +1102,7 @@ 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" + t.index ["event_id", "magic_key"], name: "index_registration_ticket_callouts_on_event_id_and_magic_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 @@ -1377,7 +1377,7 @@ t.bigint "item_id", null: false t.string "item_type", null: false t.text "object", size: :long - t.text "object_changes" + t.text "object_changes", size: :long t.string "whodunnit" t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id" end From 5e3c73541bcf258737c0a49b50014426843055bf Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:59:44 -0400 Subject: [PATCH 26/32] show/hidde callout edit on publish checkbox --- ...egistration_ticket_callout_fields.html.erb | 278 +++++++++--------- 1 file changed, 139 insertions(+), 139 deletions(-) diff --git a/app/views/events/_registration_ticket_callout_fields.html.erb b/app/views/events/_registration_ticket_callout_fields.html.erb index 1b26a064d6..26ef077e39 100644 --- a/app/views/events/_registration_ticket_callout_fields.html.erb +++ b/app/views/events/_registration_ticket_callout_fields.html.erb @@ -18,146 +18,163 @@
<% end %> -
- <% if f.object.magic? %> -
- Built in - Pre-filled defaults you can edit; the app supplies its live status and links. -
- <% end %> +
+ <%# Peer checkbox (first sibling) drives show/hide of the editable content below. %> + <%= f.check_box :published, class: "peer h-5 w-5 rounded border-gray-300 text-purple-600" %> - <%# Live preview of how this callout appears on the ticket — the same card - registrants see (icon, title, subtitle, themed colour). Seeded from the - record and repainted by the callout-preview controller as the admin edits - the title, subtitle, type, colour, or icon. The badge and live status the - app supplies at render time aren't shown here. %> - <% preview_theme = f.object.theme %> -
- -
-

<%= f.object.title %>

-

" - data-callout-preview-target="capperSubtitle"><%= f.object.subtitle %>

-
- + <%# Always-visible summary row: sits inline next to the checkbox so you + know which callout you are toggling even when it is hidden. %> +
+ <% if f.object.magic? %> + Built in + <% end %> + <%= f.label :published, "Published", class: "text-gray-600 cursor-pointer select-none" %> + <%= f.object.title %> + <% if f.object.magic? %> + <% if f.object.persisted? && DefaultTicketCallouts.customized?(f.object) %> + + <% else %> + Matches default + <% end %> + <% end %>
- <%# Two columns that collapse to one as the viewport narrows: the call-out's - identity (title + presentation) on the left, its page content on the right. %> -
-
-
-
-
- <%= f.label :title, "Title", class: "block text-xs font-medium text-gray-600 mb-0.5" %> - <%= f.text_field :title, required: true, - class: "w-full rounded border-gray-300 bg-white shadow-sm px-2 py-1 text-sm" %> -
-
- <%= f.label :subtitle, "Subtitle", class: "block text-xs font-medium text-gray-600 mb-0.5" %> - <%= f.text_field :subtitle, - class: "w-full rounded border-gray-300 bg-white shadow-sm px-2 py-1 text-sm" %> -
-
+ <%# Editable content — hidden when the Published checkbox is unchecked. %> + -
<% unless f.object.magic? %>
-
- <% if f.object.magic? %> - <% if f.object.persisted? && DefaultTicketCallouts.customized?(f.object) %> - <%# Applied on the main Save, not a separate request. %> - - <% else %> - Matches default - <% end %> - <% end %> - -
From b1fe8fe2453b9329c12958b8bba434e80b733b26 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:06:30 -0400 Subject: [PATCH 27/32] ui- add callout name to checkbox row --- ...egistration_ticket_callout_fields.html.erb | 149 ++++++++++-------- 1 file changed, 81 insertions(+), 68 deletions(-) diff --git a/app/views/events/_registration_ticket_callout_fields.html.erb b/app/views/events/_registration_ticket_callout_fields.html.erb index 26ef077e39..a575705946 100644 --- a/app/views/events/_registration_ticket_callout_fields.html.erb +++ b/app/views/events/_registration_ticket_callout_fields.html.erb @@ -1,97 +1,96 @@ -<%# One editable callout row, shared by custom callouts and every materialized - built-in. CE hours additionally shows the event's hours-offered/cost config - inline (via event_f); its text, like every other built-in, lives on the row. %> +<%# + One editable callout row, shared by custom callouts and every materialized + built-in. CE hours additionally shows the event's hours-offered/cost config + inline (via event_f); its text, like every other built-in, lives on the row. +%> <% event_f = local_assigns[:event_f] %> -
data-sortable-id="<%= f.object.id %>"<% end %>> +
-
- -
+ data-sortable-id="<%= f.object.id %>" + <% end %> +> + <% if f.object.persisted? %> +
<% else %> -
- -
+
<% end %>
- <%# Peer checkbox (first sibling) drives show/hide of the editable content below. %> - <%= f.check_box :published, class: "peer h-5 w-5 rounded border-gray-300 text-purple-600" %> + <%= f.check_box :published, class: "peer rounded border-gray-300 text-purple-600" %> + + <%# + Always-visible summary row: sits inline next to the checkbox so you + know which callout you are toggling even when it is hidden. + %> - <%# Always-visible summary row: sits inline next to the checkbox so you - know which callout you are toggling even when it is hidden. %>
- <% if f.object.magic? %> - Built in - <% end %> - <%= f.label :published, "Published", class: "text-gray-600 cursor-pointer select-none" %> <%= f.object.title %> - <% if f.object.magic? %> - <% if f.object.persisted? && DefaultTicketCallouts.customized?(f.object) %> - - <% else %> - Matches default - <% end %> - <% end %>
<%# Editable content — hidden when the Published checkbox is unchecked. %>