diff --git a/packages/shared-components/src/event-tiles/FileBody/FileBody.stories.tsx b/packages/shared-components/src/event-tiles/FileBody/FileBody.stories.tsx index d715ae3945..e33ab3ea5c 100644 --- a/packages/shared-components/src/event-tiles/FileBody/FileBody.stories.tsx +++ b/packages/shared-components/src/event-tiles/FileBody/FileBody.stories.tsx @@ -5,14 +5,34 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import type { Meta, StoryObj } from "@storybook/react-vite"; +import React, { type JSX } from "react"; +import type { Meta, StoryFn } from "@storybook/react-vite"; import { fn } from "storybook/test"; -import { FileBody } from "./FileBody"; +import { FileBody, type FileBodyViewSnapshot, type FileBodyActions } from "./FileBody"; +import { useMockedViewModel } from "../../useMockedViewModel"; -const meta: Meta = { +type FileBodyProps = FileBodyViewSnapshot & FileBodyActions; + +const FileBodyWrapper = ({ + onPlaceholderClick, + onDownloadClick, + onDecryptClick, + onIframeLoad, + ...rest +}: FileBodyProps): JSX.Element => { + const vm = useMockedViewModel(rest, { + onPlaceholderClick, + onDownloadClick, + onDecryptClick, + onIframeLoad, + }); + return ; +}; + +const meta: Meta = { title: "Event Tiles/FileBody", - component: FileBody, + component: FileBodyWrapper, tags: ["autodocs"], args: { fileInfo: { @@ -23,121 +43,115 @@ const meta: Meta = { downloadLabel: "Download", showGenericPlaceholder: true, showDownloadLink: true, + isEncrypted: false, + isDecrypted: false, + forExport: false, onPlaceholderClick: fn(), onDownloadClick: fn(), + onDecryptClick: fn(), + onIframeLoad: fn(), }, }; export default meta; -type Story = StoryObj; + +const Template: StoryFn = (args) => ; /** * Default file body with placeholder and download button */ -export const Default: Story = {}; +export const Default = Template.bind({}); /** * File body without the generic placeholder */ -export const WithoutPlaceholder: Story = { - args: { - showGenericPlaceholder: false, - }, +export const WithoutPlaceholder = Template.bind({}); +WithoutPlaceholder.args = { + showGenericPlaceholder: false, }; /** * File body without download link */ -export const WithoutDownloadLink: Story = { - args: { - showDownloadLink: false, - }, +export const WithoutDownloadLink = Template.bind({}); +WithoutDownloadLink.args = { + showDownloadLink: false, }; /** * Encrypted file that hasn't been decrypted yet - shows decrypt button */ -export const EncryptedNotDecrypted: Story = { - args: { - isEncrypted: true, - isDecrypted: false, - onDecryptClick: fn(), - }, +export const EncryptedNotDecrypted = Template.bind({}); +EncryptedNotDecrypted.args = { + isEncrypted: true, + isDecrypted: false, }; /** * Encrypted file that has been decrypted - shows iframe for download */ -export const EncryptedDecrypted: Story = { - args: { - isEncrypted: true, - isDecrypted: true, - iframeSrc: "usercontent/", - onIframeLoad: fn(), - }, +export const EncryptedDecrypted = Template.bind({}); +EncryptedDecrypted.args = { + isEncrypted: true, + isDecrypted: true, + iframeSrc: "usercontent/", }; /** * File body in export mode with a direct link */ -export const ExportMode: Story = { - args: { - forExport: true, - exportUrl: "mxc://server/file123", - }, +export const ExportMode = Template.bind({}); +ExportMode.args = { + forExport: true, + exportUrl: "mxc://server/file123", }; /** * File body with an error message */ -export const WithError: Story = { - args: { - error: "Invalid file", - }, +export const WithError = Template.bind({}); +WithError.args = { + error: "Invalid file", }; /** * Large file name that will be truncated */ -export const LongFilename: Story = { - args: { - fileInfo: { - filename: "This is a very long filename that should be truncated when displayed.pdf", - tooltip: "This is a very long filename that should be truncated when displayed.pdf", - mimeType: "application/pdf", - }, +export const LongFilename = Template.bind({}); +LongFilename.args = { + fileInfo: { + filename: "This is a very long filename that should be truncated when displayed.pdf", + tooltip: "This is a very long filename that should be truncated when displayed.pdf", + mimeType: "application/pdf", }, }; /** * Different file types */ -export const ImageFile: Story = { - args: { - fileInfo: { - filename: "photo.jpg", - tooltip: "photo.jpg (2.3 MB)", - mimeType: "image/jpeg", - }, +export const ImageFile = Template.bind({}); +ImageFile.args = { + fileInfo: { + filename: "photo.jpg", + tooltip: "photo.jpg (2.3 MB)", + mimeType: "image/jpeg", }, }; -export const VideoFile: Story = { - args: { - fileInfo: { - filename: "video.mp4", - tooltip: "video.mp4 (45 MB)", - mimeType: "video/mp4", - }, +export const VideoFile = Template.bind({}); +VideoFile.args = { + fileInfo: { + filename: "video.mp4", + tooltip: "video.mp4 (45 MB)", + mimeType: "video/mp4", }, }; -export const AudioFile: Story = { - args: { - fileInfo: { - filename: "song.mp3", - tooltip: "song.mp3 (5.2 MB)", - mimeType: "audio/mpeg", - }, +export const AudioFile = Template.bind({}); +AudioFile.args = { + fileInfo: { + filename: "song.mp3", + tooltip: "song.mp3 (5.2 MB)", + mimeType: "audio/mpeg", }, }; diff --git a/packages/shared-components/src/event-tiles/FileBody/FileBody.test.tsx b/packages/shared-components/src/event-tiles/FileBody/FileBody.test.tsx index cbe5ed9f60..ea81faa515 100644 --- a/packages/shared-components/src/event-tiles/FileBody/FileBody.test.tsx +++ b/packages/shared-components/src/event-tiles/FileBody/FileBody.test.tsx @@ -9,7 +9,8 @@ import React, { createRef } from "react"; import { render } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; -import { FileBody, type FileBodyProps } from "./FileBody"; +import { FileBody, type FileBodyViewSnapshot, type FileBodyActions } from "./FileBody"; +import { MockViewModel } from "../../viewmodel/MockViewModel"; describe("FileBody", () => { const defaultFileInfo = { @@ -18,18 +19,25 @@ describe("FileBody", () => { mimeType: "application/pdf", }; - const defaultProps: FileBodyProps = { + const defaultSnapshot: FileBodyViewSnapshot = { fileInfo: defaultFileInfo, downloadLabel: "Download", showGenericPlaceholder: true, showDownloadLink: true, + isEncrypted: false, + isDecrypted: false, + forExport: false, }; + function createViewModel(snapshot: FileBodyViewSnapshot, actions: FileBodyActions = {}) { + const vm = new MockViewModel(snapshot); + return Object.assign(vm, actions); + } + it("renders with placeholder and download button for unencrypted file", () => { const onDownloadClick = jest.fn(); - const { container } = render( - , - ); + const vm = createViewModel(defaultSnapshot, { onDownloadClick }); + const { container } = render(); expect(container.textContent).toContain("test-file.pdf"); expect(container.querySelector(".mx_MFileBody_download")).toBeTruthy(); @@ -37,9 +45,8 @@ describe("FileBody", () => { }); it("renders without placeholder when showGenericPlaceholder is false", () => { - const { container } = render( - , - ); + const vm = createViewModel({ ...defaultSnapshot, showGenericPlaceholder: false }); + const { container } = render(); expect(container.querySelector(".mx_MFileBody_info")).toBeFalsy(); expect(container.querySelector(".mx_MFileBody_download")).toBeTruthy(); @@ -47,9 +54,8 @@ describe("FileBody", () => { }); it("renders without download link when showDownloadLink is false", () => { - const { container } = render( - , - ); + const vm = createViewModel({ ...defaultSnapshot, showDownloadLink: false }); + const { container } = render(); expect(container.textContent).toContain("test-file.pdf"); expect(container.querySelector(".mx_MFileBody_download")).toBeFalsy(); @@ -59,7 +65,8 @@ describe("FileBody", () => { it("calls onPlaceholderClick when placeholder is clicked", async () => { const user = userEvent.setup(); const onPlaceholderClick = jest.fn(); - const { container } = render(); + const vm = createViewModel(defaultSnapshot, { onPlaceholderClick }); + const { container } = render(); const placeholder = container.querySelector(".mx_MFileBody_info"); await user.click(placeholder!); @@ -69,7 +76,8 @@ describe("FileBody", () => { it("calls onDownloadClick when download button is clicked for unencrypted file", async () => { const user = userEvent.setup(); const onDownloadClick = jest.fn((e: React.MouseEvent) => e.preventDefault()); - const { container } = render(); + const vm = createViewModel(defaultSnapshot, { onDownloadClick }); + const { container } = render(); const downloadLink = container.querySelector(".mx_MFileBody_download a"); await user.click(downloadLink!); @@ -78,14 +86,15 @@ describe("FileBody", () => { it("renders decrypt button for encrypted file that hasn't been decrypted", () => { const onDecryptClick = jest.fn(); - const { container } = render( - , + const vm = createViewModel( + { + ...defaultSnapshot, + isEncrypted: true, + isDecrypted: false, + }, + { onDecryptClick }, ); + const { container } = render(); expect(container.querySelector(".mx_MFileBody_download button")).toBeTruthy(); expect(container).toMatchSnapshot(); @@ -94,14 +103,15 @@ describe("FileBody", () => { it("calls onDecryptClick when decrypt button is clicked", async () => { const user = userEvent.setup(); const onDecryptClick = jest.fn(); - const { container } = render( - , + const vm = createViewModel( + { + ...defaultSnapshot, + isEncrypted: true, + isDecrypted: false, + }, + { onDecryptClick }, ); + const { container } = render(); const downloadBtn = container.querySelector(".mx_MFileBody_download button"); await user.click(downloadBtn!); @@ -112,17 +122,18 @@ describe("FileBody", () => { const iframeRef = createRef(); const dummyLinkRef = createRef(); const onIframeLoad = jest.fn(); - const { container } = render( - , + const vm = createViewModel( + { + ...defaultSnapshot, + isEncrypted: true, + isDecrypted: true, + iframeSrc: "usercontent/", + iframeRef, + dummyLinkRef, + }, + { onIframeLoad }, ); + const { container } = render(); const iframe = container.querySelector("iframe"); expect(iframe).toBeTruthy(); @@ -132,13 +143,12 @@ describe("FileBody", () => { }); it("renders export mode with link", () => { - const { container } = render( - , - ); + const vm = createViewModel({ + ...defaultSnapshot, + forExport: true, + exportUrl: "mxc://server/file", + }); + const { container } = render(); const link = container.querySelector("a"); expect(link?.getAttribute("href")).toBe("mxc://server/file"); @@ -147,12 +157,11 @@ describe("FileBody", () => { }); it("renders error message", () => { - const { container } = render( - , - ); + const vm = createViewModel({ + ...defaultSnapshot, + error: "Invalid file", + }); + const { container } = render(); expect(container.textContent).toContain("test-file.pdf"); expect(container.textContent).toContain("Invalid file"); @@ -161,17 +170,21 @@ describe("FileBody", () => { }); it("applies custom className", () => { - const { container } = render( - , - ); + const vm = createViewModel({ + ...defaultSnapshot, + className: "custom-class", + }); + const { container } = render(); expect(container.querySelector(".custom-class")).toBeTruthy(); }); it("shows tooltip on filename", () => { - const { container } = render( - , - ); + const vm = createViewModel({ + ...defaultSnapshot, + fileInfo: { ...defaultFileInfo, tooltip: "Full filename with path" }, + }); + const { container } = render(); const filenameElement = container.querySelector(".mx_MFileBody_info_filename"); expect(filenameElement?.getAttribute("title")).toBe("Full filename with path"); diff --git a/packages/shared-components/src/event-tiles/FileBody/FileBody.tsx b/packages/shared-components/src/event-tiles/FileBody/FileBody.tsx index 6e66af9737..679c9ac485 100644 --- a/packages/shared-components/src/event-tiles/FileBody/FileBody.tsx +++ b/packages/shared-components/src/event-tiles/FileBody/FileBody.tsx @@ -5,12 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React from "react"; +import React, { type JSX } from "react"; import { Button } from "@vector-im/compound-web"; import { DownloadIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import classNames from "classnames"; import styles from "./FileBody.module.css"; +import { type ViewModel } from "../../viewmodel/ViewModel"; +import { useViewModel } from "../../useViewModel"; export interface FileInfo { /** The filename to display */ @@ -21,33 +23,28 @@ export interface FileInfo { mimeType?: string; } -export interface FileBodyProps { +/** + * Snapshot of the FileBody view state + */ +export interface FileBodyViewSnapshot { /** Information about the file to display */ fileInfo: FileInfo; /** The text to display on the download button */ downloadLabel: string; /** Whether to show the generic file placeholder */ - showGenericPlaceholder?: boolean; + showGenericPlaceholder: boolean; /** Whether to show the download link */ - showDownloadLink?: boolean; + showDownloadLink: boolean; /** Whether the file is encrypted */ - isEncrypted?: boolean; + isEncrypted: boolean; /** Whether an encrypted file has been decrypted */ - isDecrypted?: boolean; + isDecrypted: boolean; /** Whether this is for export mode */ - forExport?: boolean; + forExport: boolean; /** The URL for export mode links */ exportUrl?: string; /** Error message to display instead of file content */ error?: string; - /** Called when the placeholder is clicked */ - onPlaceholderClick?: () => void; - /** Called when the download button is clicked (for unencrypted files) */ - onDownloadClick?: (e: React.MouseEvent) => void; - /** Called when the decrypt button is clicked */ - onDecryptClick?: (e: React.MouseEvent) => void; - /** Called when iframe loads (for encrypted, decrypted files) */ - onIframeLoad?: () => void; /** The iframe src URL for encrypted downloads */ iframeSrc?: string; /** Ref for the iframe element */ @@ -58,19 +55,56 @@ export interface FileBodyProps { className?: string; } +/** + * Actions that can be performed on the FileBody + */ +export interface FileBodyActions { + /** Called when the placeholder is clicked */ + onPlaceholderClick?: () => void; + /** Called when the download button is clicked (for unencrypted files) */ + onDownloadClick?: (e: React.MouseEvent) => void; + /** Called when the decrypt button is clicked */ + onDecryptClick?: (e: React.MouseEvent) => void; + /** Called when iframe loads (for encrypted, decrypted files) */ + onIframeLoad?: () => void; +} + +/** + * ViewModel type for FileBody component + */ +export type FileBodyViewModel = ViewModel & FileBodyActions; + +export interface FileBodyProps { + vm: FileBodyViewModel; +} + /** * FileBody is a presentational component for displaying file attachments. * It handles the UI for encrypted/unencrypted files, download buttons, and placeholders. */ -export class FileBody extends React.Component { - private renderPlaceholder(): React.ReactNode { - const { fileInfo, onPlaceholderClick } = this.props; +export function FileBody({ vm }: FileBodyProps): JSX.Element { + const { + fileInfo, + downloadLabel, + showGenericPlaceholder, + showDownloadLink, + isEncrypted, + isDecrypted, + forExport, + exportUrl, + error, + iframeSrc, + iframeRef, + dummyLinkRef, + className, + } = useViewModel(vm); + const renderPlaceholder = (): React.ReactNode => { return ( ); - } - - private renderDownloadButton(): React.ReactNode { - const { downloadLabel, isEncrypted, isDecrypted, onDecryptClick, onDownloadClick } = this.props; + }; + const renderDownloadButton = (): React.ReactNode => { // For encrypted files that haven't been decrypted yet if (isEncrypted && !isDecrypted) { return (
-
@@ -99,7 +131,6 @@ export class FileBody extends React.Component { // For encrypted files that have been decrypted (with iframe) if (isEncrypted && isDecrypted) { - const { iframeSrc, onIframeLoad, iframeRef, dummyLinkRef, fileInfo } = this.props; return (
@@ -110,7 +141,7 @@ export class FileBody extends React.Component { aria-hidden title={fileInfo.filename} src={iframeSrc} - onLoad={onIframeLoad} + onLoad={vm.onIframeLoad} ref={iframeRef} sandbox="allow-scripts allow-downloads" /> @@ -121,50 +152,39 @@ export class FileBody extends React.Component { // For unencrypted files return (
-
); - } + }; - public render(): React.ReactNode { - const { - showGenericPlaceholder = true, - showDownloadLink = true, - forExport, - exportUrl, - error, - className, - } = this.props; + const placeholder = showGenericPlaceholder ? renderPlaceholder() : null; - const placeholder = showGenericPlaceholder ? this.renderPlaceholder() : null; - - // Export mode - if (forExport && exportUrl) { - return ( - - {placeholder} - - ); - } - - // Error state - if (error) { - return ( - - {placeholder} - {error} - - ); - } - - // Normal display + // Export mode + if (forExport && exportUrl) { return ( - {placeholder} - {showDownloadLink && this.renderDownloadButton()} + {placeholder} ); } + + // Error state + if (error) { + return ( + + {placeholder} + {error} + + ); + } + + // Normal display + return ( + + {placeholder} + {showDownloadLink && renderDownloadButton()} + + ); } diff --git a/packages/shared-components/src/event-tiles/FileBody/__snapshots__/FileBody.test.tsx.snap b/packages/shared-components/src/event-tiles/FileBody/__snapshots__/FileBody.test.tsx.snap index 36f74227ba..dee95066b4 100644 --- a/packages/shared-components/src/event-tiles/FileBody/__snapshots__/FileBody.test.tsx.snap +++ b/packages/shared-components/src/event-tiles/FileBody/__snapshots__/FileBody.test.tsx.snap @@ -3,7 +3,7 @@ exports[`FileBody renders decrypt button for encrypted file that hasn't been decrypted 1`] = `
- Invalid file + + Invalid file +
`; @@ -75,7 +77,7 @@ exports[`FileBody renders error message 1`] = ` exports[`FileBody renders export mode with link 1`] = `