Merged PR 2227: batch uploads on frontend
batch uploads on frontend Related work items: #22422
This commit is contained in:
@@ -14,6 +14,8 @@ import {
|
|||||||
postSignedFileJson
|
postSignedFileJson
|
||||||
} from "../clients/fileClient";
|
} from "../clients/fileClient";
|
||||||
|
|
||||||
|
const MAX_FILES_PER_UPLOAD_BATCH = 10;
|
||||||
|
|
||||||
export const getAwaitingSubmissionFromBlob = (containerName) => {
|
export const getAwaitingSubmissionFromBlob = (containerName) => {
|
||||||
const route = buildFileQuery("/api/file/getawaitingsubmissionfromblob", {
|
const route = buildFileQuery("/api/file/getawaitingsubmissionfromblob", {
|
||||||
container: containerName
|
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;
|
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 {
|
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) {
|
} catch (error) {
|
||||||
consoleLogger(error);
|
consoleLogger(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1061,6 +1061,7 @@ export const RenderFileUpload = (field) => {
|
|||||||
|
|
||||||
const [completedUploadFiles, setCompletedUploadFiles] = useState(false);
|
const [completedUploadFiles, setCompletedUploadFiles] = useState(false);
|
||||||
const [uploadCountMessage, setUploadCountMessage] = useState("");
|
const [uploadCountMessage, setUploadCountMessage] = useState("");
|
||||||
|
const [totalUploadFiles, setTotalUploadFiles] = useState(0);
|
||||||
|
|
||||||
const handleDropRejected = (fileRejections) => {
|
const handleDropRejected = (fileRejections) => {
|
||||||
const errors = fileRejections
|
const errors = fileRejections
|
||||||
@@ -1169,6 +1170,7 @@ export const RenderFileUpload = (field) => {
|
|||||||
if (!acceptedFiles || acceptedFiles.length === 0) {
|
if (!acceptedFiles || acceptedFiles.length === 0) {
|
||||||
setCompletedUploadFiles(true); // optional: depends on your UX
|
setCompletedUploadFiles(true); // optional: depends on your UX
|
||||||
setUploadCountMessage(0);
|
setUploadCountMessage(0);
|
||||||
|
setTotalUploadFiles(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1206,7 +1208,8 @@ export const RenderFileUpload = (field) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setUploadCountMessage(renamedAcceptedFiles.length);
|
setUploadCountMessage(0);
|
||||||
|
setTotalUploadFiles(renamedAcceptedFiles.length);
|
||||||
|
|
||||||
uploadSingleFile(
|
uploadSingleFile(
|
||||||
renamedAcceptedFiles,
|
renamedAcceptedFiles,
|
||||||
@@ -1214,7 +1217,12 @@ export const RenderFileUpload = (field) => {
|
|||||||
field.ticketnumber +
|
field.ticketnumber +
|
||||||
"/" +
|
"/" +
|
||||||
field.filenamePrefix.representationForm.values
|
field.filenamePrefix.representationForm.values
|
||||||
.repfile_name
|
.repfile_name,
|
||||||
|
{
|
||||||
|
onChunkComplete: ({ cumulativeUploaded }) => {
|
||||||
|
setUploadCountMessage(cumulativeUploaded);
|
||||||
|
}
|
||||||
|
}
|
||||||
).then((data) => {
|
).then((data) => {
|
||||||
console.log(data);
|
console.log(data);
|
||||||
let buildAppealFilesArray = [];
|
let buildAppealFilesArray = [];
|
||||||
@@ -1324,10 +1332,13 @@ export const RenderFileUpload = (field) => {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{uploadCountMessage > 0 && completedUploadFiles == false && (
|
{totalUploadFiles > 0 && completedUploadFiles == false && (
|
||||||
<div className="govuk-body govuk-!-font-size-14">
|
<div className="govuk-body govuk-!-font-size-14">
|
||||||
{t("home:uploading-files-label", {
|
{t("home:uploading-files-label", {
|
||||||
number: uploadCountMessage
|
number:
|
||||||
|
totalUploadFiles > 0
|
||||||
|
? `${uploadCountMessage} of ${totalUploadFiles}`
|
||||||
|
: uploadCountMessage
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2147,6 +2147,7 @@ const RenderFileUpload = (field) => {
|
|||||||
|
|
||||||
const [completedUploadFiles, setCompletedUploadFiles] = useState(false);
|
const [completedUploadFiles, setCompletedUploadFiles] = useState(false);
|
||||||
const [uploadCountMessage, setUploadCountMessage] = useState("");
|
const [uploadCountMessage, setUploadCountMessage] = useState("");
|
||||||
|
const [totalUploadFiles, setTotalUploadFiles] = useState(0);
|
||||||
|
|
||||||
const handleDropRejected = (fileRejections) => {
|
const handleDropRejected = (fileRejections) => {
|
||||||
const errors = fileRejections
|
const errors = fileRejections
|
||||||
@@ -2254,6 +2255,7 @@ const RenderFileUpload = (field) => {
|
|||||||
if (!acceptedFiles || acceptedFiles.length === 0) {
|
if (!acceptedFiles || acceptedFiles.length === 0) {
|
||||||
setCompletedUploadFiles(true); // optional: depends on your UX
|
setCompletedUploadFiles(true); // optional: depends on your UX
|
||||||
setUploadCountMessage(0);
|
setUploadCountMessage(0);
|
||||||
|
setTotalUploadFiles(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2280,12 +2282,18 @@ const RenderFileUpload = (field) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setUploadCountMessage(renamedAcceptedFiles.length);
|
setUploadCountMessage(0);
|
||||||
|
setTotalUploadFiles(renamedAcceptedFiles.length);
|
||||||
|
|
||||||
uploadSingleFile(
|
uploadSingleFile(
|
||||||
renamedAcceptedFiles,
|
renamedAcceptedFiles,
|
||||||
field.containerID,
|
field.containerID,
|
||||||
field.ticketnumber
|
field.ticketnumber,
|
||||||
|
{
|
||||||
|
onChunkComplete: ({ cumulativeUploaded }) => {
|
||||||
|
setUploadCountMessage(cumulativeUploaded);
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
let buildAppealFilesArray = [];
|
let buildAppealFilesArray = [];
|
||||||
@@ -2386,10 +2394,13 @@ const RenderFileUpload = (field) => {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{uploadCountMessage > 0 && completedUploadFiles == false && (
|
{totalUploadFiles > 0 && completedUploadFiles == false && (
|
||||||
<div className="govuk-body govuk-!-font-size-14">
|
<div className="govuk-body govuk-!-font-size-14">
|
||||||
{t("home:uploading-files-label", {
|
{t("home:uploading-files-label", {
|
||||||
number: uploadCountMessage
|
number:
|
||||||
|
totalUploadFiles > 0
|
||||||
|
? `${uploadCountMessage} of ${totalUploadFiles}`
|
||||||
|
: uploadCountMessage
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3194,3 +3194,122 @@ Validation:
|
|||||||
Follow-ups:
|
Follow-ups:
|
||||||
|
|
||||||
- Optional next bounded slice: stop or switch scope; touched-function micro-tidies in this area are now largely exhausted.
|
- 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user