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
19 changes: 19 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,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 the user can click to queue them for upload once 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 retry button on files rejected due to the upload limit. The button is enabled once capacity is available and disabled (greyed out) when the limit is still full.

### 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 All @@ -171,6 +193,14 @@
<translation lang="nl_NL">Kan op dit moment geen bestanden uploaden. Neem contact op met uw systeembeheerder.</translation>
</translations>
</property>
<property key="retryButtonTextMessage" type="textTemplate">
<caption>Retry button</caption>
<description />
<translations>
<translation lang="en_US">Retry upload</translation>
<translation lang="nl_NL">Uploaden opnieuw proberen</translation>
</translations>
</property>
<property key="downloadButtonTextMessage" type="textTemplate">
<caption>Download button</caption>
<description />
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
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 {
icon: ReactElement;
title?: string;
action?: () => void;
isDisabled: boolean;
isHidden?: boolean;
}

export function ActionButton({ action, icon, title, isDisabled }: ActionButtonProps): ReactElement {
export function ActionButton({ action, icon, title, isDisabled, isHidden }: ActionButtonProps): ReactElement {
const onClick = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
Expand All @@ -22,7 +23,8 @@ export function ActionButton({ action, icon, title, isDisabled }: ActionButtonPr
<button
role={"button"}
className={classNames("action-button", {
disabled: isDisabled
disabled: isDisabled,
hidden: isHidden ?? isDisabled
})}
onClick={onClick}
title={title}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,44 @@
import { ReactElement, useCallback } from "react";
import { FileUploaderContainerProps } from "../../typings/FileUploaderProps";
import { ActionButton, FileActionButton } from "./ActionButton";
import { ReactElement } from "react";
import { IconInternal } from "@mendix/widget-plugin-component-kit/IconInternal";
import { FileStore } from "../stores/FileStore";
import { ActionButton, FileActionButton } from "./ActionButton";
import { FileUploaderContainerProps } from "../../typings/FileUploaderProps";
import { FileStatus, FileStore } from "../stores/FileStore";
import { useTranslationsStore } from "../utils/useTranslationsStore";

interface ButtonsBarProps {
interface ActionsBarProps {
actions?: FileUploaderContainerProps["customButtons"];
store: FileStore;
fileStatus: FileStatus;
canRetry: boolean;
canDownload: boolean;
canRemove: boolean;
onRetry: () => void;
onRemove: () => void;
onDownload: () => void;
}

export const ActionsBar = ({ actions, store }: ButtonsBarProps): ReactElement | null => {
export function ActionsBar(props: ActionsBarProps): ReactElement | null {
const { actions, store, fileStatus, canRetry, canDownload, canRemove, onRetry, onRemove, onDownload } = props;

if (!actions) {
return <DefaultActionsBar store={store} />;
return (
<DefaultActionsBar
fileStatus={fileStatus}
canRetry={canRetry}
canDownload={canDownload}
canRemove={canRemove}
onRetry={onRetry}
onRemove={onRemove}
onDownload={onDownload}
/>
);
}

if (fileStatus === "rejected") {
return <RetryActionsBar canRetry={canRetry} onRetry={onRetry} />;
}

if (actions && store.canExecuteActions) {
if (store.canExecuteActions) {
return (
<div className={"entry-details-actions"}>
{actions.map((a, i) => {
Expand All @@ -39,43 +62,73 @@ export const ActionsBar = ({ actions, store }: ButtonsBarProps): ReactElement |
}

return null;
};
}

function DefaultActionsBar(props: ButtonsBarProps): ReactElement {
interface RetryActionsBarProps {
canRetry: boolean;
onRetry: () => void;
}

function RetryActionsBar({ canRetry, onRetry }: RetryActionsBarProps): ReactElement {
const translations = useTranslationsStore();

const onRemove = useCallback(() => {
props.store.remove();
}, [props.store]);
return (
<div className={"entry-details-actions"}>
<ActionButton
icon={<span className={"retry-icon"} aria-hidden />}
title={translations.get("retryButtonTextMessage")}
action={onRetry}
isDisabled={!canRetry}
isHidden={false}
/>
</div>
);
}

const onViewClick = useCallback(async () => {
onDownloadClick(await props.store.getDownloadUrl());
}, [props.store]);
interface DefaultActionsBarProps {
fileStatus: FileStatus;
canRetry: boolean;
canDownload: boolean;
canRemove: boolean;
onRetry: () => void;
onRemove: () => void;
onDownload: () => void;
}

function DefaultActionsBar({
fileStatus,
canRetry,
canDownload,
canRemove,
onRetry,
onRemove,
onDownload
}: DefaultActionsBarProps): ReactElement {
const translations = useTranslationsStore();

return (
<div className={"entry-details-actions"}>
{fileStatus === "rejected" && (
<ActionButton
icon={<span className={"retry-icon"} aria-hidden />}
title={translations.get("retryButtonTextMessage")}
action={onRetry}
isDisabled={!canRetry}
isHidden={false}
/>
)}
<ActionButton
icon={<span className={"download-icon"} aria-hidden />}
title={translations.get("downloadButtonTextMessage")}
action={onViewClick}
isDisabled={!props.store.canDownload}
action={onDownload}
isDisabled={!canDownload}
/>
<ActionButton
icon={<span className={"remove-icon"} aria-hidden />}
title={translations.get("removeButtonTextMessage")}
action={onRemove}
isDisabled={!props.store.canRemove}
isDisabled={!canRemove}
/>
</div>
);
}

function onDownloadClick(fileUrl: string | undefined): void {
if (!fileUrl) {
return;
}
const url = new URL(fileUrl);
url.searchParams.append("target", "window");

window.open(url, "mendix_file");
}
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
Loading