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