♻️(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 { expect, test } from '@playwright/test';
|
||||||
import cs from 'convert-stream';
|
|
||||||
import pdf from 'pdf-parse';
|
|
||||||
|
|
||||||
import { createPad, keyCloakSignIn } from './common';
|
import { createPad, keyCloakSignIn } from './common';
|
||||||
|
|
||||||
@@ -77,33 +75,6 @@ test.describe('Pad Editor', () => {
|
|||||||
).toHaveAttribute('href', 'http://test-markdown.html');
|
).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 ({
|
test('it renders correctly when we switch from one pad to another', async ({
|
||||||
page,
|
page,
|
||||||
browserName,
|
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
|
<Text
|
||||||
aria-label={props['aria-label']}
|
aria-label={props['aria-label']}
|
||||||
className="material-icons"
|
className="material-icons"
|
||||||
$theme="primary"
|
|
||||||
$css={`
|
$css={`
|
||||||
transition: all 0.3s ease-in-out;
|
transition: all 0.3s ease-in-out;
|
||||||
transform: rotate(${isOpen ? '90' : '0'}deg);
|
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 React, { useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Box } from '@/components';
|
import { Box, DropButton, IconOptions, Text } from '@/components';
|
||||||
import { Pad } from '@/features/pads/pad';
|
import { Pad } from '@/features/pads/pad';
|
||||||
|
|
||||||
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
|
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
|
||||||
|
|
||||||
import PDFButton from './PDFButton';
|
import { ModalPDF } from './ModalPDF';
|
||||||
|
|
||||||
interface PadToolBoxProps {
|
interface PadToolBoxProps {
|
||||||
pad: Pad;
|
pad: Pad;
|
||||||
@@ -18,7 +18,8 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => {
|
|||||||
const { data: templates } = useTemplates({
|
const { data: templates } = useTemplates({
|
||||||
ordering: TemplatesOrdering.BY_CREATED_ON_DESC,
|
ordering: TemplatesOrdering.BY_CREATED_ON_DESC,
|
||||||
});
|
});
|
||||||
const [templateIdSelected, setTemplateIdSelected] = useState<string>();
|
const [isModalPDFOpen, setIsModalPDFOpen] = useState(false);
|
||||||
|
const [isDropOpen, setIsDropOpen] = useState(false);
|
||||||
|
|
||||||
const templateOptions = useMemo(() => {
|
const templateOptions = useMemo(() => {
|
||||||
if (!templates?.pages) {
|
if (!templates?.pages) {
|
||||||
@@ -34,32 +35,40 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => {
|
|||||||
)
|
)
|
||||||
.flat();
|
.flat();
|
||||||
|
|
||||||
if (templateOptions.length) {
|
|
||||||
setTemplateIdSelected(templateOptions[0].value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return templateOptions;
|
return templateOptions;
|
||||||
}, [templates?.pages]);
|
}, [templates?.pages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box $margin="big" $position="absolute" $css="right:1rem;">
|
||||||
$margin="big"
|
<DropButton
|
||||||
$align="center"
|
button={
|
||||||
$direction="row"
|
<IconOptions
|
||||||
$gap="1rem"
|
isOpen={isDropOpen}
|
||||||
$justify="flex-end"
|
aria-label={t('Open the team options')}
|
||||||
>
|
/>
|
||||||
<Select
|
|
||||||
clearable={false}
|
|
||||||
label={t('Template')}
|
|
||||||
options={templateOptions}
|
|
||||||
value={templateIdSelected}
|
|
||||||
onChange={(options) =>
|
|
||||||
setTemplateIdSelected(options.target.value as string)
|
|
||||||
}
|
}
|
||||||
/>
|
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
|
||||||
{templateIdSelected && (
|
isOpen={isDropOpen}
|
||||||
<PDFButton pad={pad} templateId={templateIdSelected} />
|
>
|
||||||
|
<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>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,16 +16,21 @@ export const PadEditor = ({ pad }: PadEditorProps) => {
|
|||||||
<>
|
<>
|
||||||
<Box
|
<Box
|
||||||
$direction="row"
|
$direction="row"
|
||||||
className="ml-b"
|
$margin={{ all: 'big', right: 'none' }}
|
||||||
$align="center"
|
$align="center"
|
||||||
$justify="space-between"
|
$position="relative"
|
||||||
>
|
>
|
||||||
<Text as="h2" $align="center">
|
<Text as="h2" $align="center" $margin="auto">
|
||||||
{pad.title}
|
{pad.title}
|
||||||
</Text>
|
</Text>
|
||||||
<PadToolBox pad={pad} />
|
<PadToolBox pad={pad} />
|
||||||
</Box>
|
</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} />
|
<BlockNoteEditor pad={pad} />
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user