Skip to content

Commit 3059a15

Browse files
committed
✨(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.
1 parent cee4059 commit 3059a15

File tree

8 files changed

+326
-11
lines changed

8 files changed

+326
-11
lines changed
Binary file not shown.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
![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/assets/icon-docs.svg)
2+
3+
# Lorem Ipsum Markdown Document
4+
5+
## Introduction
6+
7+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt, nisl nisl aliquam nisl, eget ultricies nisl nisl eget nisl.
8+
9+
### Subsection 1.1
10+
11+
* **Bold text**: Lorem ipsum dolor sit amet.
12+
13+
* *Italic text*: Consectetur adipiscing elit.
14+
15+
* ~~Strikethrough text~~: Nullam auctor, nisl eget ultricies tincidunt.
16+
17+
1. First item in an ordered list.
18+
19+
2. Second item in an ordered list.
20+
21+
* Indented bullet point.
22+
23+
* Another indented bullet point.
24+
25+
3. Third item in an ordered list.
26+
27+
### Subsection 1.2
28+
29+
**Code block:**
30+
31+
```python
32+
def hello_world():
33+
print("Hello, world!")
34+
```
35+
36+
**Blockquote:**
37+
38+
> Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt.
39+
40+
**Horizontal rule:**
41+
42+
***
43+
44+
**Table:**
45+
46+
| Syntax | Description |
47+
| --------- | ----------- |
48+
| Header | Title |
49+
| Paragraph | Text |
50+
51+
**Inline code:**
52+
53+
Use the `printf()` function.
54+
55+
**Link:** [Example](https://www.example.com)
56+
57+
**Image:**
58+
59+
![Alt text](https://via.placeholder.com/150)
60+
61+
## Conclusion
62+
63+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt, nisl nisl aliquam nisl, eget ultricies nisl nisl eget nisl.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { readFileSync } from 'fs';
2+
import path from 'path';
3+
4+
import { Page, expect, test } from '@playwright/test';
5+
6+
test.beforeEach(async ({ page }) => {
7+
await page.goto('/');
8+
});
9+
10+
test.describe('Doc Import', () => {
11+
test('it imports 2 docs with the import icon', async ({ page }) => {
12+
const fileChooserPromise = page.waitForEvent('filechooser');
13+
await page.getByLabel('Open the upload dialog').click();
14+
15+
const fileChooser = await fileChooserPromise;
16+
await fileChooser.setFiles(path.join(__dirname, 'assets/test_import.docx'));
17+
await fileChooser.setFiles(path.join(__dirname, 'assets/test_import.md'));
18+
19+
await expect(
20+
page.getByText(
21+
'The document "test_import.docx" has been successfully imported',
22+
),
23+
).toBeVisible();
24+
await expect(
25+
page.getByText(
26+
'The document "test_import.md" has been successfully imported',
27+
),
28+
).toBeVisible();
29+
30+
const docsGrid = page.getByTestId('docs-grid');
31+
await expect(docsGrid.getByText('test_import.docx')).toBeVisible();
32+
await expect(docsGrid.getByText('test_import.md')).toBeVisible();
33+
});
34+
35+
test('it imports 2 docs with the drag and drop area', async ({ page }) => {
36+
const docsGrid = page.getByTestId('docs-grid');
37+
await expect(docsGrid).toBeVisible();
38+
39+
await dragAndDropFiles(page, "[data-testid='docs-grid']", [
40+
{
41+
filePath: path.join(__dirname, 'assets/test_import.docx'),
42+
fileName: 'test_import.docx',
43+
fileType:
44+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
45+
},
46+
{
47+
filePath: path.join(__dirname, 'assets/test_import.md'),
48+
fileName: 'test_import.md',
49+
fileType: 'text/markdown',
50+
},
51+
]);
52+
53+
// Wait for success messages
54+
await expect(
55+
page.getByText(
56+
'The document "test_import.docx" has been successfully imported',
57+
),
58+
).toBeVisible();
59+
await expect(
60+
page.getByText(
61+
'The document "test_import.md" has been successfully imported',
62+
),
63+
).toBeVisible();
64+
65+
await expect(docsGrid.getByText('test_import.docx').first()).toBeVisible();
66+
await expect(docsGrid.getByText('test_import.md').first()).toBeVisible();
67+
});
68+
});
69+
70+
const dragAndDropFiles = async (
71+
page: Page,
72+
selector: string,
73+
files: Array<{ filePath: string; fileName: string; fileType?: string }>,
74+
) => {
75+
const filesData = files.map((file) => ({
76+
bufferData: `data:application/octet-stream;base64,${readFileSync(file.filePath).toString('base64')}`,
77+
fileName: file.fileName,
78+
fileType: file.fileType || '',
79+
}));
80+
81+
const dataTransfer = await page.evaluateHandle(async (filesInfo) => {
82+
const dt = new DataTransfer();
83+
84+
for (const fileInfo of filesInfo) {
85+
const blobData = await fetch(fileInfo.bufferData).then((res) =>
86+
res.blob(),
87+
);
88+
const file = new File([blobData], fileInfo.fileName, {
89+
type: fileInfo.fileType,
90+
});
91+
dt.items.add(file);
92+
}
93+
94+
return dt;
95+
}, filesData);
96+
97+
await page.dispatchEvent(selector, 'drop', { dataTransfer });
98+
};

src/frontend/apps/impress/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"react": "*",
6363
"react-aria-components": "1.13.0",
6464
"react-dom": "*",
65+
"react-dropzone": "14.3.8",
6566
"react-i18next": "16.3.3",
6667
"react-intersection-observer": "10.0.0",
6768
"react-resizable-panels": "3.0.6",

src/frontend/apps/impress/src/api/helpers.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export type DefinedInitialDataInfiniteOptionsAPI<
2020
QueryKey,
2121
TPageParam
2222
>;
23-
23+
export type UseInfiniteQueryResultAPI<Q> = InfiniteData<Q>;
2424
export type InfiniteQueryConfig<Q> = Omit<
2525
DefinedInitialDataInfiniteOptionsAPI<Q>,
2626
'queryKey' | 'initialData' | 'getNextPageParam' | 'initialPageParam'
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
2+
import {
3+
UseMutationOptions,
4+
useMutation,
5+
useQueryClient,
6+
} from '@tanstack/react-query';
7+
import { useTranslation } from 'react-i18next';
8+
9+
import {
10+
APIError,
11+
UseInfiniteQueryResultAPI,
12+
errorCauses,
13+
fetchAPI,
14+
} from '@/api';
15+
import { Doc, DocsResponse, KEY_LIST_DOC } from '@/docs/doc-management';
16+
17+
export const importDoc = async (file: File): Promise<Doc> => {
18+
const form = new FormData();
19+
form.append('file', file);
20+
21+
const response = await fetchAPI(`documents/`, {
22+
method: 'POST',
23+
body: form,
24+
withoutContentType: true,
25+
});
26+
27+
if (!response.ok) {
28+
throw new APIError('Failed to import the doc', await errorCauses(response));
29+
}
30+
31+
return response.json() as Promise<Doc>;
32+
};
33+
34+
type UseImportDocOptions = UseMutationOptions<Doc, APIError, File>;
35+
36+
export function useImportDoc(props?: UseImportDocOptions) {
37+
const { toast } = useToastProvider();
38+
const queryClient = useQueryClient();
39+
const { t } = useTranslation();
40+
41+
return useMutation<Doc, APIError, File>({
42+
mutationFn: importDoc,
43+
...props,
44+
onSuccess: (...successProps) => {
45+
queryClient.setQueriesData<UseInfiniteQueryResultAPI<DocsResponse>>(
46+
{ queryKey: [KEY_LIST_DOC] },
47+
(oldData) => {
48+
if (!oldData || oldData?.pages.length === 0) {
49+
return oldData;
50+
}
51+
52+
return {
53+
...oldData,
54+
pages: oldData.pages.map((page, index) => {
55+
// Add the new doc to the first page only
56+
if (index === 0) {
57+
return {
58+
...page,
59+
results: [successProps[0], ...page.results],
60+
};
61+
}
62+
return page;
63+
}),
64+
};
65+
},
66+
);
67+
68+
toast(
69+
t('The document "{{documentName}}" has been successfully imported', {
70+
documentName: successProps?.[0].title || '',
71+
}),
72+
VariantType.SUCCESS,
73+
);
74+
75+
props?.onSuccess?.(...successProps);
76+
},
77+
onError: (...errorProps) => {
78+
toast(
79+
t(`The document "{{documentName}}" import has failed`, {
80+
documentName: errorProps?.[1].name || '',
81+
}),
82+
VariantType.ERROR,
83+
);
84+
85+
props?.onError?.(...errorProps);
86+
},
87+
});
88+
}

src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Button } from '@openfun/cunningham-react';
2-
import { useMemo } from 'react';
2+
import { useMemo, useState } from 'react';
3+
import { useDropzone } from 'react-dropzone';
34
import { useTranslation } from 'react-i18next';
45
import { InView } from 'react-intersection-observer';
56
import { css } from 'styled-components';
@@ -10,6 +11,7 @@ import { DocDefaultFilter, useInfiniteDocs } from '@/docs/doc-management';
1011
import { useResponsiveStore } from '@/stores';
1112

1213
import { useInfiniteDocsTrashbin } from '../api';
14+
import { useImportDoc } from '../api/useImportDoc';
1315
import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid';
1416

1517
import {
@@ -25,6 +27,28 @@ export const DocsGrid = ({
2527
target = DocDefaultFilter.ALL_DOCS,
2628
}: DocsGridProps) => {
2729
const { t } = useTranslation();
30+
const [isDragOver, setIsDragOver] = useState(false);
31+
const { getRootProps, getInputProps, open } = useDropzone({
32+
accept: {
33+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
34+
['.docx'],
35+
'text/markdown': ['.md'],
36+
},
37+
onDrop(acceptedFiles) {
38+
setIsDragOver(false);
39+
for (const file of acceptedFiles) {
40+
importDoc(file);
41+
}
42+
},
43+
onDragEnter: () => {
44+
setIsDragOver(true);
45+
},
46+
onDragLeave: () => {
47+
setIsDragOver(false);
48+
},
49+
noClick: true,
50+
});
51+
const { mutate: importDoc } = useImportDoc();
2852

2953
const { isDesktop } = useResponsiveStore();
3054
const { flexLeft, flexRight } = useResponsiveDocGrid();
@@ -77,12 +101,20 @@ export const DocsGrid = ({
77101
$width="100%"
78102
$css={css`
79103
${!isDesktop ? 'border: none;' : ''}
104+
${isDragOver
105+
? `
106+
border: 2px dashed var(--c--contextuals--border--semantic--brand--primary);
107+
background-color: var(--c--contextuals--background--semantic--brand--tertiary);
108+
`
109+
: ''}
80110
`}
81111
$padding={{
82112
bottom: 'md',
83113
}}
114+
{...getRootProps({ className: 'dropzone' })}
84115
>
85-
<DocGridTitleBar target={target} />
116+
<input {...getInputProps()} />
117+
<DocGridTitleBar target={target} onUploadClick={open} />
86118

87119
{!hasDocs && !loading && (
88120
<Box $padding={{ vertical: 'sm' }} $align="center" $justify="center">
@@ -158,7 +190,13 @@ export const DocsGrid = ({
158190
);
159191
};
160192

161-
const DocGridTitleBar = ({ target }: { target: DocDefaultFilter }) => {
193+
const DocGridTitleBar = ({
194+
target,
195+
onUploadClick,
196+
}: {
197+
target: DocDefaultFilter;
198+
onUploadClick: () => void;
199+
}) => {
162200
const { t } = useTranslation();
163201
const { isDesktop } = useResponsiveStore();
164202

@@ -200,6 +238,17 @@ const DocGridTitleBar = ({ target }: { target: DocDefaultFilter }) => {
200238
{title}
201239
</Text>
202240
</Box>
241+
<Button
242+
color="brand"
243+
variant="tertiary"
244+
onClick={(e) => {
245+
e.stopPropagation();
246+
onUploadClick();
247+
}}
248+
aria-label={t('Open the upload dialog')}
249+
>
250+
<Icon iconName="upload_file" $withThemeInherited />
251+
</Button>
203252
</Box>
204253
);
205254
};

0 commit comments

Comments
 (0)