Skip to content

Commit 4bf62a8

Browse files
Feat/add attachment component (#2698)
* feat(ui file attachment): add react component * feat(ui file attachment): add storybook pages, unit and ui tests * chore: formatting * chore: remove unused import * fix(file attachments): keep track of overflow value; correctly handle files with the same name; fix script loader race problem * refactor(attachment component): remove anchor tag from file list * feat(attachment component): keep track of timerid so they can be cleanly destroyed when component is unmounted * fix(localization): correct French translations for file attachment messages * fix(localization): correct file extension in French and English text document messages * chore: formatting * fix(attachment component): remove unused isAttached status check * chore: fix spacing, text size, colors * chore: formatting * feat(attach files): change modal to expanding panel * fix: ensure loader, filename, and buttons align; dont truncate long names, wrap them instead. * chore: formatting * fix(file attach): do not include files in the list the user is not allowed to attach, only warn * fix(file attach): make max height big enough for 10 files + error messages * fix(attach files): remove focus trap since we no longer have a modal; use accessible file upload pattern for buttons * chore: formatting * fix(file attach): remove extraneous newlines from remove confirm dialog * style(file attachment): ensure bold font weight for dangerous banner file names * fix(vite config): resolve build warnings having to do with `inject` * fix(file attach): scroll slideout into view as it opens * feat(attachments): enhance file download handling and UI updates * feat(attachments): add tests for file download links and malware handling * fix(file attach): make close icon bigger * feat(attachments): add tests for upload removal confirmation and in-progress file handling * chore: formatting * fix(attachments): remove unnecessary class from remove button expectation --------- Co-authored-by: William B <7444334+whabanks@users.noreply.github.com>
1 parent 0a58494 commit 4bf62a8

18 files changed

Lines changed: 1946 additions & 22 deletions

File tree

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import React, { useEffect, useRef, useState } from "react";
2+
// Render inline as an expanding panel instead of a portal/modal
3+
import { ACCEPT_ATTRIBUTE } from "./useAttachments";
4+
import { getAttachmentTranslations } from "./localization";
5+
6+
const DEFAULT_COPY = getAttachmentTranslations("en");
7+
8+
export const AttachFilesModal = ({
9+
isOpen,
10+
issues,
11+
copy = DEFAULT_COPY,
12+
classificationUrl = "#",
13+
onIssuesChange,
14+
onValidateSelection,
15+
onClose,
16+
onAttach,
17+
}) => {
18+
const [selectedFiles, setSelectedFiles] = useState([]);
19+
const [isAnimatingOpen, setIsAnimatingOpen] = useState(false);
20+
const dialogRef = useRef(null);
21+
const headingRef = useRef(null);
22+
const previouslyFocusedElement = useRef(null);
23+
24+
useEffect(() => {
25+
if (isOpen) {
26+
setSelectedFiles([]);
27+
// trigger open animation on next tick
28+
setTimeout(() => setIsAnimatingOpen(true), 10);
29+
}
30+
}, [isOpen]);
31+
32+
useEffect(() => {
33+
if (!isOpen) {
34+
setIsAnimatingOpen(false);
35+
return undefined;
36+
}
37+
38+
previouslyFocusedElement.current = document.activeElement;
39+
40+
if (headingRef.current && headingRef.current.focus) {
41+
headingRef.current.focus();
42+
}
43+
44+
return () => {
45+
if (
46+
previouslyFocusedElement.current &&
47+
previouslyFocusedElement.current.focus
48+
) {
49+
previouslyFocusedElement.current.focus();
50+
}
51+
};
52+
}, [isOpen, onClose]);
53+
54+
useEffect(() => {
55+
if (!isOpen || !isAnimatingOpen || !dialogRef.current) {
56+
return undefined;
57+
}
58+
59+
const panel = dialogRef.current;
60+
let frameId;
61+
const startTime = performance.now();
62+
const maxFollowDurationMs = 1400;
63+
let stableFrames = 0;
64+
65+
// Keep the expanding panel in view while max-height animation runs.
66+
const scrollWithAnimation = () => {
67+
if (!panel.isConnected) {
68+
return;
69+
}
70+
71+
const rect = panel.getBoundingClientRect();
72+
const viewportPadding = 16;
73+
const bottomOverflow = rect.bottom - window.innerHeight + viewportPadding;
74+
75+
if (bottomOverflow > 0) {
76+
stableFrames = 0;
77+
const step = Math.min(48, Math.max(14, bottomOverflow * 0.35));
78+
window.scrollBy({
79+
top: step,
80+
behavior: "auto",
81+
});
82+
} else {
83+
stableFrames += 1;
84+
}
85+
86+
if (
87+
performance.now() - startTime < maxFollowDurationMs &&
88+
stableFrames < 2
89+
) {
90+
frameId = window.requestAnimationFrame(scrollWithAnimation);
91+
}
92+
};
93+
94+
frameId = window.requestAnimationFrame(scrollWithAnimation);
95+
96+
return () => {
97+
if (frameId) {
98+
window.cancelAnimationFrame(frameId);
99+
}
100+
};
101+
}, [isOpen, isAnimatingOpen]);
102+
103+
if (!isOpen) {
104+
return null;
105+
}
106+
107+
const onChange = (event) => {
108+
const files = Array.from(event.target.files || []);
109+
const validation = onValidateSelection
110+
? onValidateSelection(files)
111+
: { acceptedFiles: files, issues: [] };
112+
113+
if (typeof onIssuesChange === "function") {
114+
onIssuesChange(validation.issues);
115+
}
116+
117+
const normalizedFiles = validation.acceptedFiles.map((file, index) => ({
118+
id: `${file.name}-${file.size}-${file.lastModified || 0}-${index}`,
119+
file,
120+
}));
121+
setSelectedFiles(normalizedFiles);
122+
};
123+
124+
const onRemovePending = (fileId) => {
125+
setSelectedFiles((currentFiles) =>
126+
currentFiles.filter((pendingFile) => pendingFile.id !== fileId),
127+
);
128+
};
129+
130+
const submit = () => {
131+
onAttach(selectedFiles.map((pendingFile) => pendingFile.file));
132+
};
133+
134+
const onPanelKeyDown = (event) => {
135+
if (event.key === "Escape") {
136+
event.preventDefault();
137+
onClose();
138+
}
139+
};
140+
141+
return (
142+
<div
143+
ref={dialogRef}
144+
tabIndex="-1"
145+
role="region"
146+
aria-labelledby="attachments-modal-title"
147+
data-testid="attachments-panel"
148+
onKeyDown={onPanelKeyDown}
149+
className={`bg-white w-full max-w-[720px] p-6 shadow-sm text-base mt-4 attachments-panel border border-gray-300 ${
150+
isAnimatingOpen ? "attachments-panel--open" : ""
151+
}`}
152+
>
153+
<h2
154+
ref={headingRef}
155+
id="attachments-modal-title"
156+
className="heading-large mb-4"
157+
tabIndex="-1"
158+
>
159+
{copy.modalTitle}
160+
</h2>
161+
<p>{copy.modalIntro}</p>
162+
<p>
163+
{copy.modalClassificationPrefix}{" "}
164+
<a href={classificationUrl}>{copy.modalClassificationLinkText}</a>.
165+
</p>
166+
167+
<ul className="list list-bullet ml-5">
168+
<li>{copy.modalAttachedFilesCanBe}</li>
169+
<li>{copy.modalTextDocuments}</li>
170+
<li>{copy.modalDataDocuments}</li>
171+
<li>{copy.modalImageDocuments}</li>
172+
</ul>
173+
174+
<div className="file-upload-group relative inline-flex flex-col gap-2 items-start mb-4">
175+
<input
176+
id="attachments-file-input"
177+
type="file"
178+
name="attachments"
179+
multiple
180+
accept={ACCEPT_ATTRIBUTE}
181+
className="file-upload-field"
182+
data-testid="attachments-file-input"
183+
onChange={onChange}
184+
/>
185+
<label
186+
htmlFor="attachments-file-input"
187+
className="file-upload-button button button-secondary"
188+
>
189+
{copy.modalChooseFiles}
190+
</label>
191+
192+
<p className="mb-2">
193+
{selectedFiles.length
194+
? copy.modalFilesSelected(selectedFiles.length)
195+
: copy.modalNoFilesSelected}
196+
</p>
197+
</div>
198+
{selectedFiles.length > 0 && (
199+
<ul className="space-y-2 mb-4" data-testid="pending-files-list">
200+
{selectedFiles.map((pendingFile) => (
201+
<li
202+
key={pendingFile.id}
203+
className="border border-gray-300 p-3 flex justify-between items-start align-top"
204+
>
205+
<div className="min-w-0 pr-4">
206+
<span
207+
className="attachment-file-name-truncate mb-0 block"
208+
title={pendingFile.file.name}
209+
>
210+
{pendingFile.file.name}
211+
</span>
212+
</div>
213+
<button
214+
className="link text-red-700 self-start"
215+
type="button"
216+
data-testid="attachments-pending-remove"
217+
onClick={() => onRemovePending(pendingFile.id)}
218+
>
219+
<span className="font-bold underline">{copy.remove}</span>
220+
<span className="text-[24px]" aria-hidden="true">
221+
&nbsp;×
222+
</span>
223+
</button>
224+
</li>
225+
))}
226+
</ul>
227+
)}
228+
229+
{issues.length > 0 && (
230+
<div
231+
className="banner-dangerous p-4 mb-4"
232+
role="alert"
233+
data-testid="attach-validation-errors"
234+
>
235+
<ul className="list list-bullet">
236+
{issues.map((issue) => (
237+
<li key={issue}>{issue}</li>
238+
))}
239+
</ul>
240+
</div>
241+
)}
242+
243+
<p className="mt-10">{copy.modalScanNotice}</p>
244+
245+
<div className="flex gap-4 items-center">
246+
<button
247+
type="button"
248+
className="button"
249+
data-testid="attachments-submit"
250+
onClick={submit}
251+
>
252+
{copy.modalAttachToTemplate}
253+
</button>
254+
<button
255+
type="button"
256+
className="button button-secondary text-base"
257+
data-testid="attachments-cancel"
258+
onClick={onClose}
259+
>
260+
{copy.cancel}
261+
</button>
262+
</div>
263+
</div>
264+
);
265+
};
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import React from "react";
2+
import { ATTACHMENT_STATUSES } from "./useAttachments";
3+
import { getAttachmentTranslations } from "./localization";
4+
5+
const DEFAULT_COPY = getAttachmentTranslations("en");
6+
7+
export const AttachedFileRow = ({
8+
file,
9+
copy = DEFAULT_COPY,
10+
isConfirmingRemoval,
11+
onRequestRemove,
12+
onConfirmRemove,
13+
onCancelRemove,
14+
}) => {
15+
const isInProgress =
16+
file.status === ATTACHMENT_STATUSES.UPLOADING ||
17+
file.status === ATTACHMENT_STATUSES.PENDING_SCAN;
18+
const isMalware = file.status === ATTACHMENT_STATUSES.MALWARE;
19+
const downloadHref = file.downloadUrl || file.download_url || file.url;
20+
const canDownload = !isInProgress && !isMalware && Boolean(downloadHref);
21+
22+
const fileNameNode = canDownload ? (
23+
<a
24+
href={downloadHref}
25+
download={file.name}
26+
className="attachment-file-name-truncate"
27+
title={file.name}
28+
data-testid="attachment-download-link"
29+
>
30+
{file.name}
31+
</a>
32+
) : (
33+
<span title={file.name} className="attachment-file-name-truncate">
34+
{file.name}
35+
</span>
36+
);
37+
38+
return (
39+
<li
40+
className={`attachment-file-row border border-gray-300 p-3 mb-2${isMalware ? " bg-gray-100" : ""}${isConfirmingRemoval ? " border-red" : ""}`}
41+
data-testid="attached-file-row"
42+
>
43+
{isConfirmingRemoval ? (
44+
<div className="p-4" role="alert">
45+
<p className="heading-small mt-0 mb-3">{copy.removeConfirmTitle}</p>
46+
{canDownload ? (
47+
<p className="mb-3 mt-0">
48+
{copy.removeConfirmBodyPrefix} '{fileNameNode}'{" "}
49+
{copy.removeConfirmBodySuffix}
50+
</p>
51+
) : null}
52+
<div className="flex gap-4 mt-6">
53+
<button
54+
type="button"
55+
className="button button-red"
56+
data-testid="attachments-remove-confirm"
57+
onClick={() => onConfirmRemove(file.id)}
58+
>
59+
{copy.yesRemoveFile}
60+
</button>
61+
<button
62+
type="button"
63+
className="button button-secondary"
64+
data-testid="attachments-remove-cancel"
65+
onClick={onCancelRemove}
66+
>
67+
{copy.cancel}
68+
</button>
69+
</div>
70+
</div>
71+
) : (
72+
<div className="flex justify-between gap-6 items-start">
73+
<div className="flex flex-col min-w-0">
74+
{isMalware ? (
75+
<p
76+
className="text-red-700 font-bold"
77+
data-testid="attachment-malware-message"
78+
>
79+
{copy.malwareMessage}
80+
</p>
81+
) : null}
82+
<div className="flex items-center min-w-0">
83+
{isInProgress ? (
84+
<div
85+
className="loading-spinner shrink-0 mr-3"
86+
role="status"
87+
aria-label={copy.rowSpinnerAriaLabel}
88+
data-testid="attachment-row-spinner"
89+
></div>
90+
) : null}
91+
<p className="min-w-0 mb-0">{fileNameNode}</p>
92+
</div>
93+
</div>
94+
<button
95+
type="button"
96+
className="link text-red-700 shrink-0 inline-flex items-center gap-2 self-start"
97+
data-testid="attachments-remove"
98+
onClick={() => onRequestRemove(file.id)}
99+
>
100+
<span className="font-bold underline">{copy.remove}</span>
101+
<span className="text-[24px]" aria-hidden="true">
102+
×
103+
</span>
104+
</button>
105+
</div>
106+
)}
107+
</li>
108+
);
109+
};

0 commit comments

Comments
 (0)