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.
This commit is contained in:
Maurice
2026-10-09 15:01:55 +02:00
parent 76b4b96e16
commit 2a05e6d20d
4 changed files with 152 additions and 54 deletions
@@ -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 (
<DialogShell
<FilePreviewDialog
name={previewFile.original_name}
url={previewFile.url}
onClose={close}
labelledBy={labelId}
onOpenInTab={openInTab}
t={t}
width="editor"
bodyClassName="collab-note-md min-h-0 flex-1 overflow-y-auto px-7 py-6 leading-relaxed text-content [word-break:break-word]"
header={(
<DialogHeader
tile={<DialogTile><FileText size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={close}
title={previewFile.original_name}
pills={(
<>
<button type="button" onClick={openInTab} className={`${PILL} hover:opacity-80`}>
<ExternalLink size={13} strokeWidth={2.2} /> {t('files.openTab')}
</button>
<button type="button" onClick={() => triggerDownload(previewFile.url, previewFile.original_name)} className={`${PILL} hover:opacity-80`}>
<Download size={13} strokeWidth={2.2} /> {t('files.download') || 'Download'}
</button>
</>
)}
/>
)}
iconClassName="text-content-muted"
>
{err
? <p className="text-content-muted">{t('files.openError')}</p>
: <Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={[rehypeSanitize]}>{text}</Markdown>}
</DialogShell>
</FilePreviewDialog>
)
}
@@ -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 (
<DialogShell
<FilePreviewDialog
name={previewFile.original_name}
url={previewFile.url}
onClose={close}
labelledBy={labelId}
onOpenInTab={openInTab}
t={t}
width="wide"
bodyClassName="flex min-h-0 flex-1 flex-col"
header={(
<DialogHeader
tile={<DialogTile><FileText size={20} strokeWidth={1.9} className="text-danger" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={close}
title={previewFile.original_name}
pills={(
<>
<button type="button" onClick={openInTab} className={`${PILL} hover:opacity-80`}>
<ExternalLink size={13} strokeWidth={2.2} /> {t('files.openTab')}
</button>
<button type="button" onClick={() => triggerDownload(previewFile.url, previewFile.original_name)} className={`${PILL} hover:opacity-80`}>
<Download size={13} strokeWidth={2.2} /> {t('files.download') || 'Download'}
</button>
</>
)}
/>
)}
iconClassName="text-danger"
>
<object
data={previewFileUrl ? `${previewFileUrl}#view=FitH` : undefined}
@@ -47,6 +28,6 @@ export function PdfPreviewModal(S: FileManagerState) {
<button type="button" onClick={openInTab} className="text-content underline">{t('files.downloadPdf')}</button>
</p>
</object>
</DialogShell>
</FilePreviewDialog>
)
}
@@ -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<Parameters<typeof FilePreviewDialog>[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: <p>the body</p>,
...overrides,
};
return { props, ...render(<FilePreviewDialog {...props} />) };
}
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();
});
});
@@ -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 (
<DialogShell
onClose={onClose}
labelledBy={labelId}
width={width}
bodyClassName={bodyClassName}
header={
<DialogHeader
tile={
<DialogTile>
<FileText size={20} strokeWidth={1.9} className={iconClassName} />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={name}
pills={
<>
<button type="button" onClick={onOpenInTab} className={`${PILL} hover:opacity-80`}>
<ExternalLink size={13} strokeWidth={2.2} /> {t('files.openTab')}
</button>
<button type="button" onClick={() => triggerDownload(url, name)} className={`${PILL} hover:opacity-80`}>
<Download size={13} strokeWidth={2.2} /> {t('files.download') || 'Download'}
</button>
</>
}
/>
}
>
{children}
</DialogShell>
);
}