From c216658be877b1601ea5dcf7bf45e21b8a01042b Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 11 May 2026 16:25:26 +0200 Subject: [PATCH 01/11] chore: lint fix --- .../file-uploader-web/src/FileUploader.editorConfig.ts | 2 +- .../src/FileUploader.editorPreview.tsx | 2 +- .../file-uploader-web/src/components/ActionButton.tsx | 2 +- .../file-uploader-web/src/components/ActionsBar.tsx | 4 ++-- .../file-uploader-web/src/components/Dropzone.tsx | 4 ++-- .../file-uploader-web/src/components/FileEntry.tsx | 10 +++++----- .../file-uploader-web/src/components/UploadInfo.tsx | 2 +- .../file-uploader-web/src/stores/FileStore.ts | 4 ++-- .../file-uploader-web/src/stores/TranslationsStore.ts | 2 +- .../utils/__tests__/DatasourceUpdateProcessor.spec.ts | 4 ++-- .../src/utils/__tests__/parseAllowedFormats.spec.ts | 2 +- .../file-uploader-web/src/utils/mx-data.ts | 2 +- .../file-uploader-web/src/utils/parseAllowedFormats.ts | 2 +- .../src/utils/prepareAcceptForDropzone.ts | 2 +- .../file-uploader-web/src/utils/useRootStore.ts | 4 ++-- .../src/utils/useTranslationsStore.tsx | 2 +- 16 files changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/pluggableWidgets/file-uploader-web/src/FileUploader.editorConfig.ts b/packages/pluggableWidgets/file-uploader-web/src/FileUploader.editorConfig.ts index db775b3864..35e3b7afd4 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/FileUploader.editorConfig.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/FileUploader.editorConfig.ts @@ -1,6 +1,6 @@ +import { hideNestedPropertiesIn, hidePropertiesIn, Problem, Properties } from "@mendix/pluggable-widgets-tools"; import { FileUploaderPreviewProps } from "../typings/FileUploaderProps"; import { parseAllowedFormats } from "./utils/parseAllowedFormats"; -import { hideNestedPropertiesIn, hidePropertiesIn, Problem, Properties } from "@mendix/pluggable-widgets-tools"; import { predefinedFormats } from "./utils/predefinedFormats"; export function getProperties( diff --git a/packages/pluggableWidgets/file-uploader-web/src/FileUploader.editorPreview.tsx b/packages/pluggableWidgets/file-uploader-web/src/FileUploader.editorPreview.tsx index 860277b43f..60d7aa7221 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/FileUploader.editorPreview.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/FileUploader.editorPreview.tsx @@ -1,6 +1,6 @@ +import classNames from "classnames"; import { ReactElement } from "react"; import { FileUploaderPreviewProps } from "../typings/FileUploaderProps"; -import classNames from "classnames"; export function preview(props: FileUploaderPreviewProps): ReactElement { return ( diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/ActionButton.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/ActionButton.tsx index b2b716dae1..f5a7f3b441 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/ActionButton.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/ActionButton.tsx @@ -1,6 +1,6 @@ -import { MouseEvent, ReactElement, useCallback } from "react"; import classNames from "classnames"; import { ListActionValue } from "mendix"; +import { MouseEvent, ReactElement, useCallback } from "react"; import { FileStore } from "../stores/FileStore"; interface ActionButtonProps { diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/ActionsBar.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/ActionsBar.tsx index 0d74d7ad0d..0bc1990d7c 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/ActionsBar.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/ActionsBar.tsx @@ -1,7 +1,7 @@ import { ReactElement, useCallback } from "react"; -import { FileUploaderContainerProps } from "../../typings/FileUploaderProps"; -import { ActionButton, FileActionButton } from "./ActionButton"; import { IconInternal } from "@mendix/widget-plugin-component-kit/IconInternal"; +import { ActionButton, FileActionButton } from "./ActionButton"; +import { FileUploaderContainerProps } from "../../typings/FileUploaderProps"; import { FileStore } from "../stores/FileStore"; import { useTranslationsStore } from "../utils/useTranslationsStore"; diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/Dropzone.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/Dropzone.tsx index eb46f9b5df..e7d35fa606 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/Dropzone.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/Dropzone.tsx @@ -1,9 +1,9 @@ -import { observer } from "mobx-react-lite"; import classNames from "classnames"; +import { observer } from "mobx-react-lite"; import { Fragment, ReactElement } from "react"; import { FileRejection, useDropzone } from "react-dropzone"; -import { MimeCheckFormat } from "../utils/parseAllowedFormats"; import { TranslationsStore } from "../stores/TranslationsStore"; +import { MimeCheckFormat } from "../utils/parseAllowedFormats"; import { useTranslationsStore } from "../utils/useTranslationsStore"; interface DropzoneProps { diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/FileEntry.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/FileEntry.tsx index 3ac5575000..a8b74b9c6d 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/FileEntry.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/FileEntry.tsx @@ -1,13 +1,13 @@ import classNames from "classnames"; +import { observer } from "mobx-react-lite"; +import { KeyboardEvent, MouseEvent, ReactElement, ReactNode, useCallback } from "react"; +import { ActionsBar } from "./ActionsBar"; +import { FileIcon } from "./FileIcon"; import { ProgressBar } from "./ProgressBar"; import { UploadInfo } from "./UploadInfo"; -import { KeyboardEvent, MouseEvent, ReactElement, ReactNode, useCallback } from "react"; +import { FileUploaderContainerProps } from "../../typings/FileUploaderProps"; import { FileStatus, FileStore } from "../stores/FileStore"; -import { observer } from "mobx-react-lite"; -import { FileIcon } from "./FileIcon"; import { fileSize } from "../utils/fileSize"; -import { FileUploaderContainerProps } from "../../typings/FileUploaderProps"; -import { ActionsBar } from "./ActionsBar"; interface FileEntryContainerProps { store: FileStore; diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/UploadInfo.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/UploadInfo.tsx index 7c8b6c3fed..d1dd0949d4 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/UploadInfo.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/UploadInfo.tsx @@ -1,5 +1,5 @@ -import { FileStatus } from "../stores/FileStore"; import { ReactElement } from "react"; +import { FileStatus } from "../stores/FileStore"; import { useTranslationsStore } from "../utils/useTranslationsStore"; type UploadInfoProps = { diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts index dc1b1e3a7f..da9165edd2 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,7 +13,6 @@ import { removeObject, saveFile } from "../utils/mx-data"; -import { executeAction } from "@mendix/widget-plugin-platform/framework/execute-action"; export type FileStatus = | "existingFile" 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/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 index 340b9e6fc0..a67bda945b 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/utils/useRootStore.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/utils/useRootStore.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; -import { FileUploaderStore } from "../stores/FileUploaderStore"; -import { FileUploaderContainerProps } from "../../typings/FileUploaderProps"; import { useTranslationsStore } from "./useTranslationsStore"; +import { FileUploaderContainerProps } from "../../typings/FileUploaderProps"; +import { FileUploaderStore } from "../stores/FileUploaderStore"; export function useRootStore(props: FileUploaderContainerProps): FileUploaderStore { const translations = useTranslationsStore(); 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(() => { From bfec98f4e25d1b412e463696069be86bab16277a Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 11 May 2026 16:26:04 +0200 Subject: [PATCH 02/11] fix: show message when upload limit reached --- .../file-uploader-web/src/FileUploader.xml | 4 +- .../src/components/FileUploaderRoot.tsx | 6 +- .../src/stores/FileUploaderStore.ts | 22 ++- .../__tests__/FileUploaderStore.spec.ts | 139 ++++++++++++++++++ 4 files changed, 159 insertions(+), 12 deletions(-) create mode 100644 packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts diff --git a/packages/pluggableWidgets/file-uploader-web/src/FileUploader.xml b/packages/pluggableWidgets/file-uploader-web/src/FileUploader.xml index be3069e972..ec63ec849a 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/FileUploader.xml +++ b/packages/pluggableWidgets/file-uploader-web/src/FileUploader.xml @@ -80,9 +80,9 @@ - + Maximum number of files - Limit the number of files per upload. + Maximum number of files that can be associated at once. Leave empty or set to 0 for unlimited. diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx index 520ffbfc23..a7c6b9d701 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx @@ -3,11 +3,11 @@ import { observer } from "mobx-react-lite"; import { ReactElement, useCallback } from "react"; import { FileRejection } from "react-dropzone"; +import { Dropzone } from "./Dropzone"; +import { FileEntryContainer } from "./FileEntry"; import { FileUploaderContainerProps } from "../../typings/FileUploaderProps"; import { prepareAcceptForDropzone } from "../utils/prepareAcceptForDropzone"; import { useRootStore } from "../utils/useRootStore"; -import { FileEntryContainer } from "./FileEntry"; -import { Dropzone } from "./Dropzone"; import "../ui/FileUploader.scss"; @@ -26,7 +26,7 @@ export const FileUploaderRoot = observer((props: FileUploaderContainerProps): Re {!rootStore.isReadOnly && ( = this.maxFilesPerUpload; } + get warningMessage(): string | undefined { + if (this.isFileUploadLimitReached) { + return this.translations.get("uploadFailureTooManyFilesMessage", this.maxFilesPerUpload.toString()); + } + return this.errorMessage; + } + setMessage(msg?: string): void { this.errorMessage = msg; } 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..74635ffcd2 --- /dev/null +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts @@ -0,0 +1,139 @@ +import { Big } from "big.js"; +import { DynamicValue } from "mendix"; +import { actionValue, dynamic, ListValueBuilder, obj } from "@mendix/widget-plugin-test-utils"; + +function unavailableDynamic(): DynamicValue { + return { status: "unavailable", value: undefined } as unknown as DynamicValue; +} +import { FileUploaderContainerProps } from "../../../typings/FileUploaderProps"; +import { FileUploaderStore } from "../FileUploaderStore"; +import { TranslationsStore } from "../TranslationsStore"; + +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(2)), + 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..."), + 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."), + unavailableCreateActionMessage: dynamic( + "Can't upload files at this time. Please contact your system administrator." + ), + downloadButtonTextMessage: dynamic("Download this file"), + removeButtonTextMessage: dynamic("Remove this file"), + 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); +} + +describe("FileUploaderStore.warningMessage", () => { + test("returns undefined when no limit set and no error", () => { + const store = buildStore({ maxFilesPerUpload: unavailableDynamic() }); + expect(store.warningMessage).toBeUndefined(); + }); + + test("returns undefined when under limit and no error", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(5)) }); + expect(store.warningMessage).toBeUndefined(); + }); + + test("returns limit-reached message when file limit is reached", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + + store.files.push( + { fileStatus: "existingFile", _objectItem: obj("a") } as any, + { fileStatus: "existingFile", _objectItem: obj("b") } as any + ); + + expect(store.isFileUploadLimitReached).toBe(true); + expect(store.warningMessage).toBe("Too many files added. Only 2 files per upload are allowed."); + }); + + test("returns errorMessage when limit not reached but error set", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(5)) }); + store.setMessage("Some other error"); + + expect(store.warningMessage).toBe("Some other error"); + }); + + test("clears limit-reached message when file removed below limit", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + + const fileA = { fileStatus: "existingFile", _objectItem: obj("a") } as any; + const fileB = { fileStatus: "existingFile", _objectItem: obj("b") } as any; + store.files.push(fileA, fileB); + + expect(store.warningMessage).toBe("Too many files added. Only 2 files per upload are allowed."); + + store.files.splice(store.files.indexOf(fileA), 1); + + expect(store.isFileUploadLimitReached).toBe(false); + expect(store.warningMessage).toBeUndefined(); + }); +}); + +describe("FileUploaderStore.isFileUploadLimitReached", () => { + test("returns false when maxFilesPerUpload is 0 (unlimited)", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(0)) }); + + store.files.push( + { fileStatus: "existingFile" } as any, + { fileStatus: "existingFile" } as any, + { fileStatus: "existingFile" } as any + ); + + expect(store.isFileUploadLimitReached).toBe(false); + }); + + test("returns false when maxFilesPerUpload expression is unavailable (unlimited fallback)", () => { + const store = buildStore({ maxFilesPerUpload: unavailableDynamic() }); + + store.files.push({ fileStatus: "existingFile" } as any); + + expect(store.isFileUploadLimitReached).toBe(false); + }); + + test("excludes missing, removedFile, and validationError from active count", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + + store.files.push( + { fileStatus: "existingFile" } as any, + { fileStatus: "missing" } as any, + { fileStatus: "removedFile" } as any, + { fileStatus: "validationError" } as any + ); + + expect(store.isFileUploadLimitReached).toBe(false); + }); +}); From 6eec4b4bdc9f9d474a8693c362f1810133c29611 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 11 May 2026 16:33:08 +0200 Subject: [PATCH 03/11] chore: update changelog --- packages/pluggableWidgets/file-uploader-web/CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md index bb04444d0f..1c175dc184 100644 --- a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md +++ b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue where the dropzone turned grey without explanation when the file limit was reached. A message now appears below the dropzone informing users how many files are allowed. + +### Changed + +- The "Maximum number of files" property is now optional. Leaving it empty or setting it to 0 means unlimited files are allowed. + ## [2.4.2] - 2026-04-23 ### Fixed From dd40de6b944907bf6eb4af64c7e683e64c700550 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 11 May 2026 16:39:21 +0200 Subject: [PATCH 04/11] fix: allow maxFilesPerUpload to be optional and handle undefined value --- .../file-uploader-web/src/stores/FileUploaderStore.ts | 4 ++-- .../file-uploader-web/typings/FileUploaderProps.d.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts index abfce15e39..b74423403c 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts @@ -26,7 +26,7 @@ export class FileUploaderStore { _uploadMode: UploadModeEnum; _maxFileSizeMiB = 0; _maxFileSize = 0; - _maxFilesPerUpload: DynamicValue; + _maxFilesPerUpload: DynamicValue | undefined; errorMessage?: string = undefined; @@ -117,7 +117,7 @@ export class FileUploaderStore { } get maxFilesPerUpload(): number { - const expressionValue = this._maxFilesPerUpload.value; + const expressionValue = this._maxFilesPerUpload?.value; if (expressionValue) { return expressionValue.toNumber(); } diff --git a/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts b/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts index 751fbf1fee..94bbc1759b 100644 --- a/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts +++ b/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts @@ -59,7 +59,7 @@ export interface FileUploaderContainerProps { createFileAction?: ActionValue; createImageAction?: ActionValue; allowedFileFormats: AllowedFileFormatsType[]; - maxFilesPerUpload: DynamicValue; + maxFilesPerUpload?: DynamicValue; maxFileSize: number; dropzoneIdleMessage: DynamicValue; dropzoneAcceptedMessage: DynamicValue; From 6cca4bb3eda714c4ae36b170acb9c9608e70eb30 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 11 May 2026 16:58:33 +0200 Subject: [PATCH 05/11] chore: update changelog to reflect changes in file limit reached messaging --- packages/pluggableWidgets/file-uploader-web/CHANGELOG.md | 6 +++++- .../file-uploader-web/src/FileUploader.xml | 8 ++++++++ .../file-uploader-web/src/stores/FileUploaderStore.ts | 2 +- .../src/stores/__tests__/FileUploaderStore.spec.ts | 5 +++-- .../file-uploader-web/typings/FileUploaderProps.d.ts | 2 ++ 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md index 1c175dc184..8c2d41e6cf 100644 --- a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md +++ b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md @@ -8,7 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed -- We fixed an issue where the dropzone turned grey without explanation when the file limit was reached. A message now appears below the dropzone informing users how many files are allowed. +- We fixed an issue where the dropzone turned grey without explanation when the file limit was reached. A message now appears below the dropzone stating "Maximum file count of X reached." + +### Added + +- We added a new "File limit reached" text property to customize the message shown when the upload limit is reached. ### Changed diff --git a/packages/pluggableWidgets/file-uploader-web/src/FileUploader.xml b/packages/pluggableWidgets/file-uploader-web/src/FileUploader.xml index ec63ec849a..99fe57f4f9 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/FileUploader.xml +++ b/packages/pluggableWidgets/file-uploader-web/src/FileUploader.xml @@ -163,6 +163,14 @@ Te veel bestanden toegevoegd. Slechts ### bestanden per upload zijn toegestaan. + + File limit reached + Shown below the dropzone when the maximum number of files is already reached. + + Maximum file count of ### reached. + Maximum aantal bestanden van ### bereikt. + + Action to create new files is not available or failed diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts index b74423403c..3a82e35ea4 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts @@ -141,7 +141,7 @@ export class FileUploaderStore { get warningMessage(): string | undefined { if (this.isFileUploadLimitReached) { - return this.translations.get("uploadFailureTooManyFilesMessage", this.maxFilesPerUpload.toString()); + return this.translations.get("uploadLimitReachedMessage", this.maxFilesPerUpload.toString()); } return this.errorMessage; } 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 index 74635ffcd2..b76c68b8e7 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts @@ -34,6 +34,7 @@ function buildProps(overrides: Partial = {}): FileUp 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." ), @@ -77,7 +78,7 @@ describe("FileUploaderStore.warningMessage", () => { ); expect(store.isFileUploadLimitReached).toBe(true); - expect(store.warningMessage).toBe("Too many files added. Only 2 files per upload are allowed."); + expect(store.warningMessage).toBe("Maximum file count of 2 reached."); }); test("returns errorMessage when limit not reached but error set", () => { @@ -94,7 +95,7 @@ describe("FileUploaderStore.warningMessage", () => { const fileB = { fileStatus: "existingFile", _objectItem: obj("b") } as any; store.files.push(fileA, fileB); - expect(store.warningMessage).toBe("Too many files added. Only 2 files per upload are allowed."); + expect(store.warningMessage).toBe("Maximum file count of 2 reached."); store.files.splice(store.files.indexOf(fileA), 1); diff --git a/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts b/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts index 94bbc1759b..8c607432f4 100644 --- a/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts +++ b/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts @@ -70,6 +70,7 @@ export interface FileUploaderContainerProps { uploadFailureInvalidFileFormatMessage: DynamicValue; uploadFailureFileIsTooBigMessage: DynamicValue; uploadFailureTooManyFilesMessage: DynamicValue; + uploadLimitReachedMessage: DynamicValue; unavailableCreateActionMessage: DynamicValue; downloadButtonTextMessage: DynamicValue; removeButtonTextMessage: DynamicValue; @@ -113,6 +114,7 @@ export interface FileUploaderPreviewProps { uploadFailureInvalidFileFormatMessage: string; uploadFailureFileIsTooBigMessage: string; uploadFailureTooManyFilesMessage: string; + uploadLimitReachedMessage: string; unavailableCreateActionMessage: string; downloadButtonTextMessage: string; removeButtonTextMessage: string; From aa092c24c0ed62e705d04c9d13f4717aa3d1ea2e Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 11 May 2026 17:41:50 +0200 Subject: [PATCH 06/11] feat: add maxFilesPerBatch property and related messages for upload limits --- .../file-uploader-web/CHANGELOG.md | 3 +- .../file-uploader-web/src/FileUploader.xml | 15 ++++++++- .../src/stores/FileUploaderStore.ts | 31 +++++++++++++++++-- .../__tests__/FileUploaderStore.spec.ts | 2 ++ .../typings/FileUploaderProps.d.ts | 4 +++ 5 files changed, 50 insertions(+), 5 deletions(-) diff --git a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md index 8c2d41e6cf..1e61e00bd9 100644 --- a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md +++ b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md @@ -13,10 +13,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - We added a new "File limit reached" text property to customize the message shown when the upload limit is reached. +- We added a new "Maximum files per upload batch" property to limit how many files are committed to the server per drop or selection. Files exceeding the batch limit appear in the list with an error message explaining why they were not uploaded. ### Changed -- The "Maximum number of files" property is now optional. Leaving it empty or setting it to 0 means unlimited files are allowed. +- The "Maximum number of files" property is now optional. Leaving it empty or setting it to 0 means unlimited files are allowed. The default behavior is now unlimited (no cap). ## [2.4.2] - 2026-04-23 diff --git a/packages/pluggableWidgets/file-uploader-web/src/FileUploader.xml b/packages/pluggableWidgets/file-uploader-web/src/FileUploader.xml index 99fe57f4f9..4b5373cab5 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/FileUploader.xml +++ b/packages/pluggableWidgets/file-uploader-web/src/FileUploader.xml @@ -82,7 +82,12 @@ Maximum number of files - Maximum number of files that can be associated at once. Leave empty or set to 0 for unlimited. + Maximum total number of files that can be associated at once. Leave empty or set to 0 for unlimited. Use this to cap the total number of attachments. + + + + Maximum files per upload batch + Limits how many files are committed to the server in a single drop or selection. Leave empty or set to 0 for unlimited. Smaller batch sizes reduce peak server load. @@ -171,6 +176,14 @@ Maximum aantal bestanden van ### bereikt. + + Batch limit exceeded + Shown on files that were dropped but not uploaded because the batch limit was already reached. + + File not uploaded. Batch limit of ### files per drop was reached. + Bestand niet geüpload. Batchlimiet van ### bestanden per upload is bereikt. + + Action to create new files is not available or failed diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts index 3a82e35ea4..08fd41c77e 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts @@ -27,6 +27,7 @@ export class FileUploaderStore { _maxFileSizeMiB = 0; _maxFileSize = 0; _maxFilesPerUpload: DynamicValue | undefined; + _maxFilesPerBatch: DynamicValue | undefined; errorMessage?: string = undefined; @@ -37,6 +38,7 @@ export class FileUploaderStore { this._maxFileSizeMiB = props.maxFileSize; this._maxFileSize = this._maxFileSizeMiB * 1024 * 1024; this._maxFilesPerUpload = props.maxFilesPerUpload; + this._maxFilesPerBatch = props.maxFilesPerBatch; this._uploadMode = props.uploadMode; this.objectCreationHelper = new ObjectCreationHelper(this._widgetName, props.objectCreationTimeout); @@ -81,7 +83,9 @@ export class FileUploaderStore { errorMessage: observable, allowedFormatsDescription: computed, maxFilesPerUpload: computed, + maxFilesPerBatch: computed, _maxFilesPerUpload: observable, + _maxFilesPerBatch: observable, isFileUploadLimitReached: computed, warningMessage: computed }); @@ -92,8 +96,8 @@ export class FileUploaderStore { updateProps(props: FileUploaderContainerProps): void { this.objectCreationHelper.updateProps(props); - // Update max files properties this._maxFilesPerUpload = props.maxFilesPerUpload; + this._maxFilesPerBatch = props.maxFilesPerBatch; this.translations.updateProps(props); this.updateProcessor.processUpdate( @@ -121,7 +125,14 @@ export class FileUploaderStore { if (expressionValue) { return expressionValue.toNumber(); } - // Fallback to unlimited + return 0; + } + + get maxFilesPerBatch(): number { + const expressionValue = this._maxFilesPerBatch?.value; + if (expressionValue) { + return expressionValue.toNumber(); + } return 0; } @@ -168,6 +179,11 @@ export class FileUploaderStore { this.setMessage(); + const batchLimit = this.maxFilesPerBatch; + const filesToProcess = + batchLimit > 0 && acceptedFiles.length > batchLimit ? acceptedFiles.slice(0, batchLimit) : acceptedFiles; + const batchExcess = batchLimit > 0 && acceptedFiles.length > batchLimit ? acceptedFiles.slice(batchLimit) : []; + for (const file of fileRejections) { const newFileStore = FileStore.newFileWithError( file.file, @@ -194,7 +210,16 @@ export class FileUploaderStore { this.files.unshift(newFileStore); } - for (const file of acceptedFiles) { + for (const file of batchExcess) { + const newFileStore = FileStore.newFileWithError( + file, + this.translations.get("uploadBatchLimitExceededMessage", batchLimit.toString()), + this + ); + this.files.unshift(newFileStore); + } + + for (const file of filesToProcess) { const newFileStore = FileStore.newFile(file, this); if (this.isFileUploadLimitReached) { 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 index b76c68b8e7..31e5c4c7a3 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts @@ -23,6 +23,7 @@ function buildProps(overrides: Partial = {}): FileUp createImageAction: actionValue(true, false), allowedFileFormats: [], maxFilesPerUpload: dynamic(new Big(2)), + maxFilesPerBatch: unavailableDynamic(), maxFileSize: 25, objectCreationTimeout: 10, dropzoneIdleMessage: dynamic("Drag and drop files here"), @@ -35,6 +36,7 @@ function buildProps(overrides: Partial = {}): FileUp 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."), + uploadBatchLimitExceededMessage: dynamic("File not uploaded. Batch limit of ### files per drop was reached."), unavailableCreateActionMessage: dynamic( "Can't upload files at this time. Please contact your system administrator." ), diff --git a/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts b/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts index 8c607432f4..ea5521b265 100644 --- a/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts +++ b/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts @@ -60,6 +60,7 @@ export interface FileUploaderContainerProps { createImageAction?: ActionValue; allowedFileFormats: AllowedFileFormatsType[]; maxFilesPerUpload?: DynamicValue; + maxFilesPerBatch?: DynamicValue; maxFileSize: number; dropzoneIdleMessage: DynamicValue; dropzoneAcceptedMessage: DynamicValue; @@ -71,6 +72,7 @@ export interface FileUploaderContainerProps { uploadFailureFileIsTooBigMessage: DynamicValue; uploadFailureTooManyFilesMessage: DynamicValue; uploadLimitReachedMessage: DynamicValue; + uploadBatchLimitExceededMessage: DynamicValue; unavailableCreateActionMessage: DynamicValue; downloadButtonTextMessage: DynamicValue; removeButtonTextMessage: DynamicValue; @@ -104,6 +106,7 @@ export interface FileUploaderPreviewProps { createImageAction: {} | null; allowedFileFormats: AllowedFileFormatsPreviewType[]; maxFilesPerUpload: string; + maxFilesPerBatch: string; maxFileSize: number | null; dropzoneIdleMessage: string; dropzoneAcceptedMessage: string; @@ -115,6 +118,7 @@ export interface FileUploaderPreviewProps { uploadFailureFileIsTooBigMessage: string; uploadFailureTooManyFilesMessage: string; uploadLimitReachedMessage: string; + uploadBatchLimitExceededMessage: string; unavailableCreateActionMessage: string; downloadButtonTextMessage: string; removeButtonTextMessage: string; From ad29522886c7865c2a8beac98f4d8d253c2da36d Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 12 May 2026 14:53:25 +0200 Subject: [PATCH 07/11] fix: improve upload limit feedback and retry behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show "Maximum file count of X reached" message when dropzone is disabled - Make maxFilesPerUpload optional (empty/0 = unlimited) - Add maxFilesPerBatch property to cap server commits per drop event - Split drops by remaining capacity — excess files shown as errors, not silently rejected - Auto-retry limit/batch-exceeded files when capacity is freed - Batch/limit-exceeded files survive dismissValidationErrors and re-queue correctly - Retry order matches visual list order (newest first) - Reorder file list: accepted files above rejected files --- .../file-uploader-web/CHANGELOG.md | 6 +- .../src/components/Dropzone.tsx | 11 +- .../src/components/FileUploaderRoot.tsx | 25 ++-- .../file-uploader-web/src/stores/FileStore.ts | 16 ++- .../src/stores/FileUploaderStore.ts | 87 ++++++++++++-- .../__tests__/FileUploaderStore.spec.ts | 110 ++++++++++++++++++ 6 files changed, 219 insertions(+), 36 deletions(-) diff --git a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md index 1e61e00bd9..1c9f461137 100644 --- a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md +++ b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md @@ -9,15 +9,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed - We fixed an issue where the dropzone turned grey without explanation when the file limit was reached. A message now appears below the dropzone stating "Maximum file count of X reached." +- We fixed an issue where dropping more files than allowed rejected the entire batch. Only the excess are shown as errors. +- We fixed an issue where files rejected due to the upload or batch limit could not recover. They now automatically retry when capacity becomes available. ### Added - We added a new "File limit reached" text property to customize the message shown when the upload limit is reached. -- We added a new "Maximum files per upload batch" property to limit how many files are committed to the server per drop or selection. Files exceeding the batch limit appear in the list with an error message explaining why they were not uploaded. +- We added a new "Maximum files per upload batch" property to limit how many files are committed to the server per drop event. Files exceeding the batch limit appear in the list with an error message and retry automatically when capacity is freed. +- We added a new "Batch limit exceeded" text property to customize the message shown on files that exceeded the batch limit. ### Changed - The "Maximum number of files" property is now optional. Leaving it empty or setting it to 0 means unlimited files are allowed. The default behavior is now unlimited (no cap). +- Files in the list are now ordered with successful uploads above rejected files. ## [2.4.2] - 2026-04-23 diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/Dropzone.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/Dropzone.tsx index e7d35fa606..d8b4f51b3b 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/Dropzone.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/Dropzone.tsx @@ -10,24 +10,15 @@ interface DropzoneProps { warningMessage?: string; onDrop: (files: File[], fileRejections: FileRejection[]) => void; maxSize: number; - maxFilesPerUpload: number; acceptFileTypes: MimeCheckFormat; disabled: boolean; } export const Dropzone = observer( - ({ - warningMessage, - onDrop, - maxSize, - maxFilesPerUpload, - acceptFileTypes, - disabled - }: DropzoneProps): ReactElement => { + ({ warningMessage, onDrop, maxSize, acceptFileTypes, disabled }: DropzoneProps): ReactElement => { const { getRootProps, getInputProps, isDragAccept, isDragReject } = useDropzone({ onDrop, maxSize: maxSize || undefined, - maxFiles: maxFilesPerUpload, accept: acceptFileTypes, disabled }); diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx index a7c6b9d701..7872873e99 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx @@ -29,21 +29,26 @@ export const FileUploaderRoot = observer((props: FileUploaderContainerProps): Re warningMessage={rootStore.warningMessage} maxSize={rootStore._maxFileSize} acceptFileTypes={prepareAcceptForDropzone(rootStore.acceptedFileTypes)} - maxFilesPerUpload={rootStore.maxFilesPerUpload ?? 0} disabled={rootStore.isFileUploadLimitReached} /> )}
- {(rootStore.files ?? []).map(fileStore => { - return ( - - ); - })} + {[...(rootStore.files ?? [])] + .sort((a, b) => { + const isErrorA = a.fileStatus === "validationError" ? 1 : 0; + const isErrorB = b.fileStatus === "validationError" ? 1 : 0; + return isErrorA - isErrorB; + }) + .map(fileStore => { + return ( + + ); + })}
); diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts index da9165edd2..b81a5a9a2c 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts @@ -43,6 +43,7 @@ export class FileStore { key: number; errorDescription?: string = undefined; + errorType?: "limitExceeded" | "batchExceeded" | "validation" = undefined; constructor(type: FileStatus, rootStore: FileUploaderStore, file?: File, objectItem?: ObjectItem) { this.key = getFileKey(); @@ -55,12 +56,14 @@ export class FileStore { fileStatus: observable, _mxObject: observable, errorDescription: observable, + errorType: observable, _thumbnailUrl: observable, canRemove: computed, imagePreviewUrl: computed, upload: action, fetchMxObject: action, - markMissing: action + markMissing: action, + markError: action }); } @@ -71,9 +74,10 @@ export class FileStore { this._objectItem = undefined; } - markError(errorMessage: string): void { + markError(errorMessage: string, errorType: "limitExceeded" | "batchExceeded" | "validation" = "validation"): void { this.fileStatus = "validationError"; this.errorDescription = errorMessage; + this.errorType = errorType; } canExecute(listAction: ListActionValue): boolean { @@ -237,10 +241,16 @@ export class FileStore { return new FileStore("new", rootStore, file, undefined); } - static newFileWithError(file: File, errorMessage: string, rootStore: FileUploaderStore): FileStore { + static newFileWithError( + file: File, + errorMessage: string, + rootStore: FileUploaderStore, + errorType: "limitExceeded" | "batchExceeded" | "validation" = "validation" + ): FileStore { const store = new FileStore("validationError", rootStore, file, undefined); runInAction(() => { store.errorDescription = errorMessage; + store.errorType = errorType; }); return store; diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts index 08fd41c77e..16f6d32f5b 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts @@ -1,6 +1,6 @@ import { Big } from "big.js"; import { DynamicValue, ObjectItem } from "mendix"; -import { action, computed, makeObservable, observable } from "mobx"; +import { action, computed, makeObservable, observable, reaction } from "mobx"; import { FileRejection } from "react-dropzone"; import { FileStore } from "./FileStore"; import { TranslationsStore } from "./TranslationsStore"; @@ -77,6 +77,8 @@ export class FileUploaderStore { updateProps: action, processDrop: action, setMessage: action, + dismissValidationErrors: action, + retryLimitExceededFiles: action, processExistingFileItem: action, files: observable, existingItemsLoaded: observable, @@ -91,6 +93,21 @@ export class FileUploaderStore { }); this.updateProps(props); + + reaction( + () => + this.files.filter( + f => + f.fileStatus !== "missing" && + f.fileStatus !== "removedFile" && + f.fileStatus !== "validationError" + ).length, + (count, prevCount) => { + if (count < prevCount) { + this.retryLimitExceededFiles(); + } + } + ); } updateProps(props: FileUploaderContainerProps): void { @@ -161,6 +178,42 @@ export class FileUploaderStore { this.errorMessage = msg; } + dismissValidationErrors(): void { + this.files = this.files.filter( + f => + f.fileStatus !== "validationError" || f.errorType === "limitExceeded" || f.errorType === "batchExceeded" + ); + } + + retryLimitExceededFiles(): void { + const activeCount = this.files.filter( + f => f.fileStatus !== "missing" && f.fileStatus !== "removedFile" && f.fileStatus !== "validationError" + ).length; + const capacitySlots = + this.maxFilesPerUpload > 0 ? Math.max(0, this.maxFilesPerUpload - activeCount) : Number.MAX_SAFE_INTEGER; + const slots = this.maxFilesPerBatch > 0 ? Math.min(capacitySlots, this.maxFilesPerBatch) : capacitySlots; + + if (slots === 0) { + return; + } + + const waiting = [...this.files].filter( + f => + f.fileStatus === "validationError" && + (f.errorType === "limitExceeded" || f.errorType === "batchExceeded") + ); + + for (let i = 0; i < Math.min(slots, waiting.length); i++) { + const file = waiting[i]; + file.errorType = undefined; + file.errorDescription = undefined; + file.fileStatus = "new"; + if (file.validate()) { + file.upload(); + } + } + } + processDrop(acceptedFiles: File[], fileRejections: FileRejection[]): void { if (!this.objectCreationHelper.canCreateFiles) { console.error( @@ -179,11 +232,20 @@ export class FileUploaderStore { this.setMessage(); + // Split accepted files by batch limit first, then by remaining total capacity const batchLimit = this.maxFilesPerBatch; - const filesToProcess = + const afterBatchSplit = batchLimit > 0 && acceptedFiles.length > batchLimit ? acceptedFiles.slice(0, batchLimit) : acceptedFiles; const batchExcess = batchLimit > 0 && acceptedFiles.length > batchLimit ? acceptedFiles.slice(batchLimit) : []; + const activeCount = this.files.filter( + f => f.fileStatus !== "missing" && f.fileStatus !== "removedFile" && f.fileStatus !== "validationError" + ).length; + const remaining = + this.maxFilesPerUpload > 0 ? Math.max(0, this.maxFilesPerUpload - activeCount) : afterBatchSplit.length; + const capacityFiles = afterBatchSplit.slice(0, remaining); + const capacityExcess = afterBatchSplit.slice(remaining); + for (const file of fileRejections) { const newFileStore = FileStore.newFileWithError( file.file, @@ -206,7 +268,6 @@ export class FileUploaderStore { .join(" "), this ); - this.files.unshift(newFileStore); } @@ -214,22 +275,24 @@ export class FileUploaderStore { const newFileStore = FileStore.newFileWithError( file, this.translations.get("uploadBatchLimitExceededMessage", batchLimit.toString()), - this + this, + "batchExceeded" ); this.files.unshift(newFileStore); } - for (const file of filesToProcess) { + for (const file of capacityExcess) { const newFileStore = FileStore.newFile(file, this); - - if (this.isFileUploadLimitReached) { - newFileStore.markError( - this.translations.get("uploadFailureTooManyFilesMessage", this.maxFilesPerUpload.toString()) - ); - } - + newFileStore.markError( + this.translations.get("uploadLimitReachedMessage", this.maxFilesPerUpload.toString()), + "limitExceeded" + ); this.files.unshift(newFileStore); + } + 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/__tests__/FileUploaderStore.spec.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts index 31e5c4c7a3..bb3e2ee23b 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts @@ -140,3 +140,113 @@ describe("FileUploaderStore.isFileUploadLimitReached", () => { expect(store.isFileUploadLimitReached).toBe(false); }); }); + +describe("FileUploaderStore.processDrop — capacity split", () => { + function makeFile(name: string): File { + return new File([""], name, { type: "text/plain" }); + } + + test("dismissValidationErrors preserves batchExceeded files", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(5)) }); + + store.files.push( + { fileStatus: "validationError", errorType: "validation" } as any, + { fileStatus: "validationError", errorType: "batchExceeded" } as any + ); + + store.dismissValidationErrors(); + + expect(store.files).toHaveLength(1); + expect(store.files[0].errorType).toBe("batchExceeded"); + }); + + test("dismissValidationErrors clears format errors but preserves limitExceeded files", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(5)) }); + + store.files.push( + { fileStatus: "validationError", errorType: "validation" } as any, + { fileStatus: "validationError", errorType: "limitExceeded" } as any + ); + + store.dismissValidationErrors(); + + expect(store.files).toHaveLength(1); + expect(store.files[0].errorType).toBe("limitExceeded"); + }); + + test("removing an active file promotes newest limitExceeded file to upload", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + + const activeA = { fileStatus: "existingFile", errorType: undefined } as any; + const activeB = { fileStatus: "existingFile", errorType: undefined } as any; + // waitingNew is first in array (unshifted last = newest at top) + const waitingNew = { + fileStatus: "validationError", + errorType: "limitExceeded", + _file: makeFile("new.txt"), + validate: () => true, + upload: jest.fn() + } as any; + const waitingOld = { + fileStatus: "validationError", + errorType: "limitExceeded", + _file: makeFile("old.txt"), + validate: () => true, + upload: jest.fn() + } as any; + + store.files.push(waitingNew, waitingOld, activeA, activeB); + + store.files.splice(store.files.indexOf(activeA), 1); + store.retryLimitExceededFiles(); + + expect(waitingNew.upload).toHaveBeenCalledTimes(1); + expect(waitingOld.upload).not.toHaveBeenCalled(); + }); + + test("accepts files up to remaining capacity and marks overflow as limitExceeded", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(5)) }); + + const files = [1, 2, 3, 4, 5, 6].map(n => makeFile(`file${n}.txt`)); + store.processDrop(files, []); + + const errorFiles = store.files.filter(f => f.fileStatus === "validationError"); + const acceptedFiles = store.files.filter(f => f.fileStatus !== "validationError"); + + expect(errorFiles).toHaveLength(1); + expect(errorFiles[0].errorType).toBe("limitExceeded"); + expect(acceptedFiles).toHaveLength(5); + }); + + test("retryLimitExceededFiles promotes batchExceeded files when slots open", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + + const active = { fileStatus: "existingFile", errorType: undefined } as any; + const waiting = { + fileStatus: "validationError", + errorType: "batchExceeded", + validate: () => true, + upload: jest.fn() + } as any; + + store.files.push(waiting, active, { fileStatus: "existingFile" } as any); + + store.files.splice(store.files.indexOf(active), 1); + store.retryLimitExceededFiles(); + + expect(waiting.upload).toHaveBeenCalledTimes(1); + }); + + test("marks batch-excess files with errorType batchExceeded", () => { + const store = buildStore({ + maxFilesPerUpload: unavailableDynamic(), + maxFilesPerBatch: dynamic(new Big(2)) + }); + + const files = [1, 2, 3, 4].map(n => makeFile(`file${n}.txt`)); + store.processDrop(files, []); + + const batchErrorFiles = store.files.filter(f => f.errorType === "batchExceeded"); + expect(batchErrorFiles).toHaveLength(2); + }); +}); From 6ee29bdb597010281500b0f2621c3fbded3421d6 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 12 May 2026 15:36:21 +0200 Subject: [PATCH 08/11] refactor: reorder imports for better organization in FileUploaderStore tests --- .../src/components/FileUploaderRoot.tsx | 24 +++++++------------ .../file-uploader-web/src/stores/FileStore.ts | 9 ++++++- .../src/stores/FileUploaderStore.ts | 22 +++++++++++++---- .../__tests__/FileUploaderStore.spec.ts | 6 ++--- .../src/utils/useRootStore.ts | 4 ++++ 5 files changed, 41 insertions(+), 24 deletions(-) diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx index 7872873e99..6ea07ff27c 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx @@ -34,21 +34,15 @@ export const FileUploaderRoot = observer((props: FileUploaderContainerProps): Re )}
- {[...(rootStore.files ?? [])] - .sort((a, b) => { - const isErrorA = a.fileStatus === "validationError" ? 1 : 0; - const isErrorB = b.fileStatus === "validationError" ? 1 : 0; - return isErrorA - isErrorB; - }) - .map(fileStore => { - return ( - - ); - })} + {rootStore.sortedFiles.map(fileStore => { + return ( + + ); + })}
); diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts index b81a5a9a2c..0f7258396a 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts @@ -63,7 +63,8 @@ export class FileStore { upload: action, fetchMxObject: action, markMissing: action, - markError: action + markError: action, + reset: action }); } @@ -80,6 +81,12 @@ export class FileStore { this.errorType = errorType; } + reset(): void { + this.errorType = undefined; + this.errorDescription = undefined; + this.fileStatus = "new"; + } + canExecute(listAction: ListActionValue): boolean { if (!this._objectItem) { return false; diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts index 16f6d32f5b..0aa6aa5814 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts @@ -28,6 +28,7 @@ export class FileUploaderStore { _maxFileSize = 0; _maxFilesPerUpload: DynamicValue | undefined; _maxFilesPerBatch: DynamicValue | undefined; + _disposeRetryReaction: (() => void) | undefined; errorMessage?: string = undefined; @@ -89,12 +90,13 @@ export class FileUploaderStore { _maxFilesPerUpload: observable, _maxFilesPerBatch: observable, isFileUploadLimitReached: computed, - warningMessage: computed + warningMessage: computed, + sortedFiles: computed }); this.updateProps(props); - reaction( + this._disposeRetryReaction = reaction( () => this.files.filter( f => @@ -167,6 +169,14 @@ export class FileUploaderStore { return activeFiles.length >= this.maxFilesPerUpload; } + 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; + }); + } + get warningMessage(): string | undefined { if (this.isFileUploadLimitReached) { return this.translations.get("uploadLimitReachedMessage", this.maxFilesPerUpload.toString()); @@ -205,15 +215,17 @@ export class FileUploaderStore { for (let i = 0; i < Math.min(slots, waiting.length); i++) { const file = waiting[i]; - file.errorType = undefined; - file.errorDescription = undefined; - file.fileStatus = "new"; + file.reset(); if (file.validate()) { file.upload(); } } } + dispose(): void { + this._disposeRetryReaction?.(); + } + processDrop(acceptedFiles: File[], fileRejections: FileRejection[]): void { if (!this.objectCreationHelper.canCreateFiles) { console.error( 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 index bb3e2ee23b..2075695101 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts @@ -1,13 +1,13 @@ 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 { FileUploaderStore } from "../FileUploaderStore"; +import { TranslationsStore } from "../TranslationsStore"; function unavailableDynamic(): DynamicValue { return { status: "unavailable", value: undefined } as unknown as DynamicValue; } -import { FileUploaderContainerProps } from "../../../typings/FileUploaderProps"; -import { FileUploaderStore } from "../FileUploaderStore"; -import { TranslationsStore } from "../TranslationsStore"; function buildProps(overrides: Partial = {}): FileUploaderContainerProps { return { diff --git a/packages/pluggableWidgets/file-uploader-web/src/utils/useRootStore.ts b/packages/pluggableWidgets/file-uploader-web/src/utils/useRootStore.ts index a67bda945b..981a8f8faf 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/utils/useRootStore.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/utils/useRootStore.ts @@ -13,5 +13,9 @@ export function useRootStore(props: FileUploaderContainerProps): FileUploaderSto rootStore.updateProps(props); }, [rootStore, props]); + useEffect(() => { + return () => rootStore.dispose(); + }, [rootStore]); + return rootStore; } From 3bccb0aafee3465f68c4c886bcf856f3fc6b5dcb Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 12 May 2026 16:12:06 +0200 Subject: [PATCH 09/11] fix: remove redundant error handling for too many files in processDrop method --- .../file-uploader-web/src/stores/FileUploaderStore.ts | 7 ------- .../src/stores/__tests__/FileUploaderStore.spec.ts | 5 +++-- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts index 0aa6aa5814..d39882d4e3 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts @@ -235,13 +235,6 @@ export class FileUploaderStore { return; } - if (fileRejections.length && fileRejections[0].errors[0].code === "too-many-files") { - this.setMessage( - this.translations.get("uploadFailureTooManyFilesMessage", this.maxFilesPerUpload.toString()) - ); - return; - } - this.setMessage(); // Split accepted files by batch limit first, then by remaining total capacity 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 index 2075695101..13ef55962c 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts @@ -184,6 +184,7 @@ describe("FileUploaderStore.processDrop — capacity split", () => { fileStatus: "validationError", errorType: "limitExceeded", _file: makeFile("new.txt"), + reset: jest.fn(), validate: () => true, upload: jest.fn() } as any; @@ -191,6 +192,7 @@ describe("FileUploaderStore.processDrop — capacity split", () => { fileStatus: "validationError", errorType: "limitExceeded", _file: makeFile("old.txt"), + reset: jest.fn(), validate: () => true, upload: jest.fn() } as any; @@ -198,7 +200,6 @@ describe("FileUploaderStore.processDrop — capacity split", () => { store.files.push(waitingNew, waitingOld, activeA, activeB); store.files.splice(store.files.indexOf(activeA), 1); - store.retryLimitExceededFiles(); expect(waitingNew.upload).toHaveBeenCalledTimes(1); expect(waitingOld.upload).not.toHaveBeenCalled(); @@ -225,6 +226,7 @@ describe("FileUploaderStore.processDrop — capacity split", () => { const waiting = { fileStatus: "validationError", errorType: "batchExceeded", + reset: jest.fn(), validate: () => true, upload: jest.fn() } as any; @@ -232,7 +234,6 @@ describe("FileUploaderStore.processDrop — capacity split", () => { store.files.push(waiting, active, { fileStatus: "existingFile" } as any); store.files.splice(store.files.indexOf(active), 1); - store.retryLimitExceededFiles(); expect(waiting.upload).toHaveBeenCalledTimes(1); }); From 66065042da9213a34e1cfbbd8b4341f95477e761 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 19 May 2026 10:39:39 +0200 Subject: [PATCH 10/11] feat: replace error/retry throttling with upload queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add "queued" and "rejected" statuses; remove "new", "removedAfterError" - Remove errorType field and dismissValidationErrors - maxFilesPerBatch (XML) → maxConcurrentUploads (TS) - maxFilesPerUpload (XML) → maxTotalFiles (TS) - processDrop is pure classifier; two reactions drain the queue - Add uploadQueuedMessage XML property - Remove uploadBatchLimitExceededMessage XML property --- .../file-uploader-web/CHANGELOG.md | 9 +- .../file-uploader-web/src/FileUploader.xml | 21 +- .../src/components/FileEntry.tsx | 2 +- .../src/components/UploadInfo.tsx | 5 +- .../file-uploader-web/src/stores/FileStore.ts | 49 +- .../src/stores/FileUploaderStore.ts | 185 ++--- .../__tests__/FileUploaderStore.spec.ts | 646 +++++++++++++++--- .../typings/FileUploaderProps.d.ts | 4 +- 8 files changed, 672 insertions(+), 249 deletions(-) diff --git a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md index 1c9f461137..74ff791f74 100644 --- a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md +++ b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md @@ -9,18 +9,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed - We fixed an issue where the dropzone turned grey without explanation when the file limit was reached. A message now appears below the dropzone stating "Maximum file count of X reached." -- We fixed an issue where dropping more files than allowed rejected the entire batch. Only the excess are shown as errors. -- We fixed an issue where files rejected due to the upload or batch limit could not recover. They now automatically retry when capacity becomes available. +- We fixed an issue where dropping more files than allowed rejected the entire batch. Only the excess files are now rejected; the rest upload normally. +- We fixed an issue where files rejected due to the total file limit had no way to recover. They are now automatically queued for upload when capacity becomes available (e.g. after a file is removed or an upload fails). ### Added +- We added a new "Maximum concurrent uploads" property to control how many files upload simultaneously. Files beyond this limit wait in a queue and upload automatically as slots free up. - We added a new "File limit reached" text property to customize the message shown when the upload limit is reached. -- We added a new "Maximum files per upload batch" property to limit how many files are committed to the server per drop event. Files exceeding the batch limit appear in the list with an error message and retry automatically when capacity is freed. -- We added a new "Batch limit exceeded" text property to customize the message shown on files that exceeded the batch limit. +- We added a new "Upload queued" text property to customize the message shown on files that are waiting to upload. ### Changed - The "Maximum number of files" property is now optional. Leaving it empty or setting it to 0 means unlimited files are allowed. The default behavior is now unlimited (no cap). +- Files now upload in a queue rather than being marked as errors when too many are dropped at once. Queued files show a "Waiting..." state while they wait for a concurrent slot. - Files in the list are now ordered with successful uploads above rejected files. ## [2.4.2] - 2026-04-23 diff --git a/packages/pluggableWidgets/file-uploader-web/src/FileUploader.xml b/packages/pluggableWidgets/file-uploader-web/src/FileUploader.xml index 4b5373cab5..26faed942e 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/FileUploader.xml +++ b/packages/pluggableWidgets/file-uploader-web/src/FileUploader.xml @@ -86,8 +86,8 @@
- Maximum files per upload batch - Limits how many files are committed to the server in a single drop or selection. Leave empty or set to 0 for unlimited. Smaller batch sizes reduce peak server load. + Maximum concurrent uploads + Maximum number of files uploading simultaneously. Remaining files wait in a queue and upload automatically as slots free up. Leave empty or set to 0 for unlimited. @@ -128,6 +128,14 @@ Uploaden... + + Upload queued + + + Waiting... + Wachten... + + Uploading success @@ -176,14 +184,7 @@ Maximum aantal bestanden van ### bereikt. - - Batch limit exceeded - Shown on files that were dropped but not uploaded because the batch limit was already reached. - - File not uploaded. Batch limit of ### files per drop was reached. - Bestand niet geüpload. Batchlimiet van ### bestanden per upload is bereikt. - - + Action to create new files is not available or failed diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/FileEntry.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/FileEntry.tsx index a8b74b9c6d..d49a8e2bb4 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/FileEntry.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/FileEntry.tsx @@ -83,7 +83,7 @@ function FileEntry(props: FileEntryProps): ReactElement { return (
{translations.get("uploadSuccessMessage")}; case "uploadingError": - case "removedAfterError": return {translations.get("uploadFailureGenericMessage")}; case "validationError": + case "rejected": 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 0f7258396a..90edb8fff4 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts @@ -17,13 +17,13 @@ import { export type FileStatus = | "existingFile" | "missing" - | "new" + | "queued" | "uploading" | "done" | "uploadingError" - | "removedAfterError" | "removedFile" - | "validationError"; + | "validationError" + | "rejected"; let fileKey = 0; @@ -43,7 +43,6 @@ export class FileStore { key: number; errorDescription?: string = undefined; - errorType?: "limitExceeded" | "batchExceeded" | "validation" = undefined; constructor(type: FileStatus, rootStore: FileUploaderStore, file?: File, objectItem?: ObjectItem) { this.key = getFileKey(); @@ -56,35 +55,26 @@ export class FileStore { fileStatus: observable, _mxObject: observable, errorDescription: observable, - errorType: observable, _thumbnailUrl: observable, canRemove: computed, imagePreviewUrl: computed, upload: action, fetchMxObject: action, markMissing: action, - markError: action, - reset: action + setQueued: 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, errorType: "limitExceeded" | "batchExceeded" | "validation" = "validation"): void { - this.fileStatus = "validationError"; - this.errorDescription = errorMessage; - this.errorType = errorType; - } - - reset(): void { - this.errorType = undefined; + setQueued(): void { this.errorDescription = undefined; - this.fileStatus = "new"; + this.fileStatus = "queued"; } canExecute(listAction: ListActionValue): boolean { @@ -102,12 +92,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 @@ -245,19 +235,22 @@ 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, errorMessage: string, rootStore: FileUploaderStore): FileStore { + const store = new FileStore("rejected", rootStore, file, undefined); + runInAction(() => { + store.errorDescription = errorMessage; + }); + + return store; } - static newFileWithError( - file: File, - errorMessage: string, - rootStore: FileUploaderStore, - errorType: "limitExceeded" | "batchExceeded" | "validation" = "validation" - ): FileStore { + static newFileWithValidationError(file: File, errorMessage: string, rootStore: FileUploaderStore): FileStore { const store = new FileStore("validationError", rootStore, file, undefined); runInAction(() => { store.errorDescription = errorMessage; - store.errorType = errorType; }); return store; diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts index d39882d4e3..ab982ceb91 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts @@ -26,9 +26,10 @@ export class FileUploaderStore { _uploadMode: UploadModeEnum; _maxFileSizeMiB = 0; _maxFileSize = 0; - _maxFilesPerUpload: DynamicValue | undefined; - _maxFilesPerBatch: DynamicValue | undefined; - _disposeRetryReaction: (() => void) | undefined; + _maxTotalFiles: DynamicValue | undefined; + _maxConcurrentUploads: DynamicValue | undefined; + _disposePromoteRejectedReaction: (() => void) | undefined; + _disposePromoteQueuedReaction: (() => void) | undefined; errorMessage?: string = undefined; @@ -38,8 +39,8 @@ export class FileUploaderStore { this._widgetName = props.name; this._maxFileSizeMiB = props.maxFileSize; this._maxFileSize = this._maxFileSizeMiB * 1024 * 1024; - this._maxFilesPerUpload = props.maxFilesPerUpload; - this._maxFilesPerBatch = props.maxFilesPerBatch; + this._maxTotalFiles = props.maxFilesPerUpload; + this._maxConcurrentUploads = props.maxFilesPerBatch; this._uploadMode = props.uploadMode; this.objectCreationHelper = new ObjectCreationHelper(this._widgetName, props.objectCreationTimeout); @@ -78,35 +79,42 @@ export class FileUploaderStore { updateProps: action, processDrop: action, setMessage: action, - dismissValidationErrors: action, - retryLimitExceededFiles: action, + promoteRejectedFiles: action, + promoteQueuedFiles: action, processExistingFileItem: action, files: observable, existingItemsLoaded: observable, errorMessage: observable, allowedFormatsDescription: computed, - maxFilesPerUpload: computed, - maxFilesPerBatch: computed, - _maxFilesPerUpload: observable, - _maxFilesPerBatch: observable, + maxTotalFiles: computed, + maxConcurrentUploads: computed, + _maxTotalFiles: observable, + _maxConcurrentUploads: observable, isFileUploadLimitReached: computed, warningMessage: computed, - sortedFiles: computed + sortedFiles: computed, + activeCount: computed, + uploadingCount: computed }); this.updateProps(props); - this._disposeRetryReaction = reaction( - () => - this.files.filter( - f => - f.fileStatus !== "missing" && - f.fileStatus !== "removedFile" && - f.fileStatus !== "validationError" - ).length, + // Reaction 1: active count drops → promote rejected files into queue + this._disposePromoteRejectedReaction = reaction( + () => this.activeCount, (count, prevCount) => { if (count < prevCount) { - this.retryLimitExceededFiles(); + this.promoteRejectedFiles(); + } + } + ); + + // Reaction 2: uploading count drops → promote queued files to uploading + this._disposePromoteQueuedReaction = reaction( + () => this.uploadingCount, + (count, prevCount) => { + if (count < prevCount) { + this.promoteQueuedFiles(); } } ); @@ -115,8 +123,8 @@ export class FileUploaderStore { updateProps(props: FileUploaderContainerProps): void { this.objectCreationHelper.updateProps(props); - this._maxFilesPerUpload = props.maxFilesPerUpload; - this._maxFilesPerBatch = props.maxFilesPerBatch; + this._maxTotalFiles = props.maxFilesPerUpload; + this._maxConcurrentUploads = props.maxFilesPerBatch; this.translations.updateProps(props); this.updateProcessor.processUpdate( @@ -139,34 +147,43 @@ export class FileUploaderStore { .join(", "); } - get maxFilesPerUpload(): number { - const expressionValue = this._maxFilesPerUpload?.value; + get maxTotalFiles(): number { + const expressionValue = this._maxTotalFiles?.value; if (expressionValue) { return expressionValue.toNumber(); } return 0; } - get maxFilesPerBatch(): number { - const expressionValue = this._maxFilesPerBatch?.value; + get maxConcurrentUploads(): number { + const expressionValue = this._maxConcurrentUploads?.value; if (expressionValue) { return expressionValue.toNumber(); } 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 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[] { @@ -179,7 +196,7 @@ export class FileUploaderStore { get warningMessage(): string | undefined { if (this.isFileUploadLimitReached) { - return this.translations.get("uploadLimitReachedMessage", this.maxFilesPerUpload.toString()); + return this.translations.get("uploadLimitReachedMessage", this.maxTotalFiles.toString()); } return this.errorMessage; } @@ -188,42 +205,46 @@ export class FileUploaderStore { this.errorMessage = msg; } - dismissValidationErrors(): void { - this.files = this.files.filter( - f => - f.fileStatus !== "validationError" || f.errorType === "limitExceeded" || f.errorType === "batchExceeded" - ); + promoteRejectedFiles(): void { + if (this.maxTotalFiles === 0) { + return; + } + + const slots = Math.max(0, this.maxTotalFiles - this.activeCount); + if (slots === 0) { + return; + } + + // oldest first: files are unshifted (prepended), so last in array = oldest + const rejected = [...this.files].filter(f => f.fileStatus === "rejected").reverse(); + + for (let i = 0; i < Math.min(slots, rejected.length); i++) { + rejected[i].setQueued(); + } + + this.promoteQueuedFiles(); } - retryLimitExceededFiles(): void { - const activeCount = this.files.filter( - f => f.fileStatus !== "missing" && f.fileStatus !== "removedFile" && f.fileStatus !== "validationError" - ).length; - const capacitySlots = - this.maxFilesPerUpload > 0 ? Math.max(0, this.maxFilesPerUpload - activeCount) : Number.MAX_SAFE_INTEGER; - const slots = this.maxFilesPerBatch > 0 ? Math.min(capacitySlots, this.maxFilesPerBatch) : capacitySlots; + promoteQueuedFiles(): void { + const concurrentLimit = this.maxConcurrentUploads; + const availableSlots = + concurrentLimit > 0 ? Math.max(0, concurrentLimit - this.uploadingCount) : Number.MAX_SAFE_INTEGER; - if (slots === 0) { + if (availableSlots === 0) { return; } - const waiting = [...this.files].filter( - f => - f.fileStatus === "validationError" && - (f.errorType === "limitExceeded" || f.errorType === "batchExceeded") - ); + // oldest first: last in array = oldest + const queued = [...this.files].filter(f => f.fileStatus === "queued").reverse(); - for (let i = 0; i < Math.min(slots, waiting.length); i++) { - const file = waiting[i]; - file.reset(); - if (file.validate()) { - file.upload(); - } + for (let i = 0; i < Math.min(availableSlots, queued.length); i++) { + queued[i].upload(); } } dispose(): void { - this._disposeRetryReaction?.(); + this._disposePromoteRejectedReaction?.(); + this._disposePromoteQueuedReaction?.(); } processDrop(acceptedFiles: File[], fileRejections: FileRejection[]): void { @@ -237,22 +258,13 @@ export class FileUploaderStore { this.setMessage(); - // Split accepted files by batch limit first, then by remaining total capacity - const batchLimit = this.maxFilesPerBatch; - const afterBatchSplit = - batchLimit > 0 && acceptedFiles.length > batchLimit ? acceptedFiles.slice(0, batchLimit) : acceptedFiles; - const batchExcess = batchLimit > 0 && acceptedFiles.length > batchLimit ? acceptedFiles.slice(batchLimit) : []; - - const activeCount = this.files.filter( - f => f.fileStatus !== "missing" && f.fileStatus !== "removedFile" && f.fileStatus !== "validationError" - ).length; - const remaining = - this.maxFilesPerUpload > 0 ? Math.max(0, this.maxFilesPerUpload - activeCount) : afterBatchSplit.length; - const capacityFiles = afterBatchSplit.slice(0, remaining); - const capacityExcess = afterBatchSplit.slice(remaining); + 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 => { @@ -276,21 +288,11 @@ export class FileUploaderStore { this.files.unshift(newFileStore); } - for (const file of batchExcess) { - const newFileStore = FileStore.newFileWithError( - file, - this.translations.get("uploadBatchLimitExceededMessage", batchLimit.toString()), - this, - "batchExceeded" - ); - this.files.unshift(newFileStore); - } - for (const file of capacityExcess) { - const newFileStore = FileStore.newFile(file, this); - newFileStore.markError( - this.translations.get("uploadLimitReachedMessage", this.maxFilesPerUpload.toString()), - "limitExceeded" + const newFileStore = FileStore.newRejectedFile( + file, + this.translations.get("uploadLimitReachedMessage", this.maxTotalFiles.toString()), + this ); this.files.unshift(newFileStore); } @@ -298,9 +300,8 @@ export class FileUploaderStore { for (const file of capacityFiles) { const newFileStore = FileStore.newFile(file, this); this.files.unshift(newFileStore); - if (newFileStore.validate()) { - newFileStore.upload(); - } } + + this.promoteQueuedFiles(); } } 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 index 13ef55962c..fa385f6c4c 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts @@ -2,6 +2,7 @@ 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"; @@ -22,7 +23,7 @@ function buildProps(overrides: Partial = {}): FileUp createFileAction: actionValue(true, false), createImageAction: actionValue(true, false), allowedFileFormats: [], - maxFilesPerUpload: dynamic(new Big(2)), + maxFilesPerUpload: dynamic(new Big(5)), maxFilesPerBatch: unavailableDynamic(), maxFileSize: 25, objectCreationTimeout: 10, @@ -30,13 +31,13 @@ function buildProps(overrides: Partial = {}): FileUp 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."), - uploadBatchLimitExceededMessage: dynamic("File not uploaded. Batch limit of ### files per drop was reached."), unavailableCreateActionMessage: dynamic( "Can't upload files at this time. Please contact your system administrator." ), @@ -60,74 +61,302 @@ function buildStore(overrides: Partial = {}): FileUp return new FileUploaderStore(props, translations); } -describe("FileUploaderStore.warningMessage", () => { - test("returns undefined when no limit set and no error", () => { +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("rejected", rootStore, makeFile("test.txt")); + file.errorDescription = "too many files"; + + 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 and errorDescription", () => { + const rootStore = buildStore(); + const file = FileStore.newRejectedFile(makeFile("test.txt"), "Too many files", rootStore); + + expect(file.fileStatus).toBe("rejected"); + expect(file.errorDescription).toBe("Too many files"); + }); +}); + +// ─── 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.warningMessage).toBeUndefined(); + + expect(store.maxTotalFiles).toBe(0); + }); + + test("maxConcurrentUploads returns 0 (unlimited) when expression unavailable", () => { + const store = buildStore({ maxFilesPerBatch: unavailableDynamic() }); + + expect(store.maxConcurrentUploads).toBe(0); + }); +}); + +// ─── 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 ───────────────────────── - test("returns undefined when under limit and no error", () => { +describe("FileUploaderStore.processDrop — pure classifier", () => { + test("accepted files within capacity enter upload pipeline (queued or uploading)", () => { const store = buildStore({ maxFilesPerUpload: dynamic(new Big(5)) }); - expect(store.warningMessage).toBeUndefined(); + + 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("returns limit-reached message when file limit is reached", () => { + test("files exceeding maxTotalFiles go to 'rejected' status", () => { const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); - store.files.push( - { fileStatus: "existingFile", _objectItem: obj("a") } as any, - { fileStatus: "existingFile", _objectItem: obj("b") } as any + store.processDrop( + [1, 2, 3, 4].map(n => makeFile(`file${n}.txt`)), + [] ); - expect(store.isFileUploadLimitReached).toBe(true); - expect(store.warningMessage).toBe("Maximum file count of 2 reached."); + 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("returns errorMessage when limit not reached but error set", () => { - const store = buildStore({ maxFilesPerUpload: dynamic(new Big(5)) }); - store.setMessage("Some other error"); + 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" }] } + ]; - expect(store.warningMessage).toBe("Some other error"); + store.processDrop([], rejections as any); + + expect(store.files.filter(f => f.fileStatus === "validationError")).toHaveLength(1); }); - test("clears limit-reached message when file removed below limit", () => { + 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)) }); - const fileA = { fileStatus: "existingFile", _objectItem: obj("a") } as any; - const fileB = { fileStatus: "existingFile", _objectItem: obj("b") } as any; - store.files.push(fileA, fileB); + store.processDrop( + [1, 2, 3, 4].map(n => makeFile(`file${n}.txt`)), + [] + ); - expect(store.warningMessage).toBe("Maximum file count of 2 reached."); + const withErrorType = store.files.filter(f => (f as any).errorType !== undefined); + expect(withErrorType).toHaveLength(0); + }); - store.files.splice(store.files.indexOf(fileA), 1); + 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")); - expect(store.isFileUploadLimitReached).toBe(false); - expect(store.warningMessage).toBeUndefined(); + 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); }); }); +// ─── FileUploaderStore.isFileUploadLimitReached ─────────────────────────────── + describe("FileUploaderStore.isFileUploadLimitReached", () => { - test("returns false when maxFilesPerUpload is 0 (unlimited)", () => { - const store = buildStore({ maxFilesPerUpload: dynamic(new Big(0)) }); + test("'queued' counts toward maxTotalFiles", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + + store.files.push({ fileStatus: "queued" } as any, { fileStatus: "queued" } as any); + + expect(store.isFileUploadLimitReached).toBe(true); + }); + + test("'existingFile' counts toward maxTotalFiles", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + + store.files.push({ fileStatus: "existingFile" } as any, { fileStatus: "existingFile" } as any); + + expect(store.isFileUploadLimitReached).toBe(true); + }); + + test("'uploading' counts toward maxTotalFiles", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(1)) }); + + store.files.push({ fileStatus: "uploading" } as any); + + expect(store.isFileUploadLimitReached).toBe(true); + }); + + test("'done' counts toward maxTotalFiles", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(1)) }); + + store.files.push({ fileStatus: "done" } as any); + + expect(store.isFileUploadLimitReached).toBe(true); + }); + + test("'rejected' does NOT count toward maxTotalFiles", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); store.files.push( - { fileStatus: "existingFile" } as any, - { fileStatus: "existingFile" } as any, - { fileStatus: "existingFile" } as any + { fileStatus: "rejected" } as any, + { fileStatus: "rejected" } as any, + { fileStatus: "rejected" } as any ); expect(store.isFileUploadLimitReached).toBe(false); }); - test("returns false when maxFilesPerUpload expression is unavailable (unlimited fallback)", () => { - const store = buildStore({ maxFilesPerUpload: unavailableDynamic() }); + test("'uploadingError' does NOT count toward maxTotalFiles", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(1)) }); - store.files.push({ fileStatus: "existingFile" } as any); + store.files.push({ fileStatus: "uploadingError" } as any); expect(store.isFileUploadLimitReached).toBe(false); }); - test("excludes missing, removedFile, and validationError from active count", () => { + test("excludes missing, removedFile, validationError from active count", () => { const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); store.files.push( @@ -139,115 +368,312 @@ describe("FileUploaderStore.isFileUploadLimitReached", () => { expect(store.isFileUploadLimitReached).toBe(false); }); -}); -describe("FileUploaderStore.processDrop — capacity split", () => { - function makeFile(name: string): File { - return new File([""], name, { type: "text/plain" }); - } - - test("dismissValidationErrors preserves batchExceeded files", () => { - const store = buildStore({ maxFilesPerUpload: dynamic(new Big(5)) }); + test("returns false when maxTotalFiles is 0 (unlimited)", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(0)) }); store.files.push( - { fileStatus: "validationError", errorType: "validation" } as any, - { fileStatus: "validationError", errorType: "batchExceeded" } as any + { fileStatus: "existingFile" } as any, + { fileStatus: "existingFile" } as any, + { fileStatus: "existingFile" } as any ); - store.dismissValidationErrors(); + expect(store.isFileUploadLimitReached).toBe(false); + }); + + test("returns false when maxTotalFiles expression is unavailable (unlimited fallback)", () => { + const store = buildStore({ maxFilesPerUpload: unavailableDynamic() }); - expect(store.files).toHaveLength(1); - expect(store.files[0].errorType).toBe("batchExceeded"); + store.files.push({ fileStatus: "existingFile" } as any); + + expect(store.isFileUploadLimitReached).toBe(false); }); +}); - test("dismissValidationErrors clears format errors but preserves limitExceeded files", () => { - const store = buildStore({ maxFilesPerUpload: dynamic(new Big(5)) }); +// ─── FileUploaderStore.promoteRejectedFiles ─────────────────────────────────── + +describe("FileUploaderStore.promoteRejectedFiles", () => { + test("promotes oldest 'rejected' file first (FIFO)", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(3)) }); + + // Files are unshifted (prepended), so highest index = oldest. + // newest at index 0, oldest at index 1 + const newest = { fileStatus: "rejected", setQueued: jest.fn(), upload: jest.fn() } as any; + const oldest = { fileStatus: "rejected", setQueued: jest.fn(), upload: jest.fn() } as any; + + store.files.push(newest, oldest, { fileStatus: "existingFile" } as any, { fileStatus: "existingFile" } as any); + + store.promoteRejectedFiles(); + + expect(oldest.setQueued).toHaveBeenCalledTimes(1); + expect(newest.setQueued).not.toHaveBeenCalled(); + }); + + test("promotes multiple rejected files when multiple slots open", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(4)) }); + + // 2 existing, 2 slots open + const newest = { fileStatus: "rejected", setQueued: jest.fn(), upload: jest.fn() } as any; + const middle = { fileStatus: "rejected", setQueued: jest.fn(), upload: jest.fn() } as any; + const oldest = { fileStatus: "rejected", setQueued: jest.fn(), upload: jest.fn() } as any; store.files.push( - { fileStatus: "validationError", errorType: "validation" } as any, - { fileStatus: "validationError", errorType: "limitExceeded" } as any + newest, + middle, + oldest, + { fileStatus: "existingFile" } as any, + { fileStatus: "existingFile" } as any ); - store.dismissValidationErrors(); + store.promoteRejectedFiles(); - expect(store.files).toHaveLength(1); - expect(store.files[0].errorType).toBe("limitExceeded"); + expect(oldest.setQueued).toHaveBeenCalledTimes(1); + expect(middle.setQueued).toHaveBeenCalledTimes(1); + expect(newest.setQueued).not.toHaveBeenCalled(); }); - test("removing an active file promotes newest limitExceeded file to upload", () => { + test("does nothing when at or above maxTotalFiles", () => { const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); - const activeA = { fileStatus: "existingFile", errorType: undefined } as any; - const activeB = { fileStatus: "existingFile", errorType: undefined } as any; - // waitingNew is first in array (unshifted last = newest at top) - const waitingNew = { - fileStatus: "validationError", - errorType: "limitExceeded", - _file: makeFile("new.txt"), - reset: jest.fn(), - validate: () => true, - upload: jest.fn() - } as any; - const waitingOld = { - fileStatus: "validationError", - errorType: "limitExceeded", - _file: makeFile("old.txt"), - reset: jest.fn(), - validate: () => true, - upload: jest.fn() - } as any; - - store.files.push(waitingNew, waitingOld, activeA, activeB); - - store.files.splice(store.files.indexOf(activeA), 1); - - expect(waitingNew.upload).toHaveBeenCalledTimes(1); - expect(waitingOld.upload).not.toHaveBeenCalled(); - }); - - test("accepts files up to remaining capacity and marks overflow as limitExceeded", () => { - const store = buildStore({ maxFilesPerUpload: dynamic(new Big(5)) }); + const rejected = { fileStatus: "rejected", setQueued: jest.fn(), upload: jest.fn() } as any; - const files = [1, 2, 3, 4, 5, 6].map(n => makeFile(`file${n}.txt`)); - store.processDrop(files, []); + store.files.push(rejected, { fileStatus: "existingFile" } as any, { fileStatus: "existingFile" } as any); - const errorFiles = store.files.filter(f => f.fileStatus === "validationError"); - const acceptedFiles = store.files.filter(f => f.fileStatus !== "validationError"); + store.promoteRejectedFiles(); - expect(errorFiles).toHaveLength(1); - expect(errorFiles[0].errorType).toBe("limitExceeded"); - expect(acceptedFiles).toHaveLength(5); + expect(rejected.setQueued).not.toHaveBeenCalled(); }); - test("retryLimitExceededFiles promotes batchExceeded files when slots open", () => { + test("does nothing when no rejected files exist", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(3)) }); + + store.files.push({ fileStatus: "existingFile" } as any); + + // Should not throw + expect(() => store.promoteRejectedFiles()).not.toThrow(); + }); + + test("triggers when active file is removed from the array", () => { const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); - const active = { fileStatus: "existingFile", errorType: undefined } as any; - const waiting = { - fileStatus: "validationError", - errorType: "batchExceeded", - reset: jest.fn(), - validate: () => true, - upload: jest.fn() - } as any; + const active = { fileStatus: "existingFile" } as any; + const rejected = { fileStatus: "rejected", setQueued: jest.fn(), upload: jest.fn() } as any; - store.files.push(waiting, active, { fileStatus: "existingFile" } as any); + store.files.push(rejected, active, { fileStatus: "existingFile" } as any); store.files.splice(store.files.indexOf(active), 1); - expect(waiting.upload).toHaveBeenCalledTimes(1); + expect(rejected.setQueued).toHaveBeenCalledTimes(1); }); - test("marks batch-excess files with errorType batchExceeded", () => { + test("promoted rejected file actually starts uploading after setQueued", async () => { const store = buildStore({ - maxFilesPerUpload: unavailableDynamic(), - maxFilesPerBatch: dynamic(new Big(2)) + maxFilesPerUpload: dynamic(new Big(2)), + maxFilesPerBatch: unavailableDynamic() }); + const neverResolve = new Promise(() => {}); + store.objectCreationHelper.request = jest.fn().mockReturnValue(neverResolve); + + // Drop 3 files: 2 upload, 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); + + // Delete one uploading file's slot by marking it done + store.files[store.files.findIndex(f => f.fileStatus === "uploading")].fileStatus = "removedFile" as any; + + // Reaction: activeCount drops → promoteRejectedFiles → setQueued → promoteQueuedFiles → upload + expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(2); + expect(store.files.filter(f => f.fileStatus === "rejected")).toHaveLength(0); + }); +}); + +// ─── 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 files = [1, 2, 3, 4].map(n => makeFile(`file${n}.txt`)); - store.processDrop(files, []); + 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); - const batchErrorFiles = store.files.filter(f => f.errorType === "batchExceeded"); - expect(batchErrorFiles).toHaveLength(2); + 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.warningMessage ──────────────────────────────────────── + +describe("FileUploaderStore.warningMessage", () => { + test("returns undefined when no limit set and no error", () => { + const store = buildStore({ maxFilesPerUpload: unavailableDynamic() }); + + expect(store.warningMessage).toBeUndefined(); + }); + + test("returns undefined when under maxTotalFiles limit", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(5)) }); + + expect(store.warningMessage).toBeUndefined(); + }); + + test("returns limit-reached message when maxTotalFiles reached", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + + store.files.push( + { fileStatus: "existingFile", _objectItem: obj("a") } as any, + { fileStatus: "existingFile", _objectItem: obj("b") } as any + ); + + expect(store.isFileUploadLimitReached).toBe(true); + expect(store.warningMessage).toBe("Maximum file count of 2 reached."); + }); + + test("returns errorMessage when limit not reached but error set", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(5)) }); + store.setMessage("Some other error"); + + expect(store.warningMessage).toBe("Some other error"); + }); + + test("clears limit-reached message when file removed below limit", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + + const fileA = { fileStatus: "existingFile", _objectItem: obj("a") } as any; + const fileB = { fileStatus: "existingFile", _objectItem: obj("b") } as any; + store.files.push(fileA, fileB); + + expect(store.warningMessage).toBe("Maximum file count of 2 reached."); + + store.files.splice(store.files.indexOf(fileA), 1); + + expect(store.isFileUploadLimitReached).toBe(false); + expect(store.warningMessage).toBeUndefined(); + }); +}); + +// ─── 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 free active slots and promote oldest rejected file to uploading", async () => { + const store = buildStore({ + maxFilesPerUpload: dynamic(new Big(2)), + maxFilesPerBatch: unavailableDynamic() + }); + // First two requests fail, third hangs so we can assert the stable "uploading" state + const neverResolve = new Promise(() => {}); + store.objectCreationHelper.request = jest + .fn() + .mockRejectedValueOnce(new Error("fail a")) + .mockRejectedValueOnce(new Error("fail b")) + .mockReturnValueOnce(neverResolve); + + // 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(); + + // Both errors free active count → rejected promotes to uploading (third request hangs) + expect(store.files.filter(f => f.fileStatus === "uploadingError")).toHaveLength(2); + expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(1); + expect(store.files.filter(f => f.fileStatus === "rejected")).toHaveLength(0); }); }); diff --git a/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts b/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts index ea5521b265..c4fe62b775 100644 --- a/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts +++ b/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts @@ -66,13 +66,13 @@ export interface FileUploaderContainerProps { dropzoneAcceptedMessage: DynamicValue; dropzoneRejectedMessage: DynamicValue; uploadInProgressMessage: DynamicValue; + uploadQueuedMessage: DynamicValue; uploadSuccessMessage: DynamicValue; uploadFailureGenericMessage: DynamicValue; uploadFailureInvalidFileFormatMessage: DynamicValue; uploadFailureFileIsTooBigMessage: DynamicValue; uploadFailureTooManyFilesMessage: DynamicValue; uploadLimitReachedMessage: DynamicValue; - uploadBatchLimitExceededMessage: DynamicValue; unavailableCreateActionMessage: DynamicValue; downloadButtonTextMessage: DynamicValue; removeButtonTextMessage: DynamicValue; @@ -112,13 +112,13 @@ export interface FileUploaderPreviewProps { dropzoneAcceptedMessage: string; dropzoneRejectedMessage: string; uploadInProgressMessage: string; + uploadQueuedMessage: string; uploadSuccessMessage: string; uploadFailureGenericMessage: string; uploadFailureInvalidFileFormatMessage: string; uploadFailureFileIsTooBigMessage: string; uploadFailureTooManyFilesMessage: string; uploadLimitReachedMessage: string; - uploadBatchLimitExceededMessage: string; unavailableCreateActionMessage: string; downloadButtonTextMessage: string; removeButtonTextMessage: string; From 160f8a7c7d9af74344c436c7f83a60ffec9912ee Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Wed, 20 May 2026 15:45:38 +0200 Subject: [PATCH 11/11] refactor: enhance file upload limit feedback and error handling --- .../src/FileUploader.editorConfig.ts | 1 + .../src/components/FileUploaderRoot.tsx | 11 +- .../src/stores/FileUploaderStore.ts | 22 +- .../__tests__/FileUploaderStore.spec.ts | 279 +++++++++++++----- 4 files changed, 225 insertions(+), 88 deletions(-) diff --git a/packages/pluggableWidgets/file-uploader-web/src/FileUploader.editorConfig.ts b/packages/pluggableWidgets/file-uploader-web/src/FileUploader.editorConfig.ts index 35e3b7afd4..852db910b7 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/FileUploader.editorConfig.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/FileUploader.editorConfig.ts @@ -21,6 +21,7 @@ export function getProperties( "createFileAction", "allowedFileFormats", "maxFilesPerUpload", + "maxFilesPerBatch", "maxFileSize", "objectCreationTimeout" ]); diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx index 6ea07ff27c..fb7dee2903 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx @@ -8,11 +8,13 @@ import { FileEntryContainer } from "./FileEntry"; import { FileUploaderContainerProps } from "../../typings/FileUploaderProps"; import { prepareAcceptForDropzone } from "../utils/prepareAcceptForDropzone"; import { useRootStore } from "../utils/useRootStore"; +import { useTranslationsStore } from "../utils/useTranslationsStore"; import "../ui/FileUploader.scss"; export const FileUploaderRoot = observer((props: FileUploaderContainerProps): ReactElement => { const rootStore = useRootStore(props); + const translations = useTranslationsStore(); const onDrop = useCallback( (acceptedFiles: File[], fileRejections: FileRejection[]) => { @@ -21,12 +23,19 @@ 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 && ( void) | undefined; _disposePromoteQueuedReaction: (() => void) | undefined; - errorMessage?: string = undefined; + createActionFailed = false; translations: TranslationsStore; @@ -78,20 +78,19 @@ export class FileUploaderStore { makeObservable(this, { updateProps: action, processDrop: action, - setMessage: action, + setCreateActionFailed: action, promoteRejectedFiles: action, promoteQueuedFiles: action, processExistingFileItem: action, files: observable, existingItemsLoaded: observable, - errorMessage: observable, + createActionFailed: observable, allowedFormatsDescription: computed, maxTotalFiles: computed, maxConcurrentUploads: computed, _maxTotalFiles: observable, _maxConcurrentUploads: observable, isFileUploadLimitReached: computed, - warningMessage: computed, sortedFiles: computed, activeCount: computed, uploadingCount: computed @@ -194,15 +193,8 @@ export class FileUploaderStore { }); } - get warningMessage(): string | undefined { - if (this.isFileUploadLimitReached) { - return this.translations.get("uploadLimitReachedMessage", this.maxTotalFiles.toString()); - } - return this.errorMessage; - } - - setMessage(msg?: string): void { - this.errorMessage = msg; + setCreateActionFailed(failed: boolean): void { + this.createActionFailed = failed; } promoteRejectedFiles(): void { @@ -252,11 +244,11 @@ 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; } - this.setMessage(); + this.setCreateActionFailed(false); const activeCount = this.activeCount; const remaining = this.maxTotalFiles > 0 ? Math.max(0, this.maxTotalFiles - activeCount) : acceptedFiles.length; 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 index fa385f6c4c..5fb9a28b22 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts @@ -304,89 +304,134 @@ describe("FileUploaderStore.processDrop — pure classifier", () => { // ─── FileUploaderStore.isFileUploadLimitReached ─────────────────────────────── describe("FileUploaderStore.isFileUploadLimitReached", () => { - test("'queued' counts toward maxTotalFiles", () => { - const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); - - store.files.push({ fileStatus: "queued" } as any, { fileStatus: "queued" } as any); + test("false when no files have been dropped", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(3)) }); - expect(store.isFileUploadLimitReached).toBe(true); + expect(store.isFileUploadLimitReached).toBe(false); }); - test("'existingFile' counts toward maxTotalFiles", () => { - const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + 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.files.push({ fileStatus: "existingFile" } as any, { fileStatus: "existingFile" } as any); + store.processDrop([makeFile("a.txt"), makeFile("b.txt")], []); - expect(store.isFileUploadLimitReached).toBe(true); + expect(store.isFileUploadLimitReached).toBe(false); }); - test("'uploading' counts toward maxTotalFiles", () => { - const store = buildStore({ maxFilesPerUpload: dynamic(new Big(1)) }); + 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.files.push({ fileStatus: "uploading" } as any); + store.processDrop([makeFile("a.txt"), makeFile("b.txt")], []); expect(store.isFileUploadLimitReached).toBe(true); }); - test("'done' counts toward maxTotalFiles", () => { - const store = buildStore({ maxFilesPerUpload: dynamic(new Big(1)) }); + 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.files.push({ fileStatus: "done" } as any); + store.processDrop([makeFile("a.txt"), makeFile("b.txt"), makeFile("c.txt")], []); - expect(store.isFileUploadLimitReached).toBe(true); + expect(store.files.filter(f => f.fileStatus === "rejected")).toHaveLength(1); + expect(store.activeCount).toBe(2); }); - test("'rejected' does NOT count toward maxTotalFiles", () => { + test("validationError files do not count toward active count", () => { const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); - store.files.push( - { fileStatus: "rejected" } as any, - { fileStatus: "rejected" } as any, - { fileStatus: "rejected" } as any - ); + 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' does NOT count toward maxTotalFiles", () => { - const store = buildStore({ maxFilesPerUpload: dynamic(new Big(1)) }); + 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.files.push({ fileStatus: "uploadingError" } as any); + 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("excludes missing, removedFile, validationError from active count", () => { - const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + 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.files.push( - { fileStatus: "existingFile" } as any, - { fileStatus: "missing" } as any, - { fileStatus: "removedFile" } as any, - { fileStatus: "validationError" } as any - ); + store.processDrop([makeFile("a.txt"), makeFile("b.txt"), makeFile("c.txt")], []); expect(store.isFileUploadLimitReached).toBe(false); }); - test("returns false when maxTotalFiles is 0 (unlimited)", () => { - const store = buildStore({ maxFilesPerUpload: dynamic(new Big(0)) }); + 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.files.push( - { fileStatus: "existingFile" } as any, - { fileStatus: "existingFile" } as any, - { fileStatus: "existingFile" } as any - ); + store.processDrop([makeFile("a.txt"), makeFile("b.txt"), makeFile("c.txt")], []); expect(store.isFileUploadLimitReached).toBe(false); }); +}); - test("returns false when maxTotalFiles expression is unavailable (unlimited fallback)", () => { - const store = buildStore({ maxFilesPerUpload: unavailableDynamic() }); +// ─── FileUploaderStore.sortedFiles ─────────────────────────────────────────── - store.files.push({ fileStatus: "existingFile" } as any); +describe("FileUploaderStore.sortedFiles", () => { + test("validationError files sort to the end", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(10)) }); - expect(store.isFileUploadLimitReached).toBe(false); + // 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); }); }); @@ -568,53 +613,143 @@ describe("FileUploaderStore.promoteQueuedFiles", () => { }); }); -// ─── FileUploaderStore.warningMessage ──────────────────────────────────────── +// ─── FileUploaderStore.createActionFailed ──────────────────────────────────── -describe("FileUploaderStore.warningMessage", () => { - test("returns undefined when no limit set and no error", () => { - const store = buildStore({ maxFilesPerUpload: unavailableDynamic() }); +describe("FileUploaderStore.createActionFailed", () => { + test("defaults to false", () => { + const store = buildStore({}); - expect(store.warningMessage).toBeUndefined(); + expect(store.createActionFailed).toBe(false); }); - test("returns undefined when under maxTotalFiles limit", () => { - const store = buildStore({ maxFilesPerUpload: dynamic(new Big(5)) }); + test("setCreateActionFailed(true) sets flag", () => { + const store = buildStore({}); + store.setCreateActionFailed(true); - expect(store.warningMessage).toBeUndefined(); + expect(store.createActionFailed).toBe(true); }); - test("returns limit-reached message when maxTotalFiles reached", () => { - const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + test("setCreateActionFailed(false) clears flag", () => { + const store = buildStore({}); + store.setCreateActionFailed(true); + store.setCreateActionFailed(false); - store.files.push( - { fileStatus: "existingFile", _objectItem: obj("a") } as any, - { fileStatus: "existingFile", _objectItem: obj("b") } as any - ); + expect(store.createActionFailed).toBe(false); + }); +}); - expect(store.isFileUploadLimitReached).toBe(true); - expect(store.warningMessage).toBe("Maximum file count of 2 reached."); +// ─── 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("returns errorMessage when limit not reached but error set", () => { - const store = buildStore({ maxFilesPerUpload: dynamic(new Big(5)) }); - store.setMessage("Some other error"); + 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.warningMessage).toBe("Some other error"); + expect(store.createActionFailed).toBe(false); }); +}); - test("clears limit-reached message when file removed below limit", () => { - const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); +// ─── 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"); + }); + + test("files over total cap get limit-reached message", () => { + const store = buildStore({ + maxFilesPerUpload: dynamic(new Big(1)), + maxFilesPerBatch: unavailableDynamic() + }); + store.objectCreationHelper.request = jest.fn().mockReturnValue(new Promise(() => {})); + + store.processDrop([makeFile("a.txt"), makeFile("b.txt")], []); + + const rejected = store.files.find(f => f.fileStatus === "rejected"); + expect(rejected?.errorDescription).toContain("1"); + }); +}); - const fileA = { fileStatus: "existingFile", _objectItem: obj("a") } as any; - const fileB = { fileStatus: "existingFile", _objectItem: obj("b") } as any; - store.files.push(fileA, fileB); +// ─── FileUploaderStore.updateProps ─────────────────────────────────────────── - expect(store.warningMessage).toBe("Maximum file count of 2 reached."); +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.files.splice(store.files.indexOf(fileA), 1); + 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); - expect(store.warningMessage).toBeUndefined(); + }); + + 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("reactions stop firing after dispose", () => { + const store = buildStore({ + maxFilesPerUpload: dynamic(new Big(2)), + maxFilesPerBatch: unavailableDynamic() + }); + const neverResolve = new Promise(() => {}); + store.objectCreationHelper.request = jest.fn().mockReturnValue(neverResolve); + + store.processDrop([makeFile("a.txt"), makeFile("b.txt"), makeFile("c.txt")], []); + + expect(store.files.filter(f => f.fileStatus === "rejected")).toHaveLength(1); + + store.dispose(); + + // Free a slot — reaction should NOT fire after dispose + store.files[store.files.findIndex(f => f.fileStatus === "uploading")].fileStatus = "removedFile" as any; + + expect(store.files.filter(f => f.fileStatus === "rejected")).toHaveLength(1); }); });