diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c3342a8..9044dea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to ### Added - ✨(frontend) integrate configurable Waffle #1795 +- ✨ Import of documents #1609 ### Changed 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..f5269bce --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-import.spec.ts @@ -0,0 +1,181 @@ +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'), + 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 */ + + /** + * Divider are not supported in docx import in DocSpec 2.4.4 + */ + /* eslint-disable playwright/no-conditional-expect */ + if (isMDCheck) { + await expect( + editor.locator('div[data-content-type="divider"] hr'), + ).toBeVisible(); + } + /* eslint-enable playwright/no-conditional-expect */ + }; + + await contentCheck(true); + + // 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 71646706..46e015d2 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -62,6 +62,7 @@ "react": "*", "react-aria-components": "1.14.0", "react-dom": "*", + "react-dropzone": "14.3.8", "react-i18next": "16.5.1", "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..e25122d4 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/api/useImportDoc.tsx @@ -0,0 +1,146 @@ +import { + VariantType, + useToastProvider, +} from '@gouvfr-lasuite/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) => { + const importedDoc = successProps[0]; + + const updateDocsListCache = (isCreatorMe: boolean | undefined) => { + queryClient.setQueriesData>( + { + queryKey: [ + KEY_LIST_DOC, + { + page: 1, + ordering: undefined, + is_creator_me: isCreatorMe, + title: undefined, + is_favorite: undefined, + }, + ], + }, + (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: [importedDoc, ...page.results], + }; + } + return page; + }), + }; + }, + ); + }; + + updateDocsListCache(undefined); + updateDocsListCache(true); + + toast( + t('The document "{{documentName}}" has been successfully imported', { + documentName: importedDoc.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 ba4d8db2..ed074c1e 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,8 +1,14 @@ -import { Button } from '@gouvfr-lasuite/cunningham-react'; -import { useMemo } from 'react'; +import { + Button, + Tooltip as TooltipBase, + VariantType, + useToastProvider, +} from '@gouvfr-lasuite/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'; +import styled, { css } from 'styled-components'; import AllDocs from '@/assets/icons/doc-all.svg'; import { Box, Card, Icon, Text } from '@/components'; @@ -10,6 +16,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 { @@ -18,13 +25,89 @@ import { } from './DocGridContentList'; import { DocsGridLoader } from './DocsGridLoader'; +const Tooltip = styled(TooltipBase)` + & { + max-width: 200px; + + .c__tooltip__content { + max-width: 200px; + width: max-content; + } + } +`; + type DocsGridProps = { target?: DocDefaultFilter; }; + export const DocsGrid = ({ target = DocDefaultFilter.ALL_DOCS, }: DocsGridProps) => { const { t } = useTranslation(); + const [isDragOver, setIsDragOver] = useState(false); + const { toast } = useToastProvider(); + + const MAX_FILE_SIZE = { + bytes: 10 * 1024 * 1024, // 10 MB + text: '10MB', + }; + + const { getRootProps, getInputProps, open } = useDropzone({ + accept: { + [ContentTypesAllowed.Docx]: ['.docx'], + [ContentTypesAllowed.Markdown]: ['.md'], + }, + maxSize: MAX_FILE_SIZE.bytes, + onDrop(acceptedFiles) { + setIsDragOver(false); + for (const file of acceptedFiles) { + importDoc(file); + } + }, + onDragEnter: () => { + setIsDragOver(true); + }, + onDragLeave: () => { + setIsDragOver(false); + }, + onDropRejected(fileRejections) { + fileRejections.forEach((rejection) => { + const isFileTooLarge = rejection.errors.some( + (error) => error.code === 'file-too-large', + ); + + if (isFileTooLarge) { + toast( + t( + 'The document "{{documentName}}" is too large. Maximum file size is {{maxFileSize}}.', + { + documentName: rejection.file.name, + maxFileSize: MAX_FILE_SIZE.text, + }, + ), + VariantType.ERROR, + ); + } else { + toast( + t( + `The document "{{documentName}}" import has failed (only .docx and .md files are allowed)`, + { + documentName: rejection.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 +160,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 +253,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(); @@ -194,6 +297,27 @@ const DocGridTitleBar = ({ target }: { target: DocDefaultFilter }) => { {title} + {withUpload && ( + + {t('Import Docx or Markdown files')} + + } + > + + + )} ); }; diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 5b7c282b..db0dfc88 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -7858,6 +7858,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" @@ -9905,6 +9910,13 @@ file-entry-cache@^8.0.0: dependencies: flat-cache "^4.0.0" +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" + filelist@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5" @@ -13927,6 +13939,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.1: version "16.5.1" resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-16.5.1.tgz#a299224b6d9c054dc92d7d65b642e48964b318ee" @@ -15732,7 +15753,7 @@ tslib@2.6.2: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== -tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.6.2, tslib@^2.8.0: +tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.6.2, tslib@^2.7.0, tslib@^2.8.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==