feat: upload file dialog err handling & new flow

- add basic err handling to upload file dialog.
- rework the upload flow. now, on all successful uploads, the dialog
won't auto disappear. if some fails, the dialog will allow for retry.
This commit is contained in:
2025-10-12 23:48:21 +00:00
parent b17de812b9
commit 2ed8be94f1
5 changed files with 345 additions and 50 deletions

View File

@@ -0,0 +1,71 @@
import { useAtomValue } from "jotai"
import { CircleAlertIcon, XIcon } from "lucide-react"
import type React from "react"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import { Tooltip } from "@/components/ui/tooltip"
import { FileUploadStatusKind, fileUploadStatusAtomFamily } from "./store"
import type { PickedFile } from "./upload-file-dialog"
export function PickedFileItem({
file: pickedFile,
onRemove,
}: {
file: PickedFile
onRemove: (file: PickedFile) => void
}) {
const fileUploadAtom = fileUploadStatusAtomFamily(pickedFile.id)
const fileUpload = useAtomValue(fileUploadAtom)
console.log("fileUpload", fileUpload)
const { file, id } = pickedFile
let statusIndicator: React.ReactNode
if (!fileUpload) {
statusIndicator = (
<Button
variant="ghost"
size="icon"
onClick={() => onRemove(pickedFile)}
>
<XIcon className="size-4" />
</Button>
)
} else {
switch (fileUpload.kind) {
case FileUploadStatusKind.InProgress:
statusIndicator = <Progress value={fileUpload.progress * 100} />
break
case FileUploadStatusKind.Error:
statusIndicator = (
<Tooltip>
<TooltipTrigger>
<CircleAlertIcon />
</TooltipTrigger>
</Tooltip>
)
}
}
return (
<li
className="pl-3 pr-1 py-0.5 h-8 hover:bg-muted flex justify-between items-center"
key={id}
>
<span>{file.name}</span>
{fileUpload ? (
<Progress
className="max-w-20"
value={fileUpload.progress * 100}
/>
) : (
<Button
variant="ghost"
size="icon"
onClick={() => onRemove(pickedFile)}
>
<XIcon className="size-4" />
</Button>
)}
</li>
)
}

View File

