Skip to content

Commit ed8c688

Browse files
authored
Merge pull request #4759 from cloud-gov/feat-create-file-upload-hook-queue
feat: Create a file upload hook to queue uploads
2 parents 39f353c + a458a3b commit ed8c688

File tree

8 files changed

+412
-249
lines changed

8 files changed

+412
-249
lines changed

api/services/file-storage/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ class SiteFileStorageSerivce {
251251

252252
await this.#hasDuplicateKey(key, 'file');
253253

254-
await this.s3Client.putObject(fileBuffer, key);
254+
await this.s3Client.putObject(fileBuffer, key, { ContentType: type });
255255

256256
const fsf = await FileStorageFile.create({
257257
name,

frontend/hooks/useFileStorage.js

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,10 @@ export default function useFileStorage(
2222
const previousData = useRef();
2323
const queryClient = useQueryClient();
2424

25-
const [uploadError, setUploadError] = useState(null);
26-
const [uploadSuccess, setUploadSuccess] = useState(null);
2725
const [deleteError, setDeleteError] = useState(null);
2826
const [deleteSuccess, setDeleteSuccess] = useState(null);
2927
const [createFolderError, setCreateFolderError] = useState(null);
3028
const [createFolderSuccess, setCreateFolderSuccess] = useState(null);
31-
const uploadTimeout = useRef(null);
3229
const deleteTimeout = useRef(null);
3330
const createFolderTimeout = useRef(null);
3431

@@ -77,7 +74,6 @@ export default function useFileStorage(
7774

7875
useEffect(() => {
7976
return () => {
80-
if (uploadTimeout.current) clearTimeout(uploadTimeout.current);
8177
if (deleteTimeout.current) clearTimeout(deleteTimeout.current);
8278
if (createFolderTimeout.current) clearTimeout(createFolderTimeout.current);
8379
};
@@ -104,20 +100,15 @@ export default function useFileStorage(
104100
const uploadMutation = useMutation({
105101
mutationFn: ({ parent = '/', file }) =>
106102
federalist.uploadPublicFile(fileStorageId, parent, file),
107-
onSuccess: () =>
108-
handleSuccess(
109-
setUploadSuccess,
110-
setUploadError,
111-
uploadTimeout,
112-
'File uploaded successfully.',
113-
),
114-
onError: (err, { file }) => {
115-
const errorMessage = err?.message || 'Upload failed.';
116-
const formattedMessage = errorMessage.includes('already exists')
117-
? `A file named "${file.name}" already exists in this folder.`
118-
: errorMessage;
119-
handleError(setUploadError, setUploadSuccess, uploadTimeout, formattedMessage);
103+
onSuccess: (file) => {
104+
queryClient.invalidateQueries({
105+
queryKey: ['fileStorage', fileStorageId, path, sortKey, sortOrder, page],
106+
});
107+
108+
return file;
120109
},
110+
onError: (error) => error,
111+
retry: false,
121112
});
122113

123114
const createFolderMutation = useMutation({
@@ -157,8 +148,6 @@ export default function useFileStorage(
157148
deleteError,
158149
deleteSuccess,
159150
uploadFile: (parent, file) => uploadMutation.mutateAsync({ parent, file }),
160-
uploadError,
161-
uploadSuccess,
162151
createFolder: (parent, name) => createFolderMutation.mutateAsync({ parent, name }),
163152
createFolderError,
164153
createFolderSuccess,

frontend/hooks/useFileStorage.test.jsx

Lines changed: 19 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
11
import { waitFor, renderHook, act } from '@testing-library/react';
22
import nock from 'nock';
3-
import { spy } from 'sinon';
3+
import sinon from 'sinon';
44
import { createTestQueryClient } from '@support/queryClient';
55
import {
66
getFileStorageFiles,
77
getFileStorageFilesError,
88
deletePublicItem,
99
deletePublicItemError,
10-
uploadPublicFile,
11-
uploadPublicFileError,
1210
createPublicDirectory,
1311
createPublicDirectoryError,
1412
} from '@support/nocks/fileStorage';
1513
import useFileStorage from './useFileStorage';
1614
import { getFileStorageData } from '../../test/frontend/support/data/fileStorageData';
15+
import federalist from '@util/federalistApi';
1716

1817
const createWrapper = createTestQueryClient();
1918

@@ -26,6 +25,7 @@ const props = {
2625
};
2726
describe('useFileStorage', () => {
2827
beforeEach(() => nock.cleanAll());
28+
afterEach(() => sinon.restore());
2929
afterAll(() => nock.restore());
3030

3131
it('should fetch public files successfully', async () => {
@@ -119,7 +119,7 @@ describe('useFileStorage', () => {
119119
wrapper: createWrapper(),
120120
});
121121

122-
const qcSpy = spy(result.current.queryClient, 'invalidateQueries');
122+
const qcSpy = sinon.spy(result.current.queryClient, 'invalidateQueries');
123123
await act(async () => {
124124
await result.current.deleteItem(fileItem);
125125
});
@@ -141,7 +141,7 @@ describe('useFileStorage', () => {
141141
wrapper: createWrapper(),
142142
});
143143

144-
const qcSpy = spy(result.current.queryClient, 'invalidateQueries');
144+
const qcSpy = sinon.spy(result.current.queryClient, 'invalidateQueries');
145145

146146
await act(async () => {
147147
await result.current.deleteItem(fileItem).catch(() => {});
@@ -155,100 +155,42 @@ describe('useFileStorage', () => {
155155
it('should upload a file successfully and invalidate the query', async () => {
156156
const parent = '/';
157157
const mockFile = new File(['file content'], 'test-file.txt', { type: 'text/plain' });
158+
const stub = sinon.stub(federalist, 'uploadPublicFile');
158159

159-
await uploadPublicFile(props.fileStorageId, parent, mockFile);
160160
await getFileStorageFiles({ ...props, path: parent }, { times: 3 });
161161

162+
stub.resolves();
163+
162164
const { result } = renderHook(() => useFileStorage(props.fileStorageId), {
163165
wrapper: createWrapper(),
164166
});
165167

166-
const qcSpy = spy(result.current.queryClient, 'invalidateQueries');
167-
168-
await act(async () => {
169-
await result.current.uploadFile(parent, mockFile);
170-
});
171-
172-
await waitFor(() =>
173-
expect(result.current.uploadSuccess).toContain('File uploaded successfully'),
174-
);
175-
expect(qcSpy.calledOnce).toBe(true);
176-
});
177-
178-
it('should successfully upload a file even inside a nested folder', async () => {
179-
const parent = '/nested/folder';
180-
const mockFile = new File(['file content'], 'nested-file.txt', {
181-
type: 'text/plain',
182-
});
183-
184-
await uploadPublicFile(props.fileStorageId, parent, mockFile);
185-
await getFileStorageFiles({ ...props, path: parent }, { times: 3 });
186-
187-
const { result } = renderHook(
188-
() =>
189-
// don't spread from Object.values() if you're going to overwrite something
190-
useFileStorage(
191-
props.fileStorageId,
192-
parent,
193-
props.sortKey,
194-
props.sortOrder,
195-
props.page,
196-
),
197-
{
198-
wrapper: createWrapper(),
199-
},
200-
);
201-
202-
const qcSpy = spy(result.current.queryClient, 'invalidateQueries');
168+
const qcSpy = sinon.spy(result.current.queryClient, 'invalidateQueries');
203169

204-
await act(async () => {
205-
await result.current.uploadFile(parent, mockFile);
206-
});
170+
await waitFor(async () => result.current.uploadFile(parent, mockFile));
207171

208-
await waitFor(() =>
209-
expect(result.current.uploadSuccess).toContain('File uploaded successfully'),
210-
);
211172
expect(qcSpy.calledOnce).toBe(true);
212173
});
213174

214-
it('should return an error message when file upload fails', async () => {
175+
it('should throw error when upload file fails', async () => {
215176
const parent = '/';
216177
const mockFile = new File(['file content'], 'test-file.txt', { type: 'text/plain' });
178+
const stub = sinon.stub(federalist, 'uploadPublicFile');
179+
stub.rejects();
217180

218-
const errorMessage = 'Upload failed.';
219-
await uploadPublicFileError(props.fileStorageId, parent, mockFile, errorMessage);
220181
await getFileStorageFiles({ ...props, path: parent }, { times: 3 });
221182

222183
const { result } = renderHook(() => useFileStorage(props.fileStorageId), {
223184
wrapper: createWrapper(),
224185
});
225186

226-
await act(async () => {
227-
await result.current.uploadFile(parent, mockFile).catch(() => {});
228-
});
229-
230-
await waitFor(() => expect(result.current.uploadError).toBe(errorMessage));
231-
});
187+
const qcSpy = sinon.spy(result.current.queryClient, 'invalidateQueries');
232188

233-
it('should return a specific error when uploading a duplicate file', async () => {
234-
const parent = '/';
235-
const mockFile = new File(['file content'], 'existing-file.txt', {
236-
type: 'text/plain',
237-
});
238-
const errorMessage = `A file named "${mockFile.name}" already exists in this folder.`;
239-
240-
await uploadPublicFileError(props.fileStorageId, parent, mockFile, errorMessage);
241-
await getFileStorageFiles({ ...props, path: parent }, { times: 3 });
242-
243-
const { result } = renderHook(() => useFileStorage(props.fileStorageId), {
244-
wrapper: createWrapper(),
245-
});
246-
247-
await act(async () => {
248-
await result.current.uploadFile(parent, mockFile).catch(() => {});
249-
});
189+
await waitFor(async () =>
190+
result.current.uploadFile(parent, mockFile).catch((e) => e),
191+
);
250192

251-
await waitFor(() => expect(result.current.uploadError).toBe(errorMessage));
193+
expect(qcSpy.calledOnce).toBe(false);
252194
});
253195

254196
it('should create a folder successfully and invalidate the query', async () => {
@@ -262,7 +204,7 @@ describe('useFileStorage', () => {
262204
wrapper: createWrapper(),
263205
});
264206

265-
const qcSpy = spy(result.current.queryClient, 'invalidateQueries');
207+
const qcSpy = sinon.spy(result.current.queryClient, 'invalidateQueries');
266208

267209
await act(async () => {
268210
await result.current.createFolder(parent, folderName);

frontend/hooks/useMultiFileUpload.js

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { useState, useCallback } from 'react';
2+
3+
// interface FileUploadItem {
4+
// data: File;
5+
// id: string;
6+
// status: | 'queued' | 'uploading' | 'success' | 'error';
7+
// message?: string
8+
// }
9+
10+
// interface UseMultiFileUploadOptions {
11+
// maxFileSizeMB?: number;
12+
// onUpload: (file: File) => Promise<any>;
13+
// }
14+
15+
const MEGABYTE_LIMIT = 100;
16+
const mbToBytes = (mb) => mb * 1024 * 1024;
17+
18+
export const useMultiFileUpload = ({ onUpload, maxFileSizeMB = MEGABYTE_LIMIT }) => {
19+
const [isUploading, setIsUploading] = useState('pending');
20+
const [files, setFiles] = useState([]);
21+
22+
// eslint-disable-next-line sonarjs/pseudo-random
23+
const generateFileId = () => Math.random().toString(36).substring(2, 15);
24+
25+
const addFiles = useCallback((newFiles) => {
26+
const fileItems = Array.from(newFiles).map((data) => {
27+
const byteLimit = mbToBytes(maxFileSizeMB);
28+
29+
if (data.size > byteLimit) {
30+
return {
31+
data,
32+
id: generateFileId(),
33+
status: 'error',
34+
message: `Exceeds the ${maxFileSizeMB}MB limit.`,
35+
};
36+
}
37+
38+
return {
39+
data,
40+
id: generateFileId(),
41+
status: 'queued',
42+
message: null,
43+
};
44+
});
45+
46+
setFiles((prevFiles) => [...prevFiles, ...fileItems]);
47+
}, []);
48+
49+
const clearFiles = useCallback(() => setFiles(() => []));
50+
51+
const uploadFile = useCallback(
52+
async (fileItem) => {
53+
try {
54+
// Update the uploading status of the files
55+
setFiles((prevFiles) =>
56+
prevFiles.map((f) =>
57+
f.id === fileItem.id ? { ...f, status: 'uploading' } : f,
58+
),
59+
);
60+
61+
// Perform the upload
62+
await onUpload(fileItem.data);
63+
64+
// Update state on success
65+
// Remove successful upload
66+
setFiles((prevFiles) =>
67+
prevFiles.map((f) =>
68+
f.id === fileItem.id ? { ...f, status: 'success', message: 'Success.' } : f,
69+
),
70+
);
71+
} catch (error) {
72+
// Update state on error
73+
const message = error?.message || `Unable to upload ${fileItem.data.name}`;
74+
75+
setFiles((prevFiles) =>
76+
prevFiles.map((f) =>
77+
f.id === fileItem.id ? { ...f, status: 'error', message } : f,
78+
),
79+
);
80+
}
81+
},
82+
[onUpload],
83+
);
84+
85+
const startUploads = useCallback(async () => {
86+
setIsUploading(() => 'uploading');
87+
const pendingFiles = files.filter((f) => f.status === 'queued');
88+
89+
if (pendingFiles.length === 0) {
90+
return setIsUploading(() => 'complete');
91+
}
92+
93+
await Promise.allSettled(pendingFiles.map((fileItem) => uploadFile(fileItem)));
94+
95+
setIsUploading(() => 'complete');
96+
}, [files]);
97+
98+
const removeFile = useCallback((fileId) => {
99+
setFiles((prevFiles) => prevFiles.filter((f) => f.id !== fileId));
100+
}, []);
101+
102+
return {
103+
files,
104+
addFiles,
105+
clearFiles,
106+
isUploading,
107+
removeFile,
108+
startUploads,
109+
};
110+
};

0 commit comments

Comments
 (0)