Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/pluggableWidgets/file-uploader-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -21,6 +21,7 @@ export function getProperties(
"createFileAction",
"allowedFileFormats",
"maxFilesPerUpload",
"maxFilesPerBatch",
"maxFileSize",
"objectCreationTimeout"
]);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,14 @@
</propertyGroup>
</properties>
</property>
<property key="maxFilesPerUpload" type="expression" defaultValue="10">
<property key="maxFilesPerUpload" type="expression" defaultValue="10" required="false">
<caption>Maximum number of files</caption>
<description>Limit the number of files per upload.</description>
<description>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.</description>
<returnType type="Integer" />
</property>
<property key="maxFilesPerBatch" type="expression" required="false">
<caption>Maximum concurrent uploads</caption>
<description>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.</description>
<returnType type="Integer" />
</property>
<property key="maxFileSize" type="integer" defaultValue="25">
Expand Down Expand Up @@ -123,6 +128,14 @@
<translation lang="nl_NL">Uploaden...</translation>
</translations>
</property>
<property key="uploadQueuedMessage" type="textTemplate">
<caption>Upload queued</caption>
<description />
<translations>
<translation lang="en_US">Waiting...</translation>
<translation lang="nl_NL">Wachten...</translation>
</translations>
</property>
<property key="uploadSuccessMessage" type="textTemplate">
<caption>Uploading success</caption>
<description />
Expand Down Expand Up @@ -163,6 +176,15 @@
<translation lang="nl_NL">Te veel bestanden toegevoegd. Slechts ### bestanden per upload zijn toegestaan.</translation>
</translations>
</property>
<property key="uploadLimitReachedMessage" type="textTemplate">
<caption>File limit reached</caption>
<description>Shown below the dropzone when the maximum number of files is already reached.</description>
<translations>
<translation lang="en_US">Maximum file count of ### reached.</translation>
<translation lang="nl_NL">Maximum aantal bestanden van ### bereikt.</translation>
</translations>
</property>

<property key="unavailableCreateActionMessage" type="textTemplate">
<caption>Action to create new files is not available or failed</caption>
<description />
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
Original file line number Diff line number Diff line change
@@ -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
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -83,7 +83,7 @@ function FileEntry(props: FileEntryProps): ReactElement {
return (
<div
className={classNames("file-entry", {
removed: props.fileStatus === "removedFile" || props.fileStatus === "removedAfterError",
removed: props.fileStatus === "removedFile",
invalid: props.fileStatus === "validationError"
})}
title={props.title}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@ import { observer } from "mobx-react-lite";
import { ReactElement, useCallback } from "react";
import { FileRejection } from "react-dropzone";

import { Dropzone } from "./Dropzone";
import { FileEntryContainer } from "./FileEntry";
import { FileUploaderContainerProps } from "../../typings/FileUploaderProps";
import { prepareAcceptForDropzone } from "../utils/prepareAcceptForDropzone";
import { useRootStore } from "../utils/useRootStore";
import { FileEntryContainer } from "./FileEntry";
import { Dropzone } from "./Dropzone";
import { useTranslationsStore } from "../utils/useTranslationsStore";

import "../ui/FileUploader.scss";

export const FileUploaderRoot = observer((props: FileUploaderContainerProps): ReactElement => {
const rootStore = useRootStore(props);
const translations = useTranslationsStore();

const onDrop = useCallback(
(acceptedFiles: File[], fileRejections: FileRejection[]) => {
Expand All @@ -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 (
<div className={classNames(props.class, "widget-file-uploader")} style={props.style}>
{!rootStore.isReadOnly && (
<Dropzone
onDrop={onDrop}
warningMessage={rootStore.errorMessage}
Comment thread
yordan-st marked this conversation as resolved.
warningMessage={warningMessage}
maxSize={rootStore._maxFileSize}
acceptFileTypes={prepareAcceptForDropzone(rootStore.acceptedFileTypes)}
maxFilesPerUpload={rootStore.maxFilesPerUpload ?? 0}
disabled={rootStore.isFileUploadLimitReached}
/>
)}

<div className={"files-list"}>
{(rootStore.files ?? []).map(fileStore => {
{rootStore.sortedFiles.map(fileStore => {
return (
<FileEntryContainer
store={fileStore}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FileStatus } from "../stores/FileStore";
import { ReactElement } from "react";
import { FileStatus } from "../stores/FileStore";
import { useTranslationsStore } from "../utils/useTranslationsStore";

type UploadInfoProps = {
Expand All @@ -15,13 +15,14 @@ export function UploadInfo({ status, error }: UploadInfoProps): ReactElement {
case "done":
return <span className={"upload-status success"}>{translations.get("uploadSuccessMessage")}</span>;
case "uploadingError":
case "removedAfterError":
return <span className={"upload-status error"}>{translations.get("uploadFailureGenericMessage")}</span>;
case "validationError":
case "rejected":
return <span className={"upload-status error"}>{error}</span>;
case "removedFile":
return <span className={"upload-status error"}>{translations.get("removeSuccessMessage")}</span>;
case "new":
case "queued":
return <span className={"upload-status"}>{translations.get("uploadQueuedMessage")}</span>;
case "existingFile":
default:
return <span className={"upload-status"}></span>;
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;

Expand Down Expand Up @@ -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 {
Expand All @@ -91,12 +92,12 @@ export class FileStore {
}

validate(): boolean {
return !(this.fileStatus !== "new" || !this._file);
return !(this.fileStatus !== "queued" || !this._file);
}

async upload(): Promise<void> {
if (this.fileStatus === "existingFile") {
throw new Error("Calling upload on already uploaded files is not supported");
if (this.fileStatus !== "queued") {
return;
}

// set status
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading