(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
2026-01-08 16:02:14 +01:00
committed by Stephan Meijer
parent 0fdc42fa5f
commit d3088b82d7
9 changed files with 462 additions and 5 deletions
+1
View File
@@ -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
@@ -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.
@@ -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 });
};
+1
View File
@@ -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",
@@ -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'
@@ -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<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) => {
queryClient.setQueriesData<UseInfiniteQueryResultAPI<DocsResponse>>(
{ 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);
},
});
}
@@ -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' }) : {})}
>
<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 +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}
</Text>
</Box>
{withUpload && (
<Button
color="brand"
variant="tertiary"
onClick={(e) => {
e.stopPropagation();
onUploadClick();
}}
aria-label={t('Open the upload dialog')}
>
<Icon iconName="upload_file" $withThemeInherited />
</Button>
)}
</Box>
);
};
+21
View File
@@ -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 @@ [email protected]:
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@*, [email protected]:
dependencies:
scheduler "^0.27.0"
[email protected]:
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"
[email protected]:
version "16.5.0"
resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-16.5.0.tgz#107e4323742344a2f8792feb905cea551da6fd2c"