♻️(frontend) add PDF generation inside a modal
Refacto the pad tools, we will use modals to handle the different actions on the pad. We start by implementing the PDF generation inside a modal.
This commit is contained in:
@@ -1,6 +1,4 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import cs from 'convert-stream';
|
||||
import pdf from 'pdf-parse';
|
||||
|
||||
import { createPad, keyCloakSignIn } from './common';
|
||||
|
||||
@@ -77,33 +75,6 @@ test.describe('Pad Editor', () => {
|
||||
).toHaveAttribute('href', 'http://test-markdown.html');
|
||||
});
|
||||
|
||||
test('it converts the pad to pdf with a template integrated', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes('impress-document.pdf');
|
||||
});
|
||||
|
||||
const randomPad = await createPad(page, 'pad-editor', browserName, 1);
|
||||
await expect(page.locator('h2').getByText(randomPad[0])).toBeVisible();
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
|
||||
await page.getByText('Generate PDF').first().click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe('impress-document.pdf');
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfText = (await pdf(pdfBuffer)).text;
|
||||
|
||||
expect(pdfText).toContain('Monsieur le Premier Ministre'); // This is the template text
|
||||
expect(pdfText).toContain('La directrice'); // This is the template text
|
||||
expect(pdfText).toContain('Hello World'); // This is the pad text
|
||||
});
|
||||
|
||||
test('it renders correctly when we switch from one pad to another', async ({
|
||||
page,
|
||||
browserName,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import cs from 'convert-stream';
|
||||
import pdf from 'pdf-parse';
|
||||
|
||||
import { createPad, keyCloakSignIn } from './common';
|
||||
|
||||
test.beforeEach(async ({ page, browserName }) => {
|
||||
await page.goto('/');
|
||||
await keyCloakSignIn(page, browserName);
|
||||
});
|
||||
|
||||
test.describe('Pad Tools', () => {
|
||||
test('it converts the pad to pdf with a template integrated', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes('impress-document.pdf');
|
||||
});
|
||||
|
||||
const [randomPad] = await createPad(page, 'pad-editor', browserName, 1);
|
||||
await expect(page.locator('h2').getByText(randomPad)).toBeVisible();
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Generate PDF',
|
||||
})
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Download',
|
||||
})
|
||||
.click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe('impress-document.pdf');
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfText = (await pdf(pdfBuffer)).text;
|
||||
|
||||
expect(pdfText).toContain('Monsieur le Premier Ministre'); // This is the template text
|
||||
expect(pdfText).toContain('La directrice'); // This is the template text
|
||||
expect(pdfText).toContain('Hello World'); // This is the pad text
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,6 @@ export const IconOptions = ({ isOpen, ...props }: IconOptionsProps) => {
|
||||
<Text
|
||||
aria-label={props['aria-label']}
|
||||
className="material-icons"
|
||||
$theme="primary"
|
||||
$css={`
|
||||
transition: all 0.3s ease-in-out;
|
||||
transform: rotate(${isOpen ? '90' : '0'}deg);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
Button,
|
||||
Loader,
|
||||
Modal,
|
||||
ModalSize,
|
||||
Select,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { t } from 'i18next';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { Pad, usePadStore } from '@/features/pads/pad/';
|
||||
|
||||
import { useCreatePdf } from '../api/useCreatePdf';
|
||||
import { downloadFile } from '../utils';
|
||||
|
||||
interface ModalPDFProps {
|
||||
onClose: () => void;
|
||||
templateOptions: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
pad: Pad;
|
||||
}
|
||||
|
||||
export const ModalPDF = ({ onClose, templateOptions, pad }: ModalPDFProps) => {
|
||||
const { toast } = useToastProvider();
|
||||
const { padsStore } = usePadStore();
|
||||
const {
|
||||
mutate: createPdf,
|
||||
data: pdf,
|
||||
isSuccess,
|
||||
|
||||
isPending,
|
||||
error,
|
||||
} = useCreatePdf();
|
||||
const [templateIdSelected, setTemplateIdSelected] = useState<string>(
|
||||
templateOptions?.[0].value,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast(error.message, VariantType.ERROR);
|
||||
|
||||
onClose();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [error, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pdf || !isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadFile(pdf, 'impress-document.pdf');
|
||||
|
||||
toast(t('Your pdf was downloaded succesfully'), VariantType.SUCCESS);
|
||||
|
||||
onClose();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pdf, isSuccess, t]);
|
||||
|
||||
async function onSubmit() {
|
||||
if (!templateIdSelected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = padsStore[pad.id].editor;
|
||||
|
||||
if (!editor) {
|
||||
toast(t('No editor found'), VariantType.ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await editor.blocksToHTMLLossy(editor.document);
|
||||
|
||||
createPdf({
|
||||
templateId: templateIdSelected,
|
||||
body,
|
||||
body_type: 'html',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
hideCloseButton
|
||||
leftActions={
|
||||
<Button
|
||||
aria-label={t('Close the modal')}
|
||||
color="secondary"
|
||||
fullWidth
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
}
|
||||
onClose={() => onClose()}
|
||||
rightActions={
|
||||
<Button
|
||||
aria-label={t('Download')}
|
||||
color="primary"
|
||||
fullWidth
|
||||
onClick={() => void onSubmit()}
|
||||
disabled={isPending || !templateIdSelected}
|
||||
>
|
||||
{t('Download')}
|
||||
</Button>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<Box $align="center" $gap="1rem">
|
||||
<Text className="material-icons" $size="3.5rem" $theme="primary">
|
||||
picture_as_pdf
|
||||
</Text>
|
||||
<Text as="h2" $size="h3" $margin="none" $theme="primary">
|
||||
{t('Generate PDF')}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
$margin={{ bottom: 'xl' }}
|
||||
aria-label={t('Content modal to generate a PDF')}
|
||||
>
|
||||
<Text as="p" $margin={{ bottom: 'big' }}>
|
||||
{t(
|
||||
'Generate a PDF from your document, it will be inserted in the selected template.',
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Select
|
||||
clearable={false}
|
||||
label={t('Template')}
|
||||
options={templateOptions}
|
||||
value={templateIdSelected}
|
||||
onChange={(options) =>
|
||||
setTemplateIdSelected(options.target.value as string)
|
||||
}
|
||||
/>
|
||||
|
||||
{isPending && (
|
||||
<Box $align="center" $margin={{ top: 'big' }}>
|
||||
<Loader />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,88 +0,0 @@
|
||||
import {
|
||||
Button,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Pad, usePadStore } from '@/features/pads/pad';
|
||||
|
||||
import { useCreatePdf } from '../api/useCreatePdf';
|
||||
import { Template } from '../types';
|
||||
import { downloadFile } from '../utils';
|
||||
|
||||
interface PDFButtonProps {
|
||||
pad: Pad;
|
||||
templateId: Template['id'];
|
||||
}
|
||||
|
||||
const PDFButton = ({ pad, templateId }: PDFButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const { toast } = useToastProvider();
|
||||
const { padsStore } = usePadStore();
|
||||
const {
|
||||
mutate: createPdf,
|
||||
data: pdf,
|
||||
isSuccess,
|
||||
isPending,
|
||||
error,
|
||||
} = useCreatePdf();
|
||||
|
||||
useEffect(() => {
|
||||
setIsFetching(isPending);
|
||||
}, [isPending]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast(error.message, VariantType.ERROR);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [error, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pdf || !isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadFile(pdf, 'impress-document.pdf');
|
||||
setIsFetching(false);
|
||||
|
||||
toast(t('Your pdf was downloaded succesfully'), VariantType.SUCCESS);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pdf, isSuccess, t]);
|
||||
|
||||
async function onSubmit() {
|
||||
const editor = padsStore[pad.id].editor;
|
||||
|
||||
if (!editor) {
|
||||
toast(t('No editor found'), VariantType.ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await editor.blocksToHTMLLossy(editor.document);
|
||||
|
||||
createPdf({
|
||||
templateId,
|
||||
body,
|
||||
body_type: 'html',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => void onSubmit()}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
}}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{t('Generate PDF')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default PDFButton;
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Select } from '@openfun/cunningham-react';
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { Box, DropButton, IconOptions, Text } from '@/components';
|
||||
import { Pad } from '@/features/pads/pad';
|
||||
|
||||
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
|
||||
|
||||
import PDFButton from './PDFButton';
|
||||
import { ModalPDF } from './ModalPDF';
|
||||
|
||||
interface PadToolBoxProps {
|
||||
pad: Pad;
|
||||
@@ -18,7 +18,8 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => {
|
||||
const { data: templates } = useTemplates({
|
||||
ordering: TemplatesOrdering.BY_CREATED_ON_DESC,
|
||||
});
|
||||
const [templateIdSelected, setTemplateIdSelected] = useState<string>();
|
||||
const [isModalPDFOpen, setIsModalPDFOpen] = useState(false);
|
||||
const [isDropOpen, setIsDropOpen] = useState(false);
|
||||
|
||||
const templateOptions = useMemo(() => {
|
||||
if (!templates?.pages) {
|
||||
@@ -34,32 +35,40 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => {
|
||||
)
|
||||
.flat();
|
||||
|
||||
if (templateOptions.length) {
|
||||
setTemplateIdSelected(templateOptions[0].value);
|
||||
}
|
||||
|
||||
return templateOptions;
|
||||
}, [templates?.pages]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
$margin="big"
|
||||
$align="center"
|
||||
$direction="row"
|
||||
$gap="1rem"
|
||||
$justify="flex-end"
|
||||
>
|
||||
<Select
|
||||
clearable={false}
|
||||
label={t('Template')}
|
||||
options={templateOptions}
|
||||
value={templateIdSelected}
|
||||
onChange={(options) =>
|
||||
setTemplateIdSelected(options.target.value as string)
|
||||
<Box $margin="big" $position="absolute" $css="right:1rem;">
|
||||
<DropButton
|
||||
button={
|
||||
<IconOptions
|
||||
isOpen={isDropOpen}
|
||||
aria-label={t('Open the team options')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{templateIdSelected && (
|
||||
<PDFButton pad={pad} templateId={templateIdSelected} />
|
||||
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
|
||||
isOpen={isDropOpen}
|
||||
>
|
||||
<Box>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsModalPDFOpen(true);
|
||||
setIsDropOpen(false);
|
||||
}}
|
||||
color="primary-text"
|
||||
icon={<span className="material-icons">picture_as_pdf</span>}
|
||||
>
|
||||
<Text $theme="primary">{t('Generate PDF')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</DropButton>
|
||||
{isModalPDFOpen && (
|
||||
<ModalPDF
|
||||
onClose={() => setIsModalPDFOpen(false)}
|
||||
templateOptions={templateOptions}
|
||||
pad={pad}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -16,16 +16,21 @@ export const PadEditor = ({ pad }: PadEditorProps) => {
|
||||
<>
|
||||
<Box
|
||||
$direction="row"
|
||||
className="ml-b"
|
||||
$margin={{ all: 'big', right: 'none' }}
|
||||
$align="center"
|
||||
$justify="space-between"
|
||||
$position="relative"
|
||||
>
|
||||
<Text as="h2" $align="center">
|
||||
<Text as="h2" $align="center" $margin="auto">
|
||||
{pad.title}
|
||||
</Text>
|
||||
<PadToolBox pad={pad} />
|
||||
</Box>
|
||||
<Card className="m-b p-b" $css="margin-top:0;flex:1;" $overflow="auto">
|
||||
<Card
|
||||
$margin={{ all: 'big', top: 'none' }}
|
||||
$padding="big"
|
||||
$css="flex:1;"
|
||||
$overflow="auto"
|
||||
>
|
||||
<BlockNoteEditor pad={pad} />
|
||||
</Card>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user