Skip to content

Commit 949373d

Browse files
committed
feat: Add abort signal support for file uploads and S3 storage operations
1 parent 6e51c34 commit 949373d

File tree

5 files changed

+182
-56
lines changed

5 files changed

+182
-56
lines changed

frontend/src/common/api/reportService.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,16 @@ export interface UploadProgressCallback {
1414
/**
1515
* Creates an authenticated request config with bearer token
1616
*/
17-
export const getAuthConfig = async (): Promise<{ headers: { Accept: string, 'Content-Type': string, Authorization: string }, onUploadProgress?: (progressEvent: AxiosProgressEvent) => void }> => {
17+
export const getAuthConfig = async (signal?: AbortSignal): Promise<{ headers: { Accept: string, 'Content-Type': string, Authorization: string }, signal?: AbortSignal, onUploadProgress?: (progressEvent: AxiosProgressEvent) => void }> => {
1818
const session = await fetchAuthSession();
1919
const idToken = session.tokens?.idToken?.toString() || '';
2020
return {
2121
headers: {
2222
Accept: 'application/json',
2323
'Content-Type': 'application/json',
2424
Authorization: idToken ? `Bearer ${idToken}` : ''
25-
}
25+
},
26+
signal
2627
};
2728
};
2829

@@ -40,11 +41,13 @@ export class ReportError extends Error {
4041
* Uploads a medical report file
4142
* @param file - The file to upload
4243
* @param onProgress - Optional callback for tracking upload progress
44+
* @param signal - Optional abort signal for canceling the request
4345
* @returns Promise with the created medical report
4446
*/
4547
export const uploadReport = async (
4648
file: File,
47-
onProgress?: UploadProgressCallback
49+
onProgress?: UploadProgressCallback,
50+
signal?: AbortSignal
4851
): Promise<MedicalReport> => {
4952
try {
5053
// Import s3StorageService dynamically to avoid circular dependency
@@ -54,11 +57,12 @@ export const uploadReport = async (
5457
const s3Key = await s3StorageService.uploadFile(
5558
file,
5659
'reports',
57-
onProgress as (progress: number) => void
60+
onProgress as (progress: number) => void,
61+
signal
5862
);
5963

6064
// Then create the report record with the S3 key
61-
const config = await getAuthConfig();
65+
const config = await getAuthConfig(signal);
6266

6367
// Send the report metadata to the API
6468
const response = await axios.post(
@@ -71,6 +75,11 @@ export const uploadReport = async (
7175

7276
return response.data;
7377
} catch (error) {
78+
// If the request was aborted, propagate the abort error
79+
if (signal?.aborted) {
80+
throw new DOMException('The operation was aborted', 'AbortError');
81+
}
82+
7483
if (axios.isAxiosError(error)) {
7584
console.error('API Error Details:', error.response?.data, error.response?.headers);
7685
throw new ReportError(`Failed to upload report: ${error.message}`);

frontend/src/common/components/Upload/UploadModal.scss

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -281,8 +281,16 @@
281281
width: 100%;
282282
max-width: 15rem;
283283
--color: white;
284-
--border-color: white;
285-
--background: transparent;
284+
--border-color: rgba(255, 255, 255, 0.6);
285+
--background: rgba(255, 255, 255, 0.1);
286+
--border-radius: 0.5rem;
287+
--border-width: 1px;
288+
--border-style: solid;
289+
290+
ion-icon {
291+
margin-right: 0.5rem;
292+
font-size: 1.25rem;
293+
}
286294
}
287295

288296
&__file-item {
@@ -338,4 +346,4 @@
338346
--background: rgba(0, 0, 0, 0.1);
339347
--progress-background: var(--ion-color-primary);
340348
}
341-
}
349+
}

frontend/src/common/components/Upload/UploadModal.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
IonLabel,
99
IonItem
1010
} from '@ionic/react';
11-
import { closeOutline, cloudUploadOutline, documentOutline, checkmarkOutline } from 'ionicons/icons';
11+
import { closeOutline, cloudUploadOutline, documentOutline, checkmarkOutline, closeCircleOutline } from 'ionicons/icons';
1212
import { useTranslation } from 'react-i18next';
1313
import { UploadStatus, useFileUpload } from '../../hooks/useFileUpload';
1414
import { MedicalReport } from '../../models/medicalReport';
@@ -165,6 +165,7 @@ const UploadModal = ({ isOpen, onClose, onUploadComplete }: UploadModalProps): J
165165
className="upload-modal__cancel-btn"
166166
onClick={handleCancel}
167167
>
168+
<IonIcon icon={closeCircleOutline} slot="start" />
168169
{t('common.cancel')}
169170
</IonButton>
170171
</div>
@@ -227,4 +228,4 @@ const UploadModal = ({ isOpen, onClose, onUploadComplete }: UploadModalProps): J
227228
);
228229
};
229230

230-
export default UploadModal;
231+
export default UploadModal;

frontend/src/common/hooks/useFileUpload.ts

Lines changed: 82 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,33 @@ export const useFileUpload = ({ onUploadComplete }: UseFileUploadOptions = {}):
4545
const [error, setError] = useState<string | null>(null);
4646
// Use a ref to track if upload should be canceled
4747
const cancelRef = useRef<boolean>(false);
48+
// Use a ref to hold the AbortController
49+
const abortControllerRef = useRef<AbortController | null>(null);
4850

4951
const reset = useCallback(() => {
5052
setFile(null);
5153
setStatus(UploadStatus.IDLE);
5254
setProgress(0);
5355
setError(null);
5456
cancelRef.current = false;
57+
58+
// Abort any pending requests from previous uploads
59+
if (abortControllerRef.current) {
60+
abortControllerRef.current.abort();
61+
abortControllerRef.current = null;
62+
}
5563
}, []);
5664

5765
const cancelUpload = useCallback(() => {
66+
cancelRef.current = true;
67+
68+
// Abort the ongoing request if there's one
69+
if (abortControllerRef.current) {
70+
abortControllerRef.current.abort();
71+
abortControllerRef.current = null;
72+
}
73+
5874
if (status === UploadStatus.UPLOADING || status === UploadStatus.REQUESTING_PERMISSION) {
59-
cancelRef.current = true;
6075
setStatus(UploadStatus.IDLE);
6176
setProgress(0);
6277
} else {
@@ -89,6 +104,20 @@ export const useFileUpload = ({ onUploadComplete }: UseFileUploadOptions = {}):
89104
setError(null);
90105
}, [t]);
91106

107+
// Extract the progress callback outside of uploadFile to reduce complexity
108+
const createProgressCallback = useCallback((signal: AbortSignal): UploadProgressCallback => {
109+
return (progress: number) => {
110+
if (!cancelRef.current && !signal.aborted) {
111+
setProgress(progress);
112+
}
113+
};
114+
}, []);
115+
116+
// Helper to check if the upload has been canceled
117+
const isUploadCanceled = useCallback((signal: AbortSignal): boolean => {
118+
return cancelRef.current || signal.aborted;
119+
}, []);
120+
92121
const uploadFile = useCallback(async () => {
93122
if (!file) {
94123
setError(t('upload.error.noFile'));
@@ -97,70 +126,88 @@ export const useFileUpload = ({ onUploadComplete }: UseFileUploadOptions = {}):
97126

98127
// Reset cancel flag
99128
cancelRef.current = false;
129+
130+
// Create a new AbortController for this upload request
131+
abortControllerRef.current = new AbortController();
132+
const { signal } = abortControllerRef.current;
100133

101134
try {
102135
setStatus(UploadStatus.REQUESTING_PERMISSION);
103136

104-
// Check for permissions
105-
let hasPermission = await checkFilePermissions();
137+
// Check and request permissions if needed
138+
const hasPermission = await checkPermissions();
106139

107140
if (!hasPermission) {
108-
// Request permissions
109-
hasPermission = await requestFilePermissions();
110-
111-
if (!hasPermission) {
112-
setStatus(UploadStatus.ERROR);
113-
setError(t('upload.error.permissionDenied'));
114-
return;
115-
}
141+
setStatus(UploadStatus.ERROR);
142+
setError(t('upload.error.permissionDenied'));
143+
return;
116144
}
117145

118146
// Check if canceled during permission check
119-
if (cancelRef.current) {
147+
if (isUploadCanceled(signal)) {
120148
setStatus(UploadStatus.IDLE);
121149
return;
122150
}
123151

124152
setStatus(UploadStatus.UPLOADING);
125153
setProgress(0);
126154

127-
// Create a progress callback for the upload
128-
const updateProgress: UploadProgressCallback = (progress) => {
129-
// Only update progress if not canceled
130-
if (!cancelRef.current) {
131-
setProgress(progress);
132-
}
133-
};
155+
// Get a progress callback
156+
const updateProgress = createProgressCallback(signal);
134157

135-
// Upload the file using the API service
136-
const result = await uploadReport(file, updateProgress);
158+
// Upload the file
159+
const result = await uploadReport(file, updateProgress, signal);
137160

138161
// Check if canceled during upload
139-
if (cancelRef.current) {
162+
if (isUploadCanceled(signal)) {
140163
setStatus(UploadStatus.IDLE);
141164
return;
142165
}
143166

144-
// Set progress to 100% to indicate completion
167+
// Success
145168
setProgress(1);
146169
setStatus(UploadStatus.SUCCESS);
147170

148-
// Notify parent component if callback provided
149171
if (onUploadComplete) {
150172
onUploadComplete(result);
151173
}
152174
} catch (error) {
153-
// Don't show error if canceled
154-
if (!cancelRef.current) {
155-
setStatus(UploadStatus.ERROR);
156-
setError(
157-
error instanceof Error
158-
? error.message
159-
: t('upload.error.unknown')
160-
);
161-
}
175+
handleUploadError(error as Error, signal);
176+
} finally {
177+
cleanupAbortController(signal);
178+
}
179+
}, [file, onUploadComplete, t, createProgressCallback, isUploadCanceled]);
180+
181+
// Helper to handle file permissions
182+
const checkPermissions = useCallback(async (): Promise<boolean> => {
183+
let hasPermission = await checkFilePermissions();
184+
185+
if (!hasPermission) {
186+
hasPermission = await requestFilePermissions();
162187
}
163-
}, [file, onUploadComplete, t]);
188+
189+
return hasPermission;
190+
}, []);
191+
192+
// Helper to handle upload errors
193+
const handleUploadError = useCallback((error: Error, signal: AbortSignal) => {
194+
// Don't show error for aborted requests
195+
if (error instanceof DOMException && error.name === 'AbortError') {
196+
return;
197+
}
198+
199+
if (!isUploadCanceled(signal)) {
200+
setStatus(UploadStatus.ERROR);
201+
setError(error instanceof Error ? error.message : t('upload.error.unknown'));
202+
}
203+
}, [t, isUploadCanceled]);
204+
205+
// Helper to clean up the AbortController
206+
const cleanupAbortController = useCallback((signal: AbortSignal) => {
207+
if (abortControllerRef.current?.signal === signal) {
208+
abortControllerRef.current = null;
209+
}
210+
}, []);
164211

165212
return {
166213
file,
@@ -173,4 +220,4 @@ export const useFileUpload = ({ onUploadComplete }: UseFileUploadOptions = {}):
173220
formatFileSize,
174221
cancelUpload
175222
};
176-
};
223+
};

0 commit comments

Comments
 (0)