(frontend) add import document area in docs grid

Add import document area with drag and drop
support in the docs grid component.
We can now import docx and and md files just
by dropping them into the designated area.

We are using the `react-dropzone` library to
handle the drag and drop functionality.
This commit is contained in:
Anthony LC
2025-11-28 16:45:17 +01:00
parent feb9f7d4a9
commit 2e6c39262d
9 changed files with 541 additions and 7 deletions

View File

@@ -9,6 +9,7 @@ and this project adheres to
### Added
- ✨(frontend) integrate configurable Waffle #1795
- ✨ Import of documents #1609
### Changed

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ export type DefinedInitialDataInfiniteOptionsAPI<
QueryKey,
TPageParam
>;
export type UseInfiniteQueryResultAPI<Q> = InfiniteData<Q>;
export type InfiniteQueryConfig<Q> = Omit<
DefinedInitialDataInfiniteOptionsAPI<Q>,
'queryKey' | 'initialData' | 'getNextPageParam' | 'initialPageParam'

View File

@@ -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<Doc> => {
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<Doc>;
};
type UseImportDocOptions = UseMutationOptions<Doc, APIError, File>;
export function useImportDoc(props?: UseImportDocOptions) {
const { toast } = useToastProvider();
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<Doc, APIError, File>({
mutationFn: importDoc,
...props,
onSuccess: (...successProps) => {
const importedDoc = successProps[0];
const updateDocsListCache = (isCreatorMe: boolean | undefined) => {
queryClient.setQueriesData<UseInfiniteQueryResultAPI<DocsResponse>>(
{
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);
},
});
}

View File

@@ -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' }) : {})}
>
<DocGridTitleBar target={target} />
{withUpload && <input {...getInputProps()} />}
<DocGridTitleBar
target={target}
onUploadClick={open}
withUpload={withUpload}
/>
{!hasDocs && !loading && (
<Box $padding={{ vertical: 'sm' }} $align="center" $justify="center">
@@ -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}
</Text>
</Box>
{withUpload && (
<Tooltip
content={
<Text $textAlign="center" $theme="neutral" $variation="tertiary">
{t('Import Docx or Markdown files')}
</Text>
}
>
<Button
color="brand"
variant="tertiary"
onClick={(e) => {
e.stopPropagation();
onUploadClick();
}}
aria-label={t('Open the upload dialog')}
>
<Icon iconName="upload_file" $withThemeInherited />
</Button>
</Tooltip>
)}
</Box>
);
};

View File

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