Skip to content

Commit d675037

Browse files
committed
[#4821] Persist profile attached documents through validation errors
Use direct uploads for multiple file input so that file blobs remain available even if the model fails to save due to validation errors. - Submit hidden fields containing the signed_id of attached but unpersisted documents so that previously selected documents will be saved on the next successful form submission - Hide the native file input to prevent "No file chosen" from appearing when files were selected before a validation error - Use a custom button and file listing to display selected files during the current selection or on form re-render
1 parent c5601ef commit d675037

File tree

4 files changed

+171
-19
lines changed

4 files changed

+171
-19
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
module Partners
2+
module ProfileHelper
3+
# Returns an array of filenames that are attached to the profile but not persisted.
4+
# This is to display to the user that the system remembers their file selections
5+
# even if there was a form validation error.
6+
# The method returns a JSON string (an array of filenames) to be used in a Stimulus controller.
7+
def attached_but_not_persisted_file_names(profile)
8+
filenames = profile.documents.attachments
9+
.select { |att| !att.persisted? }
10+
.map { |att| att.blob.filename.to_s }
11+
12+
filenames.to_json
13+
end
14+
15+
# Returns true if at least one document attachment is actually persisted
16+
def has_persisted_documents?(profile)
17+
profile.documents.attachments.any?(&:persisted?)
18+
end
19+
end
20+
end

app/javascript/controllers/file_input_controller.js

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import { Controller } from "@hotwired/stimulus";
55
*
66
* This controller:
77
* - Listens for file selection on an `<input type="file" multiple="multiple">`
8-
* - Displays selected file names in a custom list **when multiple files are selected
9-
* - Defaults to the browser’s built-in display for a single file selection
8+
* - Displays selected file names in a custom list when multiple files are selected
9+
* - If provided a `filenames` array, displays those file names as if user had just selected them.
10+
* This is useful for displaying previously selected files on page load with validation errors.
1011
*
1112
* Expected HTML structure should have a placeholder div for the selected file names:
1213
*
@@ -20,29 +21,60 @@ import { Controller } from "@hotwired/stimulus";
2021
export default class extends Controller {
2122
static targets = ["input", "list"];
2223

24+
static values = {
25+
filenames: Array
26+
}
27+
2328
connect() {
2429
this.inputTarget.addEventListener("change", () => this.updateFileList());
30+
31+
if (this.hasFilenamesValue && this.filenamesValue.length > 0) {
32+
this.updateFileListFromValue();
33+
}
34+
}
35+
36+
// Opens the hidden file input when "Choose Files" button is clicked
37+
triggerFileSelection() {
38+
this.inputTarget.click();
2539
}
2640

41+
// native file input selection
2742
updateFileList() {
2843
const files = this.inputTarget.files;
29-
this.listTarget.innerHTML = ""; // Clear previous list
3044

31-
// If no files or only one file is selected, let the native UI handle it
32-
if (files.length <= 1) {
45+
if (files.length === 0) {
3346
return;
3447
}
3548

49+
this.renderFileList(Array.from(files).map(file => file.name));
50+
}
51+
52+
updateFileListFromValue() {
53+
this.renderFileList(this.filenamesValue);
54+
}
55+
56+
renderFileList(fileNames) {
57+
// Clear previous list
58+
this.listTarget.innerHTML = "";
59+
60+
// Create subheader
61+
const header = document.createElement("p");
62+
header.textContent = "Selected files:";
63+
header.classList.add("font-weight-bold", "mb-1");
64+
65+
// Create file list
3666
const ul = document.createElement("ul");
3767
ul.classList.add("list-unstyled", "mt-2");
3868

39-
Array.from(files).forEach((file) => {
69+
fileNames.forEach((name) => {
4070
const li = document.createElement("li");
4171
li.classList.add("p-1", "rounded", "mb-1");
42-
li.textContent = file.name;
72+
li.textContent = name;
4373
ul.appendChild(li);
4474
});
4575

76+
// Append header and list to target container
77+
this.listTarget.appendChild(header);
4678
this.listTarget.appendChild(ul);
4779
}
4880
}

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

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
<%= f.fields_for :profile, profile do |pf| %>
2-
<div class="form-group" data-controller="file-input">
3-
<% if profile.documents.attached? %>
2+
<div class="form-group"
3+
data-controller="file-input"
4+
data-file-input-filenames-value="<%= attached_but_not_persisted_file_names(profile) %>">
5+
6+
<%# Allow user to download and/or remove existing attachments %>
7+
<% if has_persisted_documents?(profile) %>
48
<strong>Attached files:</strong>
59
<ul class="list-unstyled">
610
<% profile.documents.each do |doc| %>
@@ -17,8 +21,27 @@
1721
</ul>
1822
<% end %>
1923

20-
<%# Native file input and placeholder for selected file names %>
21-
<%= pf.file_field :documents, multiple: true, class: "form-control-file", data: { file_input_target: "input" } %>
24+
<%# Submit hidden fields for attachments that are not persisted to preserve them through form validation errors %>
25+
<% profile.documents.attachments.each do |att| %>
26+
<% if !att.persisted? %>
27+
<%= pf.hidden_field :documents, multiple: true, value: att.blob.signed_id %>
28+
<% end %>
29+
<% end %>
30+
31+
<%# Hide native file input to support custom behaviour to display %>
32+
<%# previously selected files when validation error occurs %>
33+
<%= pf.file_field :documents,
34+
multiple: true,
35+
direct_upload: true,
36+
class: "form-control-file d-none",
37+
data: { file_input_target: "input" } %>
38+
39+
<%# Custom button to trigger file selection %>
40+
<button type="button" class="btn btn-outline-primary" data-action="click->file-input#triggerFileSelection">
41+
Choose Files
42+
</button>
43+
44+
<%# Placeholder to display selected file(s) populated by app/javascript/controllers/file_input_controller.js %>
2245
<div data-file-input-target="list" class="mt-2"></div>
2346
</div>
2447
<% end %>

spec/system/partners/profile_edit_system_spec.rb

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -143,22 +143,32 @@
143143
find("button[data-bs-target='#attached_documents']").click
144144
expect(page).to have_css("#attached_documents.accordion-collapse.collapse.show", visible: true)
145145

146-
# Upload two documents - needs to be done individually because Capybara doesn't have attach_files multiple support
147-
# https://github.com/teamcapybara/capybara/issues/37
146+
# Upload multiple documents at once
148147
within "#attached_documents" do
149-
attach_file("partner_profile_documents", Rails.root.join("spec/fixtures/files/document1.md"), make_visible: true)
148+
attach_file("partner_profile_documents", [
149+
Rails.root.join("spec/fixtures/files/document1.md"),
150+
Rails.root.join("spec/fixtures/files/document2.md")
151+
], make_visible: true)
152+
153+
# Verify both documents are displayed in custom selection list
154+
expect(page).to have_text("Selected files:")
155+
expect(page).to have_css("[data-file-input-target='list'] li", text: "document1.md")
156+
expect(page).to have_css("[data-file-input-target='list'] li", text: "document2.md")
150157
end
158+
159+
# Save Progress
151160
all("input[type='submit'][value='Save Progress']").last.click
161+
expect(page).to have_css(".alert-success", text: "Details were successfully updated.")
162+
163+
# Verify both documents persist after page reload
152164
visit edit_partners_profile_path
153165
find("button[data-bs-target='#attached_documents']").click
154166
within "#attached_documents" do
155-
attach_file("partner_profile_documents", Rails.root.join("spec/fixtures/files/document2.md"), make_visible: true)
167+
expect(page).to have_link("document1.md")
168+
expect(page).to have_link("document2.md")
156169
end
157-
all("input[type='submit'][value='Save Progress']").last.click
158170

159171
# Remove the first document
160-
visit edit_partners_profile_path
161-
find("button[data-bs-target='#attached_documents']").click
162172
within "#attached_documents" do
163173
document_name = "document1.md"
164174
document_li = find("li.attached-document", text: document_name)
@@ -170,11 +180,12 @@
170180
all("input[type='submit'][value='Save Progress']").last.click
171181
expect(page).to have_css(".alert-success", text: "Details were successfully updated.")
172182

173-
# Verify only one document is listed
183+
# Verify only one document remains
174184
visit edit_partners_profile_path
175185
find("button[data-bs-target='#attached_documents']").click
176186
within "#attached_documents" do
177187
expect(page).to have_link("document2.md")
188+
expect(page).not_to have_link("document1.md")
178189
end
179190
end
180191

@@ -225,5 +236,71 @@
225236
expect(find("label[for='partner_profile_proof_of_partner_status']")).to have_content("irs_determination_letter.md")
226237
end
227238
end
239+
240+
it "persists multiple file uploads when there are validation errors" do
241+
# Open Pick up person section and fill in 4 email addresses which will generate a validation error
242+
find("button[data-bs-target='#pick_up_person']").click
243+
within "#pick_up_person" do
244+
fill_in "Pick Up Person's Email", with: "[email protected], [email protected], [email protected], [email protected]"
245+
end
246+
247+
# Open attached documents section
248+
find("button[data-bs-target='#attached_documents']").click
249+
expect(page).to have_css("#attached_documents.accordion-collapse.collapse.show", visible: true)
250+
251+
# Upload multiple documents
252+
within "#attached_documents" do
253+
attach_file("partner_profile_documents", [
254+
Rails.root.join("spec/fixtures/files/document1.md"),
255+
Rails.root.join("spec/fixtures/files/document2.md")
256+
], make_visible: true)
257+
258+
# Verify both documents are displayed in custom selection list
259+
expect(page).to have_css("[data-file-input-target='list'] li", text: "document1.md")
260+
expect(page).to have_css("[data-file-input-target='list'] li", text: "document2.md")
261+
end
262+
263+
# Save Progress
264+
all("input[type='submit'][value='Save Progress']").last.click
265+
266+
# Expect an alert-danger message containing validation errors
267+
expect(page).to have_css(".alert-danger", text: /There is a problem/)
268+
269+
# Open attached documents section
270+
find("button[data-bs-target='#attached_documents']").click
271+
expect(page).to have_css("#attached_documents.accordion-collapse.collapse.show", visible: true)
272+
273+
# Expect both documents are still displayed in custom list as selected, but nothing is actually attached
274+
within "#attached_documents" do
275+
expect(page).to have_text("Selected files:")
276+
expect(page).to have_css("[data-file-input-target='list'] li", text: "document1.md")
277+
expect(page).to have_css("[data-file-input-target='list'] li", text: "document2.md")
278+
279+
expect(page).not_to have_text("Attached files:")
280+
expect(page).not_to have_link("document1.md")
281+
expect(page).not_to have_link("document2.md")
282+
end
283+
284+
# Fix validation error in Pick up person section: It's already open due to having a validation error
285+
within "#pick_up_person" do
286+
fill_in "Pick Up Person's Email", with: "[email protected], [email protected], [email protected]"
287+
end
288+
289+
# Save Progress
290+
all("input[type='submit'][value='Save Progress']").last.click
291+
expect(page).to have_css(".alert-success", text: "Details were successfully updated.")
292+
293+
# Open attached documents section
294+
find("button[data-bs-target='#attached_documents']").click
295+
expect(page).to have_css("#attached_documents.accordion-collapse.collapse.show", visible: true)
296+
297+
# Expect both documents are now rendered as downloadable links
298+
# i.e. they've been saved, without user having had to select them again
299+
within "#attached_documents" do
300+
expect(page).to have_text("Attached files:")
301+
expect(page).to have_link("document1.md")
302+
expect(page).to have_link("document2.md")
303+
end
304+
end
228305
end
229306
end

0 commit comments

Comments
 (0)