From 2a05e6d20d9d1172fda99c3dee8e3c96f53ba898 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 15:01:55 +0200 Subject: [PATCH] refactor(files): share the preview dialog frame between the PDF and Markdown previews Both previews built the same dialog head band with the file name, the open in tab pill and the download pill. FilePreviewDialog now draws it; the dialog width, the body class and the icon colour stay parameters, and each preview keeps its own body. --- .../Files/FileManagerMarkdownPreviewModal.tsx | 36 +++------- .../Files/FileManagerPdfPreviewModal.tsx | 35 +++------ .../Files/FileManagerPreviewDialog.test.tsx | 63 ++++++++++++++++ .../Files/FileManagerPreviewDialog.tsx | 72 +++++++++++++++++++ 4 files changed, 152 insertions(+), 54 deletions(-) create mode 100644 client/src/components/Files/FileManagerPreviewDialog.test.tsx create mode 100644 client/src/components/Files/FileManagerPreviewDialog.tsx 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} + + ); +}