♻️(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:
Anthony LC
2024-05-23 12:34:08 +02:00
committed by Anthony LC
parent eb2936f48b
commit 996cea49b4
7 changed files with 248 additions and 147 deletions

View File

@@ -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,

View File

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

View File

@@ -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);

View File

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

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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>
</>