Skip to content
9 changes: 9 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/controllers/distributions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions app/javascript/controllers/csv_download_controller.js
Original file line number Diff line number Diff line change
@@ -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:
// <div data-controller="csv-download" data-csv-download-url-value="/some/path.csv"></div>
//
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
}
}
26 changes: 26 additions & 0 deletions app/javascript/controllers/toast_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="toast"
// Shows a toastr notification when the element is rendered.
//
// Usage:
// <div data-controller="toast" data-toast-message-value="Hello!" data-toast-type-value="info"></div>
//
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;
}
}
4 changes: 3 additions & 1 deletion app/views/distributions/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -169,3 +169,5 @@
</div>
</div>
</section>

<%= render "shared/csv_download" %>
7 changes: 7 additions & 0 deletions app/views/shared/_csv_download.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<% if flash[:trigger_csv_download] %>
<% csv_url = "#{request.path}.csv#{"?#{request.query_string}" if request.query_string.present?}" %>
<div data-controller="toast csv-download"
data-csv-download-url-value="<%= csv_url %>"
data-toast-message-value="Your CSV export is downloading!"
class="d-none"></div>
<% end %>
10 changes: 10 additions & 0 deletions spec/requests/distributions_requests_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions spec/system/distribution_system_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -930,4 +930,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
Loading