From eb72947efe8832ae104e73672102c6ef4e4369c1 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 11 May 2026 16:25:26 +0200 Subject: [PATCH 1/9] 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 6eb01df5b518f96bcc1513c1c4098b8ccfffd988 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 11 May 2026 16:26:04 +0200 Subject: [PATCH 2/9] 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 1bfc14550cdf0c099b987d7ff68de97a6943996b Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 11 May 2026 16:33:08 +0200 Subject: [PATCH 3/9] 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 87048d8412bb45fa32a5918c3ae069f43a5c6886 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 11 May 2026 16:39:21 +0200 Subject: [PATCH 4/9] 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 577d6bd178de6bf98d58c5fb6ed58d3c6c8a4483 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 11 May 2026 16:58:33 +0200 Subject: [PATCH 5/9] 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 0ddd05f993ae77c6a9d981bb9601745a01d2ecce Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 11 May 2026 17:41:50 +0200 Subject: [PATCH 6/9] 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 7d054a3b4660f2dda486cb176a265847e01f7f03 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 12 May 2026 14:53:25 +0200 Subject: [PATCH 7/9] 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 09489e2665d7acdc1c4efa710827d0739acaa199 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 12 May 2026 15:36:21 +0200 Subject: [PATCH 8/9] 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 d06abf7fe846b235e31aaba42d930817327f8473 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 12 May 2026 16:12:06 +0200 Subject: [PATCH 9/9] 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); });