From 65883d4f1fa0716e6b49c872e8c6b6c7862903b5 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Tue, 16 Jun 2026 16:04:12 +0200 Subject: [PATCH 1/7] fix(file-uploader-web): sort rejected files below successful, show warning on invalid-format drop --- .../src/components/FileUploaderRoot.tsx | 2 ++ .../file-uploader-web/src/stores/FileUploaderStore.ts | 9 +++++++-- .../src/stores/__tests__/FileUploaderStore.spec.ts | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx index 519ca7d642..f782fbcb77 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx @@ -28,6 +28,8 @@ export const FileUploaderRoot = observer((props: FileUploaderContainerProps): Re warningMessage = translations.get("uploadLimitReachedMessage", rootStore.maxTotalFiles.toString()); } else if (rootStore.createActionFailed) { warningMessage = translations.get("unavailableCreateActionMessage"); + } else if (rootStore.hasValidationErrors) { + warningMessage = translations.get("dropzoneRejectedMessage"); } return ( diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts index 9c78942c45..9088737403 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts @@ -91,6 +91,7 @@ export class FileUploaderStore { _maxTotalFiles: observable, _maxConcurrentUploads: observable, isFileUploadLimitReached: computed, + hasValidationErrors: computed, sortedFiles: computed, activeCount: computed, uploadingCount: computed, @@ -188,10 +189,14 @@ export class FileUploaderStore { return this.activeCount >= this.maxTotalFiles; } + get hasValidationErrors(): boolean { + return this.files.some(f => f.fileStatus === "validationError"); + } + 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; }); } 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 index b544f88ad5..f0f9f53779 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts @@ -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() From 85115c5751600e0b5552e20661608c79eb568c5e Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Thu, 18 Jun 2026 15:58:39 +0200 Subject: [PATCH 2/7] fix(file-uploader-web): add dismiss button to over-limit rejected files --- .../src/components/ActionsBar.tsx | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/ActionsBar.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/ActionsBar.tsx index 36f0fd3902..bbe428c523 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/ActionsBar.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/ActionsBar.tsx @@ -59,11 +59,7 @@ const DefaultActionsBar = observer(function DefaultActionsBar(props: ButtonsBarP }, [props.store]); if (props.store.fileStatus === "rejected") { - return ( -
- -
- ); + return ; } return ( @@ -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 ( +
+ + } + title={translations.get("removeButtonTextMessage")} + action={onDismiss} + isDisabled={false} + /> +
+ ); +} + function DismissActionsBar({ store }: ButtonsBarProps): ReactElement { const translations = useTranslationsStore(); From 1af31bc817dace9d43816fb328491b6b5f22f9ff Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Thu, 18 Jun 2026 17:02:50 +0200 Subject: [PATCH 3/7] fix(file-uploader-web): inline validation error check to fix warning not clearing on dismiss --- .../src/components/FileUploaderRoot.tsx | 2 +- .../__tests__/FileUploaderStore.spec.ts | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx index f782fbcb77..a7e0a1f9e4 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx @@ -28,7 +28,7 @@ export const FileUploaderRoot = observer((props: FileUploaderContainerProps): Re warningMessage = translations.get("uploadLimitReachedMessage", rootStore.maxTotalFiles.toString()); } else if (rootStore.createActionFailed) { warningMessage = translations.get("unavailableCreateActionMessage"); - } else if (rootStore.hasValidationErrors) { + } else if (rootStore.files.some(f => f.fileStatus === "validationError")) { warningMessage = translations.get("dropzoneRejectedMessage"); } 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 index f0f9f53779..04a4305261 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts @@ -1018,3 +1018,33 @@ describe("upload queue — end-to-end", () => { expect(store.files.filter(f => f.fileStatus === "uploading")).toHaveLength(0); }); }); + +describe("FileUploaderStore.hasValidationErrors", () => { + test("returns true when validationError files exist", () => { + const store = buildStore(); + store.processDrop([], [{ file: makeFile("bad.exe"), errors: [{ code: "file-invalid-type", message: "bad" }] }]); + expect(store.hasValidationErrors).toBe(true); + }); + + test("returns false after all validationError files 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.hasValidationErrors).toBe(false); + }); + + test("remains true when one validationError file dismissed but others remain", () => { + 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.hasValidationErrors).toBe(true); + }); +}); From 9d0d3133202295fc8d5874d5743f27f6bbe219f7 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Fri, 19 Jun 2026 14:35:09 +0200 Subject: [PATCH 4/7] fix(file-uploader-web): update validation error handling and tests --- .../src/components/Dropzone.tsx | 17 +++++++++++++---- .../src/stores/FileUploaderStore.ts | 5 ----- .../stores/__tests__/FileUploaderStore.spec.ts | 14 +++++++------- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/Dropzone.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/Dropzone.tsx index d8b4f51b3b..764b7c7cf0 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/Dropzone.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/Dropzone.tsx @@ -16,7 +16,7 @@ interface DropzoneProps { export const Dropzone = observer( ({ warningMessage, onDrop, maxSize, acceptFileTypes, disabled }: DropzoneProps): ReactElement => { - const { getRootProps, getInputProps, isDragAccept, isDragReject } = useDropzone({ + const { getRootProps, getInputProps, isDragActive, isDragAccept, isDragReject } = useDropzone({ onDrop, maxSize: maxSize || undefined, accept: acceptFileTypes, @@ -24,7 +24,12 @@ export const Dropzone = observer( }); const translations = useTranslationsStore(); - const [type, msg] = getMessage(translations, isDragAccept, isDragReject); + const [type, msg] = getMessage( + translations, + isDragActive && isDragAccept, + isDragActive && isDragReject, + warningMessage + ); return ( @@ -32,7 +37,7 @@ export const Dropzone = observer( className={classNames("dropzone", { active: type === "active", disabled, - warning: !!warningMessage || type === "warning" + warning: type === "warning" })} {...getRootProps()} > @@ -52,7 +57,8 @@ type MessageType = "active" | "warning" | "idle"; function getMessage( translations: TranslationsStore, isDragAccept: boolean, - isDragReject: boolean + isDragReject: boolean, + warningMessage?: string ): [MessageType, string] { if (isDragAccept) { return ["active", translations.get("dropzoneAcceptedMessage")]; @@ -60,6 +66,9 @@ function getMessage( if (isDragReject) { return ["warning", translations.get("dropzoneRejectedMessage")]; } + if (warningMessage) { + return ["warning", warningMessage]; + } return ["idle", translations.get("dropzoneIdleMessage")]; } diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts index 9088737403..9fb7b02f1c 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts @@ -91,7 +91,6 @@ export class FileUploaderStore { _maxTotalFiles: observable, _maxConcurrentUploads: observable, isFileUploadLimitReached: computed, - hasValidationErrors: computed, sortedFiles: computed, activeCount: computed, uploadingCount: computed, @@ -189,10 +188,6 @@ export class FileUploaderStore { return this.activeCount >= this.maxTotalFiles; } - get hasValidationErrors(): boolean { - return this.files.some(f => f.fileStatus === "validationError"); - } - get sortedFiles(): FileStore[] { return [...this.files].sort((a, b) => { const isErrorA = a.fileStatus === "validationError" || a.fileStatus === "rejected" ? 1 : 0; 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 index 04a4305261..722ea57bdf 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts @@ -1019,22 +1019,22 @@ describe("upload queue — end-to-end", () => { }); }); -describe("FileUploaderStore.hasValidationErrors", () => { - test("returns true when validationError files exist", () => { +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.hasValidationErrors).toBe(true); + expect(store.files.some(f => f.fileStatus === "validationError")).toBe(true); }); - test("returns false after all validationError files are dismissed", () => { + 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.hasValidationErrors).toBe(false); + expect(store.files.some(f => f.fileStatus === "validationError")).toBe(false); }); - test("remains true when one validationError file dismissed but others remain", () => { + test("validationError files remain when one dismissed but others exist", () => { const store = buildStore(); store.processDrop( [], @@ -1045,6 +1045,6 @@ describe("FileUploaderStore.hasValidationErrors", () => { ); const firstError = store.files.find(f => f.fileStatus === "validationError")!; store.dismissFile(firstError); - expect(store.hasValidationErrors).toBe(true); + expect(store.files.some(f => f.fileStatus === "validationError")).toBe(true); }); }); From 4952d9510c89937d7e2cde4aa029f8485594be11 Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Mon, 22 Jun 2026 15:55:43 +0200 Subject: [PATCH 5/7] fix: remove check icon and update related styles in Dropzone --- .../src/components/Dropzone.tsx | 57 +++++++++++-------- .../src/components/FileUploaderRoot.tsx | 4 +- .../file-uploader-web/src/stores/FileStore.ts | 4 +- .../src/ui/FileUploader.scss | 29 +++++----- 4 files changed, 52 insertions(+), 42 deletions(-) diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/Dropzone.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/Dropzone.tsx index 764b7c7cf0..bf32c6146b 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/Dropzone.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/Dropzone.tsx @@ -1,6 +1,6 @@ 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"; @@ -8,6 +8,7 @@ import { useTranslationsStore } from "../utils/useTranslationsStore"; interface DropzoneProps { warningMessage?: string; + statusMessage?: string; onDrop: (files: File[], fileRejections: FileRejection[]) => void; maxSize: number; acceptFileTypes: MimeCheckFormat; @@ -15,7 +16,7 @@ interface DropzoneProps { } export const Dropzone = observer( - ({ warningMessage, onDrop, maxSize, acceptFileTypes, disabled }: DropzoneProps): ReactElement => { + ({ warningMessage, statusMessage, onDrop, maxSize, acceptFileTypes, disabled }: DropzoneProps): ReactElement => { const { getRootProps, getInputProps, isDragActive, isDragAccept, isDragReject } = useDropzone({ onDrop, maxSize: maxSize || undefined, @@ -24,51 +25,57 @@ export const Dropzone = observer( }); const translations = useTranslationsStore(); - const [type, msg] = getMessage( + const [type, msg, icon] = getMessage( translations, isDragActive && isDragAccept, isDragActive && isDragReject, - warningMessage + warningMessage, + statusMessage ); return ( - -
-
- {!disabled &&

{msg}

} +
+
+

+ {icon && } + {msg} +

- {!disabled && } -
- {warningMessage &&
{warningMessage}
} - + {!disabled && } +
); } ); type MessageType = "active" | "warning" | "idle"; +type IconType = "warning-icon" | null; function getMessage( translations: TranslationsStore, isDragAccept: boolean, isDragReject: boolean, - warningMessage?: string -): [MessageType, string] { + 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]; + return ["warning", warningMessage, "warning-icon"]; + } + if (statusMessage) { + return ["idle", statusMessage, null]; } - return ["idle", translations.get("dropzoneIdleMessage")]; + return ["idle", translations.get("dropzoneIdleMessage"), null]; } diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx index a7e0a1f9e4..27c41e39e5 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/FileUploaderRoot.tsx @@ -24,8 +24,9 @@ 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")) { @@ -38,6 +39,7 @@ export const FileUploaderRoot = observer((props: FileUploaderContainerProps): Re { - this.fileStatus = "removedFile"; - this._mxObject = undefined; - this.updateThumbnailUrl(); + this._rootStore.dismissFile(this); }); } catch (e: unknown) { console.error("Could not remove object:", e); diff --git a/packages/pluggableWidgets/file-uploader-web/src/ui/FileUploader.scss b/packages/pluggableWidgets/file-uploader-web/src/ui/FileUploader.scss index 2cfee0e2e6..672199a055 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/ui/FileUploader.scss +++ b/packages/pluggableWidgets/file-uploader-web/src/ui/FileUploader.scss @@ -66,6 +66,9 @@ Place your custom CSS here &.warning { border: 1.5px dashed var(--brand-warning, $file-brand-warning); background-color: var(--color-warning-lighter, $file-color-warning-lighter); + .upload-text { + color: var(--file-dropzone-color, $file-dropzone-color); + } } &.error { border: 1.5px dashed var(--brand-danger, $file-brand-danger); @@ -97,21 +100,21 @@ Place your custom CSS here line-height: 17px; font-weight: 400; color: var(--gray-light, $file-gray-light); - } - } - - .dropzone-message { - font-size: 12px; - line-height: 17px; - color: var(--file-dropzone-color, $file-dropzone-color); - padding-left: 18px; - margin-bottom: 1em; + .inline-icon { + display: inline-block; + width: 14px; + height: 14px; + background-repeat: no-repeat; + background-size: contain; + vertical-align: sub; + margin-right: 4px; - background-image: var(--file-warning-icon, $file-warning-icon); - background-repeat: no-repeat; - background-position-x: left; - background-position-y: center; + &.warning-icon { + background-image: var(--file-warning-icon, $file-warning-icon); + } + } + } } .files-list { From a258407568ad6eb66e8377e7e2392a368da949be Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Wed, 24 Jun 2026 11:27:36 +0200 Subject: [PATCH 6/7] docs(file-uploader-web): add openspec for dropzone warning and remove fixes --- .../.openspec.yaml | 2 + .../design.md | 77 +++++++++++++++++++ .../proposal.md | 34 ++++++++ .../tasks.md | 34 ++++++++ 4 files changed, 147 insertions(+) create mode 100644 openspec/changes/fix-fileuploader-dropzone-warning/.openspec.yaml create mode 100644 openspec/changes/fix-fileuploader-dropzone-warning/design.md create mode 100644 openspec/changes/fix-fileuploader-dropzone-warning/proposal.md create mode 100644 openspec/changes/fix-fileuploader-dropzone-warning/tasks.md diff --git a/openspec/changes/fix-fileuploader-dropzone-warning/.openspec.yaml b/openspec/changes/fix-fileuploader-dropzone-warning/.openspec.yaml new file mode 100644 index 0000000000..39f46ea2fa --- /dev/null +++ b/openspec/changes/fix-fileuploader-dropzone-warning/.openspec.yaml @@ -0,0 +1,2 @@ +schema: tdd-refactor +created: 2026-06-24 diff --git a/openspec/changes/fix-fileuploader-dropzone-warning/design.md b/openspec/changes/fix-fileuploader-dropzone-warning/design.md new file mode 100644 index 0000000000..a398cba26b --- /dev/null +++ b/openspec/changes/fix-fileuploader-dropzone-warning/design.md @@ -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. diff --git a/openspec/changes/fix-fileuploader-dropzone-warning/proposal.md b/openspec/changes/fix-fileuploader-dropzone-warning/proposal.md new file mode 100644 index 0000000000..9b204e3a07 --- /dev/null +++ b/openspec/changes/fix-fileuploader-dropzone-warning/proposal.md @@ -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). diff --git a/openspec/changes/fix-fileuploader-dropzone-warning/tasks.md b/openspec/changes/fix-fileuploader-dropzone-warning/tasks.md new file mode 100644 index 0000000000..d034e7e1d6 --- /dev/null +++ b/openspec/changes/fix-fileuploader-dropzone-warning/tasks.md @@ -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). From d47c1bcf55ced93ba4e501ff8b9b14bbf9d46dcd Mon Sep 17 00:00:00 2001 From: Yordan Stoyanov Date: Wed, 24 Jun 2026 14:44:05 +0200 Subject: [PATCH 7/7] docs: move openspec changes to respective widget package roots --- .../pluggableWidgets/combobox-web/openspec}/.openspec.yaml | 0 .../pluggableWidgets/combobox-web/openspec}/proposal.md | 0 .../pluggableWidgets/combobox-web/openspec}/tests.md | 0 .../pluggableWidgets/file-uploader-web/openspec}/.openspec.yaml | 0 .../pluggableWidgets/file-uploader-web/openspec}/design.md | 0 .../pluggableWidgets/file-uploader-web/openspec}/proposal.md | 0 .../pluggableWidgets/file-uploader-web/openspec}/tasks.md | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename {openspec/changes/fix-combobox-flex-collapse => packages/pluggableWidgets/combobox-web/openspec}/.openspec.yaml (100%) rename {openspec/changes/fix-combobox-flex-collapse => packages/pluggableWidgets/combobox-web/openspec}/proposal.md (100%) rename {openspec/changes/fix-combobox-flex-collapse => packages/pluggableWidgets/combobox-web/openspec}/tests.md (100%) rename {openspec/changes/fix-fileuploader-dropzone-warning => packages/pluggableWidgets/file-uploader-web/openspec}/.openspec.yaml (100%) rename {openspec/changes/fix-fileuploader-dropzone-warning => packages/pluggableWidgets/file-uploader-web/openspec}/design.md (100%) rename {openspec/changes/fix-fileuploader-dropzone-warning => packages/pluggableWidgets/file-uploader-web/openspec}/proposal.md (100%) rename {openspec/changes/fix-fileuploader-dropzone-warning => packages/pluggableWidgets/file-uploader-web/openspec}/tasks.md (100%) diff --git a/openspec/changes/fix-combobox-flex-collapse/.openspec.yaml b/packages/pluggableWidgets/combobox-web/openspec/.openspec.yaml similarity index 100% rename from openspec/changes/fix-combobox-flex-collapse/.openspec.yaml rename to packages/pluggableWidgets/combobox-web/openspec/.openspec.yaml diff --git a/openspec/changes/fix-combobox-flex-collapse/proposal.md b/packages/pluggableWidgets/combobox-web/openspec/proposal.md similarity index 100% rename from openspec/changes/fix-combobox-flex-collapse/proposal.md rename to packages/pluggableWidgets/combobox-web/openspec/proposal.md diff --git a/openspec/changes/fix-combobox-flex-collapse/tests.md b/packages/pluggableWidgets/combobox-web/openspec/tests.md similarity index 100% rename from openspec/changes/fix-combobox-flex-collapse/tests.md rename to packages/pluggableWidgets/combobox-web/openspec/tests.md diff --git a/openspec/changes/fix-fileuploader-dropzone-warning/.openspec.yaml b/packages/pluggableWidgets/file-uploader-web/openspec/.openspec.yaml similarity index 100% rename from openspec/changes/fix-fileuploader-dropzone-warning/.openspec.yaml rename to packages/pluggableWidgets/file-uploader-web/openspec/.openspec.yaml diff --git a/openspec/changes/fix-fileuploader-dropzone-warning/design.md b/packages/pluggableWidgets/file-uploader-web/openspec/design.md similarity index 100% rename from openspec/changes/fix-fileuploader-dropzone-warning/design.md rename to packages/pluggableWidgets/file-uploader-web/openspec/design.md diff --git a/openspec/changes/fix-fileuploader-dropzone-warning/proposal.md b/packages/pluggableWidgets/file-uploader-web/openspec/proposal.md similarity index 100% rename from openspec/changes/fix-fileuploader-dropzone-warning/proposal.md rename to packages/pluggableWidgets/file-uploader-web/openspec/proposal.md diff --git a/openspec/changes/fix-fileuploader-dropzone-warning/tasks.md b/packages/pluggableWidgets/file-uploader-web/openspec/tasks.md similarity index 100% rename from openspec/changes/fix-fileuploader-dropzone-warning/tasks.md rename to packages/pluggableWidgets/file-uploader-web/openspec/tasks.md