Skip to content

Commit 6985595

Browse files
committed
[#4472] Persist file uploads through validation errors
Ensure Active Storage direct uploads retain selected files during form resubmissions when validation errors occur. This improves the user experience by avoiding the need to re-select files. - Add a custom file input partial styled with Bootstrap to display file names for new and previously uploaded files. - Include a hidden field with the signed ID of uploaded files for re-association after successful submission. - Create a Stimulus controller (`file-input-label`) to dynamically update labels with the selected file name or default text. - Update forms in `Partners::Profiles` to use the shared file upload partial. - Add a system test to validate the persistence of file uploads through validation errors and final submission.
1 parent 7e2f97d commit 6985595

File tree

11 files changed

+189
-50
lines changed

11 files changed

+189
-50
lines changed

app/javascript/application.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ import 'utils/purchases'
3535

3636
import Rails from "@rails/ujs"
3737
Rails.start()
38+
39+
// Initialize Active Storage
40+
import * as ActiveStorage from "@rails/activestorage";
41+
ActiveStorage.start();
42+
3843
// Disable turbo by default to avoid issues with turbolinks
3944
Turbo.session.drive = false
4045

@@ -107,4 +112,3 @@ $(document).ready(function(){
107112
});
108113
picker.setDateRange(startDate, endDate);
109114
});
110-
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
// Connects to data-controller="file-input-label"
4+
//
5+
// Reproduces the native browser behavior of updating a file input label
6+
// to show the selected file's name. This is necessary when using a custom
7+
// file input, such as with Bootstrap, that does not update automatically.
8+
//
9+
// Key Features:
10+
// 1. Handles initial display of a default label text (e.g., "Choose file..." or
11+
// the previously selected file name if present).
12+
// 2. Updates the label dynamically when a new file is selected.
13+
//
14+
// How it works:
15+
// - When a file is selected, the `fileSelected` method updates the text of the
16+
// label to reflect the name of the selected file.
17+
// - On page load, the `connect` method ensures the label is initialized to the
18+
// correct state (default text or file name, if a file was previously selected).
19+
//
20+
// This controller is used in coordination with direct uploads in Active Storage.
21+
// When a validation error occurs, previously selected files persist on the server
22+
// (via direct upload), and the file name can be displayed to the user.
23+
export default class extends Controller {
24+
static targets = ["input", "label"];
25+
static values = {
26+
defaultText: { type: String, default: 'Choose file...' }
27+
}
28+
29+
connect() {
30+
this.updateLabel();
31+
}
32+
33+
updateLabel() {
34+
const input = this.inputTarget;
35+
const label = this.labelTarget;
36+
37+
// Check if the file input has a file selected
38+
if (input.files.length > 0) {
39+
label.textContent = input.files[0].name;
40+
} else {
41+
label.textContent = this.defaultTextValue;
42+
}
43+
}
44+
45+
// Update the label when a file is selected
46+
fileSelected() {
47+
this.updateLabel();
48+
}
49+
}

app/views/partners/profiles/edit/_agency_information.html.erb

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,14 @@
1010
<%= form.input :name, label: "Agency Name", class: "form-control", wrapper: :input_group %>
1111
<%= profile_form.input :agency_type, collection: Partner::AGENCY_TYPES.values, label: "Agency Type", class: "form-control", wrapper: :input_group %>
1212
<%= profile_form.input :other_agency_type, label: "Other Agency Type", class: "form-control", wrapper: :input_group %>
13-
<div class="form-group row">
14-
<label class="control-label col-md-3">501(c)(3) IRS Determination Letter or other Proof of Agency Status</label>
15-
<% if profile.proof_of_partner_status.attached? %>
16-
<div class="col-md-8">
17-
Attached
18-
file: <%= link_to profile.proof_of_partner_status.blob['filename'], rails_blob_path(profile.proof_of_partner_status), class: "font-weight-bold" %>
19-
<%= profile_form.file_field :proof_of_partner_status, class: "form-control-file form-control" %>
20-
</div>
21-
<% else %>
22-
<div class="col-md-8">
23-
<%= profile_form.file_field :proof_of_partner_status, class: "form-control-file" %>
24-
</div>
25-
<% end %>
26-
</div>
13+
14+
<%= render "shared/custom_file_input",
15+
form_builder: profile_form,
16+
attachment: profile.proof_of_partner_status,
17+
attachment_name: :proof_of_partner_status,
18+
label_for: "partner_profile_proof_of_partner_status",
19+
label_text: "501(c)(3) IRS Determination Letter or other Proof of Agency Status" %>
20+
2721
<%= profile_form.input :agency_mission, label: "Agency Mission", class: "form-control", wrapper: :input_group %>
2822
<%= profile_form.input :address1, label: "Address (line 1)", class: "form-control", wrapper: :input_group %>
2923
<%= profile_form.input :address2, label: "Address (line 2)", class: "form-control", wrapper: :input_group %>

