mirror of
https://github.com/mattermost/mattermost.git
synced 2026-09-19 10:12:47 +08:00
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 <cursoragent@cursor.com> * Document draft thumbnail preview handler for CodeRabbit/doc checks. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix ESLint import/order in FilePreview connector and tests. Co-authored-by: Cursor <cursoragent@cursor.com> * Add tests for archived and deleted draft attachment preview guards. Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+16
-8
@@ -8,14 +8,16 @@ exports[`FilePreview should match snapshot 1`] = `
|
||||
<div
|
||||
class="file-preview post-image__column"
|
||||
>
|
||||
<div
|
||||
<a
|
||||
aria-label="file thumbnail test_filename"
|
||||
class="post-image__thumbnail"
|
||||
href="#"
|
||||
>
|
||||
<div
|
||||
class="post-image normal"
|
||||
style="background-image: url(\\"/api/v4/files/file_id_1/thumbnail\\"); background-size: cover;"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
<div
|
||||
class="post-image__details"
|
||||
>
|
||||
@@ -123,14 +125,16 @@ exports[`FilePreview should match snapshot when props are changed 1`] = `
|
||||
<div
|
||||
class="file-preview post-image__column"
|
||||
>
|
||||
<div
|
||||
<a
|
||||
aria-label="file thumbnail test_filename"
|
||||
class="post-image__thumbnail"
|
||||
href="#"
|
||||
>
|
||||
<div
|
||||
class="post-image normal"
|
||||
style="background-image: url(\\"/api/v4/files/file_id_1/thumbnail\\"); background-size: cover;"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
<div
|
||||
class="post-image__details"
|
||||
>
|
||||
@@ -238,14 +242,16 @@ exports[`FilePreview should match snapshot when props are changed 2`] = `
|
||||
<div
|
||||
class="file-preview post-image__column"
|
||||
>
|
||||
<div
|
||||
<a
|
||||
aria-label="file thumbnail test_filename"
|
||||
class="post-image__thumbnail"
|
||||
href="#"
|
||||
>
|
||||
<div
|
||||
class="post-image normal"
|
||||
style="background-image: url(\\"/api/v4/files/file_id_1/thumbnail\\"); background-size: cover;"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
<div
|
||||
class="post-image__details"
|
||||
>
|
||||
@@ -286,14 +292,16 @@ exports[`FilePreview should match snapshot when props are changed 2`] = `
|
||||
<div
|
||||
class="file-preview post-image__column"
|
||||
>
|
||||
<div
|
||||
<a
|
||||
aria-label="file thumbnail file_two.jpg"
|
||||
class="post-image__thumbnail"
|
||||
href="#"
|
||||
>
|
||||
<div
|
||||
class="post-image normal"
|
||||
style="background-image: url(\\"/api/v4/files/file_id_2/thumbnail\\"); background-size: cover;"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
<div
|
||||
class="post-image__details"
|
||||
>
|
||||
|
||||
@@ -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(
|
||||
<FilePreview {...baseProps}/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<FilePreview
|
||||
{...baseProps}
|
||||
fileInfos={pdfFileInfos}
|
||||
uploadsInProgress={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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<HTMLElement>;
|
||||
|
||||
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(
|
||||
<FilePreview
|
||||
{...baseProps}
|
||||
fileInfos={[{...fileInfos[0], archived: true}]}
|
||||
uploadsInProgress={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('.post-image__thumbnail')).toBeTruthy();
|
||||
expect(container.querySelector('a.post-image__thumbnail')).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<FilePreview
|
||||
{...baseProps}
|
||||
fileInfos={[{...fileInfos[0], delete_at: 1}]}
|
||||
uploadsInProgress={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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,
|
||||
|
||||
@@ -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: <P>(modalData: ModalData<P>) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export default class FilePreview extends React.PureComponent<Props> {
|
||||
@@ -45,14 +52,46 @@ export default class FilePreview extends React.PureComponent<Props> {
|
||||
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<HTMLElement>, 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 = (
|
||||
<img
|
||||
@@ -93,12 +132,34 @@ export default class FilePreview extends React.PureComponent<Props> {
|
||||
className += ' compact';
|
||||
}
|
||||
|
||||
const thumbnailLabel = `${Utils.localizeMessage({id: 'file_attachment.thumbnail', defaultMessage: 'file thumbnail'})} ${info.name}`.toLowerCase();
|
||||
|
||||
let thumbnailWrap: ReactNode;
|
||||
if (canOpenPreviewModal) {
|
||||
thumbnailWrap = (
|
||||
<a
|
||||
aria-label={thumbnailLabel}
|
||||
className='post-image__thumbnail'
|
||||
href='#'
|
||||
onClick={(e) => this.handleThumbnailPreviewClick(e, index)}
|
||||
>
|
||||
{previewImage}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
thumbnailWrap = (
|
||||
<div className='post-image__thumbnail'>
|
||||
{previewImage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
previews.push(
|
||||
<div
|
||||
key={info.id}
|
||||
className={className}
|
||||
>
|
||||
<div className='post-image__thumbnail'>{previewImage}</div>
|
||||
{thumbnailWrap}
|
||||
<div className='post-image__details'>
|
||||
<div className='post-image__detail_wrapper'>
|
||||
<div
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import type {Dispatch} from 'redux';
|
||||
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
|
||||
import {openModal} from 'actions/views/modals';
|
||||
import {isCompactMode} from 'selectors/preferences';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
@@ -21,4 +24,12 @@ function mapStateToProps(state: GlobalState) {
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(FilePreview);
|
||||
function mapDispatchToProps(dispatch: Dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
openModal,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(FilePreview);
|
||||
|
||||
Reference in New Issue
Block a user