Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: tdd-refactor
created: 2026-06-24
77 changes: 77 additions & 0 deletions packages/pluggableWidgets/file-uploader-web/openspec/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
## Test Cases

### Reproduction Tests

- Dropzone warning clears after dismissing all invalid files (unit)
- **Given**: Dropzone rendered with `warningMessage` set (invalid files present)
- **When**: All invalid files dismissed, `warningMessage` becomes undefined
- **Then**: Dropzone has no `.warning` class, `.upload-text` shows idle message, no inline warning icon

- Dropzone warning does not use isDragReject after drop (unit)
- **Given**: Dropzone component with react-dropzone's `isDragReject` stuck true (post-drop state)
- **When**: `isDragActive` is false and no `warningMessage` prop
- **Then**: Dropzone shows idle state — no `.warning` class, idle text displayed

- Default remove button removes file from list (unit)
- **Given**: FileStore with status "done" or "existingFile", `removeObject` resolves successfully
- **When**: `store.remove()` called
- **Then**: File is removed from `rootStore.files` array (not kept as "removedFile")

### Edge Cases

- Warning icon shown only during warning state (unit)
- **Given**: Dropzone rendered with `warningMessage`
- **When**: Component renders
- **Then**: `.inline-icon.warning-icon` span present inside `.upload-text`

- No icon shown for status message (limit reached) (unit)
- **Given**: Dropzone rendered with `statusMessage` and `disabled=true`
- **When**: Component renders
- **Then**: No `.inline-icon` span rendered, text shown in neutral color

- Warning during active drag still works (unit)
- **Given**: Dropzone in active drag state with rejected files (`isDragActive=true`, `isDragReject=true`)
- **When**: Files being dragged over dropzone
- **Then**: `.warning` class applied, rejected message shown with warning icon

- Dismissing one of multiple invalid files keeps warning (unit)
- **Given**: Store has 2 files with `fileStatus === "validationError"`
- **When**: One file dismissed via `dismissFile`
- **Then**: `files.some(f => f.fileStatus === "validationError")` still true, warning remains

- Status message shown when disabled (unit)
- **Given**: Dropzone with `disabled=true` and `statusMessage="Maximum file count of 5 reached."`
- **When**: Component renders
- **Then**: `.upload-text` contains the status message, dropzone has `.disabled` class not `.warning`

- File input not rendered when disabled (unit)
- **Given**: Dropzone with `disabled=true`
- **When**: Component renders
- **Then**: No `input[type="file"]` in DOM

### Regression Tests

- Idle state shows drag and drop message (unit)
- **Given**: Dropzone with no warnings, not disabled
- **When**: Component renders
- **Then**: `.upload-text` shows "Drag and drop files here", no `.warning`/`.disabled` class

- Drop accepted files works normally (unit)
- **Given**: Store with capacity available
- **When**: `processDrop` called with accepted files, no rejections
- **Then**: Files added to store with "queued" status, no warning state

- Dropzone-message element no longer exists (unit)
- **Given**: Dropzone rendered in any state (warning, disabled, idle)
- **When**: Component renders
- **Then**: No `.dropzone-message` element in DOM

- Upload limit reached message does not show warning styling (unit)
- **Given**: `isFileUploadLimitReached` is true
- **When**: FileUploaderRoot renders Dropzone
- **Then**: Dropzone has `.disabled` class, text is neutral, no warning icon

## Notes

- react-dropzone v14.3.8 has a patched `isExt` regex in this repo but the `isDragReject` persistence after drop is upstream behavior — not a local bug. The fix works around it by only consulting drag state flags when `isDragActive` is true.
- The "removedFile" status type still exists in `FileStatus` union for backward compatibility with `markMissing()` logic (uploadingError → removedFile transition). It is no longer set by the user-initiated `remove()` flow.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## Why

Three issues in file-uploader-web dropzone behavior:

1. **Yellow warning persists** — After dropping invalid-format files and dismissing all of them, the dropzone stays yellow with "Some files may not be uploadable." text. Expected: reverts to idle (gray, "Drag and drop files here"). Only a subsequent valid drop clears it.

2. **Warning message placement** — The `.dropzone-message` element renders below the dropzone as a separate div. The limit-reached message ("Maximum file count of N reached") feels like an error when it should feel neutral/informational. All messages should appear inside the dropzone in `.upload-text`.

3. **Default remove greys out instead of removing** — When using default buttons, removing an uploaded file sets it to "removedFile" status (greyed out in list). With custom buttons, the file disappears immediately. Behavior should be consistent: file disappears on remove.

