diff --git a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md index bb04444d0f..74ff791f74 100644 --- a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md +++ b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md @@ -6,6 +6,24 @@ 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 files are now rejected; the rest upload normally. +- We fixed an issue where files rejected due to the total file limit had no way to recover. They are now automatically queued for upload when capacity becomes available (e.g. after a file is removed or an upload fails). + +### Added + +- We added a new "Maximum concurrent uploads" property to control how many files upload simultaneously. Files beyond this limit wait in a queue and upload automatically as slots free up. +- We added a new "File limit reached" text property to customize the message shown when the upload limit is reached. +- We added a new "Upload queued" text property to customize the message shown on files that are waiting to upload. + +### Changed + +- The "Maximum number of files" property is now optional. Leaving it empty or setting it to 0 means unlimited files are allowed. The default behavior is now unlimited (no cap). +- Files now upload in a queue rather than being marked as errors when too many are dropped at once. Queued files show a "Waiting..." state while they wait for a concurrent slot. +- Files in the list are now ordered with successful uploads above rejected files. + ## [2.4.2] - 2026-04-23 ### 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..26faed942e 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 concurrent uploads + Maximum number of files uploading simultaneously. Remaining files wait in a queue and upload automatically as slots free up. Leave empty or set to 0 for unlimited. @@ -123,6 +128,14 @@ Uploaden... + + Upload queued + + + Waiting... + Wachten... + + Uploading success @@ -163,6 +176,15 @@ 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/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..d49a8e2bb4 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; @@ -83,7 +83,7 @@ function FileEntry(props: FileEntryProps): ReactElement { return (
)}
- {(rootStore.files ?? []).map(fileStore => { + {rootStore.sortedFiles.map(fileStore => { return ( {translations.get("uploadSuccessMessage")}; case "uploadingError": - case "removedAfterError": return {translations.get("uploadFailureGenericMessage")}; case "validationError": + case "rejected": return {error}; case "removedFile": return {translations.get("removeSuccessMessage")}; - case "new": + case "queued": + return {translations.get("uploadQueuedMessage")}; case "existingFile": default: return ; diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts index dc1b1e3a7f..90edb8fff4 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts @@ -1,8 +1,9 @@ import { Big } from "big.js"; import { ListActionValue, ObjectItem } from "mendix"; -import { action, computed, makeObservable, observable, runInAction } from "mobx"; import mimeTypes from "mime-types"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { executeAction } from "@mendix/widget-plugin-platform/framework/execute-action"; import { FileUploaderStore } from "./FileUploaderStore"; import { fetchDocumentUrl, @@ -12,18 +13,17 @@ import { removeObject, saveFile } from "../utils/mx-data"; -import { executeAction } from "@mendix/widget-plugin-platform/framework/execute-action"; export type FileStatus = | "existingFile" | "missing" - | "new" + | "queued" | "uploading" | "done" | "uploadingError" - | "removedAfterError" | "removedFile" - | "validationError"; + | "validationError" + | "rejected"; let fileKey = 0; @@ -60,20 +60,21 @@ export class FileStore { imagePreviewUrl: computed, upload: action, fetchMxObject: action, - markMissing: action + markMissing: action, + setQueued: action }); } markMissing(): void { - this.fileStatus = this.fileStatus === "uploadingError" ? "removedAfterError" : "missing"; + this.fileStatus = this.fileStatus === "uploadingError" ? "removedFile" : "missing"; this._mxObject = undefined; this._objectItem = undefined; } - markError(errorMessage: string): void { - this.fileStatus = "validationError"; - this.errorDescription = errorMessage; + setQueued(): void { + this.errorDescription = undefined; + this.fileStatus = "queued"; } canExecute(listAction: ListActionValue): boolean { @@ -91,12 +92,12 @@ export class FileStore { } validate(): boolean { - return !(this.fileStatus !== "new" || !this._file); + return !(this.fileStatus !== "queued" || !this._file); } async upload(): Promise { - if (this.fileStatus === "existingFile") { - throw new Error("Calling upload on already uploaded files is not supported"); + if (this.fileStatus !== "queued") { + return; } // set status @@ -234,10 +235,19 @@ export class FileStore { } static newFile(file: File, rootStore: FileUploaderStore): FileStore { - return new FileStore("new", rootStore, file, undefined); + return new FileStore("queued", rootStore, file, undefined); + } + + static newRejectedFile(file: File, errorMessage: string, rootStore: FileUploaderStore): FileStore { + const store = new FileStore("rejected", rootStore, file, undefined); + runInAction(() => { + store.errorDescription = errorMessage; + }); + + return store; } - static newFileWithError(file: File, errorMessage: string, rootStore: FileUploaderStore): FileStore { + static newFileWithValidationError(file: File, errorMessage: string, rootStore: FileUploaderStore): FileStore { const store = new FileStore("validationError", rootStore, file, undefined); runInAction(() => { store.errorDescription = errorMessage; diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts index eb44dfb68f..ab982ceb91 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,10 @@ export class FileUploaderStore { _uploadMode: UploadModeEnum; _maxFileSizeMiB = 0; _maxFileSize = 0; - _maxFilesPerUpload: DynamicValue; + _maxTotalFiles: DynamicValue | undefined; + _maxConcurrentUploads: DynamicValue | undefined; + _disposePromoteRejectedReaction: (() => void) | undefined; + _disposePromoteQueuedReaction: (() => void) | undefined; errorMessage?: string = undefined; @@ -36,7 +39,8 @@ export class FileUploaderStore { this._widgetName = props.name; this._maxFileSizeMiB = props.maxFileSize; this._maxFileSize = this._maxFileSizeMiB * 1024 * 1024; - this._maxFilesPerUpload = props.maxFilesPerUpload; + this._maxTotalFiles = props.maxFilesPerUpload; + this._maxConcurrentUploads = props.maxFilesPerBatch; this._uploadMode = props.uploadMode; this.objectCreationHelper = new ObjectCreationHelper(this._widgetName, props.objectCreationTimeout); @@ -75,24 +79,52 @@ export class FileUploaderStore { updateProps: action, processDrop: action, setMessage: action, + promoteRejectedFiles: action, + promoteQueuedFiles: action, processExistingFileItem: action, files: observable, existingItemsLoaded: observable, errorMessage: observable, allowedFormatsDescription: computed, - maxFilesPerUpload: computed, - _maxFilesPerUpload: observable, - isFileUploadLimitReached: computed + maxTotalFiles: computed, + maxConcurrentUploads: computed, + _maxTotalFiles: observable, + _maxConcurrentUploads: observable, + isFileUploadLimitReached: computed, + warningMessage: computed, + sortedFiles: computed, + activeCount: computed, + uploadingCount: computed }); this.updateProps(props); + + // Reaction 1: active count drops → promote rejected files into queue + this._disposePromoteRejectedReaction = reaction( + () => this.activeCount, + (count, prevCount) => { + if (count < prevCount) { + this.promoteRejectedFiles(); + } + } + ); + + // Reaction 2: uploading count drops → promote queued files to uploading + this._disposePromoteQueuedReaction = reaction( + () => this.uploadingCount, + (count, prevCount) => { + if (count < prevCount) { + this.promoteQueuedFiles(); + } + } + ); } updateProps(props: FileUploaderContainerProps): void { this.objectCreationHelper.updateProps(props); - // Update max files properties - this._maxFilesPerUpload = props.maxFilesPerUpload; + this._maxTotalFiles = props.maxFilesPerUpload; + this._maxConcurrentUploads = props.maxFilesPerBatch; this.translations.updateProps(props); this.updateProcessor.processUpdate( @@ -115,33 +147,106 @@ export class FileUploaderStore { .join(", "); } - get maxFilesPerUpload(): number { - const expressionValue = this._maxFilesPerUpload.value; + get maxTotalFiles(): number { + const expressionValue = this._maxTotalFiles?.value; if (expressionValue) { return expressionValue.toNumber(); } - // Fallback to unlimited return 0; } + get maxConcurrentUploads(): number { + const expressionValue = this._maxConcurrentUploads?.value; + if (expressionValue) { + return expressionValue.toNumber(); + } + return 0; + } + + get activeCount(): number { + return this.files.filter( + f => + f.fileStatus !== "missing" && + f.fileStatus !== "removedFile" && + f.fileStatus !== "validationError" && + f.fileStatus !== "rejected" && + f.fileStatus !== "uploadingError" + ).length; + } + + get uploadingCount(): number { + return this.files.filter(f => f.fileStatus === "uploading").length; + } + get isFileUploadLimitReached(): boolean { - const activeFiles = this.files.filter( - file => - file.fileStatus !== "missing" && - file.fileStatus !== "removedFile" && - file.fileStatus !== "validationError" - ); - if (this.maxFilesPerUpload === 0) { + if (this.maxTotalFiles === 0) { return false; } - return activeFiles.length >= this.maxFilesPerUpload; + return this.activeCount >= this.maxTotalFiles; + } + + get sortedFiles(): FileStore[] { + 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.maxTotalFiles.toString()); + } + return this.errorMessage; } setMessage(msg?: string): void { this.errorMessage = msg; } + promoteRejectedFiles(): void { + if (this.maxTotalFiles === 0) { + return; + } + + const slots = Math.max(0, this.maxTotalFiles - this.activeCount); + if (slots === 0) { + return; + } + + // oldest first: files are unshifted (prepended), so last in array = oldest + const rejected = [...this.files].filter(f => f.fileStatus === "rejected").reverse(); + + for (let i = 0; i < Math.min(slots, rejected.length); i++) { + rejected[i].setQueued(); + } + + this.promoteQueuedFiles(); + } + + promoteQueuedFiles(): void { + const concurrentLimit = this.maxConcurrentUploads; + const availableSlots = + concurrentLimit > 0 ? Math.max(0, concurrentLimit - this.uploadingCount) : Number.MAX_SAFE_INTEGER; + + if (availableSlots === 0) { + return; + } + + // oldest first: last in array = oldest + const queued = [...this.files].filter(f => f.fileStatus === "queued").reverse(); + + for (let i = 0; i < Math.min(availableSlots, queued.length); i++) { + queued[i].upload(); + } + } + + dispose(): void { + this._disposePromoteRejectedReaction?.(); + this._disposePromoteQueuedReaction?.(); + } + processDrop(acceptedFiles: File[], fileRejections: FileRejection[]): void { if (!this.objectCreationHelper.canCreateFiles) { console.error( @@ -151,17 +256,15 @@ 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(); + const activeCount = this.activeCount; + const remaining = this.maxTotalFiles > 0 ? Math.max(0, this.maxTotalFiles - activeCount) : acceptedFiles.length; + const capacityFiles = acceptedFiles.slice(0, remaining); + const capacityExcess = acceptedFiles.slice(remaining); + for (const file of fileRejections) { - const newFileStore = FileStore.newFileWithError( + const newFileStore = FileStore.newFileWithValidationError( file.file, file.errors .map(e => { @@ -182,24 +285,23 @@ export class FileUploaderStore { .join(" "), this ); + this.files.unshift(newFileStore); + } + for (const file of capacityExcess) { + const newFileStore = FileStore.newRejectedFile( + file, + this.translations.get("uploadLimitReachedMessage", this.maxTotalFiles.toString()), + this + ); this.files.unshift(newFileStore); } - for (const file of acceptedFiles) { + for (const file of capacityFiles) { const newFileStore = FileStore.newFile(file, this); - - if (this.isFileUploadLimitReached) { - newFileStore.markError( - this.translations.get("uploadFailureTooManyFilesMessage", this.maxFilesPerUpload.toString()) - ); - } - this.files.unshift(newFileStore); - - if (newFileStore.validate()) { - newFileStore.upload(); - } } + + this.promoteQueuedFiles(); } } 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..fa385f6c4c --- /dev/null +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts @@ -0,0 +1,679 @@ +import { Big } from "big.js"; +import { DynamicValue } from "mendix"; +import { actionValue, dynamic, ListValueBuilder, obj } from "@mendix/widget-plugin-test-utils"; +import { FileUploaderContainerProps } from "../../../typings/FileUploaderProps"; +import { FileStore } from "../FileStore"; +import { FileUploaderStore } from "../FileUploaderStore"; +import { TranslationsStore } from "../TranslationsStore"; + +function unavailableDynamic(): DynamicValue { + return { status: "unavailable", value: undefined } as unknown as DynamicValue; +} + +function buildProps(overrides: Partial = {}): FileUploaderContainerProps { + return { + name: "fileUploader1", + class: "", + style: undefined, + tabIndex: 0, + uploadMode: "files", + associatedFiles: new ListValueBuilder().withItems([]).build(), + associatedImages: new ListValueBuilder().withItems([]).build(), + readOnlyMode: false, + createFileAction: actionValue(true, false), + createImageAction: actionValue(true, false), + allowedFileFormats: [], + maxFilesPerUpload: dynamic(new Big(5)), + maxFilesPerBatch: unavailableDynamic(), + maxFileSize: 25, + objectCreationTimeout: 10, + dropzoneIdleMessage: dynamic("Drag and drop files here"), + dropzoneAcceptedMessage: dynamic("All files can be uploaded."), + dropzoneRejectedMessage: dynamic("Some files may not be uploadable."), + uploadInProgressMessage: dynamic("Uploading..."), + uploadQueuedMessage: dynamic("Waiting..."), + uploadSuccessMessage: dynamic("Uploaded successfully."), + uploadFailureGenericMessage: dynamic("An error occurred during uploading."), + uploadFailureInvalidFileFormatMessage: dynamic("File format is not supported, supported formats are ###."), + uploadFailureFileIsTooBigMessage: dynamic("File size exceeds the maximum limit of ### megabytes."), + uploadFailureTooManyFilesMessage: dynamic("Too many files added. Only ### files per upload are allowed."), + uploadLimitReachedMessage: dynamic("Maximum file count of ### reached."), + unavailableCreateActionMessage: dynamic( + "Can't upload files at this time. Please contact your system administrator." + ), + downloadButtonTextMessage: dynamic("Download this file"), + removeButtonTextMessage: dynamic("Remove this file"), + removeSuccessMessage: dynamic("Removed successfully."), + removeErrorMessage: dynamic("An error occurred while removing this file."), + enableCustomButtons: false, + customButtons: [], + onUploadSuccessFile: undefined, + onUploadSuccessImage: undefined, + onUploadFailureFile: undefined, + onUploadFailureImage: undefined, + ...overrides + }; +} + +function buildStore(overrides: Partial = {}): FileUploaderStore { + const props = buildProps(overrides); + const translations = new TranslationsStore(props); + return new FileUploaderStore(props, translations); +} + +function makeFile(name: string): File { + return new File([""], name, { type: "text/plain" }); +} + +// ─── FileStore unit tests ──────────────────────────────────────────────────── + +describe("FileStore.setQueued", () => { + test("sets status to 'queued' and clears errorDescription", () => { + const rootStore = buildStore(); + const file = new FileStore("rejected", rootStore, makeFile("test.txt")); + file.errorDescription = "too many files"; + + file.setQueued(); + + expect(file.fileStatus).toBe("queued"); + expect(file.errorDescription).toBeUndefined(); + }); +}); + +describe("FileStore.upload", () => { + test("transitions from 'queued' to 'uploading' then to error on failure", async () => { + const rootStore = buildStore(); + const file = new FileStore("queued", rootStore, makeFile("test.txt")); + rootStore.objectCreationHelper.request = jest.fn().mockRejectedValue(new Error("mocked")); + + await file.upload(); + + expect(file.fileStatus).toBe("uploadingError"); + }); + + test("does not start upload if status is not 'queued'", async () => { + const rootStore = buildStore(); + const file = new FileStore("validationError", rootStore, makeFile("test.txt")); + rootStore.objectCreationHelper.request = jest.fn(); + + await file.upload(); + + expect(rootStore.objectCreationHelper.request).not.toHaveBeenCalled(); + }); + + test("does not start upload if status is 'existingFile'", async () => { + const rootStore = buildStore(); + const file = new FileStore("existingFile", rootStore, undefined, obj("a") as any); + rootStore.objectCreationHelper.request = jest.fn(); + + await file.upload(); + + expect(rootStore.objectCreationHelper.request).not.toHaveBeenCalled(); + }); +}); + +describe("FileStore.markMissing", () => { + test("transitions to 'missing' from 'existingFile'", () => { + const rootStore = buildStore(); + const file = new FileStore("existingFile", rootStore, undefined, obj("a") as any); + + file.markMissing(); + + expect(file.fileStatus).toBe("missing"); + }); + + test("transitions to 'removedFile' (not 'missing') when status is 'uploadingError'", () => { + const rootStore = buildStore(); + const file = new FileStore("uploadingError", rootStore, makeFile("test.txt")); + + file.markMissing(); + + expect(file.fileStatus).toBe("removedFile"); + }); +}); + +describe("FileStore — removed legacy statuses", () => { + test("FileStore does not have errorType property", () => { + const rootStore = buildStore(); + const file = new FileStore("queued", rootStore, makeFile("test.txt")); + + expect(Object.prototype.hasOwnProperty.call(file, "errorType")).toBe(false); + expect("errorType" in file).toBe(false); + }); +}); + +describe("FileStore.newFile", () => { + test("creates file with 'queued' status", () => { + const rootStore = buildStore(); + const file = FileStore.newFile(makeFile("test.txt"), rootStore); + + expect(file.fileStatus).toBe("queued"); + }); +}); + +describe("FileStore.newRejectedFile", () => { + test("creates file with 'rejected' status and errorDescription", () => { + const rootStore = buildStore(); + const file = FileStore.newRejectedFile(makeFile("test.txt"), "Too many files", rootStore); + + expect(file.fileStatus).toBe("rejected"); + expect(file.errorDescription).toBe("Too many files"); + }); +}); + +// ─── FileUploaderStore — renamed properties ────────────────────────────────── + +describe("FileUploaderStore — renamed properties", () => { + test("maxTotalFiles reads from maxFilesPerUpload XML prop", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(7)) }); + + expect(store.maxTotalFiles).toBe(7); + }); + + test("maxConcurrentUploads reads from maxFilesPerBatch XML prop", () => { + const store = buildStore({ maxFilesPerBatch: dynamic(new Big(3)) }); + + expect(store.maxConcurrentUploads).toBe(3); + }); + + test("maxTotalFiles returns 0 (unlimited) when expression unavailable", () => { + const store = buildStore({ maxFilesPerUpload: unavailableDynamic() }); + + expect(store.maxTotalFiles).toBe(0); + }); + + test("maxConcurrentUploads returns 0 (unlimited) when expression unavailable", () => { + const store = buildStore({ maxFilesPerBatch: unavailableDynamic() }); + + expect(store.maxConcurrentUploads).toBe(0); + }); +}); + +// ─── FileUploaderStore — removed legacy API ────────────────────────────────── + +describe("FileUploaderStore — removed legacy methods", () => { + test("dismissValidationErrors does not exist", () => { + const store = buildStore(); + + expect((store as any).dismissValidationErrors).toBeUndefined(); + }); + + test("retryLimitExceededFiles does not exist", () => { + const store = buildStore(); + + expect((store as any).retryLimitExceededFiles).toBeUndefined(); + }); +}); + +// ─── FileUploaderStore.processDrop — pure classifier ───────────────────────── + +describe("FileUploaderStore.processDrop — pure classifier", () => { + test("accepted files within capacity enter upload pipeline (queued or uploading)", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(5)) }); + + store.processDrop( + [1, 2, 3].map(n => makeFile(`file${n}.txt`)), + [] + ); + + const inPipeline = store.files.filter(f => f.fileStatus === "queued" || f.fileStatus === "uploading"); + expect(inPipeline).toHaveLength(3); + }); + + test("files exceeding maxTotalFiles go to 'rejected' status", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + + store.processDrop( + [1, 2, 3, 4].map(n => makeFile(`file${n}.txt`)), + [] + ); + + expect(store.files.filter(f => f.fileStatus === "rejected")).toHaveLength(2); + const inPipeline = store.files.filter(f => f.fileStatus === "queued" || f.fileStatus === "uploading"); + expect(inPipeline).toHaveLength(2); + }); + + test("format/size rejected files go to 'validationError' status", () => { + const store = buildStore(); + const rejections = [ + { file: makeFile("bad.exe"), errors: [{ code: "file-invalid-type", message: "bad type" }] } + ]; + + store.processDrop([], rejections as any); + + expect(store.files.filter(f => f.fileStatus === "validationError")).toHaveLength(1); + }); + + test("no file gets 'batchExceeded' treatment — excess files enter pipeline or rejected only", () => { + const store = buildStore({ maxFilesPerBatch: dynamic(new Big(2)), maxFilesPerUpload: dynamic(new Big(10)) }); + + store.processDrop( + [1, 2, 3, 4].map(n => makeFile(`file${n}.txt`)), + [] + ); + + const statuses = store.files.map(f => f.fileStatus); + expect(statuses.every(s => s === "queued" || s === "uploading" || s === "rejected")).toBe(true); + }); + + test("no file has errorType set after processDrop", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + + store.processDrop( + [1, 2, 3, 4].map(n => makeFile(`file${n}.txt`)), + [] + ); + + const withErrorType = store.files.filter(f => (f as any).errorType !== undefined); + expect(withErrorType).toHaveLength(0); + }); + + test("drop with maxConcurrentUploads=2: exactly 2 start uploading, rest stay queued", () => { + const store = buildStore({ + maxFilesPerUpload: dynamic(new Big(10)), + maxFilesPerBatch: dynamic(new Big(2)) + }); + store.objectCreationHelper.request = jest.fn().mockRejectedValue(new Error("no server")); + + store.processDrop( + [1, 2, 3, 4].map(n => makeFile(`file${n}.txt`)), + [] + ); + + expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(2); + expect(store.files.filter(f => f.fileStatus === "queued")).toHaveLength(2); + }); + + test("drop with no concurrent limit: all files start uploading immediately", () => { + const store = buildStore({ + maxFilesPerUpload: dynamic(new Big(10)), + maxFilesPerBatch: unavailableDynamic() + }); + store.objectCreationHelper.request = jest.fn().mockRejectedValue(new Error("no server")); + + store.processDrop( + [1, 2, 3].map(n => makeFile(`file${n}.txt`)), + [] + ); + + expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(3); + expect(store.files.filter(f => f.fileStatus === "queued")).toHaveLength(0); + }); +}); + +// ─── FileUploaderStore.isFileUploadLimitReached ─────────────────────────────── + +describe("FileUploaderStore.isFileUploadLimitReached", () => { + test("'queued' counts toward maxTotalFiles", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + + store.files.push({ fileStatus: "queued" } as any, { fileStatus: "queued" } as any); + + expect(store.isFileUploadLimitReached).toBe(true); + }); + + test("'existingFile' counts toward maxTotalFiles", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + + store.files.push({ fileStatus: "existingFile" } as any, { fileStatus: "existingFile" } as any); + + expect(store.isFileUploadLimitReached).toBe(true); + }); + + test("'uploading' counts toward maxTotalFiles", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(1)) }); + + store.files.push({ fileStatus: "uploading" } as any); + + expect(store.isFileUploadLimitReached).toBe(true); + }); + + test("'done' counts toward maxTotalFiles", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(1)) }); + + store.files.push({ fileStatus: "done" } as any); + + expect(store.isFileUploadLimitReached).toBe(true); + }); + + test("'rejected' does NOT count toward maxTotalFiles", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + + store.files.push( + { fileStatus: "rejected" } as any, + { fileStatus: "rejected" } as any, + { fileStatus: "rejected" } as any + ); + + expect(store.isFileUploadLimitReached).toBe(false); + }); + + test("'uploadingError' does NOT count toward maxTotalFiles", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(1)) }); + + store.files.push({ fileStatus: "uploadingError" } as any); + + expect(store.isFileUploadLimitReached).toBe(false); + }); + + test("excludes missing, removedFile, 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); + }); + + test("returns false when maxTotalFiles 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 maxTotalFiles expression is unavailable (unlimited fallback)", () => { + const store = buildStore({ maxFilesPerUpload: unavailableDynamic() }); + + store.files.push({ fileStatus: "existingFile" } as any); + + expect(store.isFileUploadLimitReached).toBe(false); + }); +}); + +// ─── FileUploaderStore.promoteRejectedFiles ─────────────────────────────────── + +describe("FileUploaderStore.promoteRejectedFiles", () => { + test("promotes oldest 'rejected' file first (FIFO)", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(3)) }); + + // Files are unshifted (prepended), so highest index = oldest. + // newest at index 0, oldest at index 1 + const newest = { fileStatus: "rejected", setQueued: jest.fn(), upload: jest.fn() } as any; + const oldest = { fileStatus: "rejected", setQueued: jest.fn(), upload: jest.fn() } as any; + + store.files.push(newest, oldest, { fileStatus: "existingFile" } as any, { fileStatus: "existingFile" } as any); + + store.promoteRejectedFiles(); + + expect(oldest.setQueued).toHaveBeenCalledTimes(1); + expect(newest.setQueued).not.toHaveBeenCalled(); + }); + + test("promotes multiple rejected files when multiple slots open", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(4)) }); + + // 2 existing, 2 slots open + const newest = { fileStatus: "rejected", setQueued: jest.fn(), upload: jest.fn() } as any; + const middle = { fileStatus: "rejected", setQueued: jest.fn(), upload: jest.fn() } as any; + const oldest = { fileStatus: "rejected", setQueued: jest.fn(), upload: jest.fn() } as any; + + store.files.push( + newest, + middle, + oldest, + { fileStatus: "existingFile" } as any, + { fileStatus: "existingFile" } as any + ); + + store.promoteRejectedFiles(); + + expect(oldest.setQueued).toHaveBeenCalledTimes(1); + expect(middle.setQueued).toHaveBeenCalledTimes(1); + expect(newest.setQueued).not.toHaveBeenCalled(); + }); + + test("does nothing when at or above maxTotalFiles", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + + const rejected = { fileStatus: "rejected", setQueued: jest.fn(), upload: jest.fn() } as any; + + store.files.push(rejected, { fileStatus: "existingFile" } as any, { fileStatus: "existingFile" } as any); + + store.promoteRejectedFiles(); + + expect(rejected.setQueued).not.toHaveBeenCalled(); + }); + + test("does nothing when no rejected files exist", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(3)) }); + + store.files.push({ fileStatus: "existingFile" } as any); + + // Should not throw + expect(() => store.promoteRejectedFiles()).not.toThrow(); + }); + + test("triggers when active file is removed from the array", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + + const active = { fileStatus: "existingFile" } as any; + const rejected = { fileStatus: "rejected", setQueued: jest.fn(), upload: jest.fn() } as any; + + store.files.push(rejected, active, { fileStatus: "existingFile" } as any); + + store.files.splice(store.files.indexOf(active), 1); + + expect(rejected.setQueued).toHaveBeenCalledTimes(1); + }); + + test("promoted rejected file actually starts uploading after setQueued", async () => { + const store = buildStore({ + maxFilesPerUpload: dynamic(new Big(2)), + maxFilesPerBatch: unavailableDynamic() + }); + const neverResolve = new Promise(() => {}); + store.objectCreationHelper.request = jest.fn().mockReturnValue(neverResolve); + + // Drop 3 files: 2 upload, 1 rejected + store.processDrop([makeFile("a.txt"), makeFile("b.txt"), makeFile("c.txt")], []); + + expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(2); + expect(store.files.filter(f => f.fileStatus === "rejected")).toHaveLength(1); + + // Delete one uploading file's slot by marking it done + store.files[store.files.findIndex(f => f.fileStatus === "uploading")].fileStatus = "removedFile" as any; + + // Reaction: activeCount drops → promoteRejectedFiles → setQueued → promoteQueuedFiles → upload + expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(2); + expect(store.files.filter(f => f.fileStatus === "rejected")).toHaveLength(0); + }); +}); + +// ─── FileUploaderStore.promoteQueuedFiles ───────────────────────────────────── + +describe("FileUploaderStore.promoteQueuedFiles", () => { + test("calls upload() on queued files up to maxConcurrentUploads", () => { + const store = buildStore({ maxFilesPerBatch: dynamic(new Big(2)) }); + + const queued1 = { fileStatus: "queued", upload: jest.fn() } as any; + const queued2 = { fileStatus: "queued", upload: jest.fn() } as any; + const queued3 = { fileStatus: "queued", upload: jest.fn() } as any; + + store.files.push(queued3, queued2, queued1); + + store.promoteQueuedFiles(); + + const uploadedCount = [queued1, queued2, queued3].filter(f => f.upload.mock.calls.length > 0).length; + expect(uploadedCount).toBe(2); + }); + + test("does not promote beyond available concurrent slots", () => { + const store = buildStore({ maxFilesPerBatch: dynamic(new Big(2)) }); + + const uploading = { fileStatus: "uploading" } as any; + const queued1 = { fileStatus: "queued", upload: jest.fn() } as any; + const queued2 = { fileStatus: "queued", upload: jest.fn() } as any; + + // 1 slot already used + store.files.push(queued2, queued1, uploading); + + store.promoteQueuedFiles(); + + const uploadedCount = [queued1, queued2].filter(f => f.upload.mock.calls.length > 0).length; + expect(uploadedCount).toBe(1); + }); + + test("does nothing when all concurrent slots are occupied", () => { + const store = buildStore({ maxFilesPerBatch: dynamic(new Big(2)) }); + + const uploading1 = { fileStatus: "uploading" } as any; + const uploading2 = { fileStatus: "uploading" } as any; + const queued = { fileStatus: "queued", upload: jest.fn() } as any; + + store.files.push(queued, uploading1, uploading2); + + store.promoteQueuedFiles(); + + expect(queued.upload).not.toHaveBeenCalled(); + }); + + test("does nothing when no queued files exist", () => { + const store = buildStore({ maxFilesPerBatch: dynamic(new Big(2)) }); + + store.files.push({ fileStatus: "existingFile" } as any); + + expect(() => store.promoteQueuedFiles()).not.toThrow(); + }); + + test("queued file starts uploading when a concurrent slot frees up", async () => { + const store = buildStore({ + maxFilesPerBatch: dynamic(new Big(1)), + maxFilesPerUpload: dynamic(new Big(10)) + }); + const neverResolve = new Promise(() => {}); + store.objectCreationHelper.request = jest.fn().mockReturnValue(neverResolve); + + // Drop 2 files: 1 starts uploading, 1 waits queued + store.processDrop([makeFile("a.txt"), makeFile("b.txt")], []); + + expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(1); + expect(store.files.filter(f => f.fileStatus === "queued")).toHaveLength(1); + + // Mark the uploading file as done (simulate slot freed) + store.files[store.files.findIndex(f => f.fileStatus === "uploading")].fileStatus = "done" as any; + + // Reaction fires: uploadingCount dropped → promoteQueuedFiles → queued file starts + expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(1); + expect(store.files.filter(f => f.fileStatus === "queued")).toHaveLength(0); + }); +}); + +// ─── FileUploaderStore.warningMessage ──────────────────────────────────────── + +describe("FileUploaderStore.warningMessage", () => { + test("returns undefined when no limit set and no error", () => { + const store = buildStore({ maxFilesPerUpload: unavailableDynamic() }); + + expect(store.warningMessage).toBeUndefined(); + }); + + test("returns undefined when under maxTotalFiles limit", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(5)) }); + + expect(store.warningMessage).toBeUndefined(); + }); + + test("returns limit-reached message when maxTotalFiles reached", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + + store.files.push( + { fileStatus: "existingFile", _objectItem: obj("a") } as any, + { fileStatus: "existingFile", _objectItem: obj("b") } as any + ); + + expect(store.isFileUploadLimitReached).toBe(true); + expect(store.warningMessage).toBe("Maximum file count of 2 reached."); + }); + + test("returns errorMessage when limit not reached but error set", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(5)) }); + store.setMessage("Some other error"); + + expect(store.warningMessage).toBe("Some other error"); + }); + + test("clears limit-reached message when file removed below limit", () => { + const store = buildStore({ maxFilesPerUpload: dynamic(new Big(2)) }); + + const fileA = { fileStatus: "existingFile", _objectItem: obj("a") } as any; + const fileB = { fileStatus: "existingFile", _objectItem: obj("b") } as any; + store.files.push(fileA, fileB); + + expect(store.warningMessage).toBe("Maximum file count of 2 reached."); + + store.files.splice(store.files.indexOf(fileA), 1); + + expect(store.isFileUploadLimitReached).toBe(false); + expect(store.warningMessage).toBeUndefined(); + }); +}); + +// ─── End-to-end queue integration ──────────────────────────────────────────── + +describe("upload queue — end-to-end", () => { + test("upload error frees concurrent slot and next queued file starts uploading", async () => { + const store = buildStore({ + maxFilesPerUpload: dynamic(new Big(10)), + maxFilesPerBatch: dynamic(new Big(1)) + }); + // First request fails, second hangs so we can assert the stable "uploading" state + const neverResolve = new Promise(() => {}); + store.objectCreationHelper.request = jest + .fn() + .mockRejectedValueOnce(new Error("server error")) + .mockReturnValueOnce(neverResolve); + + store.processDrop([makeFile("a.txt"), makeFile("b.txt")], []); + + expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(1); + expect(store.files.filter(f => f.fileStatus === "queued")).toHaveLength(1); + + // Wait for first upload to fail + await Promise.resolve(); + await Promise.resolve(); + + // Error frees slot → queued file promotes to uploading (second request hangs) + expect(store.files.filter(f => f.fileStatus === "uploadingError")).toHaveLength(1); + expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(1); + expect(store.files.filter(f => f.fileStatus === "queued")).toHaveLength(0); + }); + + test("upload errors free active slots and promote oldest rejected file to uploading", async () => { + const store = buildStore({ + maxFilesPerUpload: dynamic(new Big(2)), + maxFilesPerBatch: unavailableDynamic() + }); + // First two requests fail, third hangs so we can assert the stable "uploading" state + const neverResolve = new Promise(() => {}); + store.objectCreationHelper.request = jest + .fn() + .mockRejectedValueOnce(new Error("fail a")) + .mockRejectedValueOnce(new Error("fail b")) + .mockReturnValueOnce(neverResolve); + + // Drop 3 files — 2 start uploading, 1 rejected (over maxTotalFiles) + store.processDrop([makeFile("a.txt"), makeFile("b.txt"), makeFile("c.txt")], []); + + expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(2); + expect(store.files.filter(f => f.fileStatus === "rejected")).toHaveLength(1); + + // Wait for both uploads to fail + await Promise.resolve(); + await Promise.resolve(); + + // Both errors free active count → rejected promotes to uploading (third request hangs) + expect(store.files.filter(f => f.fileStatus === "uploadingError")).toHaveLength(2); + expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(1); + expect(store.files.filter(f => f.fileStatus === "rejected")).toHaveLength(0); + }); +}); diff --git a/packages/pluggableWidgets/file-uploader-web/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..c4fe62b775 100644 --- a/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts +++ b/packages/pluggableWidgets/file-uploader-web/typings/FileUploaderProps.d.ts @@ -59,17 +59,20 @@ export interface FileUploaderContainerProps { createFileAction?: ActionValue; createImageAction?: ActionValue; allowedFileFormats: AllowedFileFormatsType[]; - maxFilesPerUpload: DynamicValue; + maxFilesPerUpload?: DynamicValue; + maxFilesPerBatch?: DynamicValue; maxFileSize: number; dropzoneIdleMessage: DynamicValue; dropzoneAcceptedMessage: DynamicValue; dropzoneRejectedMessage: DynamicValue; uploadInProgressMessage: DynamicValue; + uploadQueuedMessage: DynamicValue; uploadSuccessMessage: DynamicValue; uploadFailureGenericMessage: DynamicValue; uploadFailureInvalidFileFormatMessage: DynamicValue; uploadFailureFileIsTooBigMessage: DynamicValue; uploadFailureTooManyFilesMessage: DynamicValue; + uploadLimitReachedMessage: DynamicValue; unavailableCreateActionMessage: DynamicValue; downloadButtonTextMessage: DynamicValue; removeButtonTextMessage: DynamicValue; @@ -103,16 +106,19 @@ export interface FileUploaderPreviewProps { createImageAction: {} | null; allowedFileFormats: AllowedFileFormatsPreviewType[]; maxFilesPerUpload: string; + maxFilesPerBatch: string; maxFileSize: number | null; dropzoneIdleMessage: string; dropzoneAcceptedMessage: string; dropzoneRejectedMessage: string; uploadInProgressMessage: string; + uploadQueuedMessage: string; uploadSuccessMessage: string; uploadFailureGenericMessage: string; uploadFailureInvalidFileFormatMessage: string; uploadFailureFileIsTooBigMessage: string; uploadFailureTooManyFilesMessage: string; + uploadLimitReachedMessage: string; unavailableCreateActionMessage: string; downloadButtonTextMessage: string; removeButtonTextMessage: string;