From 291a220d368afd71a4bc9ba150bdc4453b1966a3 Mon Sep 17 00:00:00 2001 From: Brock Wilcox Date: Sun, 15 Feb 2026 11:47:04 -0500 Subject: [PATCH 1/9] Show a pop-up toast for downloading in the background --- app/controllers/distributions_controller.rb | 6 +++++ .../controllers/toast_controller.js | 26 +++++++++++++++++++ app/views/distributions/index.html.erb | 7 ++++- 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 app/javascript/controllers/toast_controller.js diff --git a/app/controllers/distributions_controller.rb b/app/controllers/distributions_controller.rb index 28051501da..adbc5fd659 100644 --- a/app/controllers/distributions_controller.rb +++ b/app/controllers/distributions_controller.rb @@ -41,6 +41,12 @@ def destroy end def index + if params[:export_csv] + session[:trigger_csv_download] = true + redirect_to distributions_path(request.query_parameters.except("export_csv")) + return + end + setup_date_range_picker @highlight_id = session.delete(:created_distribution_id) diff --git a/app/javascript/controllers/toast_controller.js b/app/javascript/controllers/toast_controller.js new file mode 100644 index 0000000000..8f7d356634 --- /dev/null +++ b/app/javascript/controllers/toast_controller.js @@ -0,0 +1,26 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="toast" +// Shows a toastr notification when the element is rendered. +// +// Usage: +//
+// +export default class extends Controller { + static values = { + message: String, + type: { type: String, default: "info" }, + timeout: { type: Number, default: 5000 }, + position: { type: String, default: "toast-top-center" } + } + + connect() { + const previousTimeout = toastr.options.timeOut; + const previousPosition = toastr.options.positionClass; + toastr.options.timeOut = this.timeoutValue; + toastr.options.positionClass = this.positionValue; + toastr[this.typeValue](this.messageValue); + toastr.options.timeOut = previousTimeout; + toastr.options.positionClass = previousPosition; + } +} diff --git a/app/views/distributions/index.html.erb b/app/views/distributions/index.html.erb index 278a56bb51..95b7c53601 100644 --- a/app/views/distributions/index.html.erb +++ b/app/views/distributions/index.html.erb @@ -74,7 +74,7 @@ <%= if @distributions.any? download_button_to( - distributions_path(format: :csv, filters: filter_params.merge(date_range: date_range_params)), + distributions_path(export_csv: true, filters: filter_params.merge(date_range: date_range_params)), text: "Export Distributions" ) end @@ -169,3 +169,8 @@ + +<% if session.delete(:trigger_csv_download) %> + +
+<% end %> From 70bea1f379808a818cc6831c13ab5d76e744d9bf Mon Sep 17 00:00:00 2001 From: Brock Wilcox Date: Sun, 22 Feb 2026 10:25:21 -0500 Subject: [PATCH 2/9] Use toast style pop-up --- app/controllers/application_controller.rb | 9 +++++++++ app/controllers/distributions_controller.rb | 7 +------ app/views/distributions/index.html.erb | 5 +---- app/views/shared/_csv_download.html.erb | 5 +++++ 4 files changed, 16 insertions(+), 10 deletions(-) create mode 100644 app/views/shared/_csv_download.html.erb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index aa200634b0..c73b92ba3e 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -146,6 +146,15 @@ def setup_date_range_picker @selected_date_range_label = helpers.date_range_label end + def handle_csv_export + return unless params[:export_csv] + + session[:trigger_csv_download] = true + clean_params = request.query_parameters.except("export_csv") + redirect_url = clean_params.any? ? "#{request.path}?#{clean_params.to_query}" : request.path + redirect_to redirect_url + end + def configure_permitted_parameters devise_parameter_sanitizer.permit(:account_update, keys: [:name]) end diff --git a/app/controllers/distributions_controller.rb b/app/controllers/distributions_controller.rb index adbc5fd659..0701e59a09 100644 --- a/app/controllers/distributions_controller.rb +++ b/app/controllers/distributions_controller.rb @@ -9,6 +9,7 @@ class DistributionsController < ApplicationController include Validatable before_action :enable_turbo!, only: %i[new show] + before_action :handle_csv_export, only: [:index] skip_before_action :authenticate_user!, only: %i(calendar) skip_before_action :authorize_user, only: %i(calendar) skip_before_action :require_organization, only: %i(calendar) @@ -41,12 +42,6 @@ def destroy end def index - if params[:export_csv] - session[:trigger_csv_download] = true - redirect_to distributions_path(request.query_parameters.except("export_csv")) - return - end - setup_date_range_picker @highlight_id = session.delete(:created_distribution_id) diff --git a/app/views/distributions/index.html.erb b/app/views/distributions/index.html.erb index 95b7c53601..ee41b29bf1 100644 --- a/app/views/distributions/index.html.erb +++ b/app/views/distributions/index.html.erb @@ -170,7 +170,4 @@ -<% if session.delete(:trigger_csv_download) %> - -
-<% end %> +<%= render "shared/csv_download" %> diff --git a/app/views/shared/_csv_download.html.erb b/app/views/shared/_csv_download.html.erb new file mode 100644 index 0000000000..3117d317ab --- /dev/null +++ b/app/views/shared/_csv_download.html.erb @@ -0,0 +1,5 @@ +<% if session.delete(:trigger_csv_download) %> + <% csv_url = "#{request.path}.csv#{"?#{request.query_string}" if request.query_string.present?}" %> + +
+<% end %> From 2020f04a7abb2adff96a4a93872659333a1873c9 Mon Sep 17 00:00:00 2001 From: Brock Wilcox Date: Sat, 28 Feb 2026 15:58:51 -0500 Subject: [PATCH 3/9] Switch to flash instead of session for csv download flag --- app/controllers/application_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c73b92ba3e..8f140ff567 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -149,7 +149,7 @@ def setup_date_range_picker def handle_csv_export return unless params[:export_csv] - session[:trigger_csv_download] = true + flash[:trigger_csv_download] = true clean_params = request.query_parameters.except("export_csv") redirect_url = clean_params.any? ? "#{request.path}?#{clean_params.to_query}" : request.path redirect_to redirect_url From c72e2f28951c22a47e23d89f5a524292e2eaade3 Mon Sep 17 00:00:00 2001 From: Brock Wilcox Date: Sat, 28 Feb 2026 15:59:14 -0500 Subject: [PATCH 4/9] Explain the iframe for background download --- app/views/shared/_csv_download.html.erb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/views/shared/_csv_download.html.erb b/app/views/shared/_csv_download.html.erb index 3117d317ab..f6c25a48ef 100644 --- a/app/views/shared/_csv_download.html.erb +++ b/app/views/shared/_csv_download.html.erb @@ -1,5 +1,8 @@ -<% if session.delete(:trigger_csv_download) %> +<% if flash[:trigger_csv_download] %> <% csv_url = "#{request.path}.csv#{"?#{request.query_string}" if request.query_string.present?}" %> + <%# We use a hidden iframe to trigger the CSV download in the background so the user stays on the + current page. The iframe loads the .csv version of the current URL, which causes the browser + to download the file without navigating away. %>
<% end %> From 4dfc2e367f2ed8e63fc6bce500995b8044562d19 Mon Sep 17 00:00:00 2001 From: Brock Wilcox Date: Sat, 28 Feb 2026 16:03:38 -0500 Subject: [PATCH 5/9] Add a basic request spec for the non-js path --- spec/requests/distributions_requests_spec.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spec/requests/distributions_requests_spec.rb b/spec/requests/distributions_requests_spec.rb index f66905d72d..f82ef4ee5c 100644 --- a/spec/requests/distributions_requests_spec.rb +++ b/spec/requests/distributions_requests_spec.rb @@ -83,6 +83,16 @@ expect(response).to be_successful end + context "with export_csv param" do + it "redirects then renders an iframe to trigger CSV download" do + get distributions_path(export_csv: true, foo: "bar") + expect(response).to redirect_to(distributions_path(foo: "bar")) + follow_redirect! + expect(response.body).to include("iframe") + expect(response.body).to include("distributions.csv?foo=bar") + end + end + it "sums distribution totals accurately" do create(:distribution, :with_items, item_quantity: 5, organization: organization) create(:line_item, :distribution, itemizable_id: distribution.id, quantity: 7) From ff5ee405cf5f11d1ed7be5bc7a0c62f2c6eb2d51 Mon Sep 17 00:00:00 2001 From: Brock Wilcox Date: Sat, 28 Feb 2026 16:08:32 -0500 Subject: [PATCH 6/9] Add system spec to cover the new javascript toast --- spec/system/distribution_system_spec.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/spec/system/distribution_system_spec.rb b/spec/system/distribution_system_spec.rb index a1371f0d1d..a0706a7e3b 100644 --- a/spec/system/distribution_system_spec.rb +++ b/spec/system/distribution_system_spec.rb @@ -931,4 +931,20 @@ # will fail (the distribution is already complete) and show this error expect(page).not_to have_content("Sorry, we encountered an error when trying to mark this distribution as being completed") end + + describe "CSV export", js: true do + before do + create(:distribution, :with_items, organization: organization) + visit distributions_path + end + + it "downloads a CSV and shows a toast notification" do + click_on "Export Distributions" + + wait_for_download + expect(downloads.length).to eq(1) + expect(download).to match(/Distributions.*\.csv/) + expect(page).to have_text("Your CSV export is downloading!") + end + end end From 3231b90bd48c9e29c72690f8dce461ea93b9d505 Mon Sep 17 00:00:00 2001 From: Angarag Gansukh Date: Sat, 25 Apr 2026 00:34:02 -0700 Subject: [PATCH 7/9] Use fetch API instead of an iframe for exporting the CSV --- .../controllers/csv_download_controller.js | 39 +++++++++++++++++++ app/views/shared/_csv_download.html.erb | 9 ++--- 2 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 app/javascript/controllers/csv_download_controller.js diff --git a/app/javascript/controllers/csv_download_controller.js b/app/javascript/controllers/csv_download_controller.js new file mode 100644 index 0000000000..04b3e955d8 --- /dev/null +++ b/app/javascript/controllers/csv_download_controller.js @@ -0,0 +1,39 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="csv-download" +// Fetches a CSV file in the background and triggers a browser download. +// +// Usage: +//
+// +export default class extends Controller { + static values = { url: String } + + connect() { + fetch(this.urlValue, { headers: { "X-Requested-With": "XMLHttpRequest" } }) + .then(response => { + if (!response.ok) return + const filename = this._extractFilename(response.headers.get("Content-Disposition")) + return response.blob().then(blob => ({ blob, filename })) + }) + .then(({ blob, filename }) => { + const objectUrl = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = objectUrl + anchor.download = filename || "report.csv" + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + URL.revokeObjectURL(objectUrl) + }) + } + + _extractFilename(disposition) { + if (!disposition) return null + + const utf8Match = disposition.match(/filename\*=UTF-8''([^;\n]+)/i) + if (utf8Match) return decodeURIComponent(utf8Match[1]) + const plainMatch = disposition.match(/filename="?([^";\n]+)"?/i) + return plainMatch ? plainMatch[1] : null + } +} diff --git a/app/views/shared/_csv_download.html.erb b/app/views/shared/_csv_download.html.erb index f6c25a48ef..ddf2cff4f7 100644 --- a/app/views/shared/_csv_download.html.erb +++ b/app/views/shared/_csv_download.html.erb @@ -1,8 +1,7 @@ <% if flash[:trigger_csv_download] %> <% csv_url = "#{request.path}.csv#{"?#{request.query_string}" if request.query_string.present?}" %> - <%# We use a hidden iframe to trigger the CSV download in the background so the user stays on the - current page. The iframe loads the .csv version of the current URL, which causes the browser - to download the file without navigating away. %> - -
+
<% end %> From ac8bd8d0c4970192ab2bab9f5bbebff8c853d0c6 Mon Sep 17 00:00:00 2001 From: Angarag Gansukh Date: Sat, 25 Apr 2026 00:39:33 -0700 Subject: [PATCH 8/9] update specs --- spec/requests/distributions_requests_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/requests/distributions_requests_spec.rb b/spec/requests/distributions_requests_spec.rb index f82ef4ee5c..39c58c4b1f 100644 --- a/spec/requests/distributions_requests_spec.rb +++ b/spec/requests/distributions_requests_spec.rb @@ -84,11 +84,11 @@ end context "with export_csv param" do - it "redirects then renders an iframe to trigger CSV download" do + it "redirects then renders a csv-download stimulus controller to export CSV" do get distributions_path(export_csv: true, foo: "bar") expect(response).to redirect_to(distributions_path(foo: "bar")) follow_redirect! - expect(response.body).to include("iframe") + expect(response.body).to include("data-controller=\"toast csv-download\"") expect(response.body).to include("distributions.csv?foo=bar") end end From d5113c31feffb8a18e61cef37323f5e3f0c9ff7f Mon Sep 17 00:00:00 2001 From: Angarag Gansukh Date: Sat, 25 Apr 2026 00:43:23 -0700 Subject: [PATCH 9/9] remove unnecessary DOM manipulation --- app/javascript/controllers/csv_download_controller.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/javascript/controllers/csv_download_controller.js b/app/javascript/controllers/csv_download_controller.js index 04b3e955d8..5b5051f663 100644 --- a/app/javascript/controllers/csv_download_controller.js +++ b/app/javascript/controllers/csv_download_controller.js @@ -21,9 +21,7 @@ export default class extends Controller { const anchor = document.createElement("a") anchor.href = objectUrl anchor.download = filename || "report.csv" - document.body.appendChild(anchor) anchor.click() - document.body.removeChild(anchor) URL.revokeObjectURL(objectUrl) }) }