diff --git a/CHANGELOG.md b/CHANGELOG.md index b31dad64..086b03c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to ### Added +- ✨(frontend) add import document area in docs grid #1567 - ✨(backend) add documents/all endpoint with descendants #1553 - ✅(export) add PDF regression tests #1762 - 📝(docs) Add language configuration documentation #1757 diff --git a/src/frontend/apps/e2e/__tests__/app-impress/assets/test_import.docx b/src/frontend/apps/e2e/__tests__/app-impress/assets/test_import.docx new file mode 100644 index 00000000..8db66a06 Binary files /dev/null and b/src/frontend/apps/e2e/__tests__/app-impress/assets/test_import.docx differ diff --git a/src/frontend/apps/e2e/__tests__/app-impress/assets/test_import.md b/src/frontend/apps/e2e/__tests__/app-impress/assets/test_import.md new file mode 100644 index 00000000..461c52de --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/assets/test_import.md @@ -0,0 +1,60 @@ +![473389927-e4ff1794-69f3-460a-85f8-fec993cd74d6.png](http://localhost:3000/assets/logo-suite-numerique.png)![497094770-53e5f8e2-c93e-4a0b-a82f-cd184fd03f51.svg](http://localhost:3000/assets/icon-docs.svg) + +# Lorem Ipsum import Document + +## Introduction + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt, nisl nisl aliquam nisl, eget ultricies nisl nisl eget nisl. + +### Subsection 1.1 + +* **Bold text**: Lorem ipsum dolor sit amet. + +* *Italic text*: Consectetur adipiscing elit. + +* ~~Strikethrough text~~: Nullam auctor, nisl eget ultricies tincidunt. + +1. First item in an ordered list. + +2. Second item in an ordered list. + + * Indented bullet point. + + * Another indented bullet point. + +3. Third item in an ordered list. + +### Subsection 1.2 + +**Code block:** + +```js +const hello_world = () => { + console.log("Hello, world!"); +} +``` + +**Blockquote:** + +> Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt. + +**Horizontal rule:** + +*** + +**Table:** + +| Syntax | Description | +| --------- | ----------- | +| Header | Title | +| Paragraph | Text | + +**Inline code:** + +Use the `printf()` function. + +**Link:** [Example](http://localhost:3000/) + +## Conclusion + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt, nisl nisl aliquam nisl, eget ultricies nisl nisl eget nisl. diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-import.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-import.spec.ts new file mode 100644 index 00000000..7e0ec5a5 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-import.spec.ts @@ -0,0 +1,172 @@ +import { readFileSync } from 'fs'; +import path from 'path'; + +import { Page, expect, test } from '@playwright/test'; + +import { getEditor } from './utils-editor'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe('Doc Import', () => { + test('it imports 2 docs with the import icon', async ({ page }) => { + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByLabel('Open the upload dialog').click(); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(path.join(__dirname, 'assets/test_import.docx')); + await fileChooser.setFiles(path.join(__dirname, 'assets/test_import.md')); + + await expect( + page.getByText( + 'The document "test_import.docx" has been successfully imported', + ), + ).toBeVisible(); + await expect( + page.getByText( + 'The document "test_import.md" has been successfully imported', + ), + ).toBeVisible(); + + const docsGrid = page.getByTestId('docs-grid'); + await expect(docsGrid.getByText('test_import.docx').first()).toBeVisible(); + await expect(docsGrid.getByText('test_import.md').first()).toBeVisible(); + + // Check content of imported md + await docsGrid.getByText('test_import.md').first().click(); + const editor = await getEditor({ page }); + + const contentCheck = async (isMDCheck = false) => { + await expect( + editor.getByRole('heading', { + name: 'Lorem Ipsum import Document', + level: 1, + }), + ).toBeVisible(); + await expect( + editor.getByRole('heading', { + name: 'Introduction', + level: 2, + }), + ).toBeVisible(); + await expect( + editor.getByRole('heading', { + name: 'Subsection 1.1', + level: 3, + }), + ).toBeVisible(); + await expect( + editor + .locator('div[data-content-type="bulletListItem"] strong') + .getByText('Bold text'), + ).toBeVisible(); + await expect( + editor + .locator('div[data-content-type="codeBlock"]') + .getByText('hello_world'), + ).toBeVisible(); + await expect( + editor + .locator('div[data-content-type="table"] td') + .getByText('Paragraph'), + ).toBeVisible(); + await expect( + editor.locator('a[href="http://localhost:3000/"]').getByText('Example'), + ).toBeVisible(); + + /* eslint-disable playwright/no-conditional-expect */ + if (isMDCheck) { + await expect( + editor.locator( + 'img[src="http://localhost:3000/assets/logo-suite-numerique.png"]', + ), + ).toBeVisible(); + await expect( + editor.locator( + 'img[src="http://localhost:3000/assets/icon-docs.svg"]', + ), + ).toBeVisible(); + } else { + await expect(editor.locator('img')).toHaveCount(2); + } + /* eslint-enable playwright/no-conditional-expect */ + + await expect( + editor.locator('div[data-content-type="divider"] hr'), + ).toBeVisible(); + }; + + await contentCheck(); + + // Check content of imported docx + await page.getByLabel('Back to homepage').first().click(); + await docsGrid.getByText('test_import.docx').first().click(); + + await contentCheck(); + }); + + test('it imports 2 docs with the drag and drop area', async ({ page }) => { + const docsGrid = page.getByTestId('docs-grid'); + await expect(docsGrid).toBeVisible(); + + await dragAndDropFiles(page, "[data-testid='docs-grid']", [ + { + filePath: path.join(__dirname, 'assets/test_import.docx'), + fileName: 'test_import.docx', + fileType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }, + { + filePath: path.join(__dirname, 'assets/test_import.md'), + fileName: 'test_import.md', + fileType: 'text/markdown', + }, + ]); + + // Wait for success messages + await expect( + page.getByText( + 'The document "test_import.docx" has been successfully imported', + ), + ).toBeVisible(); + await expect( + page.getByText( + 'The document "test_import.md" has been successfully imported', + ), + ).toBeVisible(); + + await expect(docsGrid.getByText('test_import.docx').first()).toBeVisible(); + await expect(docsGrid.getByText('test_import.md').first()).toBeVisible(); + }); +}); + +const dragAndDropFiles = async ( + page: Page, + selector: string, + files: Array<{ filePath: string; fileName: string; fileType?: string }>, +) => { + const filesData = files.map((file) => ({ + bufferData: `data:application/octet-stream;base64,${readFileSync(file.filePath).toString('base64')}`, + fileName: file.fileName, + fileType: file.fileType || '', + })); + + const dataTransfer = await page.evaluateHandle(async (filesInfo) => { + const dt = new DataTransfer(); + + for (const fileInfo of filesInfo) { + const blobData = await fetch(fileInfo.bufferData).then((res) => + res.blob(), + ); + const file = new File([blobData], fileInfo.fileName, { + type: fileInfo.fileType, + }); + dt.items.add(file); + } + + return dt; + }, filesData); + + await page.dispatchEvent(selector, 'drop', { dataTransfer }); +}; diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index d08d7999..79146223 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -62,6 +62,7 @@ "react": "*", "react-aria-components": "1.13.0", "react-dom": "*", + "react-dropzone": "14.3.8", "react-i18next": "16.5.0", "react-intersection-observer": "10.0.0", "react-resizable-panels": "3.0.6", diff --git a/src/frontend/apps/impress/src/api/helpers.tsx b/src/frontend/apps/impress/src/api/helpers.tsx index cbc4d0b3..991c5176 100644 --- a/src/frontend/apps/impress/src/api/helpers.tsx +++ b/src/frontend/apps/impress/src/api/helpers.tsx @@ -20,7 +20,7 @@ export type DefinedInitialDataInfiniteOptionsAPI< QueryKey, TPageParam >; - +export type UseInfiniteQueryResultAPI = InfiniteData; export type InfiniteQueryConfig = Omit< DefinedInitialDataInfiniteOptionsAPI, 'queryKey' | 'initialData' | 'getNextPageParam' | 'initialPageParam' diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/api/useImportDoc.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/api/useImportDoc.tsx new file mode 100644 index 00000000..f48cb4d4 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/api/useImportDoc.tsx @@ -0,0 +1,125 @@ +import { VariantType, useToastProvider } from '@openfun/cunningham-react'; +import { + UseMutationOptions, + useMutation, + useQueryClient, +} from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import { + APIError, + UseInfiniteQueryResultAPI, + errorCauses, + fetchAPI, +} from '@/api'; +import { Doc, DocsResponse, KEY_LIST_DOC } from '@/docs/doc-management'; + +enum ContentTypes { + Docx = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + Markdown = 'text/markdown', + OctetStream = 'application/octet-stream', +} + +export enum ContentTypesAllowed { + Docx = ContentTypes.Docx, + Markdown = ContentTypes.Markdown, +} + +const getMimeType = (file: File): string => { + if (file.type) { + return file.type; + } + + const extension = file.name.split('.').pop()?.toLowerCase(); + + switch (extension) { + case 'md': + return ContentTypes.Markdown; + case 'markdown': + return ContentTypes.Markdown; + case 'docx': + return ContentTypes.Docx; + default: + return ContentTypes.OctetStream; + } +}; + +export const importDoc = async (file: File): Promise => { + const form = new FormData(); + + form.append( + 'file', + new File([file], file.name, { + type: getMimeType(file), + lastModified: file.lastModified, + }), + ); + + const response = await fetchAPI(`documents/`, { + method: 'POST', + body: form, + withoutContentType: true, + }); + + if (!response.ok) { + throw new APIError('Failed to import the doc', await errorCauses(response)); + } + + return response.json() as Promise; +}; + +type UseImportDocOptions = UseMutationOptions; + +export function useImportDoc(props?: UseImportDocOptions) { + const { toast } = useToastProvider(); + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: importDoc, + ...props, + onSuccess: (...successProps) => { + queryClient.setQueriesData>( + { queryKey: [KEY_LIST_DOC] }, + (oldData) => { + if (!oldData || oldData?.pages.length === 0) { + return oldData; + } + + return { + ...oldData, + pages: oldData.pages.map((page, index) => { + // Add the new doc to the first page only + if (index === 0) { + return { + ...page, + results: [successProps[0], ...page.results], + }; + } + return page; + }), + }; + }, + ); + + toast( + t('The document "{{documentName}}" has been successfully imported', { + documentName: successProps?.[0].title || '', + }), + VariantType.SUCCESS, + ); + + props?.onSuccess?.(...successProps); + }, + onError: (...errorProps) => { + toast( + t(`The document "{{documentName}}" import has failed`, { + documentName: errorProps?.[1].name || '', + }), + VariantType.ERROR, + ); + + props?.onError?.(...errorProps); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx index be01d1d6..e2329e5a 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx @@ -1,5 +1,10 @@ -import { Button } from '@openfun/cunningham-react'; -import { useMemo } from 'react'; +import { + Button, + VariantType, + useToastProvider, +} from '@openfun/cunningham-react'; +import { useMemo, useState } from 'react'; +import { useDropzone } from 'react-dropzone'; import { useTranslation } from 'react-i18next'; import { InView } from 'react-intersection-observer'; import { css } from 'styled-components'; @@ -10,6 +15,7 @@ import { DocDefaultFilter, useInfiniteDocs } from '@/docs/doc-management'; import { useResponsiveStore } from '@/stores'; import { useInfiniteDocsTrashbin } from '../api'; +import { ContentTypesAllowed, useImportDoc } from '../api/useImportDoc'; import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid'; import { @@ -25,6 +31,44 @@ export const DocsGrid = ({ target = DocDefaultFilter.ALL_DOCS, }: DocsGridProps) => { const { t } = useTranslation(); + const [isDragOver, setIsDragOver] = useState(false); + const { toast } = useToastProvider(); + const { getRootProps, getInputProps, open } = useDropzone({ + accept: { + [ContentTypesAllowed.Docx]: ['.docx'], + [ContentTypesAllowed.Markdown]: ['.md'], + }, + onDrop(acceptedFiles) { + setIsDragOver(false); + for (const file of acceptedFiles) { + importDoc(file); + } + }, + onDragEnter: () => { + setIsDragOver(true); + }, + onDragLeave: () => { + setIsDragOver(false); + }, + onDropRejected(fileRejections) { + toast( + t( + `The document "{{documentName}}" import has failed (only .docx and .md files are allowed)`, + { + documentName: fileRejections?.[0].file.name || '', + }, + ), + VariantType.ERROR, + ); + }, + noClick: true, + }); + const { mutate: importDoc } = useImportDoc(); + + const withUpload = + !target || + target === DocDefaultFilter.ALL_DOCS || + target === DocDefaultFilter.MY_DOCS; const { isDesktop } = useResponsiveStore(); const { flexLeft, flexRight } = useResponsiveDocGrid(); @@ -77,12 +121,24 @@ export const DocsGrid = ({ $width="100%" $css={css` ${!isDesktop ? 'border: none;' : ''} + ${isDragOver + ? ` + border: 2px dashed var(--c--contextuals--border--semantic--brand--primary); + background-color: var(--c--contextuals--background--semantic--brand--tertiary); + ` + : ''} `} $padding={{ bottom: 'md', }} + {...(withUpload ? getRootProps({ className: 'dropzone' }) : {})} > - + {withUpload && } + {!hasDocs && !loading && ( @@ -158,7 +214,15 @@ export const DocsGrid = ({ ); }; -const DocGridTitleBar = ({ target }: { target: DocDefaultFilter }) => { +const DocGridTitleBar = ({ + target, + onUploadClick, + withUpload, +}: { + target: DocDefaultFilter; + onUploadClick: () => void; + withUpload: boolean; +}) => { const { t } = useTranslation(); const { isDesktop } = useResponsiveStore(); @@ -200,6 +264,19 @@ const DocGridTitleBar = ({ target }: { target: DocDefaultFilter }) => { {title} + {withUpload && ( + + )} ); }; diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 2be1b1f8..8d830051 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -6815,6 +6815,11 @@ at-least-node@^1.0.0: resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== +attr-accept@^2.2.4: + version "2.2.5" + resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.5.tgz#d7061d958e6d4f97bf8665c68b75851a0713ab5e" + integrity sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ== + available-typed-arrays@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" @@ -8817,6 +8822,13 @@ figlet@1.8.1: resolved "https://registry.yarnpkg.com/figlet/-/figlet-1.8.1.tgz#e8e8a07e8c16be24c31086d7d5de8a9b9cf7f0fd" integrity sha512-kEC3Sme+YvA8Hkibv0NR1oClGcWia0VB2fC1SlMy027cwe795Xx40Xiv/nw/iFAwQLupymWh+uhAAErn/7hwPg== +file-selector@^2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-2.1.2.tgz#fe7c7ee9e550952dfbc863d73b14dc740d7de8b4" + integrity sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig== + dependencies: + tslib "^2.7.0" + file-entry-cache@^11.1.1: version "11.1.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-11.1.1.tgz#728918c624dbeb09372276837ea0c413ec78806b" @@ -12786,6 +12798,15 @@ react-dom@*, react-dom@19.2.3: dependencies: scheduler "^0.27.0" +react-dropzone@14.3.8: + version "14.3.8" + resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.3.8.tgz#a7eab118f8a452fe3f8b162d64454e81ba830582" + integrity sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug== + dependencies: + attr-accept "^2.2.4" + file-selector "^2.1.0" + prop-types "^15.8.1" + react-i18next@16.5.0: version "16.5.0" resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-16.5.0.tgz#107e4323742344a2f8792feb905cea551da6fd2c"