diff --git a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md index bb04444d0f..1c9f461137 100644 --- a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md +++ b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md @@ -6,6 +6,23 @@ 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 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 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 ### Fixed 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/FileUploader.xml b/packages/pluggableWidgets/file-uploader-web/src/FileUploader.xml index be3069e972..4b5373cab5 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/FileUploader.xml +++ b/packages/pluggableWidgets/file-uploader-web/src/FileUploader.xml @@ -80,9 +80,14 @@ - + Maximum number of files - Limit the number of files per upload. + 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. @@ -163,6 +168,22 @@ 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. + + + + 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/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..d8b4f51b3b 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/Dropzone.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/Dropzone.tsx @@ -1,33 +1,24 @@ -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 { 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/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/FileUploaderRoot.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx index 520ffbfc23..6ea07ff27c 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,16 +26,15 @@ export const FileUploaderRoot = observer((props: FileUploaderContainerProps): Re {!rootStore.isReadOnly && ( )}
- {(rootStore.files ?? []).map(fileStore => { + {rootStore.sortedFiles.map(fileStore => { return ( { 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 eb44dfb68f..d39882d4e3 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts @@ -1,14 +1,14 @@ -import { DynamicValue, ObjectItem } from "mendix"; -import { FileUploaderContainerProps, UploadModeEnum } from "../../typings/FileUploaderProps"; -import { action, computed, makeObservable, observable } from "mobx"; import { Big } from "big.js"; -import { getImageUploaderFormats, parseAllowedFormats } from "../utils/parseAllowedFormats"; -import { FileStore } from "./FileStore"; +import { DynamicValue, ObjectItem } from "mendix"; +import { action, computed, makeObservable, observable, reaction } from "mobx"; import { FileRejection } from "react-dropzone"; -import { FileCheckFormat } from "../utils/predefinedFormats"; +import { FileStore } from "./FileStore"; import { TranslationsStore } from "./TranslationsStore"; -import { ObjectCreationHelper } from "../utils/ObjectCreationHelper"; +import { FileUploaderContainerProps, UploadModeEnum } from "../../typings/FileUploaderProps"; import { DatasourceUpdateProcessor } from "../utils/DatasourceUpdateProcessor"; +import { ObjectCreationHelper } from "../utils/ObjectCreationHelper"; +import { getImageUploaderFormats, parseAllowedFormats } from "../utils/parseAllowedFormats"; +import { FileCheckFormat } from "../utils/predefinedFormats"; export class FileUploaderStore { files: FileStore[] = []; @@ -26,7 +26,9 @@ export class FileUploaderStore { _uploadMode: UploadModeEnum; _maxFileSizeMiB = 0; _maxFileSize = 0; - _maxFilesPerUpload: DynamicValue; + _maxFilesPerUpload: DynamicValue | undefined; + _maxFilesPerBatch: DynamicValue | undefined; + _disposeRetryReaction: (() => void) | undefined; errorMessage?: string = undefined; @@ -37,6 +39,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); @@ -75,24 +78,45 @@ export class FileUploaderStore { updateProps: action, processDrop: action, setMessage: action, + dismissValidationErrors: action, + retryLimitExceededFiles: action, processExistingFileItem: action, files: observable, existingItemsLoaded: observable, errorMessage: observable, allowedFormatsDescription: computed, maxFilesPerUpload: computed, + maxFilesPerBatch: computed, _maxFilesPerUpload: observable, - isFileUploadLimitReached: computed + _maxFilesPerBatch: observable, + isFileUploadLimitReached: computed, + warningMessage: computed, + sortedFiles: computed }); this.updateProps(props); + + this._disposeRetryReaction = 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 { this.objectCreationHelper.updateProps(props); - // Update max files properties this._maxFilesPerUpload = props.maxFilesPerUpload; + this._maxFilesPerBatch = props.maxFilesPerBatch; this.translations.updateProps(props); this.updateProcessor.processUpdate( @@ -116,11 +140,18 @@ export class FileUploaderStore { } get maxFilesPerUpload(): number { - const expressionValue = this._maxFilesPerUpload.value; + const expressionValue = this._maxFilesPerUpload?.value; + if (expressionValue) { + return expressionValue.toNumber(); + } + return 0; + } + + get maxFilesPerBatch(): number { + const expressionValue = this._maxFilesPerBatch?.value; if (expressionValue) { return expressionValue.toNumber(); } - // Fallback to unlimited return 0; } @@ -138,10 +169,63 @@ 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()); + } + return this.errorMessage; + } + setMessage(msg?: string): void { 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.reset(); + if (file.validate()) { + file.upload(); + } + } + } + + dispose(): void { + this._disposeRetryReaction?.(); + } + processDrop(acceptedFiles: File[], fileRejections: FileRejection[]): void { if (!this.objectCreationHelper.canCreateFiles) { console.error( @@ -151,15 +235,22 @@ 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 + 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); + for (const file of fileRejections) { const newFileStore = FileStore.newFileWithError( file.file, @@ -182,21 +273,31 @@ export class FileUploaderStore { .join(" "), this ); + 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 acceptedFiles) { + 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/TranslationsStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/TranslationsStore.ts index 2abc3f3e47..a9de503f5a 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/TranslationsStore.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/TranslationsStore.ts @@ -1,6 +1,6 @@ -import { FileUploaderContainerProps } from "../../typings/FileUploaderProps"; import { DynamicValue } from "mendix"; import { action, makeObservable, observable } from "mobx"; +import { FileUploaderContainerProps } from "../../typings/FileUploaderProps"; export class TranslationsStore { translationsMap: Map = new Map(); diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts new file mode 100644 index 0000000000..13ef55962c --- /dev/null +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts @@ -0,0 +1,253 @@ +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; +} + +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)), + maxFilesPerBatch: unavailableDynamic(), + maxFileSize: 25, + objectCreationTimeout: 10, + dropzoneIdleMessage: dynamic("Drag and drop files here"), + dropzoneAcceptedMessage: dynamic("All files can be uploaded."), + dropzoneRejectedMessage: dynamic("Some files may not be uploadable."), + uploadInProgressMessage: dynamic("Uploading..."), + 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." + ), + 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("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(); + }); +}); + +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); + }); +}); + +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"), + 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 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", + reset: jest.fn(), + validate: () => true, + upload: jest.fn() + } as any; + + store.files.push(waiting, active, { fileStatus: "existingFile" } as any); + + store.files.splice(store.files.indexOf(active), 1); + + 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); + }); +}); 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..981a8f8faf 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(); @@ -13,5 +13,9 @@ export function useRootStore(props: FileUploaderContainerProps): FileUploaderSto rootStore.updateProps(props); }, [rootStore, props]); + useEffect(() => { + return () => rootStore.dispose(); + }, [rootStore]); + return rootStore; } diff --git a/packages/pluggableWidgets/file-uploader-web/src/utils/useTranslationsStore.tsx b/packages/pluggableWidgets/file-uploader-web/src/utils/useTranslationsStore.tsx index 209bb5a5c9..e5a94af416 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/utils/useTranslationsStore.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/utils/useTranslationsStore.tsx @@ -1,6 +1,6 @@ import { createContext, ReactElement, ReactNode, useContext, useEffect, useState } from "react"; -import { TranslationsStore } from "../stores/TranslationsStore"; import { FileUploaderContainerProps } from "../../typings/FileUploaderProps"; +import { TranslationsStore } from "../stores/TranslationsStore"; function useInitTranslationsStore(props: FileUploaderContainerProps): TranslationsStore { const [store] = useState(() => { diff --git a/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts b/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts index 751fbf1fee..ea5521b265 100644 --- a/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts +++ b/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts @@ -59,7 +59,8 @@ export interface FileUploaderContainerProps { createFileAction?: ActionValue; createImageAction?: ActionValue; allowedFileFormats: AllowedFileFormatsType[]; - maxFilesPerUpload: DynamicValue; + maxFilesPerUpload?: DynamicValue; + maxFilesPerBatch?: DynamicValue; maxFileSize: number; dropzoneIdleMessage: DynamicValue; dropzoneAcceptedMessage: DynamicValue; @@ -70,6 +71,8 @@ export interface FileUploaderContainerProps { uploadFailureInvalidFileFormatMessage: DynamicValue; uploadFailureFileIsTooBigMessage: DynamicValue; uploadFailureTooManyFilesMessage: DynamicValue; + uploadLimitReachedMessage: DynamicValue; + uploadBatchLimitExceededMessage: DynamicValue; unavailableCreateActionMessage: DynamicValue; downloadButtonTextMessage: DynamicValue; removeButtonTextMessage: DynamicValue; @@ -103,6 +106,7 @@ export interface FileUploaderPreviewProps { createImageAction: {} | null; allowedFileFormats: AllowedFileFormatsPreviewType[]; maxFilesPerUpload: string; + maxFilesPerBatch: string; maxFileSize: number | null; dropzoneIdleMessage: string; dropzoneAcceptedMessage: string; @@ -113,6 +117,8 @@ export interface FileUploaderPreviewProps { uploadFailureInvalidFileFormatMessage: string; uploadFailureFileIsTooBigMessage: string; uploadFailureTooManyFilesMessage: string; + uploadLimitReachedMessage: string; + uploadBatchLimitExceededMessage: string; unavailableCreateActionMessage: string; downloadButtonTextMessage: string; removeButtonTextMessage: string;