diff --git a/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md b/packages/pluggableWidgets/file-uploader-web/CHANGELOG.md index fb30292f10..bf405e92f8 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 renaming a file through a custom dialog did not update its name in the file list. + ## [2.5.1] - 2026-10-13 ### Fixed diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/FileEntry.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/FileEntry.tsx index 0acff37673..7f4a087d97 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/FileEntry.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/FileEntry.tsx @@ -1,6 +1,6 @@ import classNames from "classnames"; import { observer } from "mobx-react-lite"; -import { KeyboardEvent, MouseEvent, ReactElement, ReactNode, useCallback } from "react"; +import { KeyboardEvent, MouseEvent, ReactElement, ReactNode, useCallback, useEffect } from "react"; import { ActionsBar } from "./ActionsBar"; import { FileIcon } from "./FileIcon"; import { ProgressBar } from "./ProgressBar"; @@ -8,6 +8,7 @@ import { UploadInfo } from "./UploadInfo"; import { FileUploaderContainerProps } from "../../typings/FileUploaderProps"; import { FileStatus, FileStore } from "../stores/FileStore"; import { fileSize } from "../utils/fileSize"; +import { subscribeToFileName } from "../utils/mx-data"; interface FileEntryContainerProps { store: FileStore; @@ -15,6 +16,18 @@ interface FileEntryContainerProps { } export const FileEntryContainer = observer(({ store, actions }: FileEntryContainerProps): ReactElement | null => { + const objectItemId = store.objectItemId; + + useEffect(() => { + if (!objectItemId) { + return; + } + + return subscribeToFileName(objectItemId, () => { + store.fetchMxObject().catch(error => console.error("Could not refresh file name:", error)); + }); + }, [store, objectItemId]); + const defaultAction = actions?.find(a => { return a.buttonIsDefault; }); diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/__tests__/FileEntryRename.spec.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/__tests__/FileEntryRename.spec.tsx new file mode 100644 index 0000000000..3ec2060d5d --- /dev/null +++ b/packages/pluggableWidgets/file-uploader-web/src/components/__tests__/FileEntryRename.spec.tsx @@ -0,0 +1,113 @@ +import "@testing-library/jest-dom"; +import { Big } from "big.js"; +import { ObjectItem } from "mendix"; +import { act, render, waitFor } from "@testing-library/react"; +import { dynamic } from "@mendix/widget-plugin-test-utils"; +import { FileUploaderContainerProps } from "../../../typings/FileUploaderProps"; +import { FileStore } from "../../stores/FileStore"; +import { FileUploaderStore } from "../../stores/FileUploaderStore"; +import { TranslationsStoreProvider } from "../../utils/useTranslationsStore"; +import { FileEntryContainer } from "../FileEntry"; + +jest.mock("../../utils/useRootStore", () => ({ + useRootStore: () => ({ maxTotalFiles: 5, isReadOnly: false }) +})); + +// Mendix may update an object in place rather than return a new instance. +class FileObject { + name = "original.txt"; + + get2(attribute: string): string | Big { + return attribute === "Name" ? this.name : new Big(10); + } +} + +describe("FileEntryContainer external rename", () => { + const item = { id: "file-1" } as ObjectItem; + let object: FileObject; + let subscriptions: Map void }>; + let unsubscribe: jest.Mock; + + beforeEach(() => { + object = new FileObject(); + subscriptions = new Map(); + let nextHandle = 0; + unsubscribe = jest.fn(handle => subscriptions.delete(handle)); + (window as any).mx = { + data: { + get: jest.fn(({ callback }) => callback(object)), + saveDocument: jest.fn((_id, _name, _meta, _file, resolve) => resolve()), + subscribe: jest.fn(args => { + const handle = nextHandle++; + subscriptions.set(handle, args); + return handle; + }), + unsubscribe + } + }; + }); + + afterEach(() => { + (window as any).mx = undefined; + }); + + it.each(["existing", "uploaded"])( + "updates an %s file's visible name and cleans up its subscription", + async type => { + const rootStore = { + uploadMode: "files", + isReadOnly: false, + announce: jest.fn(), + objectCreationHelper: { + request: jest.fn().mockResolvedValue(item), + reportUploadSuccess: jest.fn() + } + } as unknown as FileUploaderStore; + const store = + type === "existing" + ? FileStore.existingFile(item, rootStore) + : FileStore.newFile(new File(["content"], "original.txt"), rootStore); + const props = { + removeButtonTextMessage: dynamic.available("Remove"), + downloadButtonTextMessage: dynamic.available("Download"), + uploadQueuedMessage: dynamic.available("Waiting"), + uploadInProgressMessage: dynamic.available("Uploading"), + uploadSuccessMessage: dynamic.available("Uploaded") + } as FileUploaderContainerProps; + const { container, unmount } = render( + + + + ); + + if (type === "uploaded") { + await act(async () => store.upload()); + } + const name = container.querySelector(".entry-details-main-name")!; + await waitFor(() => expect(name).toHaveTextContent("original.txt")); + await waitFor(() => expect(subscriptions.size).toBe(1)); + + const subscription = [...subscriptions.values()][0]; + expect(subscription).toMatchObject({ guid: item.id, attr: "Name" }); + await act(async () => { + object.name = "renamed.pdf"; + subscription.callback(); + }); + + await waitFor(() => expect(name).toHaveTextContent("renamed.pdf")); + expect(container.querySelector(".file-entry")).toHaveAttribute("title", "renamed.pdf"); + expect(store.mimeType).toBe("application/pdf"); + + await act(async () => { + object.name = "original.txt"; + subscription.callback(); + }); + await waitFor(() => expect(name).toHaveTextContent("original.txt")); + expect(subscriptions.size).toBe(1); + + unmount(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(subscriptions.size).toBe(0); + } + ); +}); diff --git a/packages/pluggableWidgets/file-uploader-web/src/components/__tests__/FileUploaderRoot.spec.tsx b/packages/pluggableWidgets/file-uploader-web/src/components/__tests__/FileUploaderRoot.spec.tsx index ade6ac25bc..e7a7b1adea 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/components/__tests__/FileUploaderRoot.spec.tsx +++ b/packages/pluggableWidgets/file-uploader-web/src/components/__tests__/FileUploaderRoot.spec.tsx @@ -13,6 +13,7 @@ jest.mock("../../utils/mx-data", () => ({ fetchDocumentUrl: jest.fn(), fetchImageThumbnail: jest.fn(), fetchMxObject: jest.fn(), + subscribeToFileName: jest.fn(() => jest.fn()), removeObject: jest.fn(), saveFile: jest.fn(), fileHasContents: jest.fn() diff --git a/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts b/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts index a453640764..dfc89b0e70 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/stores/FileStore.ts @@ -15,7 +15,14 @@ import { } from "../utils/mx-data"; export type FileStatus = - "existingFile" | "missing" | "queued" | "uploading" | "done" | "uploadingError" | "validationError" | "rejected"; + | "existingFile" + | "missing" + | "queued" + | "uploading" + | "done" + | "uploadingError" + | "validationError" + | "rejected"; let fileKey = 0; @@ -29,6 +36,7 @@ export class FileStore { private _file?: File = undefined; private _objectItem?: ObjectItem = undefined; private _mxObject?: MxObject = undefined; + private _title?: string = undefined; private _thumbnailUrl?: string = undefined; private _documentUrl?: string = undefined; private _thumbnailFailed: boolean = false; @@ -45,9 +53,10 @@ export class FileStore { this._rootStore = rootStore; this.fileStatus = type; - makeObservable(this, { + makeObservable(this, { fileStatus: observable, _mxObject: observable, + _title: observable, errorDescription: observable, _thumbnailUrl: observable, _documentUrl: observable, @@ -76,6 +85,7 @@ export class FileStore { } this._mxObject = undefined; + this._title = undefined; this._objectItem = undefined; } @@ -159,11 +169,7 @@ export class FileStore { } get title(): string { - if (this._mxObject) { - return this._mxObject.get2("Name")?.toString(); - } - - return this._file?.name ?? "..."; + return this._title ?? this._file?.name ?? "..."; } get size(): number { @@ -218,6 +224,8 @@ export class FileStore { const obj = await fetchMxObject(this._objectItem!); runInAction(() => { this._mxObject = obj; + // Mendix can update the same object instance without notifying MobX. + this._title = obj?.get2("Name")?.toString(); this.updateThumbnailUrl(); }); } diff --git a/packages/pluggableWidgets/file-uploader-web/src/utils/mx-data.ts b/packages/pluggableWidgets/file-uploader-web/src/utils/mx-data.ts index 9b4530cf7b..cf1bbbc45b 100644 --- a/packages/pluggableWidgets/file-uploader-web/src/utils/mx-data.ts +++ b/packages/pluggableWidgets/file-uploader-web/src/utils/mx-data.ts @@ -39,6 +39,11 @@ export function fetchMxObject(objectItem: ObjectItem): Promise { }); } +export function subscribeToFileName(guid: string, callback: () => void): () => void { + const handle = (window as any).mx.data.subscribe({ guid, attr: "Name", callback }); + return () => (window as any).mx.data.unsubscribe(handle); +} + export async function fetchDocumentUrl(mxObject: MxObject): Promise { return (window as any).mx.data.getDocumentUrl( mxObject.getGuid(),