app/views/partners/profiles/edit/_agency_stability.html.erb

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,14 @@
1010
<%= form.input :founded, label: "Year Founded", class: "form-control", wrapper: :input_group %>
1111
<%= form.input :form_990, label: "Form 990 Filed", as: :radio_buttons, class: "form-control",
1212
wrapper: :input_group, wrapper_html: {class: "form-yesno"}, input_html: {class: "radio-yesno"} %>
13-
<label class="control-label col-md-3">Form 990</label>
14-
<% if profile.proof_of_form_990.attached? %>
15-
<div class="col-md-8">
16-
Attached
17-
file: <%= link_to profile.proof_of_form_990.blob['filename'], rails_blob_path(profile.proof_of_form_990), class: "font-weight-bold" %>
18-
<%= form.file_field :proof_of_form_990, class: "form-control-file form-control" %>
19-
</div>
20-
<% else %>
21-
<div class="col-md-8">
22-
<%= form.file_field :proof_of_form_990, class: "form-control-file" %>
23-
</div>
24-
<% end %>
13+
14+
<%= render "shared/custom_file_input",
15+
form_builder: form,
16+
attachment: profile.proof_of_form_990,
17+
attachment_name: :proof_of_form_990,
18+
label_for: "partner_profile_proof_of_form_990",
19+
label_text: "Form 990" %>
20+
2521
<%= form.input :program_name, label: "Program Name(s)", class: "form-control", wrapper: :input_group %>
2622
<%= form.input :program_description, label: "Program Description(s)", class: "form-control", wrapper: :input_group %>
2723
<%= form.input :program_age, label: "Agency Age", class: "form-control", wrapper: :input_group %>

app/views/partners/profiles/step/_agency_information_form.html.erb

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,12 @@
1111
<%= pf.input :other_agency_type, label: "Other Agency Type", class: "form-control" %>
1212
</div>
1313

14-
<div class="form-group row">
15-
<label class="control-label col-md-3">501(c)(3) IRS Determination Letter or other Proof of Agency Status</label>
16-
<% if profile.proof_of_partner_status.attached? %>
17-
<div class="col-md-8">
18-
Attached file: <%= link_to profile.proof_of_partner_status.blob['filename'], rails_blob_path(profile.proof_of_partner_status), class: "font-weight-bold" %>
19-
<%= pf.file_field :proof_of_partner_status, class: "form-control-file" %>
20-
</div>
21-
<% else %>
22-
<div class="col-md-8">
23-
<%= pf.file_field :proof_of_partner_status, class: "form-control-file" %>
24-
</div>
25-
<% end %>
26-
</div>
14+
<%= render "shared/custom_file_input",
15+
form_builder: pf,
16+
attachment: profile.proof_of_partner_status,
17+
attachment_name: :proof_of_partner_status,
18+
label_for: "partner_profile_proof_of_partner_status",
19+
label_text: "501(c)(3) IRS Determination Letter or other Proof of Agency Status" %>
2720

2821
<div class="form-group">
2922
<%= pf.input :agency_mission, as: :text, label: "Agency Mission", class: "form-control" %>

app/views/partners/profiles/step/_agency_stability_form.html.erb

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,12 @@
77
<%= pf.input :form_990, label: "Form 990 Filed", as: :radio_buttons, class: "form-control" %>
88
</div>
99

10-
<% if profile.proof_of_form_990.attached? %>
11-
<div class="form-group">
12-
<label>Attached file: </label>
13-
<%= link_to profile.proof_of_form_990.blob['filename'], rails_blob_path(profile.proof_of_form_990), class: "font-weight-bold" %>
14-
</div>
15-
<% end %>
16-
17-
<div class="form-group">
18-
<%= pf.file_field :proof_of_form_990, class: "form-control-file" %>
19-
</div>
10+
<%= render "shared/custom_file_input",
11+
form_builder: pf,
12+
attachment: profile.proof_of_form_990,
13+
attachment_name: :proof_of_form_990,
14+
label_for: "partner_profile_proof_of_form_990",
15+
label_text: "Form 990 Filed" %>
2016

2117
<div class="form-group">
2218
<%= pf.input :program_name, label: "Program Name(s)", class: "form-control" %>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<%# locals: (form_builder:, label_for:, label_text:, attachment:, attachment_name:) %>
2+
3+
<%# Creates a custom file input field with the following features: %>
4+
<%# - Displays the name of a previously selected file (even after validation errors). %>
5+
<%# - Integrates with Active Storage direct uploads to persist the file on the server, %>
6+
<%# even if form submission fails. %>
7+
<%# - Uses a Stimulus controller (`file_input_label`) to dynamically update the label %>
8+
<%# text when a new file is selected. %>
9+
<%# - Styled with Bootstrap's custom file input classes. %>
10+
<%# %>
11+
<%# Arguments: %>
12+
<%# - form_builder: The form builder object. %>
13+
<%# - label_for: The ID of the file input (used for the label `for` attribute). %>
14+
<%# - label_text: The text to display for the file input's label. %>
15+
<%# - attachment: The Active Storage attachment object (used to check for existing files). %>
16+
<%# - attachment_name: The name of the attachment field (e.g., `:proof_of_form_990`). %>
17+
18+
<div class="form-group">
19+
<label class="control-label"><%= label_text %></label>
20+
21+
<% if attachment.persisted? %>
22+
Attached file: <%= link_to attachment.blob.filename, rails_blob_path(attachment), class: "font-weight-bold" %>
23+
<% elsif attachment.attached? %>
24+
<%= form_builder.hidden_field attachment_name, value: attachment.signed_id %>
25+
<% end %>
26+
27+
<div class="col-md-12"
28+
data-controller="file-input-label"
29+
data-file-input-label-default-text-value="<%= attachment.attached? ? attachment.blob.filename : 'Choose file...' %>">
30+
<%= form_builder.file_field attachment_name,
31+
direct_upload: true,
32+
class: "custom-file-input",
33+
data: {
34+
action: "change->file-input-label#fileSelected",
35+
file_input_label_target: "input"
36+
} %>
37+
<label class="custom-file-label"
38+
for="<%= label_for %>"
39+
data-file-input-label-target="label">Choose file...</label>
40+
</div>
41+
</div>

config/importmap.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@
3333
pin "filterrific", to: "filterrific.js"
3434
pin "bootstrap-select", to: "https://ga.jspm.io/npm:[email protected]/dist/js/bootstrap-select.js"
3535
pin "jquery-ui", to: "https://ga.jspm.io/npm:[email protected]/ui/widget.js"
36+
pin "@rails/activestorage", to: "@rails--activestorage.js" # @8.0.100
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# 501(c)(3) IRS Determination Letter
2+
3+
This document serves as proof of Helping Hands Foundation's status as a 501(c)(3) organization under the Internal Revenue Code.
4+
5+
## Details:
6+
- **Agency Name**: Helping Hands Foundation
7+
- **EIN**: 45-6789123
8+
- **Determination Date**: February 15, 2010
9+
10+
For further verification, please contact the IRS at 1-877-123-4567.
11+
12+
---
13+
This document is for testing purposes only.

spec/system/partners/profile_edit_system_spec.rb

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,5 +98,53 @@
9898
expect(page).to have_css("#pick_up_person.accordion-collapse.collapse.show", visible: true)
9999
expect(page).to have_css("#partner_settings.accordion-collapse.collapse.show", visible: true)
100100
end
101+
102+
it "persists file upload when there are validation errors" do
103+
# Open up Agency Information section and upload proof-of-status letter
104+
find("button[data-bs-target='#agency_information']").click
105+
within "#agency_information" do
106+
expect(find("label[for='partner_profile_proof_of_partner_status']")).to have_content("Choose file...")
107+
attach_file("partner_profile_proof_of_partner_status", Rails.root.join("spec/fixtures/files/irs_determination_letter.md"), make_visible: true)
108+
expect(find("label[for='partner_profile_proof_of_partner_status']")).to have_content("irs_determination_letter.md")
109+
end
110+
111+
# Open Pick up person section and fill in 4 email addresses which will generate a validation error
112+
find("button[data-bs-target='#pick_up_person']").click
113+
within "#pick_up_person" do
114+
fill_in "Pick Up Person's Email", with: "[email protected], [email protected], [email protected], [email protected]"
115+
end
116+
117+
# Save Progress
118+
all("input[type='submit'][value='Save Progress']").last.click
119+
120+
# Expect an alert-danger message containing validation errors
121+
expect(page).to have_css(".alert-danger", text: /There is a problem/)
122+
123+
# Open up Agency Information section and expect the file field to remember users selection
124+
# but NOT be persisted because there hasn't yet been a successful form submission.
125+
find("button[data-bs-target='#agency_information']").click
126+
within "#agency_information" do
127+
expect(find("label[for='partner_profile_proof_of_partner_status']")).to have_content("irs_determination_letter.md")
128+
expect(page).not_to have_content("Attached file:")
129+
expect(page).not_to have_link("irs_determination_letter.md")
130+
end
131+
132+
# Fix validation error in Pick up person section: It's already open due to having a validation error
133+
within "#pick_up_person" do
134+
fill_in "Pick Up Person's Email", with: "[email protected], [email protected], [email protected]"
135+
end
136+
137+
# Save Progress
138+
all("input[type='submit'][value='Save Progress']").last.click
139+
expect(page).to have_css(".alert-success", text: "Details were successfully updated.")
140+
141+
# Open up Agency Information section and expect file is persisted
142+
find("button[data-bs-target='#agency_information']").click
143+
within "#agency_information" do
144+
expect(page).to have_content("Attached file:")
145+
expect(page).to have_link("irs_determination_letter.md", href: /\/rails\/active_storage\/blobs\/redirect\/.+\/irs_determination_letter\.md/)
146+
expect(find("label[for='partner_profile_proof_of_partner_status']")).to have_content("irs_determination_letter.md")
147+
end
148+
end
101149
end
102150
end

0 commit comments

Comments
 (0)