Skip to content

Commit 84e348c

Browse files
authored
Merge branch 'main' into feat/add-agents-and-skills
2 parents 799ee1e + 3e359d8 commit 84e348c

36 files changed

Lines changed: 2898 additions & 38 deletions

app/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
from app.notify_client.complaint_api_client import complaint_api_client
7272
from app.notify_client.email_branding_client import email_branding_client
7373
from app.notify_client.events_api_client import events_api_client
74+
from app.notify_client.file_api_client import file_api_client
7475
from app.notify_client.inbound_number_client import inbound_number_client
7576
from app.notify_client.invite_api_client import invite_api_client
7677
from app.notify_client.job_api_client import job_api_client
@@ -171,6 +172,7 @@ def get_locale():
171172
complaint_api_client,
172173
email_branding_client,
173174
events_api_client,
175+
file_api_client,
174176
inbound_number_client,
175177
invite_api_client,
176178
job_api_client,
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: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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+
downloadEndpoint,
12+
onRequestRemove,
13+
onConfirmRemove,
14+
onCancelRemove,
15+
}) => {
16+
const isInProgress =
17+
file.status === ATTACHMENT_STATUSES.UPLOADING ||
18+
file.status === ATTACHMENT_STATUSES.PENDING_VIRUS_SCAN;
19+
const isUploaded = file.status === ATTACHMENT_STATUSES.UPLOADED;
20+
const isMalware = file.status === ATTACHMENT_STATUSES.VIRUS_SCAN_FAILED;
21+
const downloadHref = downloadEndpoint
22+
? `${downloadEndpoint}/${encodeURIComponent(file.id)}`
23+
: null;
24+
const canDownload = !isInProgress && !isMalware && Boolean(downloadHref);
25+
26+
const fileNameNode = canDownload ? (
27+
<a
28+
href={downloadHref}
29+
download={file.name}
30+
className="attachment-file-name-truncate"
31+
title={file.name}
32+
data-testid="attachment-download-link"
33+
>
34+
{file.name}
35+
</a>
36+
) : (
37+
<span className="attachment-file-name-truncate">{file.name}</span>
38+
);
39+
40+
return (
41+
<li
42+
className={`attachment-file-row border border-gray-300 p-3 mb-2${isMalware ? " bg-gray-100" : ""}${isConfirmingRemoval ? " border-red" : ""}`}
43+
data-testid="attached-file-row"
44+
>
45+
{isConfirmingRemoval ? (
46+
<div className="p-4" role="alert">
47+
<p className="heading-small mt-0 mb-3">{copy.removeConfirmTitle}</p>
48+
{isUploaded ? (
49+
<p className="mb-3 mt-0">
50+
{copy.removeConfirmBodyPrefix} '{fileNameNode}'{" "}
51+
{copy.removeConfirmBodySuffix}
52+
</p>
53+
) : null}
54+
<div className="flex gap-4 mt-6">
55+
<button
56+
type="button"
57+
className="button button-red"
58+
data-testid="attachments-remove-confirm"
59+
onClick={() => onConfirmRemove(file.id)}
60+
>
61+
{copy.yesRemoveFile}
62+
</button>
63+
<button
64+
type="button"
65+
className="button button-secondary"
66+
data-testid="attachments-remove-cancel"
67+
onClick={onCancelRemove}
68+
>
69+
{copy.cancel}
70+
</button>
71+
</div>
72+
</div>
73+
) : (
74+
<div className="flex justify-between gap-6 items-start">
75+
<div className="flex flex-col min-w-0">
76+
{isMalware ? (
77+
<p
78+
className="text-red-700 font-bold"
79+
data-testid="attachment-malware-message"
80+
>
81+
{copy.malwareMessage}
82+
</p>
83+
) : null}
84+
<div className="flex items-center min-w-0">
85+
{isInProgress ? (
86+
<div
87+
className="loading-spinner shrink-0 mr-3"
88+
role="status"
89+
aria-label={copy.rowSpinnerAriaLabel}
90+
data-testid="attachment-row-spinner"
91+
></div>
92+
) : null}
93+
<p className="min-w-0 mb-0">{fileNameNode}</p>
94+
</div>
95+
</div>
96+
<button
97+
type="button"
98+
className="link text-red-700 shrink-0 inline-flex items-center gap-2 self-start"
99+
data-testid="attachments-remove"
100+
onClick={() => onRequestRemove(file.id)}
101+
>
102+
<span className="font-bold underline">{copy.remove}</span>
103+
<span className="text-[24px]" aria-hidden="true">
104+
×
105+
</span>
106+
</button>
107+
</div>
108+
)}
109+
</li>
110+
);
111+
};

0 commit comments

Comments
 (0)