diff --git a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md index bb04444d0f..88b9ffff0a 100644 --- a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md +++ b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue where validation errors could not be dismissed and persisted after uploading a valid file. + ## [2.4.2] - 2026-04-23 ### Fixed diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/ActionsBar.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/ActionsBar.tsx index 0d74d7ad0d..6557d83587 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/ActionsBar.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/ActionsBar.tsx @@ -11,6 +11,10 @@ interface ButtonsBarProps { } export const ActionsBar = ({ actions, store }: ButtonsBarProps): ReactElement | null => { + if (store.fileStatus === "validationError") { + return ; + } + if (!actions) { return ; } @@ -70,6 +74,25 @@ function DefaultActionsBar(props: ButtonsBarProps): ReactElement { ); } +function DismissActionsBar({ store }: ButtonsBarProps): ReactElement { + const translations = useTranslationsStore(); + + const onDismiss = useCallback(() => { + store.dismiss(); + }, [store]); + + return ( +
+ } + title={translations.get("removeButtonTextMessage")} + action={onDismiss} + isDisabled={false} + /> +
+ ); +} + function onDownloadClick(fileUrl: string | undefined): void { if (!fileUrl) { return; diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/__tests__/DismissActionsBar.spec.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/__tests__/DismissActionsBar.spec.tsx new file mode 100644 index 0000000000..4008eb5197 --- /dev/null +++ b/packages/pluggableWidgets/file-uploader-web/src/components/__tests__/DismissActionsBar.spec.tsx @@ -0,0 +1,75 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { FileUploaderContainerProps } from "../../../typings/FileUploaderProps"; +import { FileStore } from "../../stores/FileStore"; +import { TranslationsStoreProvider } from "../../utils/useTranslationsStore"; +import { ActionsBar } from "../ActionsBar"; + +jest.mock("../../utils/mx-data", () => ({ + fetchDocumentUrl: jest.fn(), + fetchImageThumbnail: jest.fn(), + fetchMxObject: jest.fn(), + removeObject: jest.fn(), + saveFile: jest.fn(), + fileHasContents: jest.fn() +})); + +function makeFakeProps(): FileUploaderContainerProps { + const dv = (v: string): { value: string; status: string } => ({ value: v, status: "available" }); + return { + name: "fileUploader1", + uploadMode: "files", + maxFileSize: 10, + maxFilesPerUpload: { value: { toNumber: () => 5 } }, + readOnlyMode: false, + objectCreationTimeout: 30, + allowedFileFormats: "", + removeButtonTextMessage: dv("Remove"), + downloadButtonTextMessage: dv("Download"), + unavailableCreateActionMessage: dv("Unavailable"), + uploadFailureTooManyFilesMessage: dv("Too many"), + uploadFailureInvalidFileFormatMessage: dv("Invalid format"), + uploadFailureFileIsTooBigMessage: dv("Too big") + } as unknown as FileUploaderContainerProps; +} + +function makeValidationErrorStore(): { store: FileStore; dismiss: jest.Mock } { + const dismiss = jest.fn(); + const rootStore = { dismissFile: dismiss, _uploadMode: "files", isReadOnly: false } as any; + const store = FileStore.newFileWithError(new File([], "bad.txt"), "bad format", rootStore); + return { store, dismiss }; +} + +function renderWithTranslations(store: FileStore): void { + const props = makeFakeProps(); + render( + + + + ); +} + +describe("DismissActionsBar", () => { + it("renders dismiss button for validationError file", () => { + const { store } = makeValidationErrorStore(); + renderWithTranslations(store); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + it("calls store.dismiss() when button clicked", () => { + const { store } = makeValidationErrorStore(); + const dismissSpy = jest.spyOn(store, "dismiss"); + renderWithTranslations(store); + fireEvent.click(screen.getByRole("button")); + expect(dismissSpy).toHaveBeenCalledTimes(1); + }); + + it("does not render DismissActionsBar for non-validationError file", () => { + const rootStore = { dismissFile: jest.fn(), _uploadMode: "files", isReadOnly: false } as any; + const store = FileStore.newFile(new File([], "ok.txt"), rootStore); + (store as any).fileStatus = "done"; + renderWithTranslations(store); + // DefaultActionsBar renders 2 buttons (download + remove) + expect(screen.queryAllByRole("button")).toHaveLength(2); + }); +}); diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts index dc1b1e3a7f..74fddc56c3 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts @@ -60,7 +60,8 @@ export class FileStore { imagePreviewUrl: computed, upload: action, fetchMxObject: action, - markMissing: action + markMissing: action, + dismiss: action }); } @@ -76,6 +77,10 @@ export class FileStore { this.errorDescription = errorMessage; } + dismiss(): void { + this._rootStore.dismissFile(this); + } + canExecute(listAction: ListActionValue): boolean { if (!this._objectItem) { return false; @@ -123,6 +128,7 @@ export class FileStore { runInAction(() => { this.fileStatus = "done"; this._rootStore.objectCreationHelper.reportUploadSuccess(this._objectItem!); + this._rootStore.dismissValidationErrors(); }); } catch (_e: unknown) { runInAction(() => { diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts index eb44dfb68f..ca829d93db 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/FileUploaderStore.ts @@ -74,6 +74,8 @@ export class FileUploaderStore { makeObservable(this, { updateProps: action, processDrop: action, + dismissValidationErrors: action, + dismissFile: action, setMessage: action, processExistingFileItem: action, files: observable, @@ -142,6 +144,14 @@ export class FileUploaderStore { this.errorMessage = msg; } + dismissValidationErrors(): void { + this.files = this.files.filter(file => file.fileStatus !== "validationError"); + } + + dismissFile(file: FileStore): void { + this.files = this.files.filter(f => f !== file); + } + processDrop(acceptedFiles: File[], fileRejections: FileRejection[]): void { if (!this.objectCreationHelper.canCreateFiles) { console.error( @@ -158,6 +168,7 @@ export class FileUploaderStore { return; } + this.dismissValidationErrors(); this.setMessage(); for (const file of fileRejections) { diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileStore.spec.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileStore.spec.ts new file mode 100644 index 0000000000..69ce298121 --- /dev/null +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileStore.spec.ts @@ -0,0 +1,29 @@ +import { FileStore } from "../FileStore"; +import { FileUploaderStore } from "../FileUploaderStore"; + +jest.mock("../../utils/mx-data", () => ({ + fetchDocumentUrl: jest.fn(), + fetchImageThumbnail: jest.fn(), + fetchMxObject: jest.fn(), + removeObject: jest.fn(), + saveFile: jest.fn(), + fileHasContents: jest.fn() +})); + +function makeRootStore(): { dismissFile: jest.Mock } { + return { + dismissFile: jest.fn(), + _uploadMode: "files", + isReadOnly: false + } as unknown as FileUploaderStore & { dismissFile: jest.Mock }; +} + +describe("FileStore.dismiss()", () => { + it("calls dismissFile on root store with itself", () => { + const rootStore = makeRootStore(); + const store = FileStore.newFileWithError(new File([], "test.txt"), "bad format", rootStore as any); + store.dismiss(); + expect(rootStore.dismissFile).toHaveBeenCalledTimes(1); + expect(rootStore.dismissFile).toHaveBeenCalledWith(store); + }); +}); 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..48afcb408d --- /dev/null +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/__tests__/FileUploaderStore.spec.ts @@ -0,0 +1,109 @@ +import { FileStore } from "../FileStore"; +import { FileUploaderStore } from "../FileUploaderStore"; +import { TranslationsStore } from "../TranslationsStore"; + +jest.mock("../../utils/mx-data", () => ({ + fetchDocumentUrl: jest.fn(), + fetchImageThumbnail: jest.fn(), + fetchMxObject: jest.fn(), + removeObject: jest.fn(), + saveFile: jest.fn(), + fileHasContents: jest.fn() +})); + +function makeStore(): FileUploaderStore { + const translations = { get: jest.fn(() => "msg"), updateProps: jest.fn() } as unknown as TranslationsStore; + const store = Object.create(FileUploaderStore.prototype) as FileUploaderStore; + store.files = []; + return Object.assign(store, { + translations, + objectCreationHelper: { canCreateFiles: true, enable: jest.fn(), updateProps: jest.fn(), request: jest.fn() }, + updateProcessor: { processUpdate: jest.fn() }, + isReadOnly: false, + _uploadMode: "files" as const, + _maxFileSizeMiB: 10, + _maxFileSize: 10 * 1024 * 1024, + acceptedFileTypes: [], + existingItemsLoaded: false + }); +} + +function makeValidationErrorFile(store: FileUploaderStore): FileStore { + return FileStore.newFileWithError(new File([], "bad.txt"), "bad format", store); +} + +function makeDoneFile(store: FileUploaderStore): FileStore { + const f = FileStore.newFile(new File([], "good.txt"), store); + (f as any).fileStatus = "done"; + return f; +} + +describe("FileUploaderStore.dismissValidationErrors()", () => { + it("removes only validationError files", () => { + const store = makeStore(); + const validationFile = makeValidationErrorFile(store); + const doneFile = makeDoneFile(store); + store.files = [validationFile, doneFile]; + + store.dismissValidationErrors(); + + expect(store.files).toHaveLength(1); + expect(store.files[0]).toBe(doneFile); + }); + + it("leaves files untouched when none have validationError status", () => { + const store = makeStore(); + const doneFile = makeDoneFile(store); + store.files = [doneFile]; + + store.dismissValidationErrors(); + + expect(store.files).toHaveLength(1); + }); + + it("empties files when all have validationError status", () => { + const store = makeStore(); + store.files = [makeValidationErrorFile(store), makeValidationErrorFile(store)]; + + store.dismissValidationErrors(); + + expect(store.files).toHaveLength(0); + }); +}); + +describe("FileUploaderStore.dismissFile()", () => { + it("removes the specific file from the list", () => { + const store = makeStore(); + const fileA = makeValidationErrorFile(store); + const fileB = makeValidationErrorFile(store); + store.files = [fileA, fileB]; + + store.dismissFile(fileA); + + expect(store.files).toHaveLength(1); + expect(store.files[0]).toBe(fileB); + }); + + it("does not remove other files", () => { + const store = makeStore(); + const fileA = makeValidationErrorFile(store); + const fileB = makeDoneFile(store); + store.files = [fileA, fileB]; + + store.dismissFile(fileA); + + expect(store.files[0]).toBe(fileB); + }); + + it("is a no-op when file is not in the list", () => { + const store = makeStore(); + const fileA = makeValidationErrorFile(store); + const fileB = makeValidationErrorFile(store); + store.files = [fileA]; + + store.dismissFile(fileB); + + expect(store.files).toHaveLength(1); + expect(store.files[0]).toBe(fileA); + }); +}); diff --git a/packages/pluggableWidgets/file-uploader-web/src/ui/FileUploader.scss b/packages/pluggableWidgets/file-uploader-web/src/ui/FileUploader.scss index 4f9bf3c3cf..d3a0905fe8 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/ui/FileUploader.scss +++ b/packages/pluggableWidgets/file-uploader-web/src/ui/FileUploader.scss @@ -328,13 +328,13 @@ Place your custom CSS here } &.invalid { - opacity: 0.7; + .entry-details-preview, + .entry-details-main { + opacity: 0.5; + } .download-icon { visibility: hidden; } - .entry-details-actions { - display: none; - } } } }