diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index aa200634b0..8f140ff567 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] + + 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 + 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 28051501da..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) diff --git a/app/javascript/controllers/csv_download_controller.js b/app/javascript/controllers/csv_download_controller.js new file mode 100644 index 0000000000..5b5051f663 --- /dev/null +++ b/app/javascript/controllers/csv_download_controller.js @@ -0,0 +1,37 @@ +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" + anchor.click() + 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/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..ee41b29bf1 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,5 @@ + +<%= 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..ddf2cff4f7 --- /dev/null +++ b/app/views/shared/_csv_download.html.erb @@ -0,0 +1,7 @@ +<% if flash[:trigger_csv_download] %> + <% csv_url = "#{request.path}.csv#{"?#{request.query_string}" if request.query_string.present?}" %> +
+<% end %> diff --git a/spec/requests/distributions_requests_spec.rb b/spec/requests/distributions_requests_spec.rb index f66905d72d..39c58c4b1f 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 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("data-controller=\"toast csv-download\"") + 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) 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