From eeb3c1ec04560f449fb0aa791f2ca097e3ac18fb Mon Sep 17 00:00:00 2001 From: Asaad Mahmood Date: Thu, 21 May 2026 18:12:25 +0500 Subject: [PATCH] MM-67237 - Open file preview modal when clicking draft attachment thumbnails. (#36590) * Open file preview modal when clicking draft attachment thumbnails. Co-authored-by: Cursor * Document draft thumbnail preview handler for CodeRabbit/doc checks. Co-authored-by: Cursor * Fix ESLint import/order in FilePreview connector and tests. Co-authored-by: Cursor * Add tests for archived and deleted draft attachment preview guards. Co-authored-by: Cursor * Enable draft attachment preview for all file types. Draft thumbnails were only clickable for images and SVGs; other attachments now open the standard file preview modal like post attachments do. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .../__snapshots__/file_preview.test.tsx.snap | 24 ++-- .../file_preview/file_preview.test.tsx | 132 +++++++++++++++++- .../components/file_preview/file_preview.tsx | 67 ++++++++- .../src/components/file_preview/index.ts | 13 +- 4 files changed, 223 insertions(+), 13 deletions(-) diff --git a/webapp/channels/src/components/file_preview/__snapshots__/file_preview.test.tsx.snap b/webapp/channels/src/components/file_preview/__snapshots__/file_preview.test.tsx.snap index 7a6a4f7afd5..0de716f5133 100644 --- a/webapp/channels/src/components/file_preview/__snapshots__/file_preview.test.tsx.snap +++ b/webapp/channels/src/components/file_preview/__snapshots__/file_preview.test.tsx.snap @@ -8,14 +8,16 @@ exports[`FilePreview should match snapshot 1`] = `
-
-
+
@@ -123,14 +125,16 @@ exports[`FilePreview should match snapshot when props are changed 1`] = `
-
-
+
@@ -238,14 +242,16 @@ exports[`FilePreview should match snapshot when props are changed 2`] = `
-
-
+
@@ -286,14 +292,16 @@ exports[`FilePreview should match snapshot when props are changed 2`] = `
-
-
+
diff --git a/webapp/channels/src/components/file_preview/file_preview.test.tsx b/webapp/channels/src/components/file_preview/file_preview.test.tsx index 1c2b7f3edf8..f55871ce188 100644 --- a/webapp/channels/src/components/file_preview/file_preview.test.tsx +++ b/webapp/channels/src/components/file_preview/file_preview.test.tsx @@ -5,12 +5,16 @@ import React from 'react'; import {getFileUrl} from 'mattermost-redux/utils/file_utils'; +import FilePreviewModal from 'components/file_preview_modal'; + import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; +import {ModalIdentifiers} from 'utils/constants'; import FilePreview from './file_preview'; describe('FilePreview', () => { const onRemove = jest.fn(); + const openModal = jest.fn(); const fileInfos = [ { width: 100, @@ -20,7 +24,7 @@ describe('FilePreview', () => { type: 'image/png', extension: 'png', has_preview_image: true, - user_id: '', + user_id: 'user_id_1', channel_id: 'channel_id', create_at: 0, update_at: 0, @@ -60,6 +64,9 @@ describe('FilePreview', () => { uploadsInProgress, onRemove, uploadsProgressPercent, + actions: { + openModal, + }, }; test('should match snapshot', () => { @@ -109,6 +116,129 @@ describe('FilePreview', () => { expect(newOnRemove).toHaveBeenCalled(); }); + test('should call openModal when image thumbnail is clicked', async () => { + openModal.mockClear(); + renderWithContext( + , + ); + + const user = userEvent.setup(); + const thumb = screen.getByLabelText(/file thumbnail.*test_filename/i); + await user.click(thumb); + + expect(openModal).toHaveBeenCalledTimes(1); + expect(openModal).toHaveBeenCalledWith({ + modalId: ModalIdentifiers.FILE_PREVIEW_MODAL, + dialogType: FilePreviewModal, + dialogProps: { + post: {user_id: 'user_id_1', channel_id: 'channel_id'}, + fileInfos, + startIndex: 0, + }, + }); + }); + + test('should call openModal when non-image file thumbnail is clicked', async () => { + const pdfFileInfos = [{ + ...fileInfos[0], + id: 'file_id_pdf', + name: 'document.pdf', + type: 'application/pdf', + extension: 'pdf', + width: 0, + height: 0, + has_preview_image: false, + }]; + openModal.mockClear(); + renderWithContext( + , + ); + + const user = userEvent.setup(); + const thumb = screen.getByLabelText(/file thumbnail.*document\.pdf/i); + await user.click(thumb); + + expect(openModal).toHaveBeenCalledTimes(1); + expect(openModal).toHaveBeenCalledWith({ + modalId: ModalIdentifiers.FILE_PREVIEW_MODAL, + dialogType: FilePreviewModal, + dialogProps: { + post: {user_id: 'user_id_1', channel_id: 'channel_id'}, + fileInfos: pdfFileInfos, + startIndex: 0, + }, + }); + }); + + /** Direct handler coverage: thumbnails for archived/deleted files are non-links, but guards must stay aligned. */ + const thumbnailClickMouseEvent = () => + ({ + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + blur: jest.fn(), + target: document.createElement('a'), + }) as unknown as React.MouseEvent; + + test('should not open preview modal via handler when attachment is archived', () => { + const openModalFn = jest.fn(); + const archivedInfos = [{...fileInfos[0], archived: true}]; + const instance = new FilePreview({ + enableSVGs: false, + fileInfos: archivedInfos, + uploadsInProgress: [], + uploadsProgressPercent: {}, + actions: {openModal: openModalFn}, + }); + + instance.handleThumbnailPreviewClick(thumbnailClickMouseEvent(), 0); + + expect(openModalFn).not.toHaveBeenCalled(); + }); + + test('should not open preview modal via handler when attachment has delete_at set', () => { + const openModalFn = jest.fn(); + const deletedInfos = [{...fileInfos[0], delete_at: 999}]; + const instance = new FilePreview({ + enableSVGs: false, + fileInfos: deletedInfos, + uploadsInProgress: [], + uploadsProgressPercent: {}, + actions: {openModal: openModalFn}, + }); + + instance.handleThumbnailPreviewClick(thumbnailClickMouseEvent(), 0); + + expect(openModalFn).not.toHaveBeenCalled(); + }); + + test('should render non-interactive thumbnail wrapper when attachment is archived or deleted', () => { + const {container, rerender} = renderWithContext( + , + ); + + expect(container.querySelector('.post-image__thumbnail')).toBeTruthy(); + expect(container.querySelector('a.post-image__thumbnail')).not.toBeInTheDocument(); + + rerender( + , + ); + + expect(container.querySelector('a.post-image__thumbnail')).not.toBeInTheDocument(); + expect(screen.queryAllByRole('link', {name: /file thumbnail/i})).toHaveLength(0); + }); + test('should not render an SVG when SVGs are disabled', () => { const props = { ...baseProps, diff --git a/webapp/channels/src/components/file_preview/file_preview.tsx b/webapp/channels/src/components/file_preview/file_preview.tsx index 43833fdca70..8f745fabbed 100644 --- a/webapp/channels/src/components/file_preview/file_preview.tsx +++ b/webapp/channels/src/components/file_preview/file_preview.tsx @@ -7,14 +7,18 @@ import type {ReactNode} from 'react'; import {WithTooltip} from '@mattermost/shared/components/tooltip'; import type {FileInfo} from '@mattermost/types/files'; +import type {Post} from '@mattermost/types/posts'; import {getFileThumbnailUrl, getFileUrl} from 'mattermost-redux/utils/file_utils'; import FilenameOverlay from 'components/file_attachment/filename_overlay'; +import FilePreviewModal from 'components/file_preview_modal'; -import Constants, {FileTypes} from 'utils/constants'; +import Constants, {FileTypes, ModalIdentifiers} from 'utils/constants'; import * as Utils from 'utils/utils'; +import type {ModalData} from 'types/actions'; + import FileProgressPreview from './file_progress_preview'; type UploadInfo = { @@ -32,6 +36,9 @@ type Props = { uploadsProgressPercent?: {[clientID: string]: FilePreviewInfo}; compactMode?: boolean; disabledRemoveTooltip?: string; + actions: { + openModal:

(modalData: ModalData

) => void; + }; }; export default class FilePreview extends React.PureComponent { @@ -45,14 +52,46 @@ export default class FilePreview extends React.PureComponent { this.props.onRemove?.(id); }; + /** + * Opens the standard file preview modal for a draft attachment. + * + * @param e - Mouse event from the thumbnail link (default prevented; does not bubble). + * @param startIndex - Index of the clicked file in {@link Props.fileInfos} for modal navigation. + */ + handleThumbnailPreviewClick = (e: React.MouseEvent, startIndex: number) => { + e.preventDefault(); + e.stopPropagation(); + + const fileInfo = this.props.fileInfos[startIndex]; + if (!fileInfo || fileInfo.archived || fileInfo.delete_at > 0) { + return; + } + + if ('blur' in e.target) { + (e.target as HTMLElement).blur(); + } + + this.props.actions.openModal({ + modalId: ModalIdentifiers.FILE_PREVIEW_MODAL, + dialogType: FilePreviewModal, + dialogProps: { + post: {user_id: fileInfo.user_id, channel_id: fileInfo.channel_id} as Post, + fileInfos: this.props.fileInfos, + startIndex, + }, + }); + }; + render() { const previews: ReactNode[] = []; - this.props.fileInfos.forEach((info) => { + this.props.fileInfos.forEach((info, index) => { const type = Utils.getFileType(info.extension); let className = 'file-preview post-image__column'; let previewImage; + const canOpenPreviewModal = !info.archived && info.delete_at === 0; + if (type === FileTypes.SVG && this.props.enableSVGs) { previewImage = ( { className += ' compact'; } + const thumbnailLabel = `${Utils.localizeMessage({id: 'file_attachment.thumbnail', defaultMessage: 'file thumbnail'})} ${info.name}`.toLowerCase(); + + let thumbnailWrap: ReactNode; + if (canOpenPreviewModal) { + thumbnailWrap = ( + this.handleThumbnailPreviewClick(e, index)} + > + {previewImage} + + ); + } else { + thumbnailWrap = ( +

+ {previewImage} +
+ ); + } + previews.push(
-
{previewImage}
+ {thumbnailWrap}