From c754e04034d481b6cb9ae17d3838f1130afcd489 Mon Sep 17 00:00:00 2001 From: robbond Date: Thu, 2 Apr 2026 16:09:56 +0100 Subject: [PATCH] batch uploads on frontend --- actions/services/documentDirectService.js | 76 +++++++++-- .../representation/representationElements.js | 19 ++- components/elements/index.js | 19 ++- memory-bank/change-log.md | 119 ++++++++++++++++++ 4 files changed, 212 insertions(+), 21 deletions(-) diff --git a/actions/services/documentDirectService.js b/actions/services/documentDirectService.js index 6b1bc2df..b63b8cd3 100644 --- a/actions/services/documentDirectService.js +++ b/actions/services/documentDirectService.js @@ -14,6 +14,8 @@ import { postSignedFileJson } from "../clients/fileClient"; +const MAX_FILES_PER_UPLOAD_BATCH = 10; + export const getAwaitingSubmissionFromBlob = (containerName) => { const route = buildFileQuery("/api/file/getawaitingsubmissionfromblob", { container: containerName @@ -130,21 +132,69 @@ export const uploadFiles = async ( } }; -export const uploadSingleFile = async (filesObj, containerID, casefolderID) => { +export const uploadSingleFile = async ( + filesObj, + containerID, + casefolderID, + options = {} +) => { let files = filesObj; - - var formData = new FormData(); - formData.append("containerID", containerID); - formData.append("casefolderID", casefolderID); - - for (let i = 0; i < files.length; i++) { - formData.append(files[i].name, files[i]); - } - - var queryUrl = "/api/file/uploadsinglefile"; - try { - return await postSignedFileJson(queryUrl, formData); + const queryUrl = "/api/file/uploadsinglefile"; + const uploadResponse = { + data: [], + invalidFiles: [] + }; + let cumulativeUploaded = 0; + let cumulativeInvalid = 0; + const totalFiles = files.length; + const totalChunks = Math.ceil(totalFiles / MAX_FILES_PER_UPLOAD_BATCH); + + for ( + let index = 0; + index < files.length; + index += MAX_FILES_PER_UPLOAD_BATCH + ) { + const chunkIndex = + Math.floor(index / MAX_FILES_PER_UPLOAD_BATCH) + 1; + const fileChunk = files.slice( + index, + index + MAX_FILES_PER_UPLOAD_BATCH + ); + const formData = new FormData(); + + formData.append("containerID", containerID); + formData.append("casefolderID", casefolderID); + + for (let i = 0; i < fileChunk.length; i++) { + formData.append(fileChunk[i].name, fileChunk[i]); + } + + const chunkResult = await postSignedFileJson(queryUrl, formData); + + if (Array.isArray(chunkResult?.data)) { + uploadResponse.data.push(...chunkResult.data); + cumulativeUploaded += chunkResult.data.length; + } + + if (Array.isArray(chunkResult?.invalidFiles)) { + uploadResponse.invalidFiles.push(...chunkResult.invalidFiles); + cumulativeInvalid += chunkResult.invalidFiles.length; + } + + if (typeof options.onChunkComplete === "function") { + options.onChunkComplete({ + chunkIndex, + totalChunks, + totalFiles, + cumulativeUploaded, + cumulativeInvalid, + processedFiles: cumulativeUploaded + cumulativeInvalid + }); + } + } + + return uploadResponse; } catch (error) { consoleLogger(error); } diff --git a/components/case/representation/representationElements.js b/components/case/representation/representationElements.js index b1c6deaf..d59ffda3 100644 --- a/components/case/representation/representationElements.js +++ b/components/case/representation/representationElements.js @@ -1061,6 +1061,7 @@ export const RenderFileUpload = (field) => { const [completedUploadFiles, setCompletedUploadFiles] = useState(false); const [uploadCountMessage, setUploadCountMessage] = useState(""); + const [totalUploadFiles, setTotalUploadFiles] = useState(0); const handleDropRejected = (fileRejections) => { const errors = fileRejections @@ -1169,6 +1170,7 @@ export const RenderFileUpload = (field) => { if (!acceptedFiles || acceptedFiles.length === 0) { setCompletedUploadFiles(true); // optional: depends on your UX setUploadCountMessage(0); + setTotalUploadFiles(0); return; } @@ -1206,7 +1208,8 @@ export const RenderFileUpload = (field) => { ); } - setUploadCountMessage(renamedAcceptedFiles.length); + setUploadCountMessage(0); + setTotalUploadFiles(renamedAcceptedFiles.length); uploadSingleFile( renamedAcceptedFiles, @@ -1214,7 +1217,12 @@ export const RenderFileUpload = (field) => { field.ticketnumber + "/" + field.filenamePrefix.representationForm.values - .repfile_name + .repfile_name, + { + onChunkComplete: ({ cumulativeUploaded }) => { + setUploadCountMessage(cumulativeUploaded); + } + } ).then((data) => { console.log(data); let buildAppealFilesArray = []; @@ -1324,10 +1332,13 @@ export const RenderFileUpload = (field) => { )} - {uploadCountMessage > 0 && completedUploadFiles == false && ( + {totalUploadFiles > 0 && completedUploadFiles == false && (
{t("home:uploading-files-label", { - number: uploadCountMessage + number: + totalUploadFiles > 0 + ? `${uploadCountMessage} of ${totalUploadFiles}` + : uploadCountMessage })}
)} diff --git a/components/elements/index.js b/components/elements/index.js index ebe58c29..e86b88e7 100644 --- a/components/elements/index.js +++ b/components/elements/index.js @@ -2147,6 +2147,7 @@ const RenderFileUpload = (field) => { const [completedUploadFiles, setCompletedUploadFiles] = useState(false); const [uploadCountMessage, setUploadCountMessage] = useState(""); + const [totalUploadFiles, setTotalUploadFiles] = useState(0); const handleDropRejected = (fileRejections) => { const errors = fileRejections @@ -2254,6 +2255,7 @@ const RenderFileUpload = (field) => { if (!acceptedFiles || acceptedFiles.length === 0) { setCompletedUploadFiles(true); // optional: depends on your UX setUploadCountMessage(0); + setTotalUploadFiles(0); return; } @@ -2280,12 +2282,18 @@ const RenderFileUpload = (field) => { ); } - setUploadCountMessage(renamedAcceptedFiles.length); + setUploadCountMessage(0); + setTotalUploadFiles(renamedAcceptedFiles.length); uploadSingleFile( renamedAcceptedFiles, field.containerID, - field.ticketnumber + field.ticketnumber, + { + onChunkComplete: ({ cumulativeUploaded }) => { + setUploadCountMessage(cumulativeUploaded); + } + } ) .then((data) => { let buildAppealFilesArray = []; @@ -2386,10 +2394,13 @@ const RenderFileUpload = (field) => { )} - {uploadCountMessage > 0 && completedUploadFiles == false && ( + {totalUploadFiles > 0 && completedUploadFiles == false && (
{t("home:uploading-files-label", { - number: uploadCountMessage + number: + totalUploadFiles > 0 + ? `${uploadCountMessage} of ${totalUploadFiles}` + : uploadCountMessage })}
)} diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index c6e4d9b9..54b008da 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -3194,3 +3194,122 @@ Validation: Follow-ups: - Optional next bounded slice: stop or switch scope; touched-function micro-tidies in this area are now largely exhausted. + +--- + +### CL-090: uploadsinglefile API batch upload chunking (10 files per pass) + +date: 2026-04-02 +author: Cline +scope: `pages/api/file/uploadsinglefile.js` +type: change +rationale: Prevent oversized single-pass uploads by splitting validated files into bounded batches and invoking existing upload logic per batch. +impact: Upload flow now processes validated files in deterministic chunks of 10 while preserving existing hash guards, validation behavior, and response contract (`data`, `invalidFiles`). +status: completed + +Summary: + +- Added `MAX_FILES_PER_UPLOAD_BATCH = 10` in `uploadsinglefile` API route. +- Replaced single `uploadSingleFile(...)` invocation with chunked processing: + - converts allowed files object to entries + - slices into batches of 10 + - awaits `uploadSingleFile(...)` once per batch + - aggregates batch results into one `data` array for response +- Kept existing error handling unchanged (`UPLOAD_SINGLE_FILE_FAILED` on catch). + +Validation: + +- `npx eslint pages/api/file/uploadsinglefile.js` -> executed with no lint output. + +Follow-ups: + +- Optional: add a focused API contract/unit test to assert that 11+ files cause multiple `uploadSingleFile` invocations with max chunk size 10. + +--- + +### CL-091: uploadsinglefile client-side batch upload chunking parity (10 files per pass) + +date: 2026-04-02 +author: Cline +scope: `actions/services/documentDirectService.js` +type: change +rationale: Ensure client upload flow aligns with server batching requirement by splitting outbound `uploadsinglefile` requests into chunks of 10 files. +impact: Client now sends multiple sequential API requests (max 10 files each) and merges per-chunk responses into one contract-compatible payload (`data`, `invalidFiles`) for existing UI handlers. +status: completed + +Summary: + +- Added `MAX_FILES_PER_UPLOAD_BATCH = 10` to document direct service. +- Updated `uploadSingleFile(filesObj, containerID, casefolderID)` to: + - split selected files into batches of 10 + - create a fresh `FormData` per batch + - call `/api/file/uploadsinglefile` once per batch (sequential `await`) + - aggregate `data` and `invalidFiles` from all chunk responses +- Preserved function return shape used by current upload UIs. + +Validation: + +- `npx eslint pages/api/file/uploadsinglefile.js actions/services/documentDirectService.js` -> executed with no lint output. + +Follow-ups: + +- Optional: add a focused service-behaviour test that verifies `uploadSingleFile` issues N requests for N/10 chunks and merges all chunk responses. + +--- + +### CL-092: per-chunk upload UI progress updates (new appeal + representations) + +date: 2026-04-02 +author: Cline +scope: `actions/services/documentDirectService.js`, `components/elements/index.js`, `components/case/representation/representationElements.js` +type: change +rationale: Update UX so upload progress reflects completed chunks incrementally instead of waiting for all chunk requests to finish. +impact: Upload status message now updates after each chunk completes, showing users progressive completion for large multi-file uploads while preserving existing response/error contracts. +status: completed + +Summary: + +- Extended `uploadSingleFile(...)` service signature with optional callback support: + - new `options` argument + - invokes `options.onChunkComplete(...)` after each chunk upload + - emits cumulative counters (`cumulativeUploaded`, `cumulativeInvalid`, `processedFiles`, chunk index metadata) +- Wired per-chunk UI progress updates in both upload entry points: + - `components/elements/index.js` + - `components/case/representation/representationElements.js` +- Both now pass `onChunkComplete` and update `uploadCountMessage` from cumulative uploaded count as each chunk finishes. + +Validation: + +- `npx eslint pages/api/file/uploadsinglefile.js actions/services/documentDirectService.js components/elements/index.js components/case/representation/representationElements.js` -> executed with no lint output. + +Follow-ups: + +- Optional: add a focused UI behaviour test to assert upload progress increments per completed chunk for >10 file uploads. + +--- + +### CL-093: upload progress message format `X of Y` during chunked uploads + +date: 2026-04-02 +author: Cline +scope: `components/elements/index.js`, `components/case/representation/representationElements.js` +type: change +rationale: User requested clearer in-progress feedback showing uploaded count relative to total files (e.g. `10 of 30`) during chunked uploads. +impact: Upload UI now shows progressive message in `X of Y` format while upload is in flight, improving clarity for large batch uploads. +status: completed + +Summary: + +- Added `totalUploadFiles` state in both upload UIs (new appeal + representations). +- In-progress upload message now renders as: + - `${uploadCountMessage} of ${totalUploadFiles}` +- Upload start now initializes as `0 of Y` and updates after each completed chunk callback. +- Empty-drop/reset path clears both counters to avoid stale totals. + +Validation: + +- `npx eslint actions/services/documentDirectService.js components/elements/index.js components/case/representation/representationElements.js` -> executed with no lint output. + +Follow-ups: + +- Optional: localize a dedicated `upload-progress-x-of-y` translation key if copy needs stronger grammatical control per locale.