Skip to content

Commit 6a0b3c2

Browse files
committed
feat: Drag and drop or click to upload multiple files and folders
https://harperdb.atlassian.net/browse/STUDIO-545
1 parent 9c38a90 commit 6a0b3c2

File tree

19 files changed

+498
-73
lines changed

19 files changed

+498
-73
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"react": "^19.0.0",
5858
"react-complex-tree": "^2.6.1",
5959
"react-dom": "^19.0.0",
60+
"react-dropzone": "^14.3.8",
6061
"react-hook-form": "^7.54.2",
6162
"react-markdown": "^10.1.0",
6263
"recharts": "^3.2.1",

pnpm-lock.yaml

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/Loading.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
interface LoadingProps extends React.ComponentProps<'div'> {
2-
text?: string;
1+
import { ComponentProps, ReactNode } from 'react';
2+
3+
interface LoadingProps extends ComponentProps<'div'> {
4+
text?: ReactNode;
35
centered?: boolean;
46
}
57

src/components/ui/input.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
99
data-slot="input"
1010
className={cn(
1111
`border-input file:text-foreground placeholder:text-muted-foreground selection:bg-purple
12-
selection:text-primary-foreground
13-
dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/50 ring-ring/10
14-
dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 aria-invalid:outline-destructive/60
15-
aria-invalid:ring-destructive/20 aria-invalid:border-destructive/60 dark:aria-invalid:border-destructive
12+
selection:text-primary-foreground
13+
dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/50 ring-ring/10
14+
dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 aria-invalid:outline-destructive/60
15+
aria-invalid:ring-destructive/20 aria-invalid:border-destructive/60 dark:aria-invalid:border-destructive
1616
flex h-9 w-full min-w-0 rounded-md border bg-grey-700 px-3 py-1 text-base text-white
17-
file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium
18-
focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-purple disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50
17+
file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium
18+
focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-purple disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50
1919
aria-invalid:focus-visible:ring-[1px] aria-invalid:focus-visible:outline-none md:text-sm dark:aria-invalid:focus-visible:ring-1`,
2020
className
2121
)}

src/config/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,6 @@ export const defaultClusterUsername = 'HDB_ADMIN';
77

88
export const defaultInstanceRoute = '/';
99
export const defaultInstanceRouteUpOne = '../';
10+
11+
export const maxUploadFileSize = 1024 /* mb */ * 1024 /* kb */ * 1024 /* b */;
12+
export const maxFabricConnectUploadFileSize = 100 /* mb */ * 1024 /* kb */ * 1024 /* b */;
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import { maxFabricConnectUploadFileSize, maxUploadFileSize } from '@/config/constants';
2+
import { useInstanceClientIdParams } from '@/config/useInstanceClient';
3+
import { authStore } from '@/features/auth/store/authStore';
4+
import { useDraggingHook } from '@/features/instance/applications/components/ApplicationsSidebar/useDraggingHook';
5+
import { isDirectory } from '@/features/instance/applications/context/isDirectory';
6+
import { useEditorView } from '@/features/instance/applications/hooks/useEditorView';
7+
import { setComponentFile } from '@/integrations/api/instance/applications/setComponentFile';
8+
import { humanFileSize } from '@/lib/humanFileSize';
9+
import { pluralize } from '@/lib/pluralize';
10+
import { readAsDataURL } from '@/lib/storage/readAsDataURL';
11+
import { useParams } from '@tanstack/react-router';
12+
import { cx } from 'class-variance-authority';
13+
import { useCallback, useState } from 'react';
14+
import { FileRejection, FileWithPath, useDropzone } from 'react-dropzone';
15+
import { toast } from 'sonner';
16+
17+
export function DropTarget() {
18+
const [uploading, setUploading] = useState('');
19+
const { clusterId }: { clusterId?: string; } = useParams({ strict: false });
20+
const { openedEntry, restrictPackageModification, reloadRootEntries, entryExists } = useEditorView();
21+
const canUpload = !!openedEntry && !openedEntry.package && !restrictPackageModification;
22+
const instanceParams = useInstanceClientIdParams();
23+
const { dragging: isDraggingAnywhere, dragTarget } = useDraggingHook();
24+
const dragTargetId = dragTarget?.getAttribute?.('data-rct-item-id');
25+
const dragTargetDirectory = dragTargetId?.split?.('/')?.pop?.();
26+
27+
const currentProject = openedEntry?.project;
28+
let currentPath: string | false = false;
29+
let currentDirectory: string | undefined = undefined;
30+
if (openedEntry?.path) {
31+
const parts = openedEntry.path.split('/');
32+
const currentPathIsDirectory = isDirectory(openedEntry);
33+
currentPath = canUpload && ((
34+
currentPathIsDirectory
35+
? parts.slice(1)
36+
: parts.slice(1, -1)
37+
).join('/'));
38+
currentDirectory = parts[parts.length - (currentPathIsDirectory ? 1 : 2)];
39+
}
40+
41+
const onUploadDrop = useCallback(async (
42+
rawAcceptedFiles: FileWithPath[],
43+
rawRejectedFiles: FileRejection[],
44+
) => {
45+
const dragItemId = dragTarget?.getAttribute?.('data-rct-item-id')?.split?.('/');
46+
const targetProject = dragItemId?.length ? dragItemId[0] : canUpload ? currentProject : false;
47+
const targetPath = dragItemId?.length ? dragItemId.slice(1).join('/') : canUpload ? currentPath : false;
48+
if (targetProject === false || targetProject === undefined || targetPath === false || targetPath === undefined) {
49+
return;
50+
}
51+
52+
const filesToUpload: FileWithPath[] = [];
53+
const filesRejected: FileRejection[] = rawRejectedFiles.slice();
54+
55+
for (const file of rawAcceptedFiles) {
56+
const filePath = getFilePath(targetPath, file);
57+
if (file.name.startsWith('.') || file.relativePath?.includes('/.')) {
58+
filesRejected.push({
59+
file,
60+
errors: [
61+
{
62+
message: 'Sensitive files and folders starting with . are skipped.',
63+
code: 'dot-ignored',
64+
},
65+
],
66+
});
67+
} else if (entryExists(`${targetProject}/${filePath}`)) {
68+
filesRejected.push({
69+
file,
70+
errors: [
71+
{
72+
message: `${filePath} already exists`,
73+
code: 'duplicate',
74+
},
75+
],
76+
});
77+
} else {
78+
filesToUpload.push(file);
79+
}
80+
}
81+
82+
let canceled = false;
83+
84+
const id = 'uploading-files';
85+
const toastCancelAction = {
86+
label: 'Cancel',
87+
onClick: () => {
88+
canceled = true;
89+
},
90+
};
91+
const toastOKAction = {
92+
label: 'OK',
93+
onClick: () => undefined,
94+
};
95+
96+
setUploading(`Uploading ${pluralize(filesToUpload.length, 'file', 'files')}...`);
97+
98+
const totalBytes = filesToUpload.reduce((sum, f) => sum + f.size, 0);
99+
let uploadedBytes = 0;
100+
let counter = 0;
101+
for (const file of filesToUpload) {
102+
counter += 1;
103+
104+
toast.loading(`Upload in progress...`, {
105+
id,
106+
descriptionClassName: 'whitespace-pre',
107+
description: `${counter} of ${pluralize(filesToUpload.length, 'file', 'files')}
108+
${file.name}
109+
${humanFileSize(uploadedBytes)} of ${humanFileSize(totalBytes)}`,
110+
action: toastCancelAction,
111+
});
112+
113+
const filePath = getFilePath(targetPath, file);
114+
const dataURLResponse = await readAsDataURL(file);
115+
const dataURLResult = dataURLResponse.target!.result as string;
116+
const demarcation = 'base64,';
117+
const encodingIndex = dataURLResult.indexOf(demarcation);
118+
if (!canceled) {
119+
await setComponentFile({
120+
...instanceParams,
121+
file: filePath,
122+
project: targetProject,
123+
encoding: 'base64',
124+
payload: dataURLResult.slice(encodingIndex + demarcation.length),
125+
});
126+
uploadedBytes += file.size;
127+
} else {
128+
filesRejected.push({
129+
file,
130+
errors: [
131+
{
132+
message: `${filePath} cancelled`,
133+
code: 'cancelled',
134+
},
135+
],
136+
});
137+
}
138+
}
139+
140+
toast.loading(`Reloading sidebar...`, {
141+
id: canceled ? undefined : id,
142+
action: toastOKAction,
143+
description: '',
144+
});
145+
await reloadRootEntries();
146+
147+
if (filesRejected.length === 0) {
148+
if (rawAcceptedFiles.length > 0) {
149+
toast.success(`Uploaded ${pluralize(filesToUpload.length, 'file', 'files')}!`, {
150+
id,
151+
action: toastOKAction,
152+
description: '',
153+
});
154+
}
155+
} else {
156+
// Note: this console.log is deliberate. It lets developers know all the files that were rejected.
157+
console.log(filesRejected);
158+
toast.error(canceled ? 'Cancelled uploads' : 'Rejected uploads', {
159+
id: canceled ? undefined : id,
160+
action: toastOKAction,
161+
descriptionClassName: 'whitespace-pre overflow-y-auto',
162+
description: filesRejected
163+
.slice(0, 5)
164+
.map(r => r.errors.map(e => e.message).join('\n'))
165+
.join('\n')
166+
+ (filesRejected?.length > 5 ? '\nCheck the console for the full list.' : ''),
167+
});
168+
}
169+
setUploading('');
170+
}, [dragTarget, canUpload, currentProject, currentPath, entryExists, instanceParams, reloadRootEntries]);
171+
172+
const isFabricConnect = !!clusterId && authStore.checkForFabricConnect(clusterId);
173+
const { getRootProps, getInputProps } = useDropzone({
174+
multiple: true,
175+
maxSize: isFabricConnect ? maxFabricConnectUploadFileSize : maxUploadFileSize,
176+
onDrop: onUploadDrop,
177+
});
178+
179+
if (!canUpload && !dragTarget) {
180+
if (!isDraggingAnywhere) {
181+
return null;
182+
}
183+
return (
184+
<div
185+
className={cx(
186+
'border-3 border-dashed',
187+
'pointer-events-auto',
188+
'cursor-copy text-sm text-center',
189+
'p-2 w-full fixed bottom-0 h-16',
190+
'flex flex-col items-center justify-center',
191+
'bg-red-950 border-red-600',
192+
)}>
193+
You cannot upload into<br /><strong>{currentDirectory}</strong>
194+
</div>
195+
);
196+
}
197+
198+
return (
199+
<div {...getRootProps()} className={isDraggingAnywhere ? 'fixed top-0 bottom-0 w-full' +
200+
' pointer-events-none' : ''}>
201+
<input id="dropTarget" {...getInputProps()} />
202+
<div
203+
className={cx(
204+
'border-3 border-dashed',
205+
'pointer-events-auto',
206+
'cursor-copy text-sm text-center',
207+
'p-2 w-full fixed bottom-0 h-16',
208+
'flex flex-col items-center justify-center',
209+
isDraggingAnywhere
210+
? 'animate-glow-pulse bg-green-950 border-green-600'
211+
: 'bg-purple-950 border-purple-600',
212+
)}>
213+
{
214+
uploading
215+
? uploading
216+
: dragTarget
217+
? <>Drop to upload into<br /><strong>{dragTargetDirectory}</strong></>
218+
: isDraggingAnywhere
219+
? <>Drop to upload into<br /><strong>{currentDirectory}</strong></>
220+
: <>Drag and drop to upload<br />or click to select files.</>
221+
}
222+
</div>
223+
</div>
224+
);
225+
}
226+
227+
function getFilePath(intoPath: string, file: FileWithPath): string {
228+
const relativePath = file.relativePath ?? file.name;
229+
const firstSlashIndex = relativePath.indexOf('/');
230+
const trimmedRelativePath = firstSlashIndex === 0 || firstSlashIndex === 1 ? relativePath.slice(firstSlashIndex + 1) : relativePath;
231+
return intoPath ? `${intoPath}/${trimmedRelativePath}` : trimmedRelativePath;
232+
}

src/features/instance/applications/components/ApplicationsSidebar/ItemTitle.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function ItemTitle({ title, item, context }: {
2727
pkg={!!item.data?.package || item.data.path === importedApplications} />
2828
: <FileTypeIcon extension={parseFileExtension(title)} />
2929
}
30-
<span className="text-nowrap">{title}{content ? '*' : ''}</span>
30+
<span className="text-nowrap pointer-events-none">{title}{content ? '*' : ''}</span>
3131
{item.data?.package && <LockedIcon />}
3232
</>;
3333
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { iconSharedClassName } from './constants';
22

33
export function LockedIcon() {
4-
return <i className={iconSharedClassName + 'fas fa-lock text-gray-400 ml-2'} />;
4+
return <i className={iconSharedClassName + 'fas fa-lock text-gray-400 ml-2 packageIsLocked'} />;
55
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export const iconSharedClassName = 'w-6 ';
1+
export const iconSharedClassName = 'w-6 pointer-events-none ';

0 commit comments

Comments
 (0)