Skip to content

Commit 5abfc3d

Browse files
nreshclaude
authored andcommitted
feat: improve DuckDB onboarding upload UX (#2180)
Co-authored-by: Claude <noreply@anthropic.com> GitOrigin-RevId: b1f09699594186209105a326976a865a3262f236
1 parent 6da1fb7 commit 5abfc3d

4 files changed

Lines changed: 192 additions & 70 deletions

File tree

web-app/src/components/workspaces/components/CreateWorkspaceDialog/components/OnboardingThread.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ function renderInputBlock(
478478
fields={block.fields}
479479
buttonLabel={block.buttonLabel}
480480
initialValues={block.initialValues}
481+
initialUploadedFiles={block.initialUploadedFiles}
481482
disabled={block.busy}
482483
errorMessage={block.errorMessage}
483484
onSubmit={(values) => {

web-app/src/components/workspaces/components/CreateWorkspaceDialog/components/components/CredentialForm.tsx

Lines changed: 152 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ArrowRight, FileIcon, Loader2, UploadCloud } from "lucide-react";
1+
import { ArrowRight, CheckCircle2, FileIcon, Loader2, Trash2, UploadCloud } from "lucide-react";
22
import { type ChangeEvent, type DragEvent, useCallback, useRef, useState } from "react";
33
import { SecretInput } from "@/components/ui/SecretInput";
44
import { Button } from "@/components/ui/shadcn/button";
@@ -8,17 +8,16 @@ import { cn } from "@/libs/shadcn/utils";
88
import type { CredentialField } from "../types";
99

1010
/**
11-
* Per-field state for `file_upload` fields. Tracks the files the user picked,
12-
* whether an upload is in flight, which paths the backend confirmed, and any
13-
* error from the last upload attempt. One instance is keyed per field, so a
14-
* form can hypothetically carry multiple upload fields — today only DuckDB
15-
* uses this.
11+
* Per-field state for `file_upload` fields. Files the user has picked stay in
12+
* `selected` until the user clicks the form CTA — only then does the actual
13+
* upload happen. After a successful upload, the server-confirmed paths land
14+
* in `uploadedPaths`. One instance is keyed per field, so a form can
15+
* hypothetically carry multiple upload fields — today only DuckDB uses this.
1616
*/
1717
interface FileUploadState {
1818
selected: File[];
1919
uploading: boolean;
2020
uploadedPaths: string[];
21-
subdir?: string;
2221
error?: string;
2322
}
2423

@@ -34,16 +33,24 @@ interface CredentialFormProps {
3433
onSubmit: (values: Record<string, string>) => void;
3534
disabled?: boolean;
3635
initialValues?: Record<string, string>;
36+
/**
37+
* Pre-populated uploaded paths per `file_upload` field key. Lets the form
38+
* remember files an earlier visit already sent so the user isn't blocked
39+
* with a disabled CTA after navigating back.
40+
*/
41+
initialUploadedFiles?: Record<string, string[]>;
3742
/**
3843
* Inline error surfaced below the form. Used by the GitHub onboarding flow
3944
* to show a failed connection-test message so the user can fix typos and
4045
* retry without losing what they've already typed.
4146
*/
4247
errorMessage?: string;
4348
/**
44-
* Called when the user picks files for a `file_upload` field. Should upload
45-
* the files and resolve with the server-assigned subdir + file paths, or
46-
* reject (the form surfaces the error inline).
49+
* Invoked when the user clicks the form CTA with pending file selections.
50+
* Should upload the files and resolve with the server-assigned subdir + file
51+
* paths, or reject (the form surfaces the error inline). Files are NOT
52+
* uploaded on drag/drop or pick — the form holds them locally so the user
53+
* can remove mistakes before committing.
4754
*/
4855
onFileUpload?: (files: File[]) => Promise<FileUploadResult | null>;
4956
}
@@ -54,6 +61,7 @@ export default function CredentialForm({
5461
onSubmit,
5562
disabled,
5663
initialValues,
64+
initialUploadedFiles,
5765
errorMessage,
5866
onFileUpload
5967
}: CredentialFormProps) {
@@ -70,7 +78,15 @@ export default function CredentialForm({
7078
return initial;
7179
});
7280

73-
const [uploads, setUploads] = useState<Record<string, FileUploadState>>({});
81+
const [uploads, setUploads] = useState<Record<string, FileUploadState>>(() => {
82+
if (!initialUploadedFiles) return {};
83+
const seeded: Record<string, FileUploadState> = {};
84+
for (const [key, paths] of Object.entries(initialUploadedFiles)) {
85+
if (paths.length === 0) continue;
86+
seeded[key] = { selected: [], uploading: false, uploadedPaths: paths };
87+
}
88+
return seeded;
89+
});
7490

7591
const handleChange = useCallback((key: string, value: string) => {
7692
setValues((prev) => ({ ...prev, [key]: value }));
@@ -81,12 +97,16 @@ export default function CredentialForm({
8197
if (!field.required) return true;
8298
if (field.type === "file_upload") {
8399
const state = uploads[field.key];
84-
return !!state && state.uploadedPaths.length > 0 && !state.uploading;
100+
if (!state || state.uploading) return false;
101+
// Pending picks only count if a handler can actually upload them —
102+
// otherwise the CTA would submit with the field value still empty.
103+
if (state.selected.length > 0 && onFileUpload) return true;
104+
return state.uploadedPaths.length > 0;
85105
}
86106
const v = values[field.key];
87107
return typeof v === "string" && v.trim() !== "";
88108
},
89-
[uploads, values]
109+
[uploads, values, onFileUpload]
90110
);
91111

92112
/** Per-field validation message, or undefined when valid / empty. */
@@ -111,67 +131,105 @@ export default function CredentialForm({
111131
const allValid = fields.every((f) => validationError(f) === undefined);
112132
const anyUploading = Object.values(uploads).some((s) => s.uploading);
113133

114-
const handleSubmit = useCallback(() => {
115-
if (!allRequiredFilled || !allValid || anyUploading) return;
116-
onSubmit(values);
117-
}, [allRequiredFilled, allValid, anyUploading, onSubmit, values]);
118-
119-
const runUpload = useCallback(
120-
async (fieldKey: string, files: File[]) => {
121-
if (files.length === 0 || !onFileUpload) return;
122-
setUploads((prev) => ({
134+
const addSelectedFiles = useCallback((fieldKey: string, files: File[]) => {
135+
if (files.length === 0) return;
136+
setUploads((prev) => {
137+
const current = prev[fieldKey] ?? {
138+
selected: [],
139+
uploading: false,
140+
uploadedPaths: []
141+
};
142+
// Drop dupes (same name + size) so re-picking the same file doesn't
143+
// create a phantom row the user can't tell apart from the original.
144+
const seen = new Set(current.selected.map((f) => `${f.name}:${f.size}`));
145+
const deduped = files.filter((f) => !seen.has(`${f.name}:${f.size}`));
146+
return {
123147
...prev,
124148
[fieldKey]: {
125-
selected: [...(prev[fieldKey]?.selected ?? []), ...files],
126-
uploading: true,
127-
uploadedPaths: prev[fieldKey]?.uploadedPaths ?? [],
128-
subdir: prev[fieldKey]?.subdir,
149+
...current,
150+
selected: [...current.selected, ...deduped],
129151
error: undefined
130152
}
153+
};
154+
});
155+
}, []);
156+
157+
const removeSelectedFile = useCallback((fieldKey: string, index: number) => {
158+
setUploads((prev) => {
159+
const current = prev[fieldKey];
160+
if (!current) return prev;
161+
const next = [...current.selected];
162+
next.splice(index, 1);
163+
return { ...prev, [fieldKey]: { ...current, selected: next } };
164+
});
165+
}, []);
166+
167+
const handleSubmit = useCallback(async () => {
168+
if (!allRequiredFilled || !allValid || anyUploading) return;
169+
170+
// Upload any pending file_upload selections first, then submit with the
171+
// server-chosen subdir as the field value (DuckDB uses this as
172+
// `file_search_path`). Bail out without submitting if an upload fails.
173+
let updatedValues = values;
174+
for (const field of fields) {
175+
if (field.type !== "file_upload") continue;
176+
const state = uploads[field.key];
177+
if (!state?.selected.length || !onFileUpload) continue;
178+
179+
const filesToUpload = state.selected;
180+
setUploads((prev) => ({
181+
...prev,
182+
[field.key]: { ...prev[field.key]!, uploading: true, error: undefined }
131183
}));
184+
132185
try {
133-
const result = await onFileUpload(files);
134-
setUploads((prev) => {
135-
const current = prev[fieldKey];
136-
if (!current) return prev;
137-
if (!result) {
138-
return {
139-
...prev,
140-
[fieldKey]: { ...current, uploading: false, error: "Upload failed." }
141-
};
142-
}
143-
return {
186+
const result = await onFileUpload(filesToUpload);
187+
if (!result) {
188+
setUploads((prev) => ({
144189
...prev,
145-
[fieldKey]: {
146-
...current,
147-
uploading: false,
148-
uploadedPaths: [...current.uploadedPaths, ...result.files],
149-
subdir: result.subdir
150-
}
151-
};
152-
});
153-
if (result) {
154-
// Seed the form value with the server-chosen subdir so the parent
155-
// gets a meaningful value on submit (e.g. ".db" for DuckDB).
156-
handleChange(fieldKey, result.subdir);
190+
[field.key]: { ...prev[field.key]!, uploading: false, error: "Upload failed." }
191+
}));
192+
return;
157193
}
158-
} catch (err) {
159194
setUploads((prev) => {
160-
const current = prev[fieldKey];
195+
const current = prev[field.key];
161196
if (!current) return prev;
162197
return {
163198
...prev,
164-
[fieldKey]: {
165-
...current,
199+
[field.key]: {
200+
selected: [],
166201
uploading: false,
167-
error: err instanceof Error ? err.message : "Upload failed."
202+
uploadedPaths: [...current.uploadedPaths, ...result.files]
168203
}
169204
};
170205
});
206+
updatedValues = { ...updatedValues, [field.key]: result.subdir };
207+
handleChange(field.key, result.subdir);
208+
} catch (err) {
209+
setUploads((prev) => ({
210+
...prev,
211+
[field.key]: {
212+
...prev[field.key]!,
213+
uploading: false,
214+
error: err instanceof Error ? err.message : "Upload failed."
215+
}
216+
}));
217+
return;
171218
}
172-
},
173-
[onFileUpload, handleChange]
174-
);
219+
}
220+
221+
onSubmit(updatedValues);
222+
}, [
223+
allRequiredFilled,
224+
allValid,
225+
anyUploading,
226+
fields,
227+
uploads,
228+
values,
229+
onFileUpload,
230+
onSubmit,
231+
handleChange
232+
]);
175233

176234
return (
177235
<div className='flex flex-col gap-3'>
@@ -193,7 +251,8 @@ export default function CredentialForm({
193251
multiple={field.multiple ?? false}
194252
disabled={disabled}
195253
state={uploads[field.key]}
196-
onPick={(files) => runUpload(field.key, files)}
254+
onPick={(files) => addSelectedFiles(field.key, files)}
255+
onRemoveSelected={(index) => removeSelectedFile(field.key, index)}
197256
/>
198257
</div>
199258
);
@@ -254,8 +313,9 @@ export default function CredentialForm({
254313
disabled={disabled || !allRequiredFilled || !allValid || anyUploading}
255314
size='sm'
256315
>
257-
{buttonLabel}
258-
<ArrowRight className='ml-1 h-3 w-3' />
316+
{anyUploading ? <Loader2 className='size-3 animate-spin' /> : null}
317+
{anyUploading ? "Uploading…" : buttonLabel}
318+
{!anyUploading && <ArrowRight className='size-3' />}
259319
</Button>
260320
</div>
261321
</div>
@@ -271,6 +331,7 @@ interface FileUploadFieldProps {
271331
disabled?: boolean;
272332
state?: FileUploadState;
273333
onPick: (files: File[]) => void;
334+
onRemoveSelected: (index: number) => void;
274335
}
275336

276337
function FileUploadField({
@@ -281,7 +342,8 @@ function FileUploadField({
281342
multiple,
282343
disabled,
283344
state,
284-
onPick
345+
onPick,
346+
onRemoveSelected
285347
}: FileUploadFieldProps) {
286348
const inputRef = useRef<HTMLInputElement | null>(null);
287349
const [isDragging, setIsDragging] = useState(false);
@@ -330,7 +392,10 @@ function FileUploadField({
330392
[handleFiles]
331393
);
332394

333-
const hasUploads = (state?.uploadedPaths.length ?? 0) > 0;
395+
const selectedFiles = state?.selected ?? [];
396+
const uploadedPaths = state?.uploadedPaths ?? [];
397+
const hasSelected = selectedFiles.length > 0;
398+
const hasUploaded = uploadedPaths.length > 0;
334399
const isUploading = state?.uploading ?? false;
335400

336401
return (
@@ -376,14 +441,36 @@ function FileUploadField({
376441
/>
377442
{helperText && <p className='text-muted-foreground text-xs'>{helperText}</p>}
378443
{state?.error && <p className='text-destructive text-xs'>{state.error}</p>}
379-
{hasUploads && state && (
444+
{(hasSelected || hasUploaded) && (
380445
<ul className='flex flex-col gap-1'>
381-
{state.uploadedPaths.map((path) => (
446+
{selectedFiles.map((file, index) => (
382447
<li
383-
key={path}
448+
// addSelectedFiles dedupes by name+size, so the pair is unique
449+
// within `selected` and stable across sibling removals.
450+
key={`${file.name}-${file.size}`}
384451
className='flex items-center gap-2 rounded border border-border bg-muted/40 px-2 py-1 text-xs'
385452
>
386453
<FileIcon className='h-3 w-3 text-muted-foreground' />
454+
<span className='flex-1 truncate font-mono'>{file.name}</span>
455+
<Button
456+
type='button'
457+
variant='ghost'
458+
size='icon'
459+
className='size-5 text-muted-foreground hover:text-destructive'
460+
onClick={() => onRemoveSelected(index)}
461+
disabled={disabled || isUploading}
462+
aria-label={`Remove ${file.name}`}
463+
>
464+
<Trash2 className='size-3' />
465+
</Button>
466+
</li>
467+
))}
468+
{uploadedPaths.map((path) => (
469+
<li
470+
key={path}
471+
className='flex items-center gap-2 rounded border border-border bg-muted/40 px-2 py-1 text-xs'
472+
>
473+
<CheckCircle2 className='size-3 text-success' />
387474
<span className='flex-1 truncate font-mono'>{path}</span>
388475
</li>
389476
))}

0 commit comments

Comments
 (0)