Update filebody to use ViewModel

This commit is contained in:
David Langley 2025-11-04 21:01:54 +00:00
parent cc73ef94b9
commit ac57fec03b
5 changed files with 246 additions and 190 deletions

View File

@ -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<typeof FileBody> = {
type FileBodyProps = FileBodyViewSnapshot & FileBodyActions;
const FileBodyWrapper = ({
onPlaceholderClick,
onDownloadClick,
onDecryptClick,
onIframeLoad,
...rest
}: FileBodyProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
onPlaceholderClick,
onDownloadClick,
onDecryptClick,
onIframeLoad,
});
return <FileBody vm={vm} />;
};
const meta: Meta<typeof FileBodyWrapper> = {
title: "Event Tiles/FileBody",
component: FileBody,
component: FileBodyWrapper,
tags: ["autodocs"],
args: {
fileInfo: {
@ -23,121 +43,115 @@ const meta: Meta<typeof FileBody> = {
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<typeof FileBody>;
const Template: StoryFn<typeof FileBodyWrapper> = (args) => <FileBodyWrapper {...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",
},
};

View File

@ -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(
<FileBody {...defaultProps} onDownloadClick={onDownloadClick} />,
);
const vm = createViewModel(defaultSnapshot, { onDownloadClick });
const { container } = render(<FileBody vm={vm} />);
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(
<FileBody {...defaultProps} showGenericPlaceholder={false} />,
);
const vm = createViewModel({ ...defaultSnapshot, showGenericPlaceholder: false });
const { container } = render(<FileBody vm={vm} />);
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(
<FileBody {...defaultProps} showDownloadLink={false} />,
);
const vm = createViewModel({ ...defaultSnapshot, showDownloadLink: false });
const { container } = render(<FileBody vm={vm} />);
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(<FileBody {...defaultProps} onPlaceholderClick={onPlaceholderClick} />);
const vm = createViewModel(defaultSnapshot, { onPlaceholderClick });
const { container } = render(<FileBody vm={vm} />);
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(<FileBody {...defaultProps} onDownloadClick={onDownloadClick} />);
const vm = createViewModel(defaultSnapshot, { onDownloadClick });
const { container } = render(<FileBody vm={vm} />);
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(
<FileBody
{...defaultProps}
isEncrypted={true}
isDecrypted={false}
onDecryptClick={onDecryptClick}
/>,
const vm = createViewModel(
{
...defaultSnapshot,
isEncrypted: true,
isDecrypted: false,
},
{ onDecryptClick },
);
const { container } = render(<FileBody vm={vm} />);
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(
<FileBody
{...defaultProps}
isEncrypted={true}
isDecrypted={false}
onDecryptClick={onDecryptClick}
/>,
const vm = createViewModel(
{
...defaultSnapshot,
isEncrypted: true,
isDecrypted: false,
},
{ onDecryptClick },
);
const { container } = render(<FileBody vm={vm} />);
const downloadBtn = container.querySelector(".mx_MFileBody_download button");
await user.click(downloadBtn!);
@ -112,17 +122,18 @@ describe("FileBody", () => {
const iframeRef = createRef<HTMLIFrameElement>();
const dummyLinkRef = createRef<HTMLAnchorElement>();
const onIframeLoad = jest.fn();
const { container } = render(
<FileBody
{...defaultProps}
isEncrypted={true}
isDecrypted={true}
iframeSrc="usercontent/"
iframeRef={iframeRef}
dummyLinkRef={dummyLinkRef}
onIframeLoad={onIframeLoad}
/>,
const vm = createViewModel(
{
...defaultSnapshot,
isEncrypted: true,
isDecrypted: true,
iframeSrc: "usercontent/",
iframeRef,
dummyLinkRef,
},
{ onIframeLoad },
);
const { container } = render(<FileBody vm={vm} />);
const iframe = container.querySelector("iframe");
expect(iframe).toBeTruthy();
@ -132,13 +143,12 @@ describe("FileBody", () => {
});
it("renders export mode with link", () => {
const { container } = render(
<FileBody
{...defaultProps}
forExport={true}
exportUrl="mxc://server/file"
/>,
);
const vm = createViewModel({
...defaultSnapshot,
forExport: true,
exportUrl: "mxc://server/file",
});
const { container } = render(<FileBody vm={vm} />);
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(
<FileBody
{...defaultProps}
error="Invalid file"
/>,
);
const vm = createViewModel({
...defaultSnapshot,
error: "Invalid file",
});
const { container } = render(<FileBody vm={vm} />);
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(
<FileBody {...defaultProps} className="custom-class" />,
);
const vm = createViewModel({
...defaultSnapshot,
className: "custom-class",
});
const { container } = render(<FileBody vm={vm} />);
expect(container.querySelector(".custom-class")).toBeTruthy();
});
it("shows tooltip on filename", () => {
const { container } = render(
<FileBody {...defaultProps} fileInfo={{ ...defaultFileInfo, tooltip: "Full filename with path" }} />,
);
const vm = createViewModel({
...defaultSnapshot,
fileInfo: { ...defaultFileInfo, tooltip: "Full filename with path" },
});
const { container } = render(<FileBody vm={vm} />);
const filenameElement = container.querySelector(".mx_MFileBody_info_filename");
expect(filenameElement?.getAttribute("title")).toBe("Full filename with path");

View File

@ -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<FileBodyViewSnapshot> & 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<FileBodyProps> {
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 (
<button
type="button"
className={classNames("mx_MediaBody", styles.info, "mx_MFileBody_info")}
onClick={onPlaceholderClick}
onClick={vm.onPlaceholderClick}
>
<span className={classNames(styles.infoIcon, "mx_MFileBody_info_icon")} />
<span
@ -81,16 +115,14 @@ export class FileBody extends React.Component<FileBodyProps> {
</span>
</button>
);
}
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 (
<div className={classNames(styles.download, "mx_MFileBody_download")}>
<Button size="sm" kind="secondary" Icon={DownloadIcon} onClick={onDecryptClick}>
<Button size="sm" kind="secondary" Icon={DownloadIcon} onClick={vm.onDecryptClick}>
{downloadLabel}
</Button>
</div>
@ -99,7 +131,6 @@ export class FileBody extends React.Component<FileBodyProps> {
// For encrypted files that have been decrypted (with iframe)
if (isEncrypted && isDecrypted) {
const { iframeSrc, onIframeLoad, iframeRef, dummyLinkRef, fileInfo } = this.props;
return (
<div className={classNames(styles.download, "mx_MFileBody_download")}>
<div aria-hidden style={{ display: "none" }}>
@ -110,7 +141,7 @@ export class FileBody extends React.Component<FileBodyProps> {
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<FileBodyProps> {
// For unencrypted files
return (
<div className={classNames(styles.download, "mx_MFileBody_download")}>
<Button size="sm" kind="secondary" Icon={DownloadIcon} as="a" onClick={onDownloadClick}>
<Button size="sm" kind="secondary" Icon={DownloadIcon} as="a" onClick={vm.onDownloadClick}>
{downloadLabel}
</Button>
</div>
);
}
};
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 (
<span className={classNames(styles.root, "mx_MFileBody", className)}>
<a href={exportUrl}>{placeholder}</a>
</span>
);
}
// Error state
if (error) {
return (
<span className={classNames(styles.root, "mx_MFileBody", className)}>
{placeholder}
<span>{error}</span>
</span>
);
}
// Normal display
// Export mode
if (forExport && exportUrl) {
return (
<span className={classNames(styles.root, "mx_MFileBody", className)}>
{placeholder}
{showDownloadLink && this.renderDownloadButton()}
<a href={exportUrl}>{placeholder}</a>
</span>
);
}
// Error state
if (error) {
return (
<span className={classNames(styles.root, "mx_MFileBody", className)}>
{placeholder}
<span>{error}</span>
</span>
);
}
// Normal display
return (
<span className={classNames(styles.root, "mx_MFileBody", className)}>
{placeholder}
{showDownloadLink && renderDownloadButton()}
</span>
);
}

View File

@ -3,7 +3,7 @@
exports[`FileBody renders decrypt button for encrypted file that hasn't been decrypted 1`] = `
<div>
<span
class="mx_MFileBody"
class="root mx_MFileBody"
>
<button
class="mx_MediaBody info mx_MFileBody_info"
@ -51,7 +51,7 @@ exports[`FileBody renders decrypt button for encrypted file that hasn't been dec
exports[`FileBody renders error message 1`] = `
<div>
<span
class="mx_MFileBody"
class="root mx_MFileBody"
>
<button
class="mx_MediaBody info mx_MFileBody_info"
@ -67,7 +67,9 @@ exports[`FileBody renders error message 1`] = `
test-file.pdf
</span>
</button>
Invalid file
<span>
Invalid file
</span>
</span>
</div>
`;
@ -75,7 +77,7 @@ exports[`FileBody renders error message 1`] = `
exports[`FileBody renders export mode with link 1`] = `
<div>
<span
class="mx_MFileBody"
class="root mx_MFileBody"
>
<a
href="mxc://server/file"
@ -102,7 +104,7 @@ exports[`FileBody renders export mode with link 1`] = `
exports[`FileBody renders iframe for encrypted file that has been decrypted 1`] = `
<div>
<span
class="mx_MFileBody"
class="root mx_MFileBody"
>
<button
class="mx_MediaBody info mx_MFileBody_info"
@ -160,7 +162,7 @@ exports[`FileBody renders iframe for encrypted file that has been decrypted 1`]
exports[`FileBody renders with placeholder and download button for unencrypted file 1`] = `
<div>
<span
class="mx_MFileBody"
class="root mx_MFileBody"
>
<button
class="mx_MediaBody info mx_MFileBody_info"
@ -208,7 +210,7 @@ exports[`FileBody renders with placeholder and download button for unencrypted f
exports[`FileBody renders without download link when showDownloadLink is false 1`] = `
<div>
<span
class="mx_MFileBody"
class="root mx_MFileBody"
>
<button
class="mx_MediaBody info mx_MFileBody_info"
@ -231,7 +233,7 @@ exports[`FileBody renders without download link when showDownloadLink is false 1
exports[`FileBody renders without placeholder when showGenericPlaceholder is false 1`] = `
<div>
<span
class="mx_MFileBody"
class="root mx_MFileBody"
>
<div
class="download mx_MFileBody_download"

View File

@ -5,4 +5,11 @@ 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.
*/
export { FileBody, type FileBodyProps, type FileInfo } from "./FileBody";
export {
FileBody,
type FileBodyProps,
type FileInfo,
type FileBodyViewSnapshot,
type FileBodyActions,
type FileBodyViewModel,
} from "./FileBody";