@@ -1,19 +1,39 @@
import { atom } from "jotai"
import { atomFamily } from "jotai/utils"
type FileUpload = {
id: string
export enum FileUploadStatusKind {
InProgress = "InProgress",
Error = "Error",
Success = "Success",
}
export type FileUploadInProgress = {
kind: FileUploadStatusKind.InProgress
progress: number
}
export const fileUploadsAtom = atom<Record<string, FileUpload>>({})
export type FileUploadError = {
kind: FileUploadStatusKind.Error
error: unknown
}
export const fileUploadAtomFamily = atomFamily((id: string) =>
export type FileUploadSuccess = {
kind: FileUploadStatusKind.Success
}
export type FileUploadStatus =
| FileUploadInProgress
| FileUploadError
| FileUploadSuccess
export const fileUploadsAtom = atom<Record<string, FileUploadStatus>>({})
export const fileUploadStatusAtomFamily = atomFamily((id: string) =>
atom(
(get) => get(fileUploadsAtom)[id],
(get, set, progress: number) => {
(get, set, status: FileUploadStatus) => {
const fileUploads = { ...get(fileUploadsAtom) }
fileUploads[id] = { id, progress }
fileUploads[id] = status
set(fileUploadsAtom, fileUploads)
},
),
@@ -22,12 +42,56 @@ export const fileUploadAtomFamily = atomFamily((id: string) =>
export const clearFileUploadAtom = atom(null, (get, set, id: string) => {
const fileUploads = { ...get(fileUploadsAtom) }
delete fileUploads[id]
fileUploadAtomFamily.remove(id)
fileUploadStatusAtomFamily.remove(id)
set(fileUploadsAtom, fileUploads)
})
export const clearFileUploadStatusesAtom = atom(
null,
(get, set, ids: string[]) => {
const fileUploads = { ...get(fileUploadsAtom) }
for (const id of ids) {
if (fileUploads[id]) {
delete fileUploads[id]
}
fileUploadStatusAtomFamily.remove(id)
}
set(fileUploadsAtom, fileUploads)
},
)
export const fileUploadCountAtom = atom(
(get) => Object.keys(get(fileUploadsAtom)).length,
)
export const hasFileUploadsAtom = atom((get) => get(fileUploadCountAtom) > 0)
export const inProgressFileUploadCountAtom = atom((get) => {
const statuses = get(fileUploadsAtom)
let count = 0
for (const status in statuses) {
if (statuses[status]?.kind === FileUploadStatusKind.InProgress) {
count += 1
}
}
return count
})
export const successfulFileUploadCountAtom = atom((get) => {
const statuses = get(fileUploadsAtom)
let count = 0
for (const status in statuses) {
if (statuses[status]?.kind === FileUploadStatusKind.Success) {
count += 1
}
}
return count
})
export const hasFileUploadsErrorAtom = atom((get) => {
const statuses = get(fileUploadsAtom)
for (const status in statuses) {
if (statuses[status]?.kind === FileUploadStatusKind.Error) {
return true
}
}
return false
})

View File

@@ -2,7 +2,13 @@ import type { Doc } from "@fileone/convex/_generated/dataModel"
import { useMutation } from "@tanstack/react-query"
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai"
import { atomEffect } from "jotai-effect"
import { FilePlus2Icon, UploadCloudIcon, XIcon } from "lucide-react"
import {
CircleAlertIcon,
CircleCheckIcon,
FilePlus2Icon,
UploadCloudIcon,
XIcon,
} from "lucide-react"
import { nanoid } from "nanoid"
import type React from "react"
import { useId, useMemo, useRef, useState } from "react"
@@ -17,8 +23,21 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Progress } from "@/components/ui/progress"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { WithAtom } from "@/components/with-atom"
import { clearFileUploadAtom, fileUploadAtomFamily } from "./store"
import { formatError } from "@/lib/error"
import {
clearFileUploadStatusesAtom,
FileUploadStatusKind,
fileUploadCountAtom,
fileUploadStatusAtomFamily,
fileUploadsAtom,
hasFileUploadsErrorAtom,
} from "./store"
import useUploadFile from "./use-upload-file"
type UploadFileDialogProps = {
@@ -41,7 +60,7 @@ export function UploadFileDialog({
const formId = useId()
const fileInputRef = useRef<HTMLInputElement>(null)
const setPickedFiles = useSetAtom(pickedFilesAtom)
const clearFileUpload = useSetAtom(clearFileUploadAtom)
const clearFileUploadStatuses = useSetAtom(clearFileUploadStatusesAtom)
const store = useStore()
const updateFileInputEffect = useMemo(
@@ -69,21 +88,58 @@ export function UploadFileDialog({
uploadFile({
file: pickedFile.file,
onStart: () => {
store.set(fileUploadAtomFamily(pickedFile.id), 0)
store.set(fileUploadStatusAtomFamily(pickedFile.id), {
kind: FileUploadStatusKind.InProgress,
progress: 0,
})
},
onProgress: (progress) => {
store.set(fileUploadAtomFamily(pickedFile.id), progress)
store.set(fileUploadStatusAtomFamily(pickedFile.id), {
kind: FileUploadStatusKind.InProgress,
progress,
})
},
}).then(() => {
clearFileUpload(pickedFile.id)
})
.then(() => {
// clearFileUpload(pickedFile.id)
})
.catch((error) => {
console.log("error", error)
store.set(fileUploadStatusAtomFamily(pickedFile.id), {
kind: FileUploadStatusKind.Error,
error,
})
throw error
}),
)
await Promise.all(promises)
return await Promise.allSettled(promises)
},
onSuccess: () => {
onSuccess: (results, files) => {
const remainingPickedFiles: PickedFile[] = []
results.forEach((result, i) => {
// biome-ignore lint/style/noNonNullAssertion: results lenght must match input files array length
const pickedFile = files[i]!
const statusAtom = fileUploadStatusAtomFamily(pickedFile.id)
switch (result.status) {
case "fulfilled":
store.set(statusAtom, {
kind: FileUploadStatusKind.Success,
})
break
case "rejected":
store.set(statusAtom, {
kind: FileUploadStatusKind.Error,
error: result.reason,
})
remainingPickedFiles.push(pickedFile)
break
}
})
// setPickedFiles(remainingPickedFiles)
if (remainingPickedFiles.length === 0) {
toast.success("All files uploaded successfully")
setPickedFiles([])
onClose()
}
},
})
@@ -105,9 +161,50 @@ export function UploadFileDialog({
}
}
function startFileUpload() {
function onUploadButtonClick() {
const uploadStatuses = store.get(fileUploadsAtom)
const fileUploadCount = store.get(fileUploadCountAtom)
const pickedFiles = store.get(pickedFilesAtom)
if (pickedFiles.length === 0) {
// no files are picked, nothing to upload
return
}
if (fileUploadCount === 0) {
// no files are being uploaded, upload all picked files
uploadFiles(pickedFiles)
return
}
const successfulUploads: PickedFile["id"][] = []
const nextPickedFiles: PickedFile[] = []
for (const file of pickedFiles) {
const uploadStatus = uploadStatuses[file.id]
if (uploadStatus) {
switch (uploadStatus.kind) {
case FileUploadStatusKind.Success:
successfulUploads.push(file.id)
continue
case FileUploadStatusKind.InProgress:
continue
case FileUploadStatusKind.Error:
nextPickedFiles.push(file)
break
}
}
}
clearFileUploadStatuses(successfulUploads)
if (successfulUploads.length === pickedFiles.length) {
// all files were successfully uploaded, close the dialog
onClose()
} else {
// some files were not successfully uploaded, set the next picked files
setPickedFiles(nextPickedFiles)
uploadFiles(nextPickedFiles)
}
}
let dialogTitle: string
@@ -165,15 +262,11 @@ export function UploadFileDialog({
Select more files
</Button>
) : null}
<Button
onClick={startFileUpload}
<UploadButton
disabled={isUploading}
loading={isUploading}
>
{pickedFiles.length === 0
? "Upload"
: `Upload ${pickedFiles.length} files`}
</Button>
onClick={onUploadButtonClick}
/>
</>
)}
</WithAtom>
@@ -183,6 +276,39 @@ export function UploadFileDialog({
)
}
function UploadButton({
disabled,
loading,
onClick,
}: {
disabled: boolean
loading: boolean
onClick: () => void
}) {
const pickedFiles = useAtomValue(pickedFilesAtom)
const hasUploadErrors = useAtomValue(hasFileUploadsErrorAtom)
const fileUploadCount = useAtomValue(fileUploadCountAtom)
let label: string
if (hasUploadErrors) {
label = "Retry failed uploads"
} else if (pickedFiles.length > 0) {
if (fileUploadCount > 0) {
label = "Done"
} else {
label = `Upload ${pickedFiles.length} files`
}
} else {
label = "Upload"
}
return (
<Button onClick={onClick} disabled={disabled} loading={loading}>
{label}
</Button>
)
}
function UploadFileDropContainer({ children }: React.PropsWithChildren) {
const [draggedFiles, setDraggedFiles] = useState<DataTransferItem[]>([])
const setPickedFiles = useSetAtom(pickedFilesAtom)
@@ -239,6 +365,7 @@ function UploadFileDropContainer({ children }: React.PropsWithChildren) {
)
}
// tag: uploadfilearea area fileuploadarea
function UploadFileArea({ onClick }: { onClick: () => void }) {
const [pickedFiles, setPickedFiles] = useAtom(pickedFilesAtom)
@@ -294,22 +421,14 @@ function PickedFileItem({
file: PickedFile
onRemove: (file: PickedFile) => void
}) {
const fileUploadAtom = fileUploadAtomFamily(pickedFile.id)
const fileUploadAtom = fileUploadStatusAtomFamily(pickedFile.id)
const fileUpload = useAtomValue(fileUploadAtom)
console.log("fileUpload", fileUpload)
const { file, id } = pickedFile
return (
<li
className="pl-3 pr-1 py-0.5 h-8 hover:bg-muted flex justify-between items-center"
key={id}
>
<span>{file.name}</span>
{fileUpload ? (
<Progress
className="max-w-20"
value={fileUpload.progress * 100}
/>
) : (
let statusIndicator: React.ReactNode
if (!fileUpload) {
statusIndicator = (
<Button
variant="ghost"
size="icon"
@@ -317,7 +436,47 @@ function PickedFileItem({
>
<XIcon className="size-4" />
</Button>
)}
)
} else {
switch (fileUpload.kind) {
case FileUploadStatusKind.InProgress:
statusIndicator = (
<Progress
className="max-w-20"
value={fileUpload.progress * 100}
/>
)
break
case FileUploadStatusKind.Error:
statusIndicator = (
<Tooltip>
<TooltipTrigger>
<CircleAlertIcon className="pr-2 text-destructive" />
</TooltipTrigger>
<TooltipContent>
<p>
Failed to upload file:{" "}
{formatError(fileUpload.error)}
</p>
</TooltipContent>
</Tooltip>
)
break
case FileUploadStatusKind.Success:
statusIndicator = (
<CircleCheckIcon className="pr-2 text-green-500" />
)
break
}
}
return (
<li
className="pl-3 pr-1 py-0.5 h-8 hover:bg-muted flex justify-between items-center"
key={id}
>
<span>{file.name}</span>
{statusIndicator}
</li>
)
}

View File

@@ -29,7 +29,6 @@ function useUploadFile({
})
xhr.upload.addEventListener("error", reject)
xhr.addEventListener("load", () => {
console.log("load", xhr.response)
resolve(
xhr.response as {
storageId: Id<"_storage">

View File

@@ -44,7 +44,7 @@ import { FilePathBreadcrumb } from "@/directories/directory-page/file-path-bread
import { NewDirectoryDialog } from "@/directories/directory-page/new-directory-dialog"
import { RenameFileDialog } from "@/directories/directory-page/rename-file-dialog"
import { FilePreviewDialog } from "@/files/file-preview-dialog"
import { fileUploadCountAtom } from "@/files/store"
import { inProgressFileUploadCountAtom } from "@/files/store"
import { UploadFileDialog } from "@/files/upload-file-dialog"
import type { FileDragInfo } from "@/files/use-file-drop"
@@ -333,7 +333,9 @@ function RenameMenuItem() {
function UploadFileButton() {
const { directory } = useContext(DirectoryPageContext)
const setActiveDialogData = useSetAtom(activeDialogDataAtom)
const fileUploadCount = useAtomValue(fileUploadCountAtom)
const inProgressFileUploadCount = useAtomValue(
inProgressFileUploadCountAtom,
)
const handleClick = () => {
setActiveDialogData({
@@ -342,10 +344,10 @@ function UploadFileButton() {
})
}
if (fileUploadCount > 0) {
if (inProgressFileUploadCount > 0) {
return (
<Button size="sm" type="button" loading onClick={handleClick}>
Uploading {fileUploadCount} files
Uploading {inProgressFileUploadCount} files
</Button>
)
}