{
- const rootStore = useRootStore(props);
+ const rootStore = useRootStore();
+ const translations = useTranslationsStore();
const onDrop = useCallback(
(acceptedFiles: File[], fileRejections: FileRejection[]) => {
@@ -21,21 +23,27 @@ export const FileUploaderRoot = observer((props: FileUploaderContainerProps): Re
[rootStore]
);
+ let warningMessage: string | undefined;
+ if (rootStore.isFileUploadLimitReached) {
+ warningMessage = translations.get("uploadLimitReachedMessage", rootStore.maxTotalFiles.toString());
+ } else if (rootStore.createActionFailed) {
+ warningMessage = translations.get("unavailableCreateActionMessage");
+ }
+
return (
{!rootStore.isReadOnly && (
)}
- {(rootStore.files ?? []).map(fileStore => {
+ {rootStore.sortedFiles.map(fileStore => {
return (
) => {
+ e.stopPropagation();
+ store.retry();
+ },
+ [store]
+ );
+
+ return (
+
+
+
+ );
+});
diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/UploadInfo.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/UploadInfo.tsx
index 7c8b6c3fed..a714f82fc4 100644
--- a/packages/pluggableWidgets/file-uploader-web/src/components/UploadInfo.tsx
+++ b/packages/pluggableWidgets/file-uploader-web/src/components/UploadInfo.tsx
@@ -1,5 +1,6 @@
-import { FileStatus } from "../stores/FileStore";
import { ReactElement } from "react";
+import { FileStatus } from "../stores/FileStore";
+import { useRootStore } from "../utils/useRootStore";
import { useTranslationsStore } from "../utils/useTranslationsStore";
type UploadInfoProps = {
@@ -9,19 +10,26 @@ type UploadInfoProps = {
export function UploadInfo({ status, error }: UploadInfoProps): ReactElement {
const translations = useTranslationsStore();
+ const rootStore = useRootStore();
switch (status) {
case "uploading":
return {translations.get("uploadInProgressMessage")} ;
case "done":
return {translations.get("uploadSuccessMessage")} ;
case "uploadingError":
- case "removedAfterError":
return {translations.get("uploadFailureGenericMessage")} ;
+ case "rejected":
+ return (
+
+ {translations.get("uploadLimitReachedMessage", rootStore.maxTotalFiles.toString())}
+
+ );
case "validationError":
return {error} ;
case "removedFile":
return {translations.get("removeSuccessMessage")} ;
- case "new":
+ case "queued":
+ return {translations.get("uploadQueuedMessage")} ;
case "existingFile":
default:
return ;
diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts
index dc1b1e3a7f..4898a5fbe7 100644
--- a/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts
+++ b/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts
@@ -1,8 +1,9 @@
import { Big } from "big.js";
import { ListActionValue, ObjectItem } from "mendix";
-import { action, computed, makeObservable, observable, runInAction } from "mobx";
import mimeTypes from "mime-types";
+import { action, computed, makeObservable, observable, runInAction } from "mobx";
+import { executeAction } from "@mendix/widget-plugin-platform/framework/execute-action";
import { FileUploaderStore } from "./FileUploaderStore";
import {
fetchDocumentUrl,
@@ -12,18 +13,17 @@ import {
removeObject,
saveFile
} from "../utils/mx-data";
-import { executeAction } from "@mendix/widget-plugin-platform/framework/execute-action";
export type FileStatus =
| "existingFile"
| "missing"
- | "new"
+ | "queued"
| "uploading"
| "done"
| "uploadingError"
- | "removedAfterError"
| "removedFile"
- | "validationError";
+ | "validationError"
+ | "rejected";
let fileKey = 0;
@@ -34,11 +34,11 @@ function getFileKey(): number {
export class FileStore {
fileStatus: FileStatus;
- _file?: File = undefined;
- _objectItem?: ObjectItem = undefined;
- _mxObject?: MxObject = undefined;
- _thumbnailUrl?: string = undefined;
- _rootStore: FileUploaderStore;
+ private _file?: File = undefined;
+ private _objectItem?: ObjectItem = undefined;
+ private _mxObject?: MxObject = undefined;
+ private _thumbnailUrl?: string = undefined;
+ private _rootStore: FileUploaderStore;
key: number;
@@ -51,29 +51,43 @@ export class FileStore {
this._rootStore = rootStore;
this.fileStatus = type;
- makeObservable(this, {
+ makeObservable(this, {
fileStatus: observable,
_mxObject: observable,
errorDescription: observable,
_thumbnailUrl: observable,
canRemove: computed,
+ canRetry: computed,
imagePreviewUrl: computed,
upload: action,
fetchMxObject: action,
- markMissing: action
+ markMissing: action,
+ setQueued: action,
+ retry: action
});
}
markMissing(): void {
- this.fileStatus = this.fileStatus === "uploadingError" ? "removedAfterError" : "missing";
+ this.fileStatus = this.fileStatus === "uploadingError" ? "removedFile" : "missing";
this._mxObject = undefined;
this._objectItem = undefined;
}
- markError(errorMessage: string): void {
- this.fileStatus = "validationError";
- this.errorDescription = errorMessage;
+ setQueued(): void {
+ this.errorDescription = undefined;
+ this.fileStatus = "queued";
+ }
+
+ get canRetry(): boolean {
+ return this.fileStatus === "rejected" && !this._rootStore.isFileUploadLimitReached;
+ }
+
+ retry(): void {
+ if (!this.canRetry) {
+ return;
+ }
+ this.setQueued();
}
canExecute(listAction: ListActionValue): boolean {
@@ -91,12 +105,12 @@ export class FileStore {
}
validate(): boolean {
- return !(this.fileStatus !== "new" || !this._file);
+ return !(this.fileStatus !== "queued" || !this._file);
}
async upload(): Promise {
- if (this.fileStatus === "existingFile") {
- throw new Error("Calling upload on already uploaded files is not supported");
+ if (this.fileStatus !== "queued") {
+ return;
}
// set status
@@ -152,6 +166,10 @@ export class FileStore {
return mimeTypes.lookup(this.title) || "application/octet-stream";
}
+ get objectItemId(): string | undefined {
+ return this._objectItem?.id;
+ }
+
get canRemove(): boolean {
return (!this._rootStore.isReadOnly && this.fileStatus === "existingFile") || this.fileStatus === "done";
}
@@ -192,7 +210,7 @@ export class FileStore {
}
async updateThumbnailUrl(): Promise {
- if (this._rootStore._uploadMode !== "images") {
+ if (this._rootStore.uploadMode !== "images") {
return;
}
@@ -212,7 +230,7 @@ export class FileStore {
}
get imagePreviewUrl(): string | undefined {
- if (this._rootStore._uploadMode !== "images") {
+ if (this._rootStore.uploadMode !== "images") {
return;
}
@@ -234,10 +252,14 @@ export class FileStore {
}
static newFile(file: File, rootStore: FileUploaderStore): FileStore {
- return new FileStore("new", rootStore, file, undefined);
+ return new FileStore("queued", rootStore, file, undefined);
+ }
+
+ static newRejectedFile(file: File, rootStore: FileUploaderStore): FileStore {
+ return new FileStore("rejected", rootStore, file, undefined);
}
- static newFileWithError(file: File, errorMessage: string, rootStore: FileUploaderStore): FileStore {
+ static newFileWithValidationError(file: File, errorMessage: string, rootStore: FileUploaderStore): FileStore {
const store = new FileStore("validationError", rootStore, file, undefined);
runInAction(() => {
store.errorDescription = errorMessage;
diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts
index eb44dfb68f..2546bc979c 100644
--- a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts
+++ b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts
@@ -1,14 +1,14 @@
-import { DynamicValue, ObjectItem } from "mendix";
-import { FileUploaderContainerProps, UploadModeEnum } from "../../typings/FileUploaderProps";
-import { action, computed, makeObservable, observable } from "mobx";
import { Big } from "big.js";
-import { getImageUploaderFormats, parseAllowedFormats } from "../utils/parseAllowedFormats";
-import { FileStore } from "./FileStore";
+import { DynamicValue, ObjectItem } from "mendix";
+import { action, comparer, computed, makeObservable, observable, reaction } from "mobx";
import { FileRejection } from "react-dropzone";
-import { FileCheckFormat } from "../utils/predefinedFormats";
+import { FileStore } from "./FileStore";
import { TranslationsStore } from "./TranslationsStore";
-import { ObjectCreationHelper } from "../utils/ObjectCreationHelper";
+import { FileUploaderContainerProps, UploadModeEnum } from "../../typings/FileUploaderProps";
import { DatasourceUpdateProcessor } from "../utils/DatasourceUpdateProcessor";
+import { ObjectCreationHelper } from "../utils/ObjectCreationHelper";
+import { getImageUploaderFormats, parseAllowedFormats } from "../utils/parseAllowedFormats";
+import { FileCheckFormat } from "../utils/predefinedFormats";
export class FileUploaderStore {
files: FileStore[] = [];
@@ -22,21 +22,24 @@ export class FileUploaderStore {
acceptedFileTypes: FileCheckFormat[];
- _widgetName: string;
- _uploadMode: UploadModeEnum;
- _maxFileSizeMiB = 0;
- _maxFileSize = 0;
- _maxFilesPerUpload: DynamicValue;
+ private _widgetName: string;
+ private _uploadMode: UploadModeEnum;
+ private _maxFileSizeMiB = 0;
+ private _maxFileSize = 0;
+ private _maxTotalFiles: DynamicValue | undefined;
+ private _maxConcurrentUploads: DynamicValue | undefined;
+ private _disposePromoteReaction: (() => void) | undefined;
- errorMessage?: string = undefined;
+ createActionFailed = false;
- translations: TranslationsStore;
+ private translations: TranslationsStore;
constructor(props: FileUploaderContainerProps, translations: TranslationsStore) {
this._widgetName = props.name;
this._maxFileSizeMiB = props.maxFileSize;
this._maxFileSize = this._maxFileSizeMiB * 1024 * 1024;
- this._maxFilesPerUpload = props.maxFilesPerUpload;
+ this._maxTotalFiles = props.maxFilesPerUpload;
+ this._maxConcurrentUploads = props.maxFilesPerBatch;
this._uploadMode = props.uploadMode;
this.objectCreationHelper = new ObjectCreationHelper(this._widgetName, props.objectCreationTimeout);
@@ -52,7 +55,7 @@ export class FileUploaderStore {
},
processMissing: (missingItem: ObjectItem) => {
const missingFile = this.files.find(f => {
- return f._objectItem?.id === missingItem.id;
+ return f.objectItemId === missingItem.id;
});
if (!missingFile) {
@@ -71,28 +74,46 @@ export class FileUploaderStore {
this.translations = translations;
- makeObservable(this, {
+ makeObservable(this, {
updateProps: action,
processDrop: action,
- setMessage: action,
+ setCreateActionFailed: action,
+ promoteQueuedFiles: action,
processExistingFileItem: action,
files: observable,
existingItemsLoaded: observable,
- errorMessage: observable,
+ createActionFailed: observable,
allowedFormatsDescription: computed,
- maxFilesPerUpload: computed,
- _maxFilesPerUpload: observable,
- isFileUploadLimitReached: computed
+ maxFileSize: computed,
+ maxTotalFiles: computed,
+ maxConcurrentUploads: computed,
+ _maxTotalFiles: observable,
+ _maxConcurrentUploads: observable,
+ isFileUploadLimitReached: computed,
+ sortedFiles: computed,
+ activeCount: computed,
+ uploadingCount: computed,
+ queuedCount: computed
});
this.updateProps(props);
+
+ this._disposePromoteReaction = reaction(
+ () => ({ uploading: this.uploadingCount, queued: this.queuedCount }),
+ ({ uploading, queued }, prev) => {
+ if (uploading < prev.uploading || queued > prev.queued) {
+ this.promoteQueuedFiles();
+ }
+ },
+ { equals: comparer.structural }
+ );
}
updateProps(props: FileUploaderContainerProps): void {
this.objectCreationHelper.updateProps(props);
- // Update max files properties
- this._maxFilesPerUpload = props.maxFilesPerUpload;
+ this._maxTotalFiles = props.maxFilesPerUpload;
+ this._maxConcurrentUploads = props.maxFilesPerBatch;
this.translations.updateProps(props);
this.updateProcessor.processUpdate(
@@ -115,31 +136,88 @@ export class FileUploaderStore {
.join(", ");
}
- get maxFilesPerUpload(): number {
- const expressionValue = this._maxFilesPerUpload.value;
+ get maxFileSize(): number {
+ return this._maxFileSize;
+ }
+
+ get uploadMode(): UploadModeEnum {
+ return this._uploadMode;
+ }
+
+ get maxTotalFiles(): number {
+ const expressionValue = this._maxTotalFiles?.value;
+ if (expressionValue) {
+ return expressionValue.toNumber();
+ }
+ return 0;
+ }
+
+ get maxConcurrentUploads(): number {
+ const expressionValue = this._maxConcurrentUploads?.value;
if (expressionValue) {
return expressionValue.toNumber();
}
- // Fallback to unlimited
return 0;
}
+ get activeCount(): number {
+ return this.files.filter(
+ f =>
+ f.fileStatus !== "missing" &&
+ f.fileStatus !== "removedFile" &&
+ f.fileStatus !== "validationError" &&
+ f.fileStatus !== "rejected" &&
+ f.fileStatus !== "uploadingError"
+ ).length;
+ }
+
+ get uploadingCount(): number {
+ return this.files.filter(f => f.fileStatus === "uploading").length;
+ }
+
+ get queuedCount(): number {
+ return this.files.filter(f => f.fileStatus === "queued").length;
+ }
+
get isFileUploadLimitReached(): boolean {
- const activeFiles = this.files.filter(
- file =>
- file.fileStatus !== "missing" &&
- file.fileStatus !== "removedFile" &&
- file.fileStatus !== "validationError"
- );
- if (this.maxFilesPerUpload === 0) {
+ if (this.maxTotalFiles === 0) {
return false;
}
- return activeFiles.length >= this.maxFilesPerUpload;
+ return this.activeCount >= this.maxTotalFiles;
+ }
+
+ get sortedFiles(): FileStore[] {
+ return [...this.files].sort((a, b) => {
+ const isErrorA = a.fileStatus === "validationError" ? 1 : 0;
+ const isErrorB = b.fileStatus === "validationError" ? 1 : 0;
+ return isErrorA - isErrorB;
+ });
+ }
+
+ setCreateActionFailed(failed: boolean): void {
+ this.createActionFailed = failed;
+ }
+
+ promoteQueuedFiles(): void {
+ const concurrentLimit = this.maxConcurrentUploads;
+ const availableSlots =
+ concurrentLimit > 0 ? Math.max(0, concurrentLimit - this.uploadingCount) : Number.MAX_SAFE_INTEGER;
+
+ if (availableSlots === 0) {
+ return;
+ }
+
+ // oldest first: last in array = oldest
+ const queued = [...this.files].filter(f => f.fileStatus === "queued").reverse();
+
+ for (let i = 0; i < Math.min(availableSlots, queued.length); i++) {
+ queued[i].upload();
+ }
}
- setMessage(msg?: string): void {
- this.errorMessage = msg;
+ dispose(): void {
+ this._disposePromoteReaction?.();
}
processDrop(acceptedFiles: File[], fileRejections: FileRejection[]): void {
@@ -147,21 +225,19 @@ export class FileUploaderStore {
console.error(
`'Action to create new files/images' is not available or can't be executed. Please check if '${this._widgetName}' widget is configured correctly.`
);
- this.setMessage(this.translations.get("unavailableCreateActionMessage"));
+ this.setCreateActionFailed(true);
return;
}
- if (fileRejections.length && fileRejections[0].errors[0].code === "too-many-files") {
- this.setMessage(
- this.translations.get("uploadFailureTooManyFilesMessage", this.maxFilesPerUpload.toString())
- );
- return;
- }
+ this.setCreateActionFailed(false);
- this.setMessage();
+ const activeCount = this.activeCount;
+ const remaining = this.maxTotalFiles > 0 ? Math.max(0, this.maxTotalFiles - activeCount) : acceptedFiles.length;
+ const capacityFiles = acceptedFiles.slice(0, remaining);
+ const capacityExcess = acceptedFiles.slice(remaining);
for (const file of fileRejections) {
- const newFileStore = FileStore.newFileWithError(
+ const newFileStore = FileStore.newFileWithValidationError(
file.file,
file.errors
.map(e => {
@@ -182,24 +258,16 @@ export class FileUploaderStore {
.join(" "),
this
);
-
this.files.unshift(newFileStore);
}
- for (const file of acceptedFiles) {
- const newFileStore = FileStore.newFile(file, this);
-
- if (this.isFileUploadLimitReached) {
- newFileStore.markError(
- this.translations.get("uploadFailureTooManyFilesMessage", this.maxFilesPerUpload.toString())
- );
- }
+ for (const file of capacityExcess) {
+ this.files.unshift(FileStore.newRejectedFile(file, this));
+ }
+ for (const file of capacityFiles) {
+ const newFileStore = FileStore.newFile(file, this);
this.files.unshift(newFileStore);
-
- if (newFileStore.validate()) {
- newFileStore.upload();
- }
}
}
}
diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/TranslationsStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/TranslationsStore.ts
index 2abc3f3e47..a9de503f5a 100644
--- a/packages/pluggableWidgets/file-uploader-web/src/stores/TranslationsStore.ts
+++ b/packages/pluggableWidgets/file-uploader-web/src/stores/TranslationsStore.ts
@@ -1,6 +1,6 @@
-import { FileUploaderContainerProps } from "../../typings/FileUploaderProps";
import { DynamicValue } from "mendix";
import { action, makeObservable, observable } from "mobx";
+import { FileUploaderContainerProps } from "../../typings/FileUploaderProps";
export class TranslationsStore {
translationsMap: Map = new Map();
diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts
new file mode 100644
index 0000000000..d909888dce
--- /dev/null
+++ b/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts
@@ -0,0 +1,953 @@
+import { Big } from "big.js";
+import { DynamicValue } from "mendix";
+import { actionValue, dynamic, ListValueBuilder, obj } from "@mendix/widget-plugin-test-utils";
+import { FileUploaderContainerProps } from "../../../typings/FileUploaderProps";
+import { FileStore } from "../FileStore";
+import { FileUploaderStore } from "../FileUploaderStore";
+import { TranslationsStore } from "../TranslationsStore";
+
+function unavailableDynamic(): DynamicValue {
+ return { status: "unavailable", value: undefined } as unknown as DynamicValue;
+}
+
+function buildProps(overrides: Partial = {}): FileUploaderContainerProps {
+ return {
+ name: "fileUploader1",
+ class: "",
+ style: undefined,
+ tabIndex: 0,
+ uploadMode: "files",
+ associatedFiles: new ListValueBuilder().withItems([]).build(),
+ associatedImages: new ListValueBuilder().withItems([]).build(),
+ readOnlyMode: false,
+ createFileAction: actionValue(true, false),
+ createImageAction: actionValue(true, false),
+ allowedFileFormats: [],
+ maxFilesPerUpload: dynamic(new Big(5)),
+ maxFilesPerBatch: unavailableDynamic(),
+ maxFileSize: 25,
+ objectCreationTimeout: 10,
+ dropzoneIdleMessage: dynamic("Drag and drop files here"),
+ dropzoneAcceptedMessage: dynamic("All files can be uploaded."),
+ dropzoneRejectedMessage: dynamic("Some files may not be uploadable."),
+ uploadInProgressMessage: dynamic("Uploading..."),
+ uploadQueuedMessage: dynamic("Waiting..."),
+ uploadSuccessMessage: dynamic("Uploaded successfully."),
+ uploadFailureGenericMessage: dynamic("An error occurred during uploading."),
+ uploadFailureInvalidFileFormatMessage: dynamic("File format is not supported, supported formats are ###."),
+ uploadFailureFileIsTooBigMessage: dynamic("File size exceeds the maximum limit of ### megabytes."),
+ uploadFailureTooManyFilesMessage: dynamic("Too many files added. Only ### files per upload are allowed."),
+ uploadLimitReachedMessage: dynamic("Maximum file count of ### reached."),
+ unavailableCreateActionMessage: dynamic(
+ "Can't upload files at this time. Please contact your system administrator."
+ ),
+ downloadButtonTextMessage: dynamic("Download this file"),
+ removeButtonTextMessage: dynamic("Remove this file"),
+ retryButtonTextMessage: dynamic("Retry upload"),
+ removeSuccessMessage: dynamic("Removed successfully."),
+ removeErrorMessage: dynamic("An error occurred while removing this file."),
+ enableCustomButtons: false,
+ customButtons: [],
+ onUploadSuccessFile: undefined,
+ onUploadSuccessImage: undefined,
+ onUploadFailureFile: undefined,
+ onUploadFailureImage: undefined,
+ ...overrides
+ };
+}
+
+function buildStore(overrides: Partial = {}): FileUploaderStore {
+ const props = buildProps(overrides);
+ const translations = new TranslationsStore(props);
+ return new FileUploaderStore(props, translations);
+}
+
+function makeFile(name: string): File {
+ return new File([""], name, { type: "text/plain" });
+}
+
+// ─── FileStore unit tests ────────────────────────────────────────────────────
+
+describe("FileStore.setQueued", () => {
+ test("sets status to 'queued' and clears errorDescription", () => {
+ const rootStore = buildStore();
+ const file = new FileStore("validationError", rootStore, makeFile("test.txt"));
+ file.errorDescription = "invalid format";
+
+ file.setQueued();
+
+ expect(file.fileStatus).toBe("queued");
+ expect(file.errorDescription).toBeUndefined();
+ });
+});
+
+describe("FileStore.upload", () => {
+ test("transitions from 'queued' to 'uploading' then to error on failure", async () => {
+ const rootStore = buildStore();
+ const file = new FileStore("queued", rootStore, makeFile("test.txt"));
+ rootStore.objectCreationHelper.request = jest.fn().mockRejectedValue(new Error("mocked"));
+
+ await file.upload();
+
+ expect(file.fileStatus).toBe("uploadingError");
+ });
+
+ test("does not start upload if status is not 'queued'", async () => {
+ const rootStore = buildStore();
+ const file = new FileStore("validationError", rootStore, makeFile("test.txt"));
+ rootStore.objectCreationHelper.request = jest.fn();
+
+ await file.upload();
+
+ expect(rootStore.objectCreationHelper.request).not.toHaveBeenCalled();
+ });
+
+ test("does not start upload if status is 'existingFile'", async () => {
+ const rootStore = buildStore();
+ const file = new FileStore("existingFile", rootStore, undefined, obj("a") as any);
+ rootStore.objectCreationHelper.request = jest.fn();
+
+ await file.upload();
+
+ expect(rootStore.objectCreationHelper.request).not.toHaveBeenCalled();
+ });
+});
+
+describe("FileStore.markMissing", () => {
+ test("transitions to 'missing' from 'existingFile'", () => {
+ const rootStore = buildStore();
+ const file = new FileStore("existingFile", rootStore, undefined, obj("a") as any);
+
+ file.markMissing();
+
+ expect(file.fileStatus).toBe("missing");
+ });
+
+ test("transitions to 'removedFile' (not 'missing') when status is 'uploadingError'", () => {
+ const rootStore = buildStore();
+ const file = new FileStore("uploadingError", rootStore, makeFile("test.txt"));
+
+ file.markMissing();
+
+ expect(file.fileStatus).toBe("removedFile");
+ });
+});
+
+describe("FileStore — removed legacy statuses", () => {
+ test("FileStore does not have errorType property", () => {
+ const rootStore = buildStore();
+ const file = new FileStore("queued", rootStore, makeFile("test.txt"));
+
+ expect(Object.prototype.hasOwnProperty.call(file, "errorType")).toBe(false);
+ expect("errorType" in file).toBe(false);
+ });
+});
+
+describe("FileStore.newFile", () => {
+ test("creates file with 'queued' status", () => {
+ const rootStore = buildStore();
+ const file = FileStore.newFile(makeFile("test.txt"), rootStore);
+
+ expect(file.fileStatus).toBe("queued");
+ });
+});
+
+describe("FileStore.newRejectedFile", () => {
+ test("creates file with 'rejected' status", () => {
+ const rootStore = buildStore();
+ const file = FileStore.newRejectedFile(makeFile("test.txt"), rootStore);
+
+ expect(file.fileStatus).toBe("rejected");
+ });
+});
+
+// ─── FileStore.canRetry ───────────────────────────────────────────────────────
+
+describe("FileStore.canRetry", () => {
+ test("true when file is rejected and limit is not full", () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(3)),
+ maxFilesPerBatch: unavailableDynamic()
+ });
+ const file = FileStore.newRejectedFile(makeFile("x.txt"), store);
+ store.files.push(file);
+
+ expect(file.canRetry).toBe(true);
+ });
+
+ test("false when file is rejected but limit is full", () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(2)),
+ maxFilesPerBatch: unavailableDynamic()
+ });
+ store.objectCreationHelper.request = jest.fn().mockReturnValue(new Promise(() => {}));
+
+ store.processDrop([makeFile("a.txt"), makeFile("b.txt"), makeFile("c.txt")], []);
+
+ const rejected = store.files.find(f => f.fileStatus === "rejected")!;
+ expect(rejected.canRetry).toBe(false);
+ });
+
+ test("false when file is not in rejected status", () => {
+ const store = buildStore();
+ const file = new FileStore("validationError", store, makeFile("x.txt"));
+ store.files.push(file);
+
+ expect(file.canRetry).toBe(false);
+ });
+});
+
+// ─── FileStore.canRetry — reactivity when limit changes ──────────────────────
+
+describe("FileStore.canRetry — reacts to freed slots", () => {
+ test("flips to true when an active file errors and frees a slot", async () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(1)),
+ maxFilesPerBatch: unavailableDynamic()
+ });
+ store.objectCreationHelper.request = jest.fn().mockRejectedValueOnce(new Error("fail"));
+
+ // 1 active, 1 rejected — limit full
+ store.processDrop([makeFile("a.txt"), makeFile("b.txt")], []);
+
+ const rejected = store.files.find(f => f.fileStatus === "rejected")!;
+ expect(rejected.canRetry).toBe(false);
+
+ // Wait for upload to fail — active file → uploadingError, slot freed
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(store.files.find(f => f.fileStatus === "uploadingError")).toBeDefined();
+ expect(rejected.canRetry).toBe(true);
+ });
+
+ test("flips to true when an active file is removed and frees a slot", () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(1)),
+ maxFilesPerBatch: unavailableDynamic()
+ });
+ store.objectCreationHelper.request = jest.fn().mockReturnValue(new Promise(() => {}));
+
+ // 1 active (uploading), 1 rejected — limit full
+ store.processDrop([makeFile("a.txt"), makeFile("b.txt")], []);
+
+ const rejected = store.files.find(f => f.fileStatus === "rejected")!;
+ expect(rejected.canRetry).toBe(false);
+
+ // Simulate removal of the active file — slot freed
+ const active = store.files.find(f => f.fileStatus === "uploading")!;
+ active.fileStatus = "removedFile" as any;
+
+ expect(rejected.canRetry).toBe(true);
+ });
+
+ test("both rejected files get canRetry=true when one slot frees", async () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(2)),
+ maxFilesPerBatch: unavailableDynamic()
+ });
+ store.objectCreationHelper.request = jest
+ .fn()
+ .mockRejectedValueOnce(new Error("fail"))
+ .mockReturnValue(new Promise(() => {}));
+
+ // 2 active, 2 rejected — limit full
+ store.processDrop([makeFile("a.txt"), makeFile("b.txt"), makeFile("c.txt"), makeFile("d.txt")], []);
+
+ const rejected = store.files.filter(f => f.fileStatus === "rejected");
+ expect(rejected).toHaveLength(2);
+ expect(rejected[0].canRetry).toBe(false);
+ expect(rejected[1].canRetry).toBe(false);
+
+ // Wait for first upload to fail — activeCount drops 2→1, limit no longer reached
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(rejected[0].canRetry).toBe(true);
+ expect(rejected[1].canRetry).toBe(true);
+ });
+});
+
+// ─── FileStore.retry ─────────────────────────────────────────────────────────
+
+describe("FileStore.retry", () => {
+ test("transitions rejected file to queued", () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(3)),
+ maxFilesPerBatch: unavailableDynamic()
+ });
+ store.objectCreationHelper.request = jest.fn().mockReturnValue(new Promise(() => {}));
+
+ const file = FileStore.newRejectedFile(makeFile("x.txt"), store);
+ store.files.push(file);
+
+ file.retry();
+
+ expect(file.fileStatus).toBe("uploading");
+ });
+
+ test("does nothing when file is not in rejected status (validationError)", () => {
+ const store = buildStore();
+ store.objectCreationHelper.request = jest.fn();
+
+ const file = new FileStore("validationError", store, makeFile("x.txt"));
+ store.files.push(file);
+
+ file.retry();
+
+ expect(store.objectCreationHelper.request).not.toHaveBeenCalled();
+ });
+
+ test("does nothing when total limit is full (limit reached)", () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(1)),
+ maxFilesPerBatch: unavailableDynamic()
+ });
+ store.objectCreationHelper.request = jest.fn().mockReturnValue(new Promise(() => {}));
+
+ // Fill the limit with 1 active file, then add 1 rejected
+ store.processDrop([makeFile("a.txt"), makeFile("b.txt")], []);
+
+ const rejected = store.files.find(f => f.fileStatus === "rejected")!;
+ expect(rejected.canRetry).toBe(false);
+
+ rejected.retry();
+
+ expect(rejected.fileStatus).toBe("rejected");
+ });
+});
+
+// ─── FileUploaderStore.queuedCount ───────────────────────────────────────────
+
+describe("FileUploaderStore.queuedCount", () => {
+ test("returns 0 when no files are queued", () => {
+ const store = buildStore();
+
+ expect(store.queuedCount).toBe(0);
+ });
+
+ test("counts files with queued status", () => {
+ const store = buildStore();
+
+ store.files.push(
+ { fileStatus: "queued" } as any,
+ { fileStatus: "queued" } as any,
+ { fileStatus: "uploading" } as any,
+ { fileStatus: "existingFile" } as any
+ );
+
+ expect(store.queuedCount).toBe(2);
+ });
+
+ test("increases when processDrop adds queued files", () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(10)),
+ maxFilesPerBatch: dynamic(new Big(1))
+ });
+ store.objectCreationHelper.request = jest.fn().mockReturnValue(new Promise(() => {}));
+
+ store.processDrop([makeFile("a.txt"), makeFile("b.txt"), makeFile("c.txt")], []);
+
+ // 1 uploading, 2 still queued
+ expect(store.queuedCount).toBe(2);
+ });
+});
+
+// ─── FileUploaderStore — renamed properties ──────────────────────────────────
+
+describe("FileUploaderStore — renamed properties", () => {
+ test("maxTotalFiles reads from maxFilesPerUpload XML prop", () => {
+ const store = buildStore({ maxFilesPerUpload: dynamic(new Big(7)) });
+
+ expect(store.maxTotalFiles).toBe(7);
+ });
+
+ test("maxConcurrentUploads reads from maxFilesPerBatch XML prop", () => {
+ const store = buildStore({ maxFilesPerBatch: dynamic(new Big(3)) });
+
+ expect(store.maxConcurrentUploads).toBe(3);
+ });
+
+ test("maxTotalFiles returns 0 (unlimited) when expression unavailable", () => {
+ const store = buildStore({ maxFilesPerUpload: unavailableDynamic() });
+
+ expect(store.maxTotalFiles).toBe(0);
+ });
+
+ test("maxConcurrentUploads returns 0 (unlimited) when expression unavailable", () => {
+ const store = buildStore({ maxFilesPerBatch: unavailableDynamic() });
+
+ expect(store.maxConcurrentUploads).toBe(0);
+ });
+
+ test("maxFileSize returns bytes converted from MiB", () => {
+ const store = buildStore({ maxFileSize: 10 });
+
+ expect(store.maxFileSize).toBe(10 * 1024 * 1024);
+ });
+});
+
+// ─── FileUploaderStore — removed legacy API ──────────────────────────────────
+
+describe("FileUploaderStore — removed legacy methods", () => {
+ test("dismissValidationErrors does not exist", () => {
+ const store = buildStore();
+
+ expect((store as any).dismissValidationErrors).toBeUndefined();
+ });
+
+ test("retryLimitExceededFiles does not exist", () => {
+ const store = buildStore();
+
+ expect((store as any).retryLimitExceededFiles).toBeUndefined();
+ });
+});
+
+// ─── FileUploaderStore.processDrop — pure classifier ─────────────────────────
+
+describe("FileUploaderStore.processDrop — pure classifier", () => {
+ test("accepted files within capacity enter upload pipeline (queued or uploading)", () => {
+ const store = buildStore({ maxFilesPerUpload: dynamic(new Big(5)) });
+
+ store.processDrop(
+ [1, 2, 3].map(n => makeFile(`file${n}.txt`)),
+ []
+ );
+
+ const inPipeline = store.files.filter(f => f.fileStatus === "queued" || f.fileStatus === "uploading");
+ expect(inPipeline).toHaveLength(3);
+ });
+
+ test("files exceeding maxTotalFiles go to 'rejected' status", () => {
+ const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) });
+
+ store.processDrop(
+ [1, 2, 3, 4].map(n => makeFile(`file${n}.txt`)),
+ []
+ );
+
+ expect(store.files.filter(f => f.fileStatus === "rejected")).toHaveLength(2);
+ const inPipeline = store.files.filter(f => f.fileStatus === "queued" || f.fileStatus === "uploading");
+ expect(inPipeline).toHaveLength(2);
+ });
+
+ test("format/size rejected files go to 'validationError' status", () => {
+ const store = buildStore();
+ const rejections = [
+ { file: makeFile("bad.exe"), errors: [{ code: "file-invalid-type", message: "bad type" }] }
+ ];
+
+ store.processDrop([], rejections as any);
+
+ expect(store.files.filter(f => f.fileStatus === "validationError")).toHaveLength(1);
+ });
+
+ test("no file gets 'batchExceeded' treatment — excess files enter pipeline or rejected only", () => {
+ const store = buildStore({ maxFilesPerBatch: dynamic(new Big(2)), maxFilesPerUpload: dynamic(new Big(10)) });
+
+ store.processDrop(
+ [1, 2, 3, 4].map(n => makeFile(`file${n}.txt`)),
+ []
+ );
+
+ const statuses = store.files.map(f => f.fileStatus);
+ expect(statuses.every(s => s === "queued" || s === "uploading" || s === "rejected")).toBe(true);
+ });
+
+ test("no file has errorType set after processDrop", () => {
+ const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) });
+
+ store.processDrop(
+ [1, 2, 3, 4].map(n => makeFile(`file${n}.txt`)),
+ []
+ );
+
+ const withErrorType = store.files.filter(f => (f as any).errorType !== undefined);
+ expect(withErrorType).toHaveLength(0);
+ });
+
+ test("drop with maxConcurrentUploads=2: exactly 2 start uploading, rest stay queued", () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(10)),
+ maxFilesPerBatch: dynamic(new Big(2))
+ });
+ store.objectCreationHelper.request = jest.fn().mockRejectedValue(new Error("no server"));
+
+ store.processDrop(
+ [1, 2, 3, 4].map(n => makeFile(`file${n}.txt`)),
+ []
+ );
+
+ expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(2);
+ expect(store.files.filter(f => f.fileStatus === "queued")).toHaveLength(2);
+ });
+
+ test("drop with no concurrent limit: all files start uploading immediately", () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(10)),
+ maxFilesPerBatch: unavailableDynamic()
+ });
+ store.objectCreationHelper.request = jest.fn().mockRejectedValue(new Error("no server"));
+
+ store.processDrop(
+ [1, 2, 3].map(n => makeFile(`file${n}.txt`)),
+ []
+ );
+
+ expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(3);
+ expect(store.files.filter(f => f.fileStatus === "queued")).toHaveLength(0);
+ });
+});
+
+// ─── Reaction 3: queuedCount rises → promoteQueuedFiles (fully reactive drain) ─
+
+describe("FileUploaderStore — Reaction 3: queue auto-drains when queued files arrive", () => {
+ test("files queued by processDrop start uploading without any manual drain call", () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(10)),
+ maxFilesPerBatch: dynamic(new Big(1))
+ });
+ store.objectCreationHelper.request = jest.fn().mockReturnValue(new Promise(() => {}));
+
+ // processDrop sets files to 'queued'; Reaction 3 must drain them automatically
+ store.processDrop([makeFile("a.txt"), makeFile("b.txt"), makeFile("c.txt")], []);
+
+ // With concurrent limit=1: exactly 1 uploading, 2 still queued
+ expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(1);
+ expect(store.files.filter(f => f.fileStatus === "queued")).toHaveLength(2);
+ });
+
+ test("manually setting a file to queued triggers upload automatically", () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(10)),
+ maxFilesPerBatch: dynamic(new Big(2))
+ });
+ store.objectCreationHelper.request = jest.fn().mockReturnValue(new Promise(() => {}));
+
+ const file = new FileStore("rejected", store, makeFile("x.txt"));
+ store.files.push(file);
+
+ // Manually queue the file — Reaction 3 should pick it up
+ file.setQueued();
+
+ expect(file.fileStatus).toBe("uploading");
+ });
+
+ test("rejected file does NOT auto-promote when a slot frees — manual retry required", () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(2)),
+ maxFilesPerBatch: unavailableDynamic()
+ });
+ store.objectCreationHelper.request = jest.fn().mockReturnValue(new Promise(() => {}));
+
+ // Fill to limit — 2 uploading, 1 rejected
+ store.processDrop([makeFile("a.txt"), makeFile("b.txt"), makeFile("c.txt")], []);
+
+ expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(2);
+ expect(store.files.filter(f => f.fileStatus === "rejected")).toHaveLength(1);
+
+ // Free a slot — rejected file must stay rejected (no auto-promote)
+ store.files[store.files.findIndex(f => f.fileStatus === "uploading")].fileStatus = "removedFile" as any;
+
+ expect(store.files.filter(f => f.fileStatus === "rejected")).toHaveLength(1);
+ expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(1);
+ });
+});
+
+// ─── FileUploaderStore.isFileUploadLimitReached ───────────────────────────────
+
+describe("FileUploaderStore.isFileUploadLimitReached", () => {
+ test("false when no files have been dropped", () => {
+ const store = buildStore({ maxFilesPerUpload: dynamic(new Big(3)) });
+
+ expect(store.isFileUploadLimitReached).toBe(false);
+ });
+
+ test("false when below the configured limit", () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(3)),
+ maxFilesPerBatch: unavailableDynamic()
+ });
+ store.objectCreationHelper.request = jest.fn().mockReturnValue(new Promise(() => {}));
+
+ store.processDrop([makeFile("a.txt"), makeFile("b.txt")], []);
+
+ expect(store.isFileUploadLimitReached).toBe(false);
+ });
+
+ test("true when exactly at the limit", () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(2)),
+ maxFilesPerBatch: unavailableDynamic()
+ });
+ store.objectCreationHelper.request = jest.fn().mockReturnValue(new Promise(() => {}));
+
+ store.processDrop([makeFile("a.txt"), makeFile("b.txt")], []);
+
+ expect(store.isFileUploadLimitReached).toBe(true);
+ });
+
+ test("rejected files (over cap) do not contribute to active count", () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(2)),
+ maxFilesPerBatch: unavailableDynamic()
+ });
+ store.objectCreationHelper.request = jest.fn().mockReturnValue(new Promise(() => {}));
+
+ store.processDrop([makeFile("a.txt"), makeFile("b.txt"), makeFile("c.txt")], []);
+
+ expect(store.files.filter(f => f.fileStatus === "rejected")).toHaveLength(1);
+ expect(store.activeCount).toBe(2);
+ });
+
+ test("validationError files do not count toward active count", () => {
+ const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) });
+
+ store.processDrop([], [
+ { file: makeFile("bad1.exe"), errors: [{ code: "file-invalid-type", message: "" }] },
+ { file: makeFile("bad2.exe"), errors: [{ code: "file-invalid-type", message: "" }] },
+ { file: makeFile("bad3.exe"), errors: [{ code: "file-invalid-type", message: "" }] }
+ ] as any);
+
+ expect(store.files.filter(f => f.fileStatus === "validationError")).toHaveLength(3);
+ expect(store.isFileUploadLimitReached).toBe(false);
+ });
+
+ test("uploadingError files do not count toward active count", async () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(2)),
+ maxFilesPerBatch: unavailableDynamic()
+ });
+ store.objectCreationHelper.request = jest.fn().mockRejectedValue(new Error("fail"));
+
+ store.processDrop([makeFile("a.txt"), makeFile("b.txt")], []);
+
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(store.files.filter(f => f.fileStatus === "uploadingError")).toHaveLength(2);
+ expect(store.isFileUploadLimitReached).toBe(false);
+ });
+
+ test("false when maxTotalFiles is 0 (unlimited), regardless of file count", () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(0)),
+ maxFilesPerBatch: unavailableDynamic()
+ });
+ store.objectCreationHelper.request = jest.fn().mockReturnValue(new Promise(() => {}));
+
+ store.processDrop([makeFile("a.txt"), makeFile("b.txt"), makeFile("c.txt")], []);
+
+ expect(store.isFileUploadLimitReached).toBe(false);
+ });
+
+ test("false when maxTotalFiles expression is unavailable (unlimited fallback)", () => {
+ const store = buildStore({
+ maxFilesPerUpload: unavailableDynamic(),
+ maxFilesPerBatch: unavailableDynamic()
+ });
+ store.objectCreationHelper.request = jest.fn().mockReturnValue(new Promise(() => {}));
+
+ store.processDrop([makeFile("a.txt"), makeFile("b.txt"), makeFile("c.txt")], []);
+
+ expect(store.isFileUploadLimitReached).toBe(false);
+ });
+});
+
+// ─── FileUploaderStore.sortedFiles ───────────────────────────────────────────
+
+describe("FileUploaderStore.sortedFiles", () => {
+ test("validationError files sort to the end", () => {
+ const store = buildStore({ maxFilesPerUpload: dynamic(new Big(10)) });
+
+ // First drop an accepted file, then a validation error — error ends up at index 0
+ store.processDrop([makeFile("good.txt")], []);
+ store.processDrop([], [
+ { file: makeFile("bad.exe"), errors: [{ code: "file-invalid-type", message: "" }] }
+ ] as any);
+
+ expect(store.files[0].fileStatus).toBe("validationError"); // confirm unsorted state
+
+ const sorted = store.sortedFiles;
+ expect(sorted[0].fileStatus).not.toBe("validationError");
+ expect(sorted[sorted.length - 1].fileStatus).toBe("validationError");
+ });
+
+ test("does not mutate the original files array", () => {
+ const store = buildStore({ maxFilesPerUpload: dynamic(new Big(10)) });
+
+ store.processDrop([makeFile("good.txt")], []);
+ store.processDrop([], [
+ { file: makeFile("bad.exe"), errors: [{ code: "file-invalid-type", message: "" }] }
+ ] as any);
+
+ const originalFirst = store.files[0];
+ const sorted = store.sortedFiles;
+ expect(sorted).not.toBe(store.files);
+ expect(store.files[0]).toBe(originalFirst);
+ });
+});
+
+// ─── FileUploaderStore.promoteQueuedFiles ─────────────────────────────────────
+
+describe("FileUploaderStore.promoteQueuedFiles", () => {
+ test("calls upload() on queued files up to maxConcurrentUploads", () => {
+ const store = buildStore({ maxFilesPerBatch: dynamic(new Big(2)) });
+
+ const queued1 = { fileStatus: "queued", upload: jest.fn() } as any;
+ const queued2 = { fileStatus: "queued", upload: jest.fn() } as any;
+ const queued3 = { fileStatus: "queued", upload: jest.fn() } as any;
+
+ store.files.push(queued3, queued2, queued1);
+
+ store.promoteQueuedFiles();
+
+ const uploadedCount = [queued1, queued2, queued3].filter(f => f.upload.mock.calls.length > 0).length;
+ expect(uploadedCount).toBe(2);
+ });
+
+ test("does not promote beyond available concurrent slots", () => {
+ const store = buildStore({ maxFilesPerBatch: dynamic(new Big(2)) });
+
+ const uploading = { fileStatus: "uploading" } as any;
+ const queued1 = { fileStatus: "queued", upload: jest.fn() } as any;
+ const queued2 = { fileStatus: "queued", upload: jest.fn() } as any;
+
+ // 1 slot already used
+ store.files.push(queued2, queued1, uploading);
+
+ store.promoteQueuedFiles();
+
+ const uploadedCount = [queued1, queued2].filter(f => f.upload.mock.calls.length > 0).length;
+ expect(uploadedCount).toBe(1);
+ });
+
+ test("does nothing when all concurrent slots are occupied", () => {
+ const store = buildStore({ maxFilesPerBatch: dynamic(new Big(2)) });
+
+ const uploading1 = { fileStatus: "uploading" } as any;
+ const uploading2 = { fileStatus: "uploading" } as any;
+ const queued = { fileStatus: "queued", upload: jest.fn() } as any;
+
+ store.files.push(queued, uploading1, uploading2);
+
+ store.promoteQueuedFiles();
+
+ expect(queued.upload).not.toHaveBeenCalled();
+ });
+
+ test("does nothing when no queued files exist", () => {
+ const store = buildStore({ maxFilesPerBatch: dynamic(new Big(2)) });
+
+ store.files.push({ fileStatus: "existingFile" } as any);
+
+ expect(() => store.promoteQueuedFiles()).not.toThrow();
+ });
+
+ test("queued file starts uploading when a concurrent slot frees up", async () => {
+ const store = buildStore({
+ maxFilesPerBatch: dynamic(new Big(1)),
+ maxFilesPerUpload: dynamic(new Big(10))
+ });
+ const neverResolve = new Promise(() => {});
+ store.objectCreationHelper.request = jest.fn().mockReturnValue(neverResolve);
+
+ // Drop 2 files: 1 starts uploading, 1 waits queued
+ store.processDrop([makeFile("a.txt"), makeFile("b.txt")], []);
+
+ expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(1);
+ expect(store.files.filter(f => f.fileStatus === "queued")).toHaveLength(1);
+
+ // Mark the uploading file as done (simulate slot freed)
+ store.files[store.files.findIndex(f => f.fileStatus === "uploading")].fileStatus = "done" as any;
+
+ // Reaction fires: uploadingCount dropped → promoteQueuedFiles → queued file starts
+ expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(1);
+ expect(store.files.filter(f => f.fileStatus === "queued")).toHaveLength(0);
+ });
+});
+
+// ─── FileUploaderStore.createActionFailed ────────────────────────────────────
+
+describe("FileUploaderStore.createActionFailed", () => {
+ test("defaults to false", () => {
+ const store = buildStore({});
+
+ expect(store.createActionFailed).toBe(false);
+ });
+
+ test("setCreateActionFailed(true) sets flag", () => {
+ const store = buildStore({});
+ store.setCreateActionFailed(true);
+
+ expect(store.createActionFailed).toBe(true);
+ });
+
+ test("setCreateActionFailed(false) clears flag", () => {
+ const store = buildStore({});
+ store.setCreateActionFailed(true);
+ store.setCreateActionFailed(false);
+
+ expect(store.createActionFailed).toBe(false);
+ });
+});
+
+// ─── FileUploaderStore.processDrop — unavailable create action ───────────────
+
+describe("FileUploaderStore.processDrop — unavailable create action", () => {
+ test("sets createActionFailed when canCreateFiles is false", () => {
+ const store = buildStore({ createFileAction: actionValue(false, false) });
+
+ store.processDrop([makeFile("a.txt")], []);
+
+ expect(store.createActionFailed).toBe(true);
+ expect(store.files).toHaveLength(0);
+ });
+
+ test("clears createActionFailed on next successful drop", () => {
+ const store = buildStore({ createFileAction: actionValue(false, false) });
+ store.processDrop([makeFile("a.txt")], []);
+ expect(store.createActionFailed).toBe(true);
+
+ store.objectCreationHelper.updateProps(buildProps({ createFileAction: actionValue(true, false) }));
+ store.processDrop([makeFile("b.txt")], []);
+
+ expect(store.createActionFailed).toBe(false);
+ });
+});
+
+// ─── FileUploaderStore.processDrop — error message mapping ───────────────────
+
+describe("FileUploaderStore.processDrop — error message mapping", () => {
+ test("file-invalid-type rejection uses allowedFormats in message", () => {
+ const store = buildStore({ allowedFileFormats: [] });
+
+ store.processDrop([], [
+ { file: makeFile("bad.exe"), errors: [{ code: "file-invalid-type", message: "" }] }
+ ] as any);
+
+ expect(store.files[0].errorDescription).toContain("not supported");
+ });
+
+ test("file-too-large rejection includes max size in message", () => {
+ const store = buildStore({ maxFileSize: 10 });
+
+ store.processDrop([], [
+ { file: makeFile("big.zip"), errors: [{ code: "file-too-large", message: "" }] }
+ ] as any);
+
+ expect(store.files[0].errorDescription).toContain("10");
+ });
+});
+
+// ─── FileUploaderStore.updateProps ───────────────────────────────────────────
+
+describe("FileUploaderStore.updateProps", () => {
+ test("live increase of maxTotalFiles unblocks dropzone mid-session", () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(2)),
+ maxFilesPerBatch: unavailableDynamic()
+ });
+ store.objectCreationHelper.request = jest.fn().mockReturnValue(new Promise(() => {}));
+
+ store.processDrop([makeFile("a.txt"), makeFile("b.txt")], []);
+ expect(store.isFileUploadLimitReached).toBe(true);
+
+ store.updateProps(
+ buildProps({ maxFilesPerUpload: dynamic(new Big(5)), maxFilesPerBatch: unavailableDynamic() })
+ );
+
+ expect(store.isFileUploadLimitReached).toBe(false);
+ });
+
+ test("live decrease of maxConcurrentUploads is reflected immediately", () => {
+ const store = buildStore({ maxFilesPerBatch: dynamic(new Big(4)) });
+ expect(store.maxConcurrentUploads).toBe(4);
+
+ store.updateProps(buildProps({ maxFilesPerBatch: dynamic(new Big(1)) }));
+
+ expect(store.maxConcurrentUploads).toBe(1);
+ });
+});
+
+// ─── FileUploaderStore.dispose ───────────────────────────────────────────────
+
+describe("FileUploaderStore.dispose", () => {
+ test("queue drain reaction stops firing after dispose", () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(10)),
+ maxFilesPerBatch: dynamic(new Big(1))
+ });
+ const neverResolve = new Promise(() => {});
+ store.objectCreationHelper.request = jest.fn().mockReturnValue(neverResolve);
+
+ store.processDrop([makeFile("a.txt"), makeFile("b.txt")], []);
+
+ expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(1);
+ expect(store.files.filter(f => f.fileStatus === "queued")).toHaveLength(1);
+
+ store.dispose();
+
+ // Freeing a concurrent slot after dispose should NOT start the queued file
+ store.files[store.files.findIndex(f => f.fileStatus === "uploading")].fileStatus = "done" as any;
+
+ expect(store.files.filter(f => f.fileStatus === "queued")).toHaveLength(1);
+ });
+});
+
+// ─── End-to-end queue integration ────────────────────────────────────────────
+
+describe("upload queue — end-to-end", () => {
+ test("upload error frees concurrent slot and next queued file starts uploading", async () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(10)),
+ maxFilesPerBatch: dynamic(new Big(1))
+ });
+ // First request fails, second hangs so we can assert the stable "uploading" state
+ const neverResolve = new Promise(() => {});
+ store.objectCreationHelper.request = jest
+ .fn()
+ .mockRejectedValueOnce(new Error("server error"))
+ .mockReturnValueOnce(neverResolve);
+
+ store.processDrop([makeFile("a.txt"), makeFile("b.txt")], []);
+
+ expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(1);
+ expect(store.files.filter(f => f.fileStatus === "queued")).toHaveLength(1);
+
+ // Wait for first upload to fail
+ await Promise.resolve();
+ await Promise.resolve();
+
+ // Error frees slot → queued file promotes to uploading (second request hangs)
+ expect(store.files.filter(f => f.fileStatus === "uploadingError")).toHaveLength(1);
+ expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(1);
+ expect(store.files.filter(f => f.fileStatus === "queued")).toHaveLength(0);
+ });
+
+ test("upload errors do NOT auto-promote rejected files — user must retry manually", async () => {
+ const store = buildStore({
+ maxFilesPerUpload: dynamic(new Big(2)),
+ maxFilesPerBatch: unavailableDynamic()
+ });
+ store.objectCreationHelper.request = jest
+ .fn()
+ .mockRejectedValueOnce(new Error("fail a"))
+ .mockRejectedValueOnce(new Error("fail b"));
+
+ // Drop 3 files — 2 start uploading, 1 rejected (over maxTotalFiles)
+ store.processDrop([makeFile("a.txt"), makeFile("b.txt"), makeFile("c.txt")], []);
+
+ expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(2);
+ expect(store.files.filter(f => f.fileStatus === "rejected")).toHaveLength(1);
+
+ // Wait for both uploads to fail
+ await Promise.resolve();
+ await Promise.resolve();
+
+ // Errors free activeCount slots but rejected file must NOT auto-promote
+ expect(store.files.filter(f => f.fileStatus === "uploadingError")).toHaveLength(2);
+ expect(store.files.filter(f => f.fileStatus === "rejected")).toHaveLength(1);
+ expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(0);
+ });
+});
diff --git a/packages/pluggableWidgets/file-uploader-web/src/ui/FileUploader.scss b/packages/pluggableWidgets/file-uploader-web/src/ui/FileUploader.scss
index 4f9bf3c3cf..2e678ef300 100644
--- a/packages/pluggableWidgets/file-uploader-web/src/ui/FileUploader.scss
+++ b/packages/pluggableWidgets/file-uploader-web/src/ui/FileUploader.scss
@@ -23,6 +23,7 @@ $file-view-icon-active: url(../assets/view-icon-active.svg);
$file-remove-icon: url(../assets/remove-icon.svg);
$file-remove-icon-hover: url(../assets/remove-icon-hover.svg);
$file-remove-icon-active: url(../assets/remove-icon-active.svg);
+$file-retry-icon: url(../assets/retry-icon.svg);
/*
Place your custom CSS here
@@ -251,6 +252,32 @@ Place your custom CSS here
}
}
+ .retry-button {
+ width: 32px;
+ text-decoration: none;
+ border-color: transparent;
+ background-color: transparent;
+
+ &:hover:not(:disabled),
+ &:focus:not(:disabled) {
+ opacity: 0.75;
+ }
+
+ &:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+
+ .retry-icon {
+ display: inline-block;
+ height: 24px;
+ width: 24px;
+ background-repeat: no-repeat;
+ background-position: center;
+ background-image: var(--file-retry-icon, $file-retry-icon);
+ }
+ }
+
.file-action-icon {
font-size: 24px;
color: var(--brand-primary, $file-brand-primary);
diff --git a/packages/pluggableWidgets/file-uploader-web/src/utils/__tests__/DatasourceUpdateProcessor.spec.ts b/packages/pluggableWidgets/file-uploader-web/src/utils/__tests__/DatasourceUpdateProcessor.spec.ts
index 46383743e0..c1961b6ee0 100644
--- a/packages/pluggableWidgets/file-uploader-web/src/utils/__tests__/DatasourceUpdateProcessor.spec.ts
+++ b/packages/pluggableWidgets/file-uploader-web/src/utils/__tests__/DatasourceUpdateProcessor.spec.ts
@@ -1,6 +1,6 @@
-import { DatasourceUpdateProcessor, DatasourceUpdateProcessorCallbacks } from "../DatasourceUpdateProcessor";
-import { ListValueBuilder, obj } from "@mendix/widget-plugin-test-utils";
import { ObjectItem } from "mendix";
+import { ListValueBuilder, obj } from "@mendix/widget-plugin-test-utils";
+import { DatasourceUpdateProcessor, DatasourceUpdateProcessorCallbacks } from "../DatasourceUpdateProcessor";
const fileHasContentsMock = jest.fn();
jest.mock("../mx-data", () => ({
diff --git a/packages/pluggableWidgets/file-uploader-web/src/utils/__tests__/parseAllowedFormats.spec.ts b/packages/pluggableWidgets/file-uploader-web/src/utils/__tests__/parseAllowedFormats.spec.ts
index cae4020b47..d23035117c 100644
--- a/packages/pluggableWidgets/file-uploader-web/src/utils/__tests__/parseAllowedFormats.spec.ts
+++ b/packages/pluggableWidgets/file-uploader-web/src/utils/__tests__/parseAllowedFormats.spec.ts
@@ -1,6 +1,6 @@
+import { dynamicValue } from "@mendix/widget-plugin-test-utils";
import { AllowedFileFormatsType } from "../../../typings/FileUploaderProps";
import { parseAllowedFormats } from "../parseAllowedFormats";
-import { dynamicValue } from "@mendix/widget-plugin-test-utils";
describe("parseAllowedFormats", () => {
test("returns parsed results for correct advanced formats", () => {
diff --git a/packages/pluggableWidgets/file-uploader-web/src/utils/mx-data.ts b/packages/pluggableWidgets/file-uploader-web/src/utils/mx-data.ts
index e6477fc96f..d07f95ab76 100644
--- a/packages/pluggableWidgets/file-uploader-web/src/utils/mx-data.ts
+++ b/packages/pluggableWidgets/file-uploader-web/src/utils/mx-data.ts
@@ -1,5 +1,5 @@
-import { ObjectItem } from "mendix";
import { Big } from "big.js";
+import { ObjectItem } from "mendix";
export type MxObject = {
getGuid(): string;
diff --git a/packages/pluggableWidgets/file-uploader-web/src/utils/parseAllowedFormats.ts b/packages/pluggableWidgets/file-uploader-web/src/utils/parseAllowedFormats.ts
index a78200785a..e43fe3d0d1 100644
--- a/packages/pluggableWidgets/file-uploader-web/src/utils/parseAllowedFormats.ts
+++ b/packages/pluggableWidgets/file-uploader-web/src/utils/parseAllowedFormats.ts
@@ -1,5 +1,5 @@
-import { AllowedFileFormatsPreviewType, AllowedFileFormatsType } from "../../typings/FileUploaderProps";
import { FileCheckFormat, predefinedFormats } from "./predefinedFormats";
+import { AllowedFileFormatsPreviewType, AllowedFileFormatsType } from "../../typings/FileUploaderProps";
export type MimeCheckFormat = {
[key: string]: string[];
diff --git a/packages/pluggableWidgets/file-uploader-web/src/utils/prepareAcceptForDropzone.ts b/packages/pluggableWidgets/file-uploader-web/src/utils/prepareAcceptForDropzone.ts
index e5d1d1fd11..6eed13a39e 100644
--- a/packages/pluggableWidgets/file-uploader-web/src/utils/prepareAcceptForDropzone.ts
+++ b/packages/pluggableWidgets/file-uploader-web/src/utils/prepareAcceptForDropzone.ts
@@ -1,5 +1,5 @@
-import { FileCheckFormat } from "./predefinedFormats";
import { MimeCheckFormat } from "./parseAllowedFormats";
+import { FileCheckFormat } from "./predefinedFormats";
export function prepareAcceptForDropzone(formats: FileCheckFormat[]): MimeCheckFormat {
const acc = {} as MimeCheckFormat;
diff --git a/packages/pluggableWidgets/file-uploader-web/src/utils/useRootStore.ts b/packages/pluggableWidgets/file-uploader-web/src/utils/useRootStore.ts
deleted file mode 100644
index 340b9e6fc0..0000000000
--- a/packages/pluggableWidgets/file-uploader-web/src/utils/useRootStore.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { useEffect, useState } from "react";
-import { FileUploaderStore } from "../stores/FileUploaderStore";
-import { FileUploaderContainerProps } from "../../typings/FileUploaderProps";
-import { useTranslationsStore } from "./useTranslationsStore";
-
-export function useRootStore(props: FileUploaderContainerProps): FileUploaderStore {
- const translations = useTranslationsStore();
- const [rootStore] = useState(() => {
- return new FileUploaderStore(props, translations);
- });
-
- useEffect(() => {
- rootStore.updateProps(props);
- }, [rootStore, props]);
-
- return rootStore;
-}
diff --git a/packages/pluggableWidgets/file-uploader-web/src/utils/useRootStore.tsx b/packages/pluggableWidgets/file-uploader-web/src/utils/useRootStore.tsx
new file mode 100644
index 0000000000..eaa0408627
--- /dev/null
+++ b/packages/pluggableWidgets/file-uploader-web/src/utils/useRootStore.tsx
@@ -0,0 +1,42 @@
+import { createContext, ReactElement, ReactNode, useContext, useEffect, useState } from "react";
+import { useTranslationsStore } from "./useTranslationsStore";
+import { FileUploaderContainerProps } from "../../typings/FileUploaderProps";
+import { FileUploaderStore } from "../stores/FileUploaderStore";
+
+function useInitRootStore(props: FileUploaderContainerProps): FileUploaderStore {
+ const translations = useTranslationsStore();
+ const [rootStore] = useState(() => {
+ return new FileUploaderStore(props, translations);
+ });
+
+ useEffect(() => {
+ rootStore.updateProps(props);
+ }, [rootStore, props]);
+
+ useEffect(() => {
+ return () => rootStore.dispose();
+ }, [rootStore]);
+
+ return rootStore;
+}
+
+const RootStoreContext = createContext(undefined);
+
+export function RootStoreProvider({
+ props,
+ children
+}: {
+ children: ReactNode;
+ props: FileUploaderContainerProps;
+}): ReactElement {
+ const store = useInitRootStore(props);
+ return {children} ;
+}
+
+export function useRootStore(): FileUploaderStore {
+ const context = useContext(RootStoreContext);
+ if (context === undefined) {
+ throw new Error("useRootStore must be used within RootStoreProvider");
+ }
+ return context;
+}
diff --git a/packages/pluggableWidgets/file-uploader-web/src/utils/useTranslationsStore.tsx b/packages/pluggableWidgets/file-uploader-web/src/utils/useTranslationsStore.tsx
index 209bb5a5c9..e5a94af416 100644
--- a/packages/pluggableWidgets/file-uploader-web/src/utils/useTranslationsStore.tsx
+++ b/packages/pluggableWidgets/file-uploader-web/src/utils/useTranslationsStore.tsx
@@ -1,6 +1,6 @@
import { createContext, ReactElement, ReactNode, useContext, useEffect, useState } from "react";
-import { TranslationsStore } from "../stores/TranslationsStore";
import { FileUploaderContainerProps } from "../../typings/FileUploaderProps";
+import { TranslationsStore } from "../stores/TranslationsStore";
function useInitTranslationsStore(props: FileUploaderContainerProps): TranslationsStore {
const [store] = useState(() => {
diff --git a/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts b/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts
index 751fbf1fee..fad6a44099 100644
--- a/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts
+++ b/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts
@@ -59,20 +59,24 @@ export interface FileUploaderContainerProps {
createFileAction?: ActionValue;
createImageAction?: ActionValue;
allowedFileFormats: AllowedFileFormatsType[];
- maxFilesPerUpload: DynamicValue;
+ maxFilesPerUpload?: DynamicValue;
+ maxFilesPerBatch?: DynamicValue;
maxFileSize: number;
dropzoneIdleMessage: DynamicValue;
dropzoneAcceptedMessage: DynamicValue;
dropzoneRejectedMessage: DynamicValue;
uploadInProgressMessage: DynamicValue;
+ uploadQueuedMessage: DynamicValue;
uploadSuccessMessage: DynamicValue;
uploadFailureGenericMessage: DynamicValue;
uploadFailureInvalidFileFormatMessage: DynamicValue;
uploadFailureFileIsTooBigMessage: DynamicValue;
uploadFailureTooManyFilesMessage: DynamicValue;
+ uploadLimitReachedMessage: DynamicValue;
unavailableCreateActionMessage: DynamicValue;
downloadButtonTextMessage: DynamicValue;
removeButtonTextMessage: DynamicValue;
+ retryButtonTextMessage: DynamicValue;
removeSuccessMessage: DynamicValue;
removeErrorMessage: DynamicValue;
objectCreationTimeout: number;
@@ -103,19 +107,23 @@ export interface FileUploaderPreviewProps {
createImageAction: {} | null;
allowedFileFormats: AllowedFileFormatsPreviewType[];
maxFilesPerUpload: string;
+ maxFilesPerBatch: string;
maxFileSize: number | null;
dropzoneIdleMessage: string;
dropzoneAcceptedMessage: string;
dropzoneRejectedMessage: string;
uploadInProgressMessage: string;
+ uploadQueuedMessage: string;
uploadSuccessMessage: string;
uploadFailureGenericMessage: string;
uploadFailureInvalidFileFormatMessage: string;
uploadFailureFileIsTooBigMessage: string;
uploadFailureTooManyFilesMessage: string;
+ uploadLimitReachedMessage: string;
unavailableCreateActionMessage: string;
downloadButtonTextMessage: string;
removeButtonTextMessage: string;
+ retryButtonTextMessage: string;
removeSuccessMessage: string;
removeErrorMessage: string;
objectCreationTimeout: number | null;