feat: enhance import/export functionality with detailed results dialog and improved data handling
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Download, Loader2 } from "lucide-react"
|
||||
import { Upload, Loader2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type ExportDataButtonProps = {
|
||||
@@ -47,7 +47,7 @@ export function ExportDataButton({
|
||||
disabled={isPending}
|
||||
onClick={handleExport}
|
||||
>
|
||||
{isPending ? <Loader2 className="animate-spin" /> : <Download />}
|
||||
{isPending ? <Loader2 className="animate-spin" /> : <Upload />}
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cn } from "@/shared/lib/utils"
|
||||
|
||||
export type ImageInputFieldProps = BaseFieldControlProps<File | null> & {
|
||||
accept?: string
|
||||
initialPreviewUrl?: string | null
|
||||
}
|
||||
|
||||
export function ImageInputField({
|
||||
@@ -17,21 +18,24 @@ export function ImageInputField({
|
||||
disabled,
|
||||
invalid,
|
||||
accept = "image/*",
|
||||
initialPreviewUrl,
|
||||
}: ImageInputFieldProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [preview, setPreview] = useState<string | null>(null)
|
||||
const [filePreview, setFilePreview] = useState<string | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setPreview(null)
|
||||
setFilePreview(null)
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(value)
|
||||
setPreview(url)
|
||||
setFilePreview(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}, [value])
|
||||
|
||||
const preview = filePreview ?? initialPreviewUrl ?? null
|
||||
|
||||
const handleFile = useCallback(
|
||||
(file: File | null) => {
|
||||
if (file && !file.type.startsWith("image/")) return
|
||||
@@ -110,7 +114,7 @@ export function ImageInputField({
|
||||
alt="Preview"
|
||||
className="max-h-30 max-w-full rounded-md object-contain"
|
||||
/>
|
||||
{!disabled && (
|
||||
{!disabled && filePreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
import React, { useRef, useState } from "react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Upload, Loader2 } from "lucide-react"
|
||||
import { Download, Loader2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { ImportResultsDialog } from "@/shared/components/import-results-dialog"
|
||||
import type { ImportDataResponse } from "@garage/api"
|
||||
|
||||
type ImportDataButtonProps = {
|
||||
onImport: (file: File) => Promise<{ message?: string }>
|
||||
onImport: (file: File) => Promise<ImportDataResponse>
|
||||
onSuccess?: () => void
|
||||
accept?: string
|
||||
label?: string
|
||||
entityLabel?: string
|
||||
}
|
||||
|
||||
export function ImportDataButton({
|
||||
@@ -17,9 +20,12 @@ export function ImportDataButton({
|
||||
onSuccess,
|
||||
accept = ".xlsx,.xls,.csv",
|
||||
label = "Import",
|
||||
entityLabel,
|
||||
}: ImportDataButtonProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const [result, setResult] = useState<ImportDataResponse["data"] | null>(null)
|
||||
const [resultOpen, setResultOpen] = useState(false)
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
@@ -27,9 +33,16 @@ export function ImportDataButton({
|
||||
|
||||
setIsPending(true)
|
||||
try {
|
||||
const result = await onImport(file)
|
||||
toast.success(result.message ?? "Data imported successfully")
|
||||
onSuccess?.()
|
||||
const response = await onImport(file)
|
||||
const data = response.data ?? { imported_count: 0, failed_count: 0, failed_rows: [] }
|
||||
setResult(data)
|
||||
setResultOpen(true)
|
||||
|
||||
if (data.failed_count === 0) {
|
||||
onSuccess?.()
|
||||
} else if (data.imported_count > 0) {
|
||||
onSuccess?.()
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message ?? "Failed to import data")
|
||||
} finally {
|
||||
@@ -53,9 +66,19 @@ export function ImportDataButton({
|
||||
disabled={isPending}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{isPending ? <Loader2 className="animate-spin" /> : <Upload />}
|
||||
{isPending ? <Loader2 className="animate-spin" /> : <Download />}
|
||||
{label}
|
||||
</Button>
|
||||
{result && (
|
||||
<ImportResultsDialog
|
||||
open={resultOpen}
|
||||
onOpenChange={setResultOpen}
|
||||
importedCount={result.imported_count}
|
||||
failedCount={result.failed_count}
|
||||
failedRows={result.failed_rows}
|
||||
entityLabel={entityLabel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckCircle2, XCircle } from "lucide-react"
|
||||
import type { ImportFailureRow } from "@garage/api"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
|
||||
export type { ImportFailureRow }
|
||||
|
||||
export type ImportResultsDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
importedCount: number
|
||||
failedCount: number
|
||||
failedRows: ImportFailureRow[]
|
||||
entityLabel?: string
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") return "—"
|
||||
if (typeof value === "string") return value
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
export function ImportResultsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
importedCount,
|
||||
failedCount,
|
||||
failedRows,
|
||||
entityLabel = "Records",
|
||||
}: ImportResultsDialogProps) {
|
||||
const hasFailures = failedCount > 0
|
||||
const flatFailures = failedRows.flatMap((r) =>
|
||||
r.errors.map((e) => ({ row: r.row, ...e })),
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[min(96vw,64rem)] sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import results</DialogTitle>
|
||||
<DialogDescription>
|
||||
{hasFailures
|
||||
? "Some rows could not be imported. Fix the issues below and re-upload only the failing rows."
|
||||
: `All ${entityLabel.toLowerCase()} imported successfully.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex items-center gap-2 rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">
|
||||
<CheckCircle2 className="size-4" />
|
||||
<span>
|
||||
<strong>{importedCount}</strong> imported
|
||||
</span>
|
||||
</div>
|
||||
{hasFailures && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
|
||||
<XCircle className="size-4" />
|
||||
<span>
|
||||
<strong>{failedCount}</strong> failed
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasFailures && (
|
||||
<ScrollArea className="max-h-96 rounded-md border">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">Row</TableHead>
|
||||
<TableHead className="w-40">Field</TableHead>
|
||||
<TableHead className="w-56">Value</TableHead>
|
||||
<TableHead>Reason</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{flatFailures.map((f, idx) => (
|
||||
<TableRow key={`${f.row}-${f.field}-${idx}`}>
|
||||
<TableCell className="font-mono text-xs">{f.row}</TableCell>
|
||||
<TableCell className="font-medium">{f.label}</TableCell>
|
||||
<TableCell className="text-muted-foreground break-all whitespace-pre-wrap">
|
||||
{formatValue(f.value)}
|
||||
</TableCell>
|
||||
<TableCell className="break-words">
|
||||
<div>{f.message}</div>
|
||||
{f.valid_examples && f.valid_examples.length > 0 && (
|
||||
<details className="mt-1 text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer">
|
||||
Try one of: {f.valid_examples.slice(0, 3).join(", ")}
|
||||
{f.valid_examples.length > 3 ? "…" : ""}
|
||||
</summary>
|
||||
<div className="mt-1">
|
||||
{f.valid_examples.join(", ")}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -16,11 +16,12 @@ type UseResourceFormOptions<TFormValues extends FieldValues, TApiData = unknown>
|
||||
queryKey?: QueryKey
|
||||
}
|
||||
|
||||
type UseResourceFormReturn<TFormValues extends FieldValues> = {
|
||||
type UseResourceFormReturn<TFormValues extends FieldValues, TApiData = unknown> = {
|
||||
form: UseFormReturn<TFormValues>
|
||||
isEditing: boolean
|
||||
isInitializing: boolean
|
||||
invalidate: () => void
|
||||
data: TApiData | undefined
|
||||
}
|
||||
|
||||
export function useResourceForm<TFormValues extends FieldValues, TApiData = unknown>({
|
||||
@@ -31,7 +32,7 @@ export function useResourceForm<TFormValues extends FieldValues, TApiData = unkn
|
||||
mapToFormValues,
|
||||
initialData,
|
||||
queryKey,
|
||||
}: UseResourceFormOptions<TFormValues, TApiData>): UseResourceFormReturn<TFormValues> {
|
||||
}: UseResourceFormOptions<TFormValues, TApiData>): UseResourceFormReturn<TFormValues, TApiData> {
|
||||
const isEditing = !!resourceId
|
||||
const queryClient = useQueryClient()
|
||||
const resolvedQueryKey = queryKey ?? ["resource", resourceId]
|
||||
@@ -44,7 +45,8 @@ export function useResourceForm<TFormValues extends FieldValues, TApiData = unkn
|
||||
refetchOnMount: "always",
|
||||
})
|
||||
|
||||
const resolvedData = queriedData ?? (isEditing ? initialData : undefined)
|
||||
const resolvedData: TApiData | undefined =
|
||||
queriedData ?? (isEditing ? (initialData ?? undefined) : undefined)
|
||||
|
||||
const form = useForm<TFormValues>({
|
||||
resolver: zodResolver(schema) as any,
|
||||
@@ -70,5 +72,11 @@ export function useResourceForm<TFormValues extends FieldValues, TApiData = unkn
|
||||
queryClient.invalidateQueries({ queryKey: resolvedQueryKey })
|
||||
}
|
||||
|
||||
return { form, isEditing, isInitializing: isEditing && !!initialize && isQueryLoading, invalidate }
|
||||
return {
|
||||
form,
|
||||
isEditing,
|
||||
isInitializing: isEditing && !!initialize && isQueryLoading,
|
||||
invalidate,
|
||||
data: resolvedData,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user