Settings Import Data

PreviousNext

Import data from JSON or CSV files with conflict resolution.

Docs
hextauiui

Preview

Loading preview…
registry/new-york/blocks/settings/settings-import-data.tsx
"use client";

import { AlertCircle, Check, FileUp, Loader2, Upload, X } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { Badge } from "@/registry/new-york/ui/badge";
import { Button } from "@/registry/new-york/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/registry/new-york/ui/card";
import { Field, FieldContent, FieldLabel } from "@/registry/new-york/ui/field";
import { Progress } from "@/registry/new-york/ui/progress";
import { RadioGroup, RadioGroupItem } from "@/registry/new-york/ui/radio-group";
import { Separator } from "@/registry/new-york/ui/separator";

export interface ImportPreview {
  totalRecords: number;
  categories: Record<string, number>;
  conflicts: number;
  fields: string[];
}

export interface ImportJob {
  id: string;
  filename: string;
  format: "json" | "csv";
  status: "preview" | "dry-run" | "importing" | "completed" | "failed";
  progress?: number;
  preview?: ImportPreview;
  conflictResolution?: "skip" | "overwrite" | "merge";
  createdAt: Date;
  completedAt?: Date;
  error?: string;
  recordsImported?: number;
  recordsSkipped?: number;
  recordsFailed?: number;
}

export interface SettingsImportDataProps {
  importHistory?: ImportJob[];
  onUpload?: (file: File) => Promise<ImportPreview>;
  onImport?: (data: {
    file: File;
    conflictResolution: "skip" | "overwrite" | "merge";
    dryRun?: boolean;
  }) => Promise<ImportJob>;
  className?: string;
}

const conflictResolutionOptions = [
  {
    value: "skip",
    label: "Skip",
    description: "Skip conflicting records, keep existing data",
  },
  {
    value: "overwrite",
    label: "Overwrite",
    description: "Replace existing data with imported data",
  },
  {
    value: "merge",
    label: "Merge",
    description: "Combine existing and imported data",
  },
];

function formatFileSize(bytes: number): string {
  if (bytes === 0) return "0 Bytes";
  const k = 1024;
  const sizes = ["Bytes", "KB", "MB", "GB"];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  return `${Math.round((bytes / k ** i) * 100) / 100} ${sizes[i]}`;
}

export default function SettingsImportData({
  importHistory = [],
  onUpload,
  onImport,
  className,
}: SettingsImportDataProps) {
  const [isDragging, setIsDragging] = useState(false);
  const [isUploading, setIsUploading] = useState(false);
  const [isImporting, setIsImporting] = useState(false);
  const [uploadedFile, setUploadedFile] = useState<File | null>(null);
  const [preview, setPreview] = useState<ImportPreview | null>(null);
  const [conflictResolution, setConflictResolution] = useState<
    "skip" | "overwrite" | "merge"
  >("skip");
  const [errors, setErrors] = useState<Record<string, string>>({});
  const fileInputRef = useRef<HTMLInputElement>(null);

  const handleFileSelect = useCallback(
    async (file: File) => {
      setErrors({});

      if (!(file.name.endsWith(".json") || file.name.endsWith(".csv"))) {
        setErrors({
          file: "Please upload a JSON or CSV file",
        });
        return;
      }

      if (file.size > 50 * 1024 * 1024) {
        setErrors({
          file: "File size must be less than 50MB",
        });
        return;
      }

      setUploadedFile(file);
      setIsUploading(true);

      try {
        const previewData = await onUpload?.(file);
        if (previewData) {
          setPreview(previewData);
        }
      } catch (error) {
        setErrors({
          file:
            error instanceof Error ? error.message : "Failed to process file",
        });
        setUploadedFile(null);
      } finally {
        setIsUploading(false);
      }
    },
    [onUpload]
  );

  const handleDragOver = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragging(true);
  }, []);

  const handleDragLeave = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragging(false);
  }, []);

  const handleDrop = useCallback(
    (e: React.DragEvent) => {
      e.preventDefault();
      e.stopPropagation();
      setIsDragging(false);

      const file = e.dataTransfer.files[0];
      if (file) {
        handleFileSelect(file);
      }
    },
    [handleFileSelect]
  );

  const handleFileInputChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const file = e.target.files?.[0];
      if (file) {
        handleFileSelect(file);
      }
    },
    [handleFileSelect]
  );

  const handleImport = async (dryRun = false) => {
    if (!uploadedFile) return;

    setIsImporting(true);
    try {
      await onImport?.({
        file: uploadedFile,
        conflictResolution,
        dryRun,
      });

      if (!dryRun) {
        setUploadedFile(null);
        setPreview(null);
        setConflictResolution("skip");
      }
    } catch (error) {
      setErrors({
        import:
          error instanceof Error ? error.message : "Failed to import data",
      });
    } finally {
      setIsImporting(false);
    }
  };

  const handleRemove = () => {
    setUploadedFile(null);
    setPreview(null);
    setErrors({});
    if (fileInputRef.current) {
      fileInputRef.current.value = "";
    }
  };

  const formatDate = (date: Date): string =>
    new Intl.DateTimeFormat("en-US", {
      month: "short",
      day: "numeric",
      year: "numeric",
      hour: "numeric",
      minute: "2-digit",
    }).format(date);

  const getStatusBadge = (status: ImportJob["status"]) => {
    switch (status) {
      case "completed":
        return (
          <Badge className="text-xs" variant="default">
            Completed
          </Badge>
        );
      case "importing":
        return (
          <Badge className="text-xs" variant="secondary">
            Importing
          </Badge>
        );
      case "dry-run":
        return (
          <Badge className="text-xs" variant="outline">
            Dry Run
          </Badge>
        );
      case "failed":
        return (
          <Badge className="text-xs" variant="destructive">
            Failed
          </Badge>
        );
      default:
        return null;
    }
  };

  return (
    <Card className={cn("w-full shadow-xs", className)}>
      <CardHeader>
        <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
          <div className="flex min-w-0 flex-1 flex-col gap-2">
            <CardTitle className="wrap-break-word">Import Data</CardTitle>
            <CardDescription className="wrap-break-word">
              Import your data from a JSON or CSV file
            </CardDescription>
          </div>
        </div>
      </CardHeader>
      <CardContent>
        <div className="flex flex-col gap-6">
          {/* File Upload */}
          <div className="flex flex-col gap-4">
            {uploadedFile ? (
              <div className="flex flex-col gap-4 rounded-lg border p-4">
                <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
                  <div className="flex min-w-0 flex-1 items-center gap-3">
                    <div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
                      <FileUp className="size-5 text-primary" />
                    </div>
                    <div className="flex min-w-0 flex-1 flex-col gap-2">
                      <p className="wrap-break-word font-medium text-sm">
                        {uploadedFile.name}
                      </p>
                      <p className="text-muted-foreground text-xs">
                        {formatFileSize(uploadedFile.size)}
                      </p>
                    </div>
                  </div>
                  <Button
                    className="self-start sm:self-auto"
                    onClick={handleRemove}
                    size="icon-sm"
                    type="button"
                    variant="ghost"
                  >
                    <X className="size-4" />
                  </Button>
                </div>

                {preview && (
                  <>
                    <Separator />
                    <div className="flex flex-col gap-3">
                      <h4 className="font-medium text-sm">Preview</h4>
                      <div className="flex flex-col gap-2 rounded-lg bg-muted/30 p-3">
                        <div className="flex items-center justify-between">
                          <span className="text-muted-foreground text-sm">
                            Total Records:
                          </span>
                          <span className="font-medium text-sm">
                            {preview.totalRecords}
                          </span>
                        </div>
                        {preview.conflicts > 0 && (
                          <div className="flex items-center justify-between">
                            <span className="text-muted-foreground text-sm">
                              Conflicts:
                            </span>
                            <Badge className="text-xs" variant="destructive">
                              {preview.conflicts}
                            </Badge>
                          </div>
                        )}
                        {Object.keys(preview.categories).length > 0 && (
                          <div className="flex flex-col gap-1">
                            <span className="text-muted-foreground text-sm">
                              Categories:
                            </span>
                            <div className="flex flex-wrap gap-1">
                              {Object.entries(preview.categories).map(
                                ([category, count]) => (
                                  <Badge
                                    className="text-xs"
                                    key={category}
                                    variant="outline"
                                  >
                                    {category}: {count}
                                  </Badge>
                                )
                              )}
                            </div>
                          </div>
                        )}
                      </div>
                    </div>
                  </>
                )}
              </div>
            ) : (
              <div
                className={cn(
                  "flex cursor-pointer flex-col items-center justify-center gap-4 rounded-lg border-2 border-dashed p-8 transition-colors",
                  isDragging
                    ? "border-primary bg-primary/5"
                    : "border-muted bg-muted/30 hover:border-primary/50"
                )}
                onClick={() => fileInputRef.current?.click()}
                onDragLeave={handleDragLeave}
                onDragOver={handleDragOver}
                onDrop={handleDrop}
              >
                {isUploading ? (
                  <Loader2 className="size-8 animate-spin text-primary" />
                ) : (
                  <Upload className="size-8 text-muted-foreground" />
                )}
                <div className="flex flex-col gap-2 text-center">
                  <p className="font-medium text-sm">
                    {isUploading
                      ? "Processing file…"
                      : "Drag and drop a file here, or click to browse"}
                  </p>
                  <p className="text-muted-foreground text-xs">
                    Supported formats: JSON, CSV • Max size: 50MB
                  </p>
                </div>
                <input
                  accept=".json,.csv"
                  className="hidden"
                  onChange={handleFileInputChange}
                  ref={fileInputRef}
                  type="file"
                />
              </div>
            )}

            {errors.file && (
              <div className="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3">
                <AlertCircle className="size-4 text-destructive" />
                <p className="text-destructive text-sm">{errors.file}</p>
              </div>
            )}
          </div>

          {/* Import Options */}
          {uploadedFile && preview && (
            <>
              <Separator />
              <div className="flex flex-col gap-4">
                <h3 className="font-semibold text-base">Import Options</h3>
                <Field>
                  <FieldLabel>Conflict Resolution</FieldLabel>
                  <FieldContent>
                    <RadioGroup
                      onValueChange={(value: "skip" | "overwrite" | "merge") =>
                        setConflictResolution(value)
                      }
                      value={conflictResolution}
                    >
                      {conflictResolutionOptions.map((option) => (
                        <div
                          className="flex items-start gap-3 rounded-lg border p-3"
                          key={option.value}
                        >
                          <RadioGroupItem
                            id={`resolution-${option.value}`}
                            value={option.value}
                          />
                          <div className="flex flex-1 flex-col gap-1">
                            <label
                              className="cursor-pointer font-medium text-sm"
                              htmlFor={`resolution-${option.value}`}
                            >
                              {option.label}
                            </label>
                            <p className="text-muted-foreground text-xs">
                              {option.description}
                            </p>
                          </div>
                        </div>
                      ))}
                    </RadioGroup>
                  </FieldContent>
                </Field>

                <div className="flex flex-col gap-2 sm:flex-row">
                  <Button
                    className="w-full sm:w-auto"
                    disabled={isImporting}
                    onClick={() => handleImport(true)}
                    type="button"
                    variant="outline"
                  >
                    <Check className="size-4" />
                    Dry Run
                  </Button>
                  <Button
                    className="w-full sm:w-auto"
                    disabled={isImporting}
                    onClick={() => handleImport(false)}
                    type="button"
                  >
                    {isImporting ? (
                      <>
                        <Loader2 className="size-4 animate-spin" />
                        Importing…
                      </>
                    ) : (
                      <>
                        <FileUp className="size-4" />
                        Import Data
                      </>
                    )}
                  </Button>
                </div>
                {errors.import && (
                  <div className="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3">
                    <AlertCircle className="size-4 text-destructive" />
                    <p className="text-destructive text-sm">{errors.import}</p>
                  </div>
                )}
              </div>
            </>
          )}

          <Separator />

          {/* Import History */}
          <div className="flex flex-col gap-4">
            <h3 className="font-semibold text-base">Import History</h3>
            {importHistory.length === 0 ? (
              <p className="text-muted-foreground text-sm">
                No imports yet. Upload a file above to get started.
              </p>
            ) : (
              <div className="flex flex-col gap-3">
                {importHistory.map((job) => (
                  <div
                    className="flex flex-col gap-3 rounded-lg border p-4"
                    key={job.id}
                  >
                    <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
                      <div className="flex min-w-0 flex-1 flex-col gap-2">
                        <div className="flex flex-wrap items-center gap-2">
                          <span className="font-medium text-sm">
                            {job.filename}
                          </span>
                          {getStatusBadge(job.status)}
                          <Badge
                            className="text-xs uppercase"
                            variant="outline"
                          >
                            {job.format}
                          </Badge>
                        </div>
                        {job.status === "importing" &&
                          job.progress !== undefined && (
                            <div className="flex flex-col gap-2">
                              <Progress value={job.progress} />
                              <p className="text-muted-foreground text-xs">
                                {job.progress}% complete
                              </p>
                            </div>
                          )}
                        {job.status === "completed" && (
                          <div className="flex flex-wrap items-center gap-3 text-muted-foreground text-xs">
                            {job.recordsImported !== undefined && (
                              <span className="text-green-600">
                                {job.recordsImported} imported
                              </span>
                            )}
                            {job.recordsSkipped !== undefined &&
                              job.recordsSkipped > 0 && (
                                <span>{job.recordsSkipped} skipped</span>
                              )}
                            {job.recordsFailed !== undefined &&
                              job.recordsFailed > 0 && (
                                <span className="text-destructive">
                                  {job.recordsFailed} failed
                                </span>
                              )}
                          </div>
                        )}
                        <div className="flex flex-wrap items-center gap-3 text-muted-foreground text-xs">
                          <span>Started: {formatDate(job.createdAt)}</span>
                          {job.completedAt && (
                            <span>
                              Completed: {formatDate(job.completedAt)}
                            </span>
                          )}
                        </div>
                        {job.error && (
                          <p className="text-destructive text-sm">
                            {job.error}
                          </p>
                        )}
                      </div>
                    </div>
                  </div>
                ))}
              </div>
            )}
          </div>
        </div>
      </CardContent>
    </Card>
  );
}

Installation

npx shadcn@latest add @hextaui/settings-import-data

Usage

import { SettingsImportData } from "@/components/ui/settings-import-data"
<SettingsImportData />