## Root Cause

1. `react-dropzone` (v14.3.8) sets `isDragReject = true` in its internal reducer after a rejected drop (`setFiles` action) and never clears it until the next drop. `Dropzone.tsx` used `isDragReject` unconditionally in `getMessage()` for both CSS class and text — so the warning state persisted indefinitely after a rejected drop.

2. The `.dropzone-message` div was a separate element below the dropzone with its own styling. There was no mechanism to show messages inside the dropzone itself when not in an active drag state.

3. `FileStore.remove()` calls `removeObject()` then sets `this.fileStatus = "removedFile"` — keeping the file in the list as greyed out. Custom buttons trigger a Mendix action that removes the object from the datasource, which triggers `processMissing` → `markMissing()` → status `"missing"` → component returns null.

## What Changes

Package: `packages/pluggableWidgets/file-uploader-web`

- `src/components/Dropzone.tsx` — Gate `isDragAccept`/`isDragReject` behind `isDragActive`. Add `statusMessage` prop for neutral messages. Render all messages inside `.upload-text` with optional inline icon. Remove `Fragment` wrapper and `.dropzone-message` div.
- `src/components/FileUploaderRoot.tsx` — Split warning vs status message: limit-reached → `statusMessage`, others → `warningMessage`.
- `src/stores/FileStore.ts` — `remove()` calls `rootStore.dismissFile(this)` after successful deletion instead of setting `"removedFile"` status.
- `src/ui/FileUploader.scss` — Remove `.dropzone-message` styles. Add `.inline-icon` styles inside `.upload-text`. Add warning text color in `.warning` state.
- `src/assets/check-icon.svg` — Removed (unused after design review).

## Impact

- Visual: dropzone messages now appear inside the dropzone instead of below it.
- Behavioral: removed files disappear immediately (no more greyed-out "removedFile" state visible to user with default buttons).
- No breaking API changes — widget XML properties unchanged.
- CSS class `.dropzone-message` removed — any custom CSS targeting it will no longer apply (unlikely external usage since it's internal widget markup).
34 changes: 34 additions & 0 deletions packages/pluggableWidgets/file-uploader-web/openspec/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## 1. Test Setup

- [x] 1.1 Write Dropzone.spec.tsx — idle state: shows idle message, no warning class, no inline icon, renders file input
- [x] 1.2 Write Dropzone.spec.tsx — warning state: shows warning message inside `.upload-text`, has `.warning` class, renders inline warning icon, no `.dropzone-message` element
- [x] 1.3 Write Dropzone.spec.tsx — disabled+statusMessage: shows status text inside `.upload-text`, has `.disabled` class (not `.warning`), no inline icon, no file input
- [x] 1.4 Write FileUploaderStore.spec.ts — validationError files tracking: present after rejected drop, cleared after all dismissed, remains when only some dismissed
- [x] 1.5 Write FileStore.spec.ts — `remove()` calls `dismissFile` on root store after successful `removeObject`

## 2. Implementation

- [x] 2.1 Fix Dropzone.tsx — gate `isDragAccept`/`isDragReject` behind `isDragActive` to prevent react-dropzone stuck state from affecting UI
- [x] 2.2 Fix Dropzone.tsx — add `statusMessage` prop, render all messages inside `.upload-text` with conditional inline icon span, remove `.dropzone-message` div and `Fragment` wrapper
- [x] 2.3 Fix FileUploaderRoot.tsx — split `warningMessage` (createActionFailed, validationError) vs `statusMessage` (limit reached), pass both to Dropzone
- [x] 2.4 Fix FileStore.ts — `remove()` calls `rootStore.dismissFile(this)` instead of setting `"removedFile"` status
- [x] 2.5 Update FileUploader.scss — remove `.dropzone-message` block, add `.inline-icon` styles (`.warning-icon` variant), add `.upload-text` color override in `.warning` state

## 3. Refactoring

- [x] 3.1 Remove unused `hasInvalidFormatFiles` observable field and related `dismissValidationErrors`/`dismissFile` flag toggling from FileUploaderStore — inline `.some()` check in component suffices now that Dropzone correctly handles post-drop state
- [x] 3.2 Remove unused `$file-dropzone-success-color`, `$file-check-icon`, and `check-icon.svg` after design review decided limit-reached should be neutral (no green, no icon)
- [x] 3.3 Simplify `getMessage()` return type — remove `"success"` MessageType and `"check-icon"` IconType since they're unused

## 4. Verification

- [x] 4.1 All tests passing — 116 total (102 existing + 14 new Dropzone specs)
- [x] 4.2 Full test suite passes (`pnpm run test` in widget dir)
- [x] 4.3 Build succeeds (`pnpm run build` in widget dir)
- [x] 4.4 Manual browser verification — warning appears on invalid drop, clears on dismiss, limit-reached shows neutral text, remove button removes instantly

## Notes

- react-dropzone v14.3.8 `setFiles` reducer sets `isDragReject: fileRejections.length > 0` which persists until next drop. This is upstream behavior, not a local bug. Fix works around it.
- The `"removedFile"` FileStatus still exists for `markMissing()` (uploadingError → removedFile transition when object disappears from datasource). It's no longer set by user-initiated remove.
- The `.dropzone-message` CSS class is removed entirely. Any external styling targeting it will break (unlikely — internal widget markup).
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,7 @@ const DefaultActionsBar = observer(function DefaultActionsBar(props: ButtonsBarP
}, [props.store]);

if (props.store.fileStatus === "rejected") {
return (
<div className={"entry-details-actions"}>
<RetryButton store={props.store} />
</div>
);
return <RejectedActionsBar store={props.store} />;
}

return (
Expand All @@ -84,6 +80,26 @@ const DefaultActionsBar = observer(function DefaultActionsBar(props: ButtonsBarP
);
});

function RejectedActionsBar({ store }: ButtonsBarProps): ReactElement {
const translations = useTranslationsStore();

const onDismiss = useCallback(() => {
store.dismiss();
}, [store]);

return (
<div className={"entry-details-actions"}>
<RetryButton store={store} />
<ActionButton
icon={<span className={"remove-icon"} aria-hidden />}
title={translations.get("removeButtonTextMessage")}
action={onDismiss}
isDisabled={false}
/>
</div>
);
}

function DismissActionsBar({ store }: ButtonsBarProps): ReactElement {
const translations = useTranslationsStore();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,65 +1,81 @@
import classNames from "classnames";
import { observer } from "mobx-react-lite";
import { Fragment, ReactElement } from "react";
import { ReactElement } from "react";
import { FileRejection, useDropzone } from "react-dropzone";
import { TranslationsStore } from "../stores/TranslationsStore";
import { MimeCheckFormat } from "../utils/parseAllowedFormats";
import { useTranslationsStore } from "../utils/useTranslationsStore";

interface DropzoneProps {
warningMessage?: string;
statusMessage?: string;
onDrop: (files: File[], fileRejections: FileRejection[]) => void;
maxSize: number;
acceptFileTypes: MimeCheckFormat;
disabled: boolean;
}

export const Dropzone = observer(
({ warningMessage, onDrop, maxSize, acceptFileTypes, disabled }: DropzoneProps): ReactElement => {
const { getRootProps, getInputProps, isDragAccept, isDragReject } = useDropzone({
({ warningMessage, statusMessage, onDrop, maxSize, acceptFileTypes, disabled }: DropzoneProps): ReactElement => {
const { getRootProps, getInputProps, isDragActive, isDragAccept, isDragReject } = useDropzone({
onDrop,
maxSize: maxSize || undefined,
accept: acceptFileTypes,
disabled
});

const translations = useTranslationsStore();
const [type, msg] = getMessage(translations, isDragAccept, isDragReject);
const [type, msg, icon] = getMessage(
translations,
isDragActive && isDragAccept,
isDragActive && isDragReject,
warningMessage,
statusMessage
);

return (
<Fragment>
<div
className={classNames("dropzone", {
active: type === "active",
disabled,
warning: !!warningMessage || type === "warning"
})}
{...getRootProps()}
>
<div className={"file-icon"} />
{!disabled && <p className={"upload-text"}>{msg}</p>}
<div
className={classNames("dropzone", {
active: type === "active",
disabled,
warning: type === "warning"
})}
{...getRootProps()}
>
<div className={"file-icon"} />
<p className={"upload-text"}>
{icon && <span className={classNames("inline-icon", icon)} />}
{msg}
</p>

{!disabled && <input {...getInputProps()} />}
</div>
{warningMessage && <div className={classNames("dropzone-message")}>{warningMessage}</div>}
</Fragment>
{!disabled && <input {...getInputProps()} />}
</div>
);
}
);

type MessageType = "active" | "warning" | "idle";
type IconType = "warning-icon" | null;

function getMessage(
translations: TranslationsStore,
isDragAccept: boolean,
isDragReject: boolean
): [MessageType, string] {
isDragReject: boolean,
warningMessage?: string,
statusMessage?: string
): [MessageType, string, IconType] {
if (isDragAccept) {
return ["active", translations.get("dropzoneAcceptedMessage")];
return ["active", translations.get("dropzoneAcceptedMessage"), null];
}
if (isDragReject) {
return ["warning", translations.get("dropzoneRejectedMessage")];
return ["warning", translations.get("dropzoneRejectedMessage"), "warning-icon"];
}
if (warningMessage) {
return ["warning", warningMessage, "warning-icon"];
}
if (statusMessage) {
return ["idle", statusMessage, null];
}

return ["idle", translations.get("dropzoneIdleMessage")];
return ["idle", translations.get("dropzoneIdleMessage"), null];
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ export const FileUploaderRoot = observer((props: FileUploaderContainerProps): Re
);

let warningMessage: string | undefined;
let statusMessage: string | undefined;
if (rootStore.isFileUploadLimitReached) {
warningMessage = translations.get("uploadLimitReachedMessage", rootStore.maxTotalFiles.toString());
statusMessage = translations.get("uploadLimitReachedMessage", rootStore.maxTotalFiles.toString());
} else if (rootStore.createActionFailed) {
warningMessage = translations.get("unavailableCreateActionMessage");
} else if (rootStore.files.some(f => f.fileStatus === "validationError")) {
warningMessage = translations.get("dropzoneRejectedMessage");
}

return (
Expand All @@ -36,6 +39,7 @@ export const FileUploaderRoot = observer((props: FileUploaderContainerProps): Re
<Dropzone
onDrop={onDrop}
warningMessage={warningMessage}
statusMessage={statusMessage}
maxSize={rootStore.maxFileSize}
acceptFileTypes={prepareAcceptForDropzone(rootStore.acceptedFileTypes)}
disabled={rootStore.isFileUploadLimitReached}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,7 @@ export class FileStore {
try {
await removeObject(this._objectItem);
runInAction(() => {
this.fileStatus = "removedFile";
this._mxObject = undefined;
this.updateThumbnailUrl();
this._rootStore.dismissFile(this);
});
} catch (e: unknown) {
console.error("Could not remove object:", e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,8 @@ export class FileUploaderStore {

get sortedFiles(): FileStore[] {
return [...this.files].sort((a, b) => {
const isErrorA = a.fileStatus === "validationError" ? 1 : 0;
const isErrorB = b.fileStatus === "validationError" ? 1 : 0;
const isErrorA = a.fileStatus === "validationError" || a.fileStatus === "rejected" ? 1 : 0;
const isErrorB = b.fileStatus === "validationError" || b.fileStatus === "rejected" ? 1 : 0;
return isErrorA - isErrorB;
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ describe("FileStore.canRetry — reacts to freed slots", () => {
// ─── FileStore.retry ─────────────────────────────────────────────────────────

describe("FileStore.retry", () => {
test("transitions rejected file to queued", () => {
test("transitions rejected file to uploading via queue reaction", () => {
const store = buildStore({
maxFilesPerUpload: dynamic(new Big(3)),
maxFilesPerBatch: unavailableDynamic()
Expand Down Expand Up @@ -1018,3 +1018,33 @@ describe("upload queue — end-to-end", () => {
expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(0);
});
});

describe("FileUploaderStore validationError files tracking", () => {
test("has validationError files after rejected drop", () => {
const store = buildStore();
store.processDrop([], [{ file: makeFile("bad.exe"), errors: [{ code: "file-invalid-type", message: "bad" }] }]);
expect(store.files.some(f => f.fileStatus === "validationError")).toBe(true);
});

test("no validationError files after all are dismissed", () => {
const store = buildStore();
store.processDrop([], [{ file: makeFile("bad.exe"), errors: [{ code: "file-invalid-type", message: "bad" }] }]);
const errorFile = store.files.find(f => f.fileStatus === "validationError")!;
store.dismissFile(errorFile);
expect(store.files.some(f => f.fileStatus === "validationError")).toBe(false);
});

test("validationError files remain when one dismissed but others exist", () => {
const store = buildStore();
store.processDrop(
[],
[
{ file: makeFile("a.exe"), errors: [{ code: "file-invalid-type", message: "bad" }] },
{ file: makeFile("b.exe"), errors: [{ code: "file-invalid-type", message: "bad" }] }
]
);
const firstError = store.files.find(f => f.fileStatus === "validationError")!;
store.dismissFile(firstError);
expect(store.files.some(f => f.fileStatus === "validationError")).toBe(true);
});
});
Loading
Loading