diff --git a/client/src/components/Files/FileManagerMarkdownPreviewModal.tsx b/client/src/components/Files/FileManagerMarkdownPreviewModal.tsx
index 672518e32..618b56d6f 100644
--- a/client/src/components/Files/FileManagerMarkdownPreviewModal.tsx
+++ b/client/src/components/Files/FileManagerMarkdownPreviewModal.tsx
@@ -1,13 +1,11 @@
-import { useEffect, useId, useState } from 'react'
-import { ExternalLink, Download, FileText } from 'lucide-react'
+import { useEffect, useState } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import rehypeSanitize from 'rehype-sanitize'
import { openFile as openFileUrl } from '../../utils/fileDownload'
import type { FileManagerState } from './useFileManager'
-import { triggerDownload } from './FileManager.helpers'
-import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT, PILL } from '../shared/DialogShell'
+import { FilePreviewDialog } from './FileManagerPreviewDialog'
/**
* Inline preview for uploaded Markdown files (#1345). Fetches the file's text via
@@ -19,7 +17,6 @@ export function MarkdownPreviewModal(S: FileManagerState) {
const { previewFile, setPreviewFile, previewFileUrl, toast, t } = S
const [text, setText] = useState('')
const [err, setErr] = useState(false)
- const labelId = useId()
const close = () => setPreviewFile(null)
const openInTab = () => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))
@@ -36,34 +33,19 @@ export function MarkdownPreviewModal(S: FileManagerState) {
}, [previewFileUrl])
return (
- }
- tint={NEUTRAL_TINT}
- labelId={labelId}
- onClose={close}
- title={previewFile.original_name}
- pills={(
- <>
-
-
- >
- )}
- />
- )}
+ iconClassName="text-content-muted"
>
{err
? {t('files.openError')}
: {text}}
-
+
)
}
diff --git a/client/src/components/Files/FileManagerPdfPreviewModal.tsx b/client/src/components/Files/FileManagerPdfPreviewModal.tsx
index 713ff055b..86f413a70 100644
--- a/client/src/components/Files/FileManagerPdfPreviewModal.tsx
+++ b/client/src/components/Files/FileManagerPdfPreviewModal.tsx
@@ -1,41 +1,22 @@
-import { useId } from 'react'
-import { ExternalLink, Download, FileText } from 'lucide-react'
import { openFile as openFileUrl } from '../../utils/fileDownload'
import type { FileManagerState } from './useFileManager'
-import { triggerDownload } from './FileManager.helpers'
-import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT, PILL } from '../shared/DialogShell'
+import { FilePreviewDialog } from './FileManagerPreviewDialog'
/** A PDF read in place, in the planner's dialog: the name on the head band, open and download beside it. */
export function PdfPreviewModal(S: FileManagerState) {
const { previewFile, setPreviewFile, previewFileUrl, toast, t } = S
- const labelId = useId()
const close = () => setPreviewFile(null)
const openInTab = () => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))
return (
- }
- tint={NEUTRAL_TINT}
- labelId={labelId}
- onClose={close}
- title={previewFile.original_name}
- pills={(
- <>
-
-
- >
- )}
- />
- )}
+ iconClassName="text-danger"
>
-
+
)
}
diff --git a/client/src/components/Files/FileManagerPreviewDialog.test.tsx b/client/src/components/Files/FileManagerPreviewDialog.test.tsx
new file mode 100644
index 000000000..1e83be5b9
--- /dev/null
+++ b/client/src/components/Files/FileManagerPreviewDialog.test.tsx
@@ -0,0 +1,63 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '../../../tests/helpers/render';
+
+const downloadFile = vi.fn(async (_url: string, _name: string) => {});
+
+vi.mock('../../utils/fileDownload', () => ({
+ openFile: vi.fn(async () => {}),
+ downloadFile: (url: string, name: string) => downloadFile(url, name),
+}));
+
+import { FilePreviewDialog } from './FileManagerPreviewDialog';
+
+const t = (key: string) => key;
+
+function renderDialog(overrides: Partial[0]> = {}) {
+ const props = {
+ name: 'plan.pdf',
+ url: '/uploads/files/plan.pdf',
+ onClose: vi.fn(),
+ onOpenInTab: vi.fn(),
+ t,
+ width: 'wide' as const,
+ bodyClassName: 'preview-body',
+ iconClassName: 'text-danger',
+ children: the body
,
+ ...overrides,
+ };
+ return { props, ...render() };
+}
+
+beforeEach(() => {
+ downloadFile.mockReset();
+ downloadFile.mockResolvedValue(undefined);
+});
+
+describe('FilePreviewDialog', () => {
+ it('names the dialog after the file and shows the body it is given', () => {
+ const { baseElement } = renderDialog();
+ const dialog = screen.getByRole('dialog');
+ const title = screen.getByText('plan.pdf');
+ expect(dialog).toHaveAttribute('aria-labelledby', title.id);
+ expect(screen.getByText('the body')).toBeInTheDocument();
+ expect(baseElement.querySelector('.preview-body')).not.toBeNull();
+ expect(baseElement.querySelector('svg.text-danger')).not.toBeNull();
+ });
+
+ it('opens the file in a new tab from the first pill', () => {
+ const { props } = renderDialog();
+ fireEvent.click(screen.getByText('files.openTab'));
+ expect(props.onOpenInTab).toHaveBeenCalledTimes(1);
+ });
+
+ it('downloads the stored url under the file name from the second pill', () => {
+ renderDialog({ name: 'notes.md', url: '/uploads/files/notes.md' });
+ fireEvent.click(screen.getByText('files.download'));
+ expect(downloadFile).toHaveBeenCalledWith('/uploads/files/notes.md', 'notes.md');
+ });
+
+ it('falls back to an English label when the key has no translation', () => {
+ renderDialog({ t: (key: string) => (key === 'files.download' ? '' : key) });
+ expect(screen.getByText('Download')).toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/Files/FileManagerPreviewDialog.tsx b/client/src/components/Files/FileManagerPreviewDialog.tsx
new file mode 100644
index 000000000..9a41ea901
--- /dev/null
+++ b/client/src/components/Files/FileManagerPreviewDialog.tsx
@@ -0,0 +1,72 @@
+import { Download, ExternalLink, FileText } from 'lucide-react';
+import type { ReactNode } from 'react';
+import { useId } from 'react';
+import type { DialogShellProps } from '../shared/DialogShell';
+import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT, PILL } from '../shared/DialogShell';
+import { triggerDownload } from './FileManager.helpers';
+
+interface FilePreviewDialogProps {
+ /** The file's name, on the head band. */
+ name: string;
+ /** The stored url the download pill fetches. */
+ url: string;
+ onClose: () => void;
+ onOpenInTab: () => void;
+ t: (key: string) => string;
+ width: DialogShellProps['width'];
+ bodyClassName: string;
+ /** The colour of the file icon on the tile. */
+ iconClassName: string;
+ children: ReactNode;
+}
+
+/**
+ * The dialog a file is read in: its name on the head band, open in a new tab
+ * and download beside it. Each preview brings its own body.
+ */
+export function FilePreviewDialog({
+ name,
+ url,
+ onClose,
+ onOpenInTab,
+ t,
+ width,
+ bodyClassName,
+ iconClassName,
+ children,
+}: FilePreviewDialogProps) {
+ const labelId = useId();
+ return (
+
+
+
+ }
+ tint={NEUTRAL_TINT}
+ labelId={labelId}
+ onClose={onClose}
+ title={name}
+ pills={
+ <>
+
+
+ >
+ }
+ />
+ }
+ >
+ {children}
+
+ );
+}