From b40d44703e937b353eff1e08ccee0969cfd8d091 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 22 Jun 2026 00:44:13 -0400 Subject: [PATCH] Add "Download all" button to forms and handouts callout pages Registrants previously had to open and download each document one at a time. A single header-bar button now fires every file in the group: the handout PDFs, and on the forms page the W-9 and letter (the invoice has no stored PDF, so it opens its print-to-PDF page in a new tab). Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 3 +- app/controllers/events/callouts_controller.rb | 26 +++++++--- .../controllers/download_all_controller.js | 34 +++++++++++++ app/frontend/javascript/controllers/index.js | 3 ++ .../events/callouts/_callout_page.html.erb | 3 ++ .../callouts/_download_all_button.html.erb | 11 +++++ app/views/events/callouts/forms.html.erb | 5 ++ app/views/events/callouts/handouts.html.erb | 5 ++ spec/requests/events/callouts_spec.rb | 48 +++++++++++++++++++ 9 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 app/frontend/javascript/controllers/download_all_controller.js create mode 100644 app/views/events/callouts/_download_all_button.html.erb create mode 100644 spec/requests/events/callouts_spec.rb diff --git a/AGENTS.md b/AGENTS.md index fdfd5e7722..9115ac6bfa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ This codebase (Rails 8.1) | Directory | Purpose | |---|---| | `app/frontend/entrypoints/` | Vite entry points (application.js, application.css) | -| `app/frontend/javascript/controllers/` | Stimulus controllers (73) | +| `app/frontend/javascript/controllers/` | Stimulus controllers (74) | | `app/frontend/javascript/rhino/` | Rich text editor customizations (mentions, grid) | | `app/frontend/stylesheets/` | Tailwind CSS and component styles | @@ -280,6 +280,7 @@ end - `confirm_email` — Email confirmation UI - `dirty_form` — Unsaved changes detection - `dismiss` — Dismissable elements +- `download_all` — Fires a batch of file downloads from one "Download all" button (callout forms/handouts pages); new-tab items open instead of downloading - `dropdown` — Dropdown menus with keyboard/click-outside handling - `event_staff_bio` — Loads a selected person's read-only profile bio (with edit link) alongside the editable event-specific bio on the staff form - `file_preview` — File upload preview diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index 5e0330662a..953c2e3479 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -50,7 +50,9 @@ def ce # Forms page: callout-card links to the W-9 (when requested), invoice (when # requested), and the letter to supervisors resource — each opens in a new tab. def forms - @form_cards = build_form_cards + letter = Resource.find_by(title: "Letter to Supervisors") + @form_cards = build_form_cards(letter) + @download_all_items = build_form_download_items(letter) end # Handouts page: callout-card links to the training worksheet/handout @@ -58,13 +60,16 @@ def forms # (PDF preview + download, with a back-to-ticket eyebrow). def handouts by_title = Resource.where(title: HANDOUT_RESOURCE_TITLES).index_by(&:title) - @handout_cards = HANDOUT_RESOURCE_TITLES.filter_map do |title| - resource = by_title[title] - next unless resource + resources = HANDOUT_RESOURCE_TITLES.filter_map { |title| by_title[title] } + @handout_cards = resources.map do |resource| resource_card(icon: "fa-solid fa-file-pdf", title: resource.title, subtitle: "Open this training resource", href: registration_resource_path(@event_registration.slug, resource), target: nil) end + @download_all_items = resources.filter_map do |resource| + next unless resource.downloadable_asset&.file&.attached? + { href: resource_download_path(resource) } + end end # Registrant-facing page for a single Resource, shown in the shared callout @@ -104,7 +109,7 @@ def set_event # Builds the callout-card links shown on the forms page. The W-9 and invoice # are always available; the letter to supervisors follows when seeded. - def build_form_cards + def build_form_cards(letter) cards = [ resource_card(icon: "fa-solid fa-file-pdf", title: "Download W-9", subtitle: "AWBW's W-9 tax form for your records", @@ -113,7 +118,6 @@ def build_form_cards subtitle: "Itemized invoice for this registration", href: registration_invoice_path(@event_registration.slug)) ] - letter = Resource.find_by(title: "Letter to Supervisors") if letter cards << resource_card(icon: "fa-solid fa-file-arrow-down", title: "Letter to supervisors", subtitle: "Share to request release time", @@ -122,6 +126,16 @@ def build_form_cards cards end + # Items for the forms "Download all" button: the W-9 and (when attached) the + # letter download directly; the invoice has no stored PDF, so it opens its + # print-to-PDF page in a new tab. + def build_form_download_items(letter) + items = [ { href: "/documents/awbw-w9.pdf" } ] + items << { href: resource_download_path(letter) } if letter&.downloadable_asset&.file&.attached? + items << { href: registration_invoice_path(@event_registration.slug), newTab: true } + items + 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/frontend/javascript/controllers/download_all_controller.js b/app/frontend/javascript/controllers/download_all_controller.js new file mode 100644 index 0000000000..968527006e --- /dev/null +++ b/app/frontend/javascript/controllers/download_all_controller.js @@ -0,0 +1,34 @@ +import { Controller } from "@hotwired/stimulus" + +// Fires a batch of downloads from a single "Download all" button. Each item is +// { href, newTab } — file URLs (W-9, handout/letter PDFs) download in place via a +// temporary anchor; newTab items (the print-to-PDF invoice page, which has no +// stored file) open in a new tab. Downloads are staggered so browsers don't +// suppress the later ones in the burst. +// +// +export default class extends Controller { + static values = { items: Array, delay: { type: Number, default: 400 } } + + start() { + this.itemsValue.forEach((item, index) => { + setTimeout(() => this.trigger(item), index * this.delayValue) + }) + } + + trigger(item) { + if (item.newTab) { + window.open(item.href, "_blank", "noopener") + return + } + const link = document.createElement("a") + link.href = item.href + link.download = "" + link.rel = "noopener" + document.body.appendChild(link) + link.click() + link.remove() + } +} diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index e3d27aa94f..3a2a3861a4 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -60,6 +60,9 @@ application.register("dirty-form", DirtyFormController) import DismissController from "./dismiss_controller" application.register("dismiss", DismissController) +import DownloadAllController from "./download_all_controller" +application.register("download-all", DownloadAllController) + import FlipCardController from "./flip_card_controller" application.register("flip-card", FlipCardController) diff --git a/app/views/events/callouts/_callout_page.html.erb b/app/views/events/callouts/_callout_page.html.erb index 374248c01d..890beef833 100644 --- a/app/views/events/callouts/_callout_page.html.erb +++ b/app/views/events/callouts/_callout_page.html.erb @@ -25,6 +25,9 @@

<%= title %>

<%= @event.title %>

+ <% if content_for?(:header_action) %> +
<%= yield :header_action %>
+ <% end %>
diff --git a/app/views/events/callouts/_download_all_button.html.erb b/app/views/events/callouts/_download_all_button.html.erb new file mode 100644 index 0000000000..d8d6726c9d --- /dev/null +++ b/app/views/events/callouts/_download_all_button.html.erb @@ -0,0 +1,11 @@ +<%# "Download all" header action for callout pages that bundle several files. + items: array of { href:, newTab: } hashes (see CalloutsController). Fires each + download in turn via the download-all Stimulus controller. %> +<%= button_tag type: "button", "aria-label": "Download all", + class: "inline-flex items-center gap-2 rounded-lg bg-white/15 px-3 py-1.5 text-sm font-medium text-white ring-1 ring-inset ring-white/30 hover:bg-white/25 transition-colors", + data: { controller: "download-all", + download_all_items_value: items, + action: "download-all#start" } do %> + + +<% end %> diff --git a/app/views/events/callouts/forms.html.erb b/app/views/events/callouts/forms.html.erb index 2ef1f43f17..3af0416e49 100644 --- a/app/views/events/callouts/forms.html.erb +++ b/app/views/events/callouts/forms.html.erb @@ -1,5 +1,10 @@ <% content_for(:page_bg_class, "public") %> <% content_for(:page_title, "Forms — #{@event.title}") %> +<% if @download_all_items.any? %> + <% content_for(:header_action) do %> + <%= render "events/callouts/download_all_button", items: @download_all_items %> + <% end %> +<% end %> <%= render layout: "events/callouts/callout_page", locals: { title: "Forms" } do %> <% if @form_cards.any? %>
diff --git a/app/views/events/callouts/handouts.html.erb b/app/views/events/callouts/handouts.html.erb index 4b6a5e6ac4..2479779c96 100644 --- a/app/views/events/callouts/handouts.html.erb +++ b/app/views/events/callouts/handouts.html.erb @@ -1,5 +1,10 @@ <% content_for(:page_bg_class, "public") %> <% content_for(:page_title, "Handouts — #{@event.title}") %> +<% if @download_all_items.any? %> + <% content_for(:header_action) do %> + <%= render "events/callouts/download_all_button", items: @download_all_items %> + <% end %> +<% end %> <%= render layout: "events/callouts/callout_page", locals: { title: "Handouts" } do %> <% if @handout_cards.any? %>
diff --git a/spec/requests/events/callouts_spec.rb b/spec/requests/events/callouts_spec.rb new file mode 100644 index 0000000000..c7ea19116b --- /dev/null +++ b/spec/requests/events/callouts_spec.rb @@ -0,0 +1,48 @@ +require "rails_helper" + +RSpec.describe "Events::Callouts", type: :request do + let(:event) { create(:event) } + let(:registration) { create(:event_registration, event: event) } + + describe "GET /registration/:slug/forms" do + it "renders a Download all button bundling the W-9 and the invoice" do + get registration_forms_path(registration.slug) + + expect(response).to have_http_status(:success) + expect(response.body).to include("download-all#start") + expect(response.body).to include("/documents/awbw-w9.pdf") + expect(response.body).to include(registration_invoice_path(registration.slug)) + end + + it "includes the letter download when the resource has an attached file" do + letter = create(:resource, title: "Letter to Supervisors") + create(:downloadable_asset, owner: letter) + + get registration_forms_path(registration.slug) + + expect(response.body).to include(resource_download_path(letter)) + end + end + + describe "GET /registration/:slug/handouts" do + let(:title) { Events::CalloutsController::HANDOUT_RESOURCE_TITLES.first } + + it "renders a Download all button with each attached handout" do + handout = create(:resource, title: title) + create(:downloadable_asset, owner: handout) + + get registration_handouts_path(registration.slug) + + expect(response).to have_http_status(:success) + expect(response.body).to include("download-all#start") + expect(response.body).to include(resource_download_path(handout)) + end + + it "omits the Download all button when no handout has an attached file" do + get registration_handouts_path(registration.slug) + + expect(response).to have_http_status(:success) + expect(response.body).not_to include("download-all#start") + end + end +end