Skip to content

Commit c39c167

Browse files
committed
feat(FR-1334): implement drag-and-drop file upload functionality (#4089)
resolves #4077 ([FR-1334](https://lablup.atlassian.net/browse/FR-1334)) This PR adds drag and drop file upload functionality to the file explorer. Users can now drag files directly into the file explorer area to upload them, providing a more intuitive and convenient way to manage files. Key changes: - Created a new `DragAndDrop` component that appears when files are dragged over the file explorer - Extracted file upload logic into a reusable hook `useUploadVFolderFiles` to avoid code duplication - Added event listeners to detect drag and drop events at the document level - Added translations for the drag and drop feature in all supported languages - Simplified the file upload process in the `ActionItems` component by using the new hook **Checklist:** - [x] Documentation - [ ] Minium required manager version - [ ] Specific setting for review (eg., KB link, endpoint or how to setup) - [ ] Minimum requirements to check during review - [ ] Test case(s) to demonstrate the difference of before/after [FR-1334]: https://lablup.atlassian.net/browse/FR-1334?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
1 parent 9ebb459 commit c39c167

File tree

28 files changed

+412
-224
lines changed

28 files changed

+412
-224
lines changed

packages/backend.ai-ui/src/components/Table/BAITable.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -350,13 +350,17 @@ const BAITable = <RecordType extends object = any>({
350350
}
351351
}
352352
}}
353-
pagination={{
354-
style: {
355-
display: 'none', // Hide default pagination as we're using custom Pagination component below
356-
},
357-
current: currentPage,
358-
pageSize: currentPageSize,
359-
}}
353+
pagination={
354+
tableProps.pagination === false
355+
? false
356+
: {
357+
style: {
358+
display: 'none', // Hide default pagination as we're using custom Pagination component below
359+
},
360+
current: currentPage,
361+
pageSize: currentPageSize,
362+
}
363+
}
360364
/>
361365
{tableProps.pagination !== false && (
362366
<BAIFlex justify="end" gap={'xs'}>

packages/backend.ai-ui/src/components/baiClient/FileExplorer/BAIFileExplorer.tsx

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ import {
66
} from '../../../helper';
77
import BAIFlex from '../../BAIFlex';
88
import BAIUnmountAfterClose from '../../BAIUnmountAfterClose';
9-
import { BAITable } from '../../Table';
9+
import { BAITable, BAITableProps } from '../../Table';
1010
import useConnectedBAIClient from '../../provider/BAIClientProvider/hooks/useConnectedBAIClient';
1111
import { VFolderFile } from '../../provider/BAIClientProvider/types';
1212
import DeleteSelectedItemsModal from './DeleteSelectedItemsModal';
13+
import DragAndDrop from './DragAndDrop';
1314
import ExplorerActionControls from './ExplorerActionControls';
1415
import FileItemControls from './FileItemControls';
1516
import { useSearchVFolderFiles } from './hooks';
@@ -53,17 +54,26 @@ export const FolderInfoContext = createContext<{
5354
export interface BAIFileExplorerProps {
5455
vfolderNodeFrgmt?: BAIFileExplorerFragment$key | null;
5556
targetVFolderId: string;
57+
fetchKey?: string;
5658
onUpload: (files: Array<RcFile>, currentPath: string) => void;
59+
tableProps?: Partial<BAITableProps<VFolderFile>>;
60+
style?: React.CSSProperties;
61+
fileDropContainerRef?: React.RefObject<HTMLDivElement | null>;
5762
}
5863

5964
const BAIFileExplorer: React.FC<BAIFileExplorerProps> = ({
6065
vfolderNodeFrgmt,
6166
targetVFolderId,
67+
fetchKey,
6268
onUpload,
69+
tableProps,
70+
fileDropContainerRef,
71+
style,
6372
}) => {
6473
const { t } = useTranslation();
6574
const { token } = theme.useToken();
6675
const { styles } = useStyles();
76+
const [isDragMode, setIsDragMode] = useState(false);
6777
const [selectedItems, setSelectedItems] = useState<Array<VFolderFile>>([]);
6878
const [selectedSingleItem, setSelectedSingleItem] =
6979
useState<VFolderFile | null>(null);
@@ -78,7 +88,7 @@ const BAIFileExplorer: React.FC<BAIFileExplorerProps> = ({
7888
navigateDown,
7989
navigateToPath,
8090
refetch,
81-
} = useSearchVFolderFiles(targetVFolderId);
91+
} = useSearchVFolderFiles(targetVFolderId, fetchKey);
8292

8393
const [fetchedFilesCache, setFetchedFilesCache] = useState<
8494
Array<VFolderFile>
@@ -99,6 +109,38 @@ const BAIFileExplorer: React.FC<BAIFileExplorerProps> = ({
99109
vfolderNodeFrgmt,
100110
);
101111

112+
useEffect(() => {
113+
const handleDragEnter = (e: DragEvent) => {
114+
e.preventDefault();
115+
setIsDragMode(true);
116+
};
117+
const handleDragLeave = (e: DragEvent) => {
118+
e.preventDefault();
119+
if (!e.relatedTarget || !document.contains(e.relatedTarget as Node)) {
120+
setIsDragMode(false);
121+
}
122+
};
123+
const handleDragOver = (e: DragEvent) => {
124+
e.preventDefault();
125+
};
126+
const handleDrop = (e: DragEvent) => {
127+
e.preventDefault();
128+
setIsDragMode(false);
129+
};
130+
131+
document.addEventListener('dragenter', handleDragEnter);
132+
document.addEventListener('dragleave', handleDragLeave);
133+
document.addEventListener('dragover', handleDragOver);
134+
document.addEventListener('drop', handleDrop);
135+
136+
return () => {
137+
document.removeEventListener('dragenter', handleDragEnter);
138+
document.removeEventListener('dragleave', handleDragLeave);
139+
document.removeEventListener('dragover', handleDragOver);
140+
document.removeEventListener('drop', handleDrop);
141+
};
142+
}, []);
143+
102144
const breadCrumbItems: Array<ItemType> = useMemo(() => {
103145
const pathParts = currentPath === '.' ? [] : currentPath.split('/');
104146

@@ -244,7 +286,19 @@ const BAIFileExplorer: React.FC<BAIFileExplorerProps> = ({
244286

245287
return (
246288
<FolderInfoContext.Provider value={{ targetVFolderId, currentPath }}>
247-
<BAIFlex direction="column" align="stretch" gap="md">
289+
{isDragMode && (
290+
<DragAndDrop
291+
portalContainer={fileDropContainerRef?.current || undefined}
292+
onUpload={(files, currentPath) => onUpload(files, currentPath)}
293+
/>
294+
)}
295+
<BAIFlex
296+
direction="column"
297+
align="stretch"
298+
justify="start"
299+
gap="md"
300+
style={{ height: '100%', ...style }}
301+
>
248302
<BAIFlex align="center" justify="between">
249303
<Breadcrumb
250304
items={breadCrumbItems}
@@ -284,7 +338,6 @@ const BAIFileExplorer: React.FC<BAIFileExplorerProps> = ({
284338

285339
<BAITable
286340
rowKey="name"
287-
bordered
288341
scroll={{ x: 'max-content' }}
289342
dataSource={fetchedFilesCache}
290343
columns={tableColumns}
@@ -315,6 +368,7 @@ const BAIFileExplorer: React.FC<BAIFileExplorerProps> = ({
315368
}
316369
},
317370
})}
371+
{...tableProps}
318372
/>
319373
</BAIFlex>
320374
<BAIUnmountAfterClose>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { useUploadVFolderFiles } from './hooks';
2+
import { InboxOutlined } from '@ant-design/icons';
3+
import { theme, Typography, Upload } from 'antd';
4+
import { RcFile } from 'antd/es/upload';
5+
import { useRef } from 'react';
6+
import { createPortal } from 'react-dom';
7+
import { useTranslation } from 'react-i18next';
8+
9+
interface DragAndDropProps {
10+
onUpload: (files: Array<RcFile>, currentPath: string) => void;
11+
/** Optional container element for portal rendering */
12+
portalContainer?: HTMLElement | null;
13+
}
14+
15+
const DragAndDrop: React.FC<DragAndDropProps> = ({
16+
onUpload,
17+
portalContainer,
18+
}) => {
19+
const { t } = useTranslation();
20+
const { token } = theme.useToken();
21+
const { uploadFiles } = useUploadVFolderFiles();
22+
const lastFileListRef = useRef<Array<RcFile>>([]);
23+
24+
const overlay = (
25+
<Upload.Dragger
26+
style={{
27+
position: 'absolute',
28+
inset: 0,
29+
zIndex: token.zIndexPopupBase + 1,
30+
backdropFilter: 'blur(4px)',
31+
borderWidth: 3,
32+
}}
33+
multiple
34+
directory
35+
showUploadList={false}
36+
beforeUpload={(_, fileList) => {
37+
if (fileList !== lastFileListRef.current) {
38+
uploadFiles(fileList, onUpload);
39+
}
40+
lastFileListRef.current = fileList;
41+
return false;
42+
}}
43+
>
44+
<p>
45+
<InboxOutlined style={{ fontSize: token.fontSizeHeading1 }} />
46+
</p>
47+
<Typography.Text style={{ fontSize: token.fontSizeHeading4 }}>
48+
{t('comp:FileExplorer.DragAndDropDesc')}
49+
</Typography.Text>
50+
</Upload.Dragger>
51+
);
52+
53+
return portalContainer ? createPortal(overlay, portalContainer) : overlay;
54+
};
55+
56+
export default DragAndDrop;

packages/backend.ai-ui/src/components/baiClient/FileExplorer/ExplorerActionControls.tsx

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,16 @@ import { VFolderFile } from '../../provider/BAIClientProvider/types';
55
import { FolderInfoContext } from './BAIFileExplorer';
66
import CreateDirectoryModal from './CreateDirectoryModal';
77
import DeleteSelectedItemsModal from './DeleteSelectedItemsModal';
8+
import { useUploadVFolderFiles } from './hooks';
89
import {
910
FileAddOutlined,
1011
FolderAddOutlined,
1112
UploadOutlined,
1213
} from '@ant-design/icons';
1314
import { useQuery } from '@tanstack/react-query';
1415
import { useToggle } from 'ahooks';
15-
import { App, Button, Dropdown, Grid, theme, Tooltip, Upload } from 'antd';
16+
import { Button, Dropdown, Grid, theme, Tooltip, Upload } from 'antd';
1617
import { RcFile } from 'antd/es/upload';
17-
import _ from 'lodash';
1818
import { use, useRef } from 'react';
1919
import { useTranslation } from 'react-i18next';
2020

@@ -36,8 +36,8 @@ const ExplorerActionControls: React.FC<ExplorerActionControlsProps> = ({
3636
const { t } = useTranslation();
3737
const { lg } = Grid.useBreakpoint();
3838
const { token } = theme.useToken();
39-
const { modal } = App.useApp();
40-
const { targetVFolderId, currentPath } = use(FolderInfoContext);
39+
const { targetVFolderId } = use(FolderInfoContext);
40+
const { uploadFiles } = useUploadVFolderFiles();
4141
const [openCreateModal, { toggle: toggleCreateModal }] = useToggle(false);
4242
const [openDeleteModal, { toggle: toggleDeleteModal }] = useToggle(false);
4343
const lastFileListRef = useRef<Array<RcFile>>([]);
@@ -51,34 +51,6 @@ const ExplorerActionControls: React.FC<ExplorerActionControlsProps> = ({
5151
gcTime: 0,
5252
});
5353

54-
const handleUpload = async (fileList: RcFile[], path: string) => {
55-
// Currently, backend.ai only supports finding existing files by using list_files API.
56-
// This API throw an error if the file does not exist in the target vfolder.
57-
// So, we need to catch the error and return undefined.
58-
const duplicateCheckResult = await baiClient.vfolder
59-
.list_files(currentPath, targetVFolderId)
60-
.then((files) => {
61-
return _.some(
62-
files.items,
63-
(existFiles) =>
64-
existFiles.name ===
65-
(fileList[0].webkitRelativePath.split('/')[0] || fileList[0].name),
66-
);
67-
});
68-
69-
if (duplicateCheckResult) {
70-
modal.confirm({
71-
title: t('comp:FileExplorer.DuplicatedFiles'),
72-
content: t('comp:FileExplorer.DuplicatedFilesDesc'),
73-
onOk: () => {
74-
onUpload(fileList, path);
75-
},
76-
});
77-
} else {
78-
onUpload(fileList, path);
79-
}
80-
};
81-
8254
return (
8355
<BAIFlex gap="xs">
8456
<BAIFlex gap={'sm'}>
@@ -116,7 +88,7 @@ const ExplorerActionControls: React.FC<ExplorerActionControlsProps> = ({
11688
label: (
11789
<Upload
11890
beforeUpload={(_, fileList) => {
119-
handleUpload(fileList, currentPath);
91+
uploadFiles(fileList, onUpload);
12092
return false; // Prevent default upload behavior
12193
}}
12294
showUploadList={false}
@@ -133,7 +105,7 @@ const ExplorerActionControls: React.FC<ExplorerActionControlsProps> = ({
133105
directory
134106
beforeUpload={(_, fileList) => {
135107
if (fileList !== lastFileListRef.current) {
136-
handleUpload(fileList, currentPath);
108+
uploadFiles(fileList, onUpload);
137109
}
138110
lastFileListRef.current = fileList;
139111
return false;

packages/backend.ai-ui/src/components/baiClient/FileExplorer/hooks.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import useConnectedBAIClient from '../../provider/BAIClientProvider/hooks/useConnectedBAIClient';
22
import { VFolderFile } from '../../provider/BAIClientProvider/types';
3+
import { FolderInfoContext } from './BAIFileExplorer';
34
import { useQuery } from '@tanstack/react-query';
4-
import { useState } from 'react';
5+
import { App } from 'antd';
6+
import { RcFile } from 'antd/es/upload';
7+
import _ from 'lodash';
8+
import { use, useState } from 'react';
9+
import { useTranslation } from 'react-i18next';
510
import { StringParam, useQueryParam, withDefault } from 'use-query-params';
611

7-
export const useSearchVFolderFiles = (vfolder: string) => {
12+
export const useSearchVFolderFiles = (vfolder: string, fetchKey?: string) => {
813
const baiClient = useConnectedBAIClient();
914
const [currentPath, setCurrentPath] = useState<string>('.');
1015
const [, setCurrentPathParam] = useQueryParam(
@@ -46,7 +51,7 @@ export const useSearchVFolderFiles = (vfolder: string) => {
4651
refetch,
4752
isFetching,
4853
} = useQuery({
49-
queryKey: ['searchVFolderFiles', vfolder, currentPath],
54+
queryKey: ['searchVFolderFiles', vfolder, currentPath, fetchKey],
5055
queryFn: () =>
5156
baiClient.vfolder.list_files(currentPath, vfolder).then((res) => {
5257
setDirectoryTree((prev) => ({
@@ -72,3 +77,50 @@ export const useSearchVFolderFiles = (vfolder: string) => {
7277
isFetching,
7378
};
7479
};
80+
81+
export const useUploadVFolderFiles = () => {
82+
const { t } = useTranslation();
83+
const { modal } = App.useApp();
84+
const { targetVFolderId, currentPath } = use(FolderInfoContext);
85+
const baiClient = useConnectedBAIClient();
86+
87+
const uploadFiles = async (
88+
fileList: Array<RcFile>,
89+
onUpload: (files: Array<RcFile>, currentPath: string) => void,
90+
afterUpload?: () => void,
91+
) => {
92+
// Currently, backend.ai only supports finding existing files by using list_files API.
93+
// This API throw an error if the file does not exist in the target vfolder.
94+
// So, we need to catch the error and return undefined.
95+
const uploadFolderName = fileList[0].webkitRelativePath.split('/')[0];
96+
97+
const duplicateCheckResult = await baiClient.vfolder
98+
.list_files(currentPath, targetVFolderId)
99+
.then((files) => {
100+
if (uploadFolderName) {
101+
return _.some(files.items, (f) => f.name === uploadFolderName);
102+
} else {
103+
return _.some(files.items, (f) => f.name === fileList[0].name);
104+
}
105+
})
106+
.catch(() => undefined);
107+
108+
if (duplicateCheckResult) {
109+
modal.confirm({
110+
title: t('comp:FileExplorer.DuplicatedFiles'),
111+
content: t('comp:FileExplorer.DuplicatedFilesDesc'),
112+
onOk: () => {
113+
onUpload(fileList, currentPath);
114+
afterUpload?.();
115+
},
116+
});
117+
} else {
118+
onUpload(fileList, currentPath);
119+
afterUpload?.();
120+
}
121+
};
122+
123+
return {
124+
uploadFiles,
125+
};
126+
};

packages/backend.ai-ui/src/locale/de.json

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,6 @@
66
"comp:PaginationInfoText": {
77
"Total": "{{start}} - {{end}} von {{total}} Elementen"
88
},
9-
"comp:BAITable": {
10-
"SettingTable": "Tabelleneinstellungen",
11-
"SelectColumnToDisplay": "Wählen Sie Spalten aus, um angezeigt zu werden",
12-
"SearchTableColumn": "Suchtabellenspalten"
13-
},
149
"error": {
1510
"UnknownError": "Ein unbekannter Fehler ist aufgetreten. \nBitte versuchen Sie es erneut."
1611
},
@@ -44,7 +39,13 @@
4439
"FileUploadSizeLimit": "Die Dateigröße überschreitet die Hochladungsgrenze."
4540
},
4641
"DuplicatedFilesDesc": "Die Datei oder der Ordner mit demselben Namen existieren bereits. \nMöchten Sie überschreiben?",
47-
"DuplicatedFiles": "Überschreibung der Bestätigung"
42+
"DuplicatedFiles": "Überschreibung der Bestätigung",
43+
"DragAndDropDesc": "Ziehen Sie Dateien in diesen Bereich zum Hochladen."
44+
},
45+
"comp:BAITable": {
46+
"SettingTable": "Tabelleneinstellungen",
47+
"SelectColumnToDisplay": "Wählen Sie Spalten aus, um angezeigt zu werden",
48+
"SearchTableColumn": "Suchtabellenspalten"
4849
},
4950
"comp:BAIPropertyFilter": {
5051
"PlaceHolder": "Suche",

0 commit comments

Comments
 (0)