mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user