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" > {t('files.downloadPdf')}

-
+ ) } 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} + + ); +}