Skip to content

Commit 4b19533

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 0bcf599 commit 4b19533

File tree

8 files changed

+351
-6
lines changed

8 files changed

+351
-6
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.5",
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: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1-
import { Button } from '@openfun/cunningham-react';
2-
import { useMemo } from 'react';
1+
import {
2+
Button,
3+
VariantType,
4+
useToastProvider,
5+
} from '@openfun/cunningham-react';
6+
import { useMemo, useState } from 'react';
7+
import { useDropzone } from 'react-dropzone';
38
import { useTranslation } from 'react-i18next';
49
import { InView } from 'react-intersection-observer';
510
import { css } from 'styled-components';
@@ -10,6 +15,7 @@ import { DocDefaultFilter, useInfiniteDocs } from '@/docs/doc-management';
1015
import { useResponsiveStore } from '@/stores';
1116

1217
import { useInfiniteDocsTrashbin } from '../api';
18+
import { useImportDoc } from '../api/useImportDoc';
1319
import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid';
1420

1521
import {
@@ -25,6 +31,41 @@ export const DocsGrid = ({
2531
target = DocDefaultFilter.ALL_DOCS,
2632
}: DocsGridProps) => {
2733
const { t } = useTranslation();
34+
const [isDragOver, setIsDragOver] = useState(false);
35+
const { toast } = useToastProvider();
36+
const { getRootProps, getInputProps, open } = useDropzone({
37+
accept: {
38+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
39+
['.docx'],
40+
'text/markdown': ['.md'],
41+
},
42+
onDrop(acceptedFiles) {
43+
setIsDragOver(false);
44+
for (const file of acceptedFiles) {
45+
importDoc(file);
46+
}
47+
},
48+
onDragEnter: () => {
49+
setIsDragOver(true);
50+
},
51+
onDragLeave: () => {
52+
setIsDragOver(false);
53+
},
54+
onDropRejected(fileRejections) {
55+
toast(
56+
t(
57+
`The document "{{documentName}}" import has failed (only .docx and .md files are allowed)`,
58+
{
59+
documentName: fileRejections?.[0].file.name || '',
60+
},
61+
),
62+
VariantType.ERROR,
63+
);
64+
},
65+
noClick: true,
66+
});
67+
const { mutate: importDoc } = useImportDoc();
68+
const withUpload = target === DocDefaultFilter.ALL_DOCS;
2869

2970
const { isDesktop } = useResponsiveStore();
3071
const { flexLeft, flexRight } = useResponsiveDocGrid();
@@ -77,12 +118,24 @@ export const DocsGrid = ({
77118
$width="100%"
78119
$css={css`
79120
${!isDesktop ? 'border: none;' : ''}
121+
${isDragOver
122+
? `
123+
border: 2px dashed var(--c--contextuals--border--semantic--brand--primary);
124+
background-color: var(--c--contextuals--background--semantic--brand--tertiary);
125+
`
126+
: ''}
80127
`}
81128
$padding={{
82129
bottom: 'md',
83130
}}
131+
{...(withUpload ? getRootProps({ className: 'dropzone' }) : {})}
84132
>
85-
<DocGridTitleBar target={target} />
133+
{withUpload && <input {...getInputProps()} />}
134+
<DocGridTitleBar
135+
target={target}
136+
onUploadClick={open}
137+
withUpload={withUpload}
138+
/>
86139

87140
{!hasDocs && !loading && (
88141
<Box $padding={{ vertical: 'sm' }} $align="center" $justify="center">
@@ -158,7 +211,15 @@ export const DocsGrid = ({
158211
);
159212
};
160213

161-
const DocGridTitleBar = ({ target }: { target: DocDefaultFilter }) => {
214+
const DocGridTitleBar = ({
215+
target,
216+
onUploadClick,
217+
withUpload,
218+
}: {
219+
target: DocDefaultFilter;
220+
onUploadClick: () => void;
221+
withUpload: boolean;
222+
}) => {
162223
const { t } = useTranslation();
163224
const { isDesktop } = useResponsiveStore();
164225

@@ -200,6 +261,19 @@ const DocGridTitleBar = ({ target }: { target: DocDefaultFilter }) => {
200261
{title}
201262
</Text>
202263
</Box>
264+
{withUpload && (
265+
<Button
266+
color="brand"
267+
variant="tertiary"
268+
onClick={(e) => {
269+
e.stopPropagation();
270+
onUploadClick();
271+
}}
272+
aria-label={t('Open the upload dialog')}
273+
>
274+
<Icon iconName="upload_file" $withThemeInherited />
275+
</Button>
276+
)}
203277
</Box>
204278
);
205279
};

0 commit comments

Comments
 (0)