shadimage
gridcn
blockdata-grid-iosource: ok · refreshed

Data Grid IO

Import/export add-on for the gridcn data grid: xlsx and csv, with delimiter detection and preview. New user-facing strings live under `labels.io` (see lib/labels.ts DataGridIOLabels) — export button/menu, import dialog (file picker, header-row toggle, delimiter select, column mapping, preview, errors).

npx shadcn add "https://gridcn.vercel.app/r/data-grid-io.json"
free

Where it goes

navbartestimonial

Interaction

modaldropdown

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
xlsxpapaparse

Registry dependencies

DammersCode/gridcn/data-gridDammersCode/gridcn/dropzonedialogselectdropdown-menubutton

Files10 · source cached 10/8/2026, 6:47:07 AM

registry/default/blocks/data-grid-io/data-grid-io.ts
/** Public entry point for the data-grid-io add-on — the only module consumers should import from. */

export { exportGrid, buildXlsx, buildExportRows, buildExportHeaders, quoteCsvField, rowsToCsv, downloadBlob, type ExportGridOptions, type BuildXlsxOptions } from "./export-grid";
export { useDataGridExport, type UseDataGridExportResult } from "./use-data-grid-export";

export { parseImportFile, type ParsedImportFile, type ParseImportFileOptions, type CsvDelimiter } from "./parse-import-file";
export { matchImportColumns, applyImportOptions, type ImportDefaults } from "./match-import-column";
export { buildImportedRows, IMPORT_CANCELLED_MESSAGE, type ImportColumnMapping, type BuildImportedRowsOptions, type BuildImportedRowsResult, type ImportRejectedCell } from "./build-imported-rows";
export {
  useDataGridImportPreview,
  IMPORT_PREVIEW_ROW_COUNT,
  type ImportFileState,
  type ImportTargetColumn,
  type ImportErrorKey,
  type UseDataGridImportPreviewResult,
} from "./use-data-grid-import";
export { DataGridImportDialog, type DataGridImportDialogProps } from "./import-dialog";

export { DataGridExportButton, type DataGridExportButtonProps } from "./export-button";
export { DataGridImportButton, type DataGridImportButtonProps } from "./import-button";
registry/default/blocks/data-grid-io/export-grid.ts
import { getSelectedViewRows, isDev, type AnyColumnDef, type DataGridStoreState } from "@/registry/default/components/data-grid/data-grid";
import type { CsvDelimiter } from "./parse-import-file";

/** Options for {@link exportGrid}. */
export type ExportGridOptions = {
  /** 'xlsx' via lazily-imported SheetJS, or hand-rolled RFC4180 'csv'. */
  format: "xlsx" | "csv";
  /** 'view' (default) exports the current sort/filter order; 'all' ignores view order and exports every row in `data` order; 'selection' exports only the selected rows, in view order. */
  scope?: "view" | "all" | "selection";
  /** Whether to emit a header row; default true. */
  includeHeaders?: boolean;
  /** CSV field delimiter; default ','. Ignored for xlsx. */
  csvDelimiter?: CsvDelimiter;
  /**
   * Prefix the CSV with a UTF-8 BOM (`\uFEFF`); default `true`. Excel only detects UTF-8 when the
   * file starts with a BOM — without it, umlauts and other non-ASCII characters open as mojibake.
   * Set `false` when a downstream consumer chokes on a leading BOM character.
   */
  csvBom?: boolean;
  /** Download file name, without extension; default 'export'. */
  fileName?: string;
  /** Name of the single sheet inside the xlsx file; default 'Sheet1'. Ignored for csv. */
  workbookName?: string;
  /** Caps the number of data rows exported (the header row is not counted); when the scope exceeds the cap, the export is truncated with a dev-only warning. */
  maxRows?: number;
};

const NEEDS_QUOTING_DEFAULT = /["\r\n]/;

/** Column display header: `headerText` override, else the string `header`, else the column id. */
function columnHeaderText(column: AnyColumnDef): string {
  return column.headerText ?? (typeof column.header === "string" ? column.header : column.id);
}

/** Quotes one RFC4180 CSV field when it contains the delimiter, a quote, or a line break; doubles internal quotes. */
export function quoteCsvField(value: string, delimiter: string): string {
  const needsQuoting = value.includes(delimiter) || NEEDS_QUOTING_DEFAULT.test(value);
  if (!needsQuoting) return value;
  return `"${value.replace(/"/g, '""')}"`;
}

/** Joins rows of already-quoted-as-needed fields into an RFC4180 CSV string (CRLF line endings). */
export function rowsToCsv(rows: readonly (readonly string[])[], delimiter: string): string {
  return rows.map((row) => row.map((field) => quoteCsvField(field, delimiter)).join(delimiter)).join("\r\n");
}

/** Truncates to `maxRows` data rows when the scope exceeds the cap, with a dev-only warning. */
function capExportRows(rows: string[][], maxRows: number | undefined): string[][] {
  if (maxRows === undefined || rows.length <= maxRows) return rows;
  if (isDev()) console.warn(`[data-grid-io] export truncated to ${maxRows} of ${rows.length} rows (maxRows)`);
  return rows.slice(0, maxRows);
}

/**
 * Resolves the rows to export via the cell-type pipeline (`toText`, never raw values), in the
 * requested `scope` order: `'view'` walks `viewIndex` (current sort/filter order), `'all'` walks
 * `data` as-is, ignoring any active sort/filter, `'selection'` walks only the selected rows in
 * view order. Every scope emits all visible columns, so a row-shaped export always matches its
 * header row. Hole rows (unloaded indices of a lazy grid's sparse `data`) are skipped in every
 * scope.
 */
export function buildExportRows(state: DataGridStoreState, scope: "view" | "all" | "selection"): string[][] {
  const dataIndices =
    scope === "all"
      ? state.data.map((_, i) => i)
      : scope === "selection"
          ? getSelectedViewRows(state.selection).map((viewRow) => state.viewIndex[viewRow]!)
        : state.viewIndex;
  // lazy grids keep `data` sparse: skip holes before any accessor runs
  return dataIndices.filter((dataRowIndex) => state.data[dataRowIndex] !== undefined).map((dataRowIndex) => {
    const row = state.data[dataRowIndex];
    return state.visibleColumns.map((column) => {
      const cellType = state.cellTypes[column.type ?? "text"];
      const value = column.accessorFn
        ? column.accessorFn(row)
        : column.accessorKey
          ? (row as Record<string, unknown> | undefined)?.[column.accessorKey]
          : undefined;
      return cellType ? cellType.toText(value, column.options) : "";
    });
  });
}

/** Header row text for each visible column, in the same order `buildExportRows` emits cells. */
export function buildExportHeaders(state: DataGridStoreState): string[] {
  return state.visibleColumns.map((column) => columnHeaderText(column));
}

/** Triggers a browser download of `blob` named `fileName`, via a temporary anchor + object URL (revoke deferred — see below). */
export function downloadBlob(blob: Blob, fileName: string): void {
  const url = URL.createObjectURL(blob);
  const anchor = document.createElement("a");
  anchor.href = url;
  anchor.download = fileName;
  anchor.style.display = "none";
  document.body.append(anchor);
  anchor.click();
  anchor.remove();
  // Firefox/Safari read the blob URL asynchronously after click(); revoking on the same tick can truncate the download.
  setTimeout(() => URL.revokeObjectURL(url), 10_000);
}

/** Options for {@link buildXlsx}. */
export type BuildXlsxOptions = {
  /** Row scope, same semantics as {@link ExportGridOptions.scope}; default 'view'. */
  scope?: "view" | "all" | "selection";
  /** Whether to emit a header row; default true. */
  includeHeaders?: boolean;
  /** Name of the single sheet inside the workbook; default 'Sheet1'. */
  workbookName?: string;
  /** Same row cap as {@link ExportGridOptions.maxRows}. */
  maxRows?: number;
};

/**
 * Builds an xlsx `Blob` from the grid's state without triggering a download — for server uploads
 * or inspecting the workbook before it leaves the browser. Values go through each column's cell
 * type `toText` like `exportGrid`; `xlsx` loads lazily.
 */
export async function buildXlsx(state: DataGridStoreState, options: BuildXlsxOptions = {}): Promise<Blob> {
  const { scope = "view", includeHeaders = true, workbookName = "Sheet1", maxRows } = options;
  const rows = capExportRows(buildExportRows(state, scope), maxRows);
  const table = includeHeaders ? [buildExportHeaders(state), ...rows] : rows;

  const XLSX = await import("xlsx");
  const worksheet = XLSX.utils.aoa_to_sheet(table);
  const workbook = XLSX.utils.book_new();
  XLSX.utils.book_append_sheet(workbook, worksheet, workbookName);
  const arrayBuffer = XLSX.write(workbook, { bookType: "xlsx", type: "array" }) as ArrayBuffer;
  return new Blob([arrayBuffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
}

/**
 * Serializes the grid to CSV or XLSX and triggers a download. Values always go through each
 * column's cell type `toText` (never raw values), so clipboard/export fidelity matches. `xlsx` is
 * loaded lazily (dynamic `import("xlsx")`) so the dependency never loads for CSV-only consumers.
 */
export async function exportGrid(state: DataGridStoreState, options: ExportGridOptions): Promise<void> {
  const { format, scope = "view", includeHeaders = true, csvDelimiter = ",", csvBom = true, fileName = "export", workbookName, maxRows } = options;
  if (format === "csv") {
    const rows = capExportRows(buildExportRows(state, scope), maxRows);
    const table = includeHeaders ? [buildExportHeaders(state), ...rows] : rows;
    const csv = (csvBom ? "\uFEFF" : "") + rowsToCsv(table, csvDelimiter);
    downloadBlob(new Blob([csv], { type: "text/csv;charset=utf-8;" }), `${fileName}.csv`);
    return;
  }

  const blob = await buildXlsx(state, { scope, includeHeaders, workbookName, maxRows });
  downloadBlob(blob, `${fileName}.xlsx`);
}
registry/default/blocks/data-grid-io/use-data-grid-export.ts
"use client";

import { useCallback } from "react";
import { useDataGridStoreApi } from "@/registry/default/components/data-grid/data-grid";
import { exportGrid, type ExportGridOptions } from "./export-grid";

/** Public export trigger returned by {@link useDataGridExport}. */
export type UseDataGridExportResult = {
  /** Serializes the grid per `options` and triggers a browser download. */
  exportGrid: (options: ExportGridOptions) => Promise<void>;
};

/** Wires {@link exportGrid} to the grid's store instance, for toolbar buttons and custom triggers. */
export function useDataGridExport(): UseDataGridExportResult {
  const storeApi = useDataGridStoreApi();

  const run = useCallback(
    (options: ExportGridOptions) => exportGrid(storeApi.getState(), options),
    [storeApi],
  );

  return { exportGrid: run };
}
registry/default/blocks/data-grid-io/export-button.tsx
"use client";

import type { ReactNode } from "react";
import { Download } from "lucide-react";
import { isDev, useDataGridLabels } from "@/registry/default/components/data-grid/data-grid";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useDataGridExport } from "./use-data-grid-export";
import type { ExportGridOptions } from "./export-grid";

/** Props for {@link DataGridExportButton}. */
export type DataGridExportButtonProps = {
  className?: string;
  /** Export options applied to both formats besides `format` itself; default `{ scope: 'view', includeHeaders: true }`. */
  options?: Omit<ExportGridOptions, "format">;
  /** Receives the rejection when an export fails; without it, the failure logs a dev-only warning. */
  onError?: (error: unknown, format: "xlsx" | "csv") => void;
};

/**
 * Toolbar-slot export trigger: a ghost icon button opening a dropdown of xlsx/csv, wired to
 * {@link useDataGridExport}. `size-8` + ghost variant matches the toolbar's other icon buttons.
 */
export function DataGridExportButton(props: DataGridExportButtonProps): ReactNode {
  const { className, options, onError } = props;
  const labels = useDataGridLabels();
  const { exportGrid } = useDataGridExport();

  const runExport = (format: "xlsx" | "csv") => {
    exportGrid({ ...options, format }).catch((error: unknown) => {
      if (onError) {
        onError(error, format);
        return;
      }
      if (isDev()) console.warn(`[data-grid-io] ${format} export failed`, error);
    });
  };

  return (
    <DropdownMenu>
      <DropdownMenuTrigger
        render={
          <Button type="button" variant="ghost" size="icon" className={className} aria-label={labels.io.exportButtonAriaLabel}>
            <Download />
          </Button>
        }
      />
      <DropdownMenuContent align="start">
        <DropdownMenuItem onClick={() => runExport("xlsx")}>
          {labels.io.exportXlsx}
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => runExport("csv")}>
          {labels.io.exportCsv}
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}
registry/default/blocks/data-grid-io/parse-import-file.ts
import Papa from "papaparse";

/** CSV delimiter choices exposed in the import dialog; `undefined` means auto-detect. */
export type CsvDelimiter = "," | ";" | "\t";

/** Options for {@link parseImportFile}. */
export type ParseImportFileOptions = {
  /** Overrides CSV delimiter auto-detection; ignored for xlsx/xls. */
  csvDelimiter?: CsvDelimiter;
  /** For xlsx/xls: import this sheet by name instead of the first; rejects when the workbook has no sheet by that name. Ignored for CSV. */
  sheetName?: string;
};

/** Normalized parse result: a plain 2D grid of strings, header row (if any) included at `rows[0]`. */
export type ParsedImportFile = {
  rows: string[][];
  /** Delimiter actually used (auto-detected or overridden); undefined for xlsx/xls. */
  delimiter?: CsvDelimiter;
  /** Every sheet name in the workbook. Undefined for CSV, or for a workbook with no sheets. */
  sheetNames?: string[];
  /** The sheet `rows` came from — the `sheetName` option, or the first sheet. Undefined for CSV. */
  sheetName?: string;
};

function isCsvFile(file: File): boolean {
  const name = file.name.toLowerCase();
  return name.endsWith(".csv") || name.endsWith(".tsv") || file.type === "text/csv" || file.type === "text/tab-separated-values";
}

function isExcelFile(file: File): boolean {
  const name = file.name.toLowerCase();
  return name.endsWith(".xlsx") || name.endsWith(".xls");
}

/** Parses CSV text with papaparse; delimiter auto-detected unless `csvDelimiter` overrides it. */
function parseCsvText(text: string, csvDelimiter?: CsvDelimiter): ParsedImportFile {
  const result = Papa.parse<string[]>(text, { delimiter: csvDelimiter, skipEmptyLines: true });
  const rows = result.data.map((row) => row.map((cell) => cell ?? ""));
  const delimiter = (csvDelimiter ?? (result.meta.delimiter as CsvDelimiter | undefined)) || ",";
  return { rows, delimiter };
}

/** Aborts the file read; rejected parses surface as an AbortError the caller can ignore. */
function abortError(): DOMException {
  return new DOMException("Aborted", "AbortError");
}

/** Parses one sheet of an xlsx/xls workbook into a 2D string grid via lazily-imported SheetJS; `sheetName` selects the sheet (first by default), and `sheetNames`/`sheetName` let the caller switch sheets later. */
async function parseExcelFile(file: File, sheetName?: string, signal?: AbortSignal): Promise<ParsedImportFile> {
  const XLSX = await import("xlsx");
  const buffer = await file.arrayBuffer();
  if (signal?.aborted) throw abortError();
  const workbook = XLSX.read(buffer, { type: "array" });
  const selected = sheetName !== undefined ? workbook.SheetNames.indexOf(sheetName) : 0;
  if (selected === -1) throw new Error(`unknown sheet "${sheetName}"`);
  const name = workbook.SheetNames[selected];
  const sheet = name !== undefined ? workbook.Sheets[name] : undefined;
  if (!sheet) return { rows: [] };
  const rows = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1, raw: false, defval: "" });
  return { rows: rows.map((row) => row.map((cell) => String(cell ?? ""))), sheetNames: workbook.SheetNames, sheetName: name };
}

/**
 * Parses a `.csv`, `.tsv`, `.xlsx`, or `.xls` file into a normalized 2D string grid. CSV/TSV goes
 * through papaparse (delimiter auto-detect + override); Excel formats go through a
 * dynamically-imported SheetJS (`xlsx` never loads for CSV-only consumers), the `sheetName` sheet
 * or the first one. `signal` aborts the file read when set; an aborted parse rejects with an
 * AbortError.
 */
export async function parseImportFile(file: File, options: ParseImportFileOptions = {}, signal?: AbortSignal): Promise<ParsedImportFile> {
  if (isExcelFile(file)) return parseExcelFile(file, options.sheetName, signal);
  if (isCsvFile(file)) {
    const text = await file.text();
    if (signal?.aborted) throw abortError();
    return parseCsvText(text, options.csvDelimiter);
  }
  throw new Error("unsupported-file-type");
}
registry/default/blocks/data-grid-io/match-import-column.ts
import type { CsvDelimiter } from "./parse-import-file";

/** Minimal column shape the header-matching/preview logic needs — matches `ColumnDef` structurally. */
export type ImportTargetColumn = { id: string; headerText?: string; header: unknown };

/**
 * Consumer-configurable defaults applied at PRESELECTION time only — the user can always
 * override any of these in the dialog once it's open. See import-export.mdx for the worked
 * example.
 */
export type ImportDefaults = {
  /** Skip auto-detect and preselect this delimiter. Detect still runs if undefined. */
  defaultDelimiter?: CsvDelimiter;
  /** false disables delimiter auto-detection; defaultDelimiter (or ",") is used. Default true. */
  autoDetectDelimiter?: boolean;
  /** Default for the header-row toggle. Default true (current behavior). */
  defaultHeaderRow?: boolean;
  /** Source columns to preselect as skipped: header names (case-insensitive) or indices. */
  defaultSkipColumns?: readonly (string | number)[];
  /**
   * Override the initial mapping per source column; return undefined to fall back to the
   * built-in matcher, null to skip. Wins over `defaultSkipColumns`.
   */
  mapColumn?: (header: string, index: number) => string | null | undefined;
};

/** Column display header for matching: `headerText`, else the string `header`, else the id. */
function columnMatchText(column: ImportTargetColumn): string {
  return column.headerText ?? (typeof column.header === "string" ? column.header : column.id);
}

/**
 * Prefills the import-column -> grid-column mapping by matching each imported header against
 * `column.headerText ?? header ?? column.id`, case-insensitively. Unmatched headers map to `null`
 * (the dialog's '—' skip option). Each grid column can only be claimed once — a later duplicate
 * match falls back to `null` (skip) since one-source-per-grid-column is enforced everywhere.
 */
export function matchImportColumns(importHeaders: readonly string[], gridColumns: readonly ImportTargetColumn[]): (string | null)[] {
  const byLowerText = new Map(gridColumns.map((column) => [columnMatchText(column).toLowerCase(), column.id] as const));
  const byLowerId = new Map(gridColumns.map((column) => [column.id.toLowerCase(), column.id] as const));

  const claimed = new Set<string>();
  return importHeaders.map((header) => {
    const key = header.trim().toLowerCase();
    const gridColumnId = byLowerText.get(key) ?? byLowerId.get(key) ?? null;
    if (gridColumnId === null || claimed.has(gridColumnId)) return null;
    claimed.add(gridColumnId);
    return gridColumnId;
  });
}

/** True when `defaultSkipColumns` names `header` (case-insensitive) or `index` (0-based). */
function isDefaultSkipped(header: string, index: number, defaultSkipColumns: readonly (string | number)[]): boolean {
  const key = header.trim().toLowerCase();
  return defaultSkipColumns.some((entry) => (typeof entry === "number" ? entry === index : entry.trim().toLowerCase() === key));
}

/**
 * Preselects the import-column -> grid-column mapping: starts from {@link matchImportColumns},
 * then applies `options.defaultSkipColumns` (by header name or index), then `options.mapColumn`
 * per column (wins over `defaultSkipColumns`; `undefined` keeps whatever came before it). All of
 * this only decides the dialog's *initial* mapping — the user can still change every cell by hand
 * once the dialog is open. Duplicate-claim dedup from {@link matchImportColumns} still applies
 * first; `mapColumn` overrides are trusted as-is and not deduped against each other, matching a
 * consumer's explicit intent.
 */
export function applyImportOptions(
  importHeaders: readonly string[],
  gridColumns: readonly ImportTargetColumn[],
  options: Pick<ImportDefaults, "defaultSkipColumns" | "mapColumn"> = {},
): (string | null)[] {
  const { defaultSkipColumns, mapColumn } = options;
  const matched = matchImportColumns(importHeaders, gridColumns);
  return matched.map((gridColumnId, index) => {
    const header = importHeaders[index] ?? "";
    const overridden = defaultSkipColumns && isDefaultSkipped(header, index, defaultSkipColumns) ? null : gridColumnId;
    const resolved = mapColumn?.(header, index);
    return resolved === undefined ? overridden : resolved;
  });
}
registry/default/blocks/data-grid-io/build-imported-rows.ts
import type { CellType, ColumnDef, ValidateBatchItem, ValidateResult } from "@/registry/default/components/data-grid/data-grid";
import { isDev, runValidateBatch, setCellValue } from "@/registry/default/components/data-grid/data-grid";

/** One import-column -> grid-column mapping entry; `null` skips the import column. */
export type ImportColumnMapping = { importColumnIndex: number; gridColumnId: string | null };

/** Above this row count, `buildImportedRows` yields between chunks so the dialog stays responsive and Cancel can land. */
export const IMPORT_CHUNK_THRESHOLD_ROWS = 5_000;

/** Rows processed per chunk once {@link IMPORT_CHUNK_THRESHOLD_ROWS} is exceeded. */
const CHUNK_SIZE = 2_000;

/** One import cell whose value failed `column.validate`: the cell is cleared with its cell type's `clearValue()`, and the row is still imported. */
export type ImportRejectedCell = {
  /** 0-based index into `dataRows` (header row excluded). */
  rowIndex: number;
  /** 0-based index of the source column in the import file. */
  importColumnIndex: number;
  /** The grid column the value was mapped to and cleared in. */
  gridColumnId: string;
};

/** Result of {@link buildImportedRows}: the built rows plus every rejected (cleared) cell. */
export type BuildImportedRowsResult<TData> = {
  rows: TData[];
  /** Cells whose value failed `column.validate` and was cleared. Their rows are still built. */
  rejected: ImportRejectedCell[];
};

/** Inputs for {@link buildImportedRows}. */
export type BuildImportedRowsOptions<TData> = {
  /** Data rows only (header row, if any, already excluded by the caller). */
  dataRows: readonly string[][];
  mapping: readonly ImportColumnMapping[];
  // ColumnDef's 3rd param (TValidate) erases to `any`, not `unknown` — see data-grid/store.tsx's
  // AnyColumnDef doc: validate's function-or-schema union can't stay bivariant as a plain property.
  columns: readonly ColumnDef<TData, unknown, any>[]; // eslint-disable-line @typescript-eslint/no-explicit-any
  cellTypes: Record<string, CellType<TData, unknown, unknown>>;
  /** Builds a fresh row for import row `index`; required — the dialog never writes to the grid store directly. */
  createRow: (index: number) => TData;
  /** Checked between chunks on the large-import path; throws `"import-cancelled"` when true. Ignored below {@link IMPORT_CHUNK_THRESHOLD_ROWS}. */
  signal?: AbortSignal;
};

/** One import cell awaiting its validation verdict: where it goes, and what to write if the value is rejected. */
type ImportCell = {
  rowIndex: number;
  importColumnIndex: number;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- same TValidate erasure as BuildImportedRowsOptions.columns.
  column: ColumnDef<any, unknown, any>;
  clearedValue: unknown;
};

/** Thrown from the chunked path when `options.signal` is aborted between chunks. */
export const IMPORT_CANCELLED_MESSAGE = "import-cancelled";

/**
 * Builds `TData` rows from parsed import rows: one `createRow(index)` per row, then each mapped
 * cell is parsed through its column's cell type `fromText` and checked by `column.validate` —
 * invalid values are cleared via `clearValue()` rather than dropping the whole row, and every
 * rejected cell is listed in the result's `rejected`.
 *
 * Returns `{ rows, rejected }` directly when every validator is synchronous AND `dataRows` is at
 * or below {@link IMPORT_CHUNK_THRESHOLD_ROWS}. Above that row count, or when a column has an ASYNC
 * Standard Schema, this returns a Promise instead: large imports are built in chunks that yield to
 * the event loop (so the dialog repaints and `options.signal` can cancel between chunks), and any
 * async validators run with the shared chunked concurrency cap. The import dialog's existing
 * pending affordance covers both waits.
 *
 * The function form's second argument is the row as `createRow` returned it, not the row with this
 * import row's earlier columns already folded in — validating a whole import row concurrently and
 * validating it left to right cannot both be true, and the fresh row is the predictable one.
 */
export function buildImportedRows<TData>(options: BuildImportedRowsOptions<TData>): BuildImportedRowsResult<TData> | Promise<BuildImportedRowsResult<TData>> {
  const { dataRows, mapping, columns, cellTypes, createRow, signal } = options;
  const columnsById = new Map(columns.map((column) => [column.id, column] as const));
  const activeMappings = mapping.filter((m): m is { importColumnIndex: number; gridColumnId: string } => m.gridColumnId !== null);

  // the dialog UI prevents duplicate mappings, a custom UI's setMapping does not — the later
  // mapping wins silently, so make it visible in dev (one warn per duplicated column)
  const seenColumns = new Set<string>();
  const warnedColumns = new Set<string>();
  for (const { gridColumnId } of activeMappings) {
    if (seenColumns.has(gridColumnId) && !warnedColumns.has(gridColumnId)) {
      warnedColumns.add(gridColumnId);
      if (isDev()) console.warn(`[data-grid-io] buildImportedRows: grid column "${gridColumnId}" is mapped by more than one import column - the later mapping wins`);
    }
    seenColumns.add(gridColumnId);
  }

  if (dataRows.length > IMPORT_CHUNK_THRESHOLD_ROWS) {
    return buildImportedRowsChunked(dataRows, activeMappings, columnsById, cellTypes, createRow, signal);
  }

  const rows = dataRows.map((_, index) => createRow(index));
  const rejected: ImportRejectedCell[] = [];
  const { items, cells } = buildCellsForRange(dataRows, 0, dataRows.length, activeMappings, columnsById, cellTypes, rows);

  const results = runValidateBatch(items);
  if (results instanceof Promise) return results.then((resolved) => ({ rows: writeCells(rows, cells, resolved, rejected), rejected }));
  return { rows: writeCells(rows, cells, results, rejected), rejected };
}

/** One resolved mapping entry: the source column index paired with its target column definition. */
type ResolvedMapping = { importColumnIndex: number; gridColumnId: string };

/** Builds `rows[start, end)`'s validation items/cells against the already-created `rows` array. */
function buildCellsForRange<TData>(
  dataRows: readonly string[][],
  start: number,
  end: number,
  activeMappings: readonly ResolvedMapping[],
  columnsById: ReadonlyMap<string, ColumnDef<TData, unknown, any>>, // eslint-disable-line @typescript-eslint/no-explicit-any
  cellTypes: Record<string, CellType<TData, unknown, unknown>>,
  rows: TData[],
): { items: ValidateBatchItem[]; cells: ImportCell[] } {
  const items: ValidateBatchItem[] = [];
  const cells: ImportCell[] = [];
  for (let rowIndex = start; rowIndex < end; rowIndex++) {
    const sourceRow = dataRows[rowIndex]!; // start/end are within dataRows.length by construction
    for (const { importColumnIndex, gridColumnId } of activeMappings) {
      const column = columnsById.get(gridColumnId);
      if (!column) continue;
      const cellType = cellTypes[column.type ?? "text"];
      if (!cellType) continue;

      const text = sourceRow[importColumnIndex] ?? "";
      // as never: validate is typed against this column's own TData/TValue, which TS can't unify with the generic TData/unknown here — safe since column and row/value are all this same import's TData.
      items.push({ validate: column.validate as never, value: cellType.fromText(text, column.options), row: rows[rowIndex] });
      cells.push({ rowIndex, importColumnIndex, column, clearedValue: cellType.clearValue(column.options) });
    }
  }
  return { items, cells };
}

/** Yields to the event loop; a chunk boundary for both the main-thread-block fix and the Cancel affordance. */
function yieldToEventLoop(): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve));
}

/**
 * Large-import path: builds and writes rows {@link CHUNK_SIZE} at a time, yielding between chunks so
 * the dialog's pending state actually repaints and `signal` gets a chance to cancel. Validation still
 * runs once per chunk (not once for the whole import) so a huge async-schema import doesn't hold
 * `CHUNK_SIZE`-multiples of items in flight at once.
 */
async function buildImportedRowsChunked<TData>(
  dataRows: readonly string[][],
  activeMappings: readonly ResolvedMapping[],
  columnsById: ReadonlyMap<string, ColumnDef<TData, unknown, any>>, // eslint-disable-line @typescript-eslint/no-explicit-any
  cellTypes: Record<string, CellType<TData, unknown, unknown>>,
  createRow: (index: number) => TData,
  signal?: AbortSignal,
): Promise<BuildImportedRowsResult<TData>> {
  const rows = dataRows.map((_, index) => createRow(index));
  const rejected: ImportRejectedCell[] = [];

  for (let start = 0; start < dataRows.length; start += CHUNK_SIZE) {
    if (signal?.aborted) throw new Error(IMPORT_CANCELLED_MESSAGE);
    const end = Math.min(start + CHUNK_SIZE, dataRows.length);
    const { items, cells } = buildCellsForRange(dataRows, start, end, activeMappings, columnsById, cellTypes, rows);
    const results = await runValidateBatch(items);
    writeCells(rows, cells, results, rejected);
    await yieldToEventLoop();
  }
  if (signal?.aborted) throw new Error(IMPORT_CANCELLED_MESSAGE);

  return { rows, rejected };
}

/**
 * Folds each verdict into its row: the validator's output on success, the cell type's `clearValue()`
 * on rejection (the rejected cell is appended to `rejected`). `cells` is grouped by `rowIndex`
 * (built in mapping order per row), so a run of
 * consecutive plain-`accessorKey` cells for the same row is merged into one spread instead of one
 * spread per cell — this was the measured hot spot, one spread + 2 temp objects per cell at scale.
 * The run is flushed (a) before a `setValue` cell in the same row, so `setValue` still sees every
 * earlier cell's write, exactly as the un-merged left-to-right spreads did, and (b) at each row
 * boundary. A column with a custom `setValue` keeps writing through `setCellValue` per cell, since
 * only `setValue` knows how to fold its own value into the row.
 */
function writeCells<TData>(rows: TData[], cells: readonly ImportCell[], results: readonly ValidateResult[], rejected: ImportRejectedCell[]): TData[] {
  let pendingRow = -1;
  let pendingPatch: Record<string, unknown> | null = null;
  const flush = () => {
    if (pendingPatch === null) return;
    // as TData: TData is generic here, but pendingPatch's keys are this same TData's own accessorKeys — mirrors setCellValue's own accessorKey-spread branch.
    rows[pendingRow] = { ...rows[pendingRow], ...pendingPatch } as TData;
    pendingPatch = null;
  };

  for (let i = 0; i < cells.length; i++) {
    const cell = cells[i]!; // i < cells.length by loop condition
    const result = results[i];
    if (!result) continue;
    const isRejected = "error" in result;
    if (isRejected) rejected.push({ rowIndex: cell.rowIndex, importColumnIndex: cell.importColumnIndex, gridColumnId: cell.column.id });
    const value = isRejected ? cell.clearedValue : result.value;

    if (cell.rowIndex !== pendingRow) flush();
    pendingRow = cell.rowIndex;

    if (!cell.column.setValue && cell.column.accessorKey) {
      pendingPatch ??= {};
      pendingPatch[cell.column.accessorKey] = value;
      continue;
    }
    flush();
    // as never: setCellValue is typed against this column's own TData/TValue, which TS can't unify with the generic TData/unknown here — safe since column and row/value are all this same import's TData.
    rows[cell.rowIndex] = setCellValue(rows[cell.rowIndex] as never, cell.column as never, value as never) as TData;
  }
  flush();
  return rows;
}
registry/default/blocks/data-grid-io/use-data-grid-import.ts
"use client";

import { useCallback, useMemo, useRef, useState } from "react";
import { parseImportFile, type CsvDelimiter, type ParsedImportFile } from "./parse-import-file";
import { applyImportOptions, type ImportTargetColumn, type ImportDefaults } from "./match-import-column";
import type { ImportColumnMapping } from "./build-imported-rows";

export type { ImportTargetColumn, ImportDefaults };

/** Rows shown in the preview table, capped for readability. */
export const IMPORT_PREVIEW_ROW_COUNT = 10;

/** Keys into `labels.io` for the parse-failure messages {@link useDataGridImportPreview} can surface. */
export type ImportErrorKey = "errorParseFailed" | "errorNoRows" | "errorUnsupportedFile";

/** Error thrown by {@link parseImportFile} for extensions outside csv/xlsx/xls. */
const UNSUPPORTED_FILE_TYPE_MESSAGE = "unsupported-file-type";

/** True when a parse was aborted (new file chosen, dialog closed) — the caller drops the result instead of surfacing an error. */
function isAbortError(err: unknown): boolean {
  return err instanceof Error && err.name === "AbortError";
}

/** Parsed-and-ready import state, once a file has been chosen and parsed successfully. */
export type ImportFileState = {
  fileName: string;
  /** All parsed rows, including the header row (if `hasHeaderRow`) at index 0. */
  rows: string[][];
  hasHeaderRow: boolean;
  /** Display header per column: the file's header row text, or `labels.io.columnFallback(N)` when there's no header row. */
  importHeaders: string[];
  mapping: ImportColumnMapping[];
  delimiter?: CsvDelimiter;
  /** Every sheet name in the workbook. Undefined for CSV. Lets a consumer offer a sheet picker when a workbook has more than one sheet. */
  sheetNames?: string[];
  /** The sheet `rows` came from (first by default, or the one selected via {@link UseDataGridImportPreviewResult.setSheetName}). Undefined for CSV. */
  sheetName?: string;
};

/** Return value of {@link useDataGridImportPreview}. */
export type UseDataGridImportPreviewResult = {
  preview: ImportFileState | null;
  error: ImportErrorKey | null;
  /** True while a file is being read/parsed. */
  isParsing: boolean;
  /** Parses `file`; on success populates `preview` with column mapping prefilled by header-text match. */
  loadFile: (file: File, gridColumns: readonly ImportTargetColumn[]) => Promise<void>;
  setHasHeaderRow: (hasHeaderRow: boolean) => void;
  /** Re-parses the current CSV file with the given delimiter override; no-op for xlsx/xls files or before a file is loaded. */
  setDelimiter: (delimiter: CsvDelimiter) => Promise<void>;
  /**
   * Re-parses the current xlsx/xls workbook with the given sheet; no-op for CSV or before a file
   * is loaded. Switching sheets recomputes the column mapping from scratch (different headers), so
   * any manual mapping the user set on the previous sheet is discarded.
   */
  setSheetName: (sheetName: string) => Promise<void>;
  setMapping: (importColumnIndex: number, gridColumnId: string | null) => void;
  reset: () => void;
  /** Data rows only (header row excluded when `hasHeaderRow`), capped to `IMPORT_PREVIEW_ROW_COUNT` for the preview table. */
  previewRows: string[][];
  /** All data rows (header row excluded when `hasHeaderRow`) — the full set `onImport` will build from. */
  importRows: string[][];
};

/** Builds display headers: the file's header row when present, else `labels.io.columnFallback(N)`. */
function computeImportHeaders(rows: string[][], hasHeaderRow: boolean, columnFallback: (index: number) => string): string[] {
  const columnCount = rows[0]?.length ?? 0;
  if (hasHeaderRow && rows[0]) return rows[0];
  return Array.from({ length: columnCount }, (_, i) => columnFallback(i + 1));
}

/** Resolves the `csvDelimiter` override to parse with, given `ImportDefaults`; undefined means "let auto-detect run". */
function resolveDelimiterOverride(options: ImportDefaults): CsvDelimiter | undefined {
  if (options.autoDetectDelimiter === false) return options.defaultDelimiter ?? ",";
  return options.defaultDelimiter;
}

/**
 * Owns the parse -> preview -> column-mapping state for the import dialog. File I/O and header
 * matching are pure functions ({@link parseImportFile}, {@link applyImportOptions}); this hook only
 * sequences them and exposes editable dialog state (header-row toggle, delimiter, per-column mapping).
 *
 * @param columnFallback `labels.io.columnFallback` — builds the header shown for column N when the file has no header row.
 * @param options Consumer-configurable preselection defaults (see {@link ImportDefaults}); omit for today's behavior unchanged.
 */
export function useDataGridImportPreview(
  columnFallback: (index: number) => string,
  options: ImportDefaults = {},
): UseDataGridImportPreviewResult {
  const [preview, setPreview] = useState<ImportFileState | null>(null);
  const [error, setError] = useState<ImportErrorKey | null>(null);
  const [isParsing, setIsParsing] = useState(false);
  // kept for delimiter-override re-parses, which need the original file + target columns again.
  const currentFileRef = useRef<File | null>(null);
  const gridColumnsRef = useRef<readonly ImportTargetColumn[]>([]);
  // aborts an in-flight file read when a newer load starts or the dialog resets.
  const parseControllerRef = useRef<AbortController | null>(null);
  // generation guard for async sheet re-parses: a newer sheet switch, file load, or reset must win over a slower older one.
  const sheetGenerationRef = useRef(0);
  const { defaultHeaderRow = true, defaultSkipColumns, mapColumn } = options;

  const reset = useCallback(() => {
    sheetGenerationRef.current += 1;
    parseControllerRef.current?.abort();
    setPreview(null);
    setError(null);
    setIsParsing(false);
    currentFileRef.current = null;
    gridColumnsRef.current = [];
  }, []);

  const loadFile = useCallback(async (file: File, gridColumns: readonly ImportTargetColumn[]) => {
    sheetGenerationRef.current += 1;
    parseControllerRef.current?.abort();
    const controller = new AbortController();
    parseControllerRef.current = controller;
    setError(null);
    setIsParsing(true);
    try {
      const parsed = await parseImportFile(file, { csvDelimiter: resolveDelimiterOverride(options) }, controller.signal);
      if (parsed.rows.length === 0) {
        setError("errorNoRows");
        setPreview(null);
        return;
      }
      currentFileRef.current = file;
      gridColumnsRef.current = gridColumns;
      const hasHeaderRow = defaultHeaderRow;
      const importHeaders = computeImportHeaders(parsed.rows, hasHeaderRow, columnFallback);
      const matched = applyImportOptions(importHeaders, gridColumns, { defaultSkipColumns, mapColumn });
      setPreview({
        fileName: file.name,
        rows: parsed.rows,
        hasHeaderRow,
        importHeaders,
        mapping: matched.map((gridColumnId, importColumnIndex) => ({ importColumnIndex, gridColumnId })),
        delimiter: parsed.delimiter,
        sheetNames: parsed.sheetNames,
        sheetName: parsed.sheetName,
      });
    } catch (err) {
      if (isAbortError(err)) return; // superseded by a newer file or a dialog reset
      const isUnsupportedType = err instanceof Error && err.message === UNSUPPORTED_FILE_TYPE_MESSAGE;
      setError(isUnsupportedType ? "errorUnsupportedFile" : "errorParseFailed");
      setPreview(null);
    } finally {
      setIsParsing(false);
    }
    // oxlint-disable-next-line react-hooks/exhaustive-deps -- destructured option fields are the real deps; `options` itself may be a fresh object each render.
  }, [columnFallback, defaultHeaderRow, defaultSkipColumns, mapColumn]);

  const setHasHeaderRow = useCallback(
    (hasHeaderRow: boolean) => {
      setPreview((prev) => {
        if (!prev) return prev;
        const importHeaders = computeImportHeaders(prev.rows, hasHeaderRow, columnFallback);
        return { ...prev, hasHeaderRow, importHeaders };
      });
    },
    [columnFallback],
  );

  const setDelimiter = useCallback(
    async (delimiter: CsvDelimiter) => {
      const file = currentFileRef.current;
      if (!file) return;
      let parsed: ParsedImportFile;
      try {
        parsed = await parseImportFile(file, { csvDelimiter: delimiter }, parseControllerRef.current?.signal);
      } catch (err) {
        if (isAbortError(err)) return;
        throw err;
      }
      if (parsed.rows.length === 0) return;
      setPreview((prev) => {
        if (!prev) return prev;
        const importHeaders = computeImportHeaders(parsed.rows, prev.hasHeaderRow, columnFallback);
        const matched = applyImportOptions(importHeaders, gridColumnsRef.current, { defaultSkipColumns, mapColumn });
        return {
          ...prev,
          rows: parsed.rows,
          importHeaders,
          delimiter,
          mapping: matched.map((gridColumnId, importColumnIndex) => ({ importColumnIndex, gridColumnId })),
        };
      });
    },
    [columnFallback, defaultSkipColumns, mapColumn],
  );

  const setSheetName = useCallback(
    async (sheetName: string) => {
      const file = currentFileRef.current;
      if (!file) return;
      const generation = ++sheetGenerationRef.current;
      setError(null);
      setIsParsing(true);
      try {
        const parsed = await parseImportFile(file, { sheetName }, parseControllerRef.current?.signal);
        if (generation !== sheetGenerationRef.current) return; // superseded by a newer sheet switch, file load, or reset
        if (parsed.rows.length === 0) {
          // empty sheet: the picker keeps the chosen name honest, the preview clears, Import stays disabled
          setPreview((prev) =>
            prev ? { ...prev, rows: [], importHeaders: [], mapping: [], sheetName: parsed.sheetName ?? sheetName } : prev,
          );
          setError("errorNoRows");
          return;
        }
        setPreview((prev) => {
          if (!prev) return prev;
          const importHeaders = computeImportHeaders(parsed.rows, prev.hasHeaderRow, columnFallback);
          const matched = applyImportOptions(importHeaders, gridColumnsRef.current, { defaultSkipColumns, mapColumn });
          return {
            ...prev,
            rows: parsed.rows,
            importHeaders,
            sheetName: parsed.sheetName,
            mapping: matched.map((gridColumnId, importColumnIndex) => ({ importColumnIndex, gridColumnId })),
          };
        });
      } catch (err) {
        if (isAbortError(err)) return;
        if (generation === sheetGenerationRef.current) setError("errorParseFailed");
      } finally {
        if (generation === sheetGenerationRef.current) setIsParsing(false);
      }
    },
    [columnFallback, defaultSkipColumns, mapColumn],
  );

  const setMapping = useCallback((importColumnIndex: number, gridColumnId: string | null) => {
    setPreview((prev) => {
      if (!prev) return prev;
      const mapping = prev.mapping.map((m) => (m.importColumnIndex === importColumnIndex ? { ...m, gridColumnId } : m));
      return { ...prev, mapping };
    });
  }, []);

  const importRows = useMemo(() => {
    if (!preview) return [];
    return preview.hasHeaderRow ? preview.rows.slice(1) : preview.rows;
  }, [preview]);

  const previewRows = useMemo(() => importRows.slice(0, IMPORT_PREVIEW_ROW_COUNT), [importRows]);

  return { preview, error, isParsing, loadFile, setHasHeaderRow, setDelimiter, setSheetName, setMapping, reset, previewRows, importRows };
}
registry/default/blocks/data-grid-io/import-dialog.tsx
"use client";

import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Paperclip, X } from "lucide-react";
import {
  isDev,
  useDataGridActions,
  useDataGridAllColumns,
  useDataGridLabels,
  useDataGridStoreApi,
  type AnyColumnDef,
} from "@/registry/default/components/data-grid/data-grid";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { buildImportedRows, type ImportRejectedCell } from "./build-imported-rows";
import { useDataGridImportPreview, type ImportTargetColumn, type ImportDefaults } from "./use-data-grid-import";
import type { CsvDelimiter } from "./parse-import-file";
import { Dropzone, DropzoneEmptyState, type DropzoneAccept } from "@/registry/default/components/dropzone/dropzone";

/** Accepted import file types, shared by the file input and the {@link Dropzone}. */
const IMPORT_ACCEPT: DropzoneAccept = {
  "text/csv": [".csv"],
  "text/tab-separated-values": [".tsv"],
  "application/vnd.ms-excel": [".xls"],
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"],
};

export type { ImportDefaults };

const DELIMITER_OPTIONS: { value: CsvDelimiter; labelKey: "delimiterComma" | "delimiterSemicolon" | "delimiterTab" }[] = [
  { value: ",", labelKey: "delimiterComma" },
  { value: ";", labelKey: "delimiterSemicolon" },
  { value: "\t", labelKey: "delimiterTab" },
];

/** Column display header: `headerText`, else the string `header`, else the id. */
function columnLabel(column: Pick<AnyColumnDef, "id" | "header" | "headerText">): string {
  return column.headerText ?? (typeof column.header === "string" ? column.header : column.id);
}

/** Props for {@link DataGridImportDialog}. */
export type DataGridImportDialogProps<TData> = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  /** Builds a fresh row for import row `index`; required — the dialog never writes to the grid store directly. */
  createRow: (index: number) => TData;
  /** Called with the fully-built rows on confirm; the consumer decides how to merge (replace/append). Cells rejected by `validate` are already cleared in these rows. May be async (e.g. a server upsert) — the dialog stays pending until it settles and surfaces a rejection instead of closing. */
  onImport: (rows: TData[]) => void | Promise<void>;
  /** Consumer-configurable preselection defaults (delimiter, header row, skip columns, mapping). Omit for today's behavior unchanged. */
  importDefaults?: ImportDefaults;
};

/**
 * File-picker -> preview -> column-mapping -> confirm dialog. Parses via
 * {@link useDataGridImportPreview}, builds `TData` rows via {@link buildImportedRows} through each mapped
 * column's cell-type `fromText` + `validate`, then hands the rows to `onImport` — it never writes
 * to the grid store directly, so replace/append semantics stay the consumer's call. A cell that
 * fails `validate` is cleared (via its cell type's `clearValue()`), not dropped as a row; the
 * rejected cells are listed in {@link buildImportedRows}'s `rejected` result.
 */
export function DataGridImportDialog<TData>(props: DataGridImportDialogProps<TData>): ReactNode {
  const { open, onOpenChange, createRow, onImport, importDefaults } = props;
  const labels = useDataGridLabels();
  const actions = useDataGridActions();
  const storeApi = useDataGridStoreApi();
  const allColumns = useDataGridAllColumns<TData>();
  const [isValidating, setIsValidating] = useState(false);
  /** Generation guard for a held async import: a newer confirm, or closing the dialog, drops the older batch. */
  const confirmTokenRef = useRef(0);
  /** Aborts the in-flight chunked build (large sync imports and async-schema imports alike) when Cancel is clicked or the dialog closes. */
  const abortControllerRef = useRef<AbortController | null>(null);
  const { preview, error, isParsing, loadFile, setHasHeaderRow, setDelimiter, setSheetName, setMapping, reset, previewRows, importRows } =
    useDataGridImportPreview(labels.io.columnFallback, importDefaults);

  useEffect(() => {
    if (open) return;
    confirmTokenRef.current += 1;
    abortControllerRef.current?.abort();
    setIsValidating(false);
    setRejectedCount(0);
    setMergeFailed(false);
    reset();
  }, [open, reset]);

  const onFile = useCallback(
    (file: File) => {
      const targets: ImportTargetColumn[] = allColumns.map((c) => ({ id: c.id, headerText: c.headerText, header: c.header }));
      setRejectedCount(0);
      setMergeFailed(false);
      void loadFile(file, targets);
    },
    [allColumns, loadFile],
  );

  const onDropFiles = useCallback(
    (acceptedFiles: File[]) => {
      const file = acceptedFiles[0];
      if (file) onFile(file);
    },
    [onFile],
  );

  /** Rejected-cell count from the last confirmed build; non-zero keeps the dialog open so the warning is visible. */
  const [rejectedCount, setRejectedCount] = useState(0);
  /** True when `onImport` rejected; the dialog stays open and the import can be retried. */
  const [mergeFailed, setMergeFailed] = useState(false);
  const finish = useCallback(
    (rows: TData[], rejected: readonly ImportRejectedCell[]) => {
      setMergeFailed(false);
      setIsValidating(true);
      void Promise.resolve()
        .then(() => onImport(rows))
        .then(() => {
          actions.clearSelection();
        })
        .then(
          () => {
            setIsValidating(false);
            if (rejected.length > 0) {
              setRejectedCount(rejected.length);
              return;
            }
            onOpenChange(false);
          },
          (error) => {
            setIsValidating(false);
            setMergeFailed(true);
            if (isDev()) console.warn("[data-grid-io] onImport rejected; the dialog stays open", error);
          },
        );
    },
    [onImport, actions, onOpenChange],
  );

  const onConfirm = useCallback(() => {
    if (!preview) return;
    const state = storeApi.getState();
    const abortController = new AbortController();
    abortControllerRef.current = abortController;
    // state.cellTypes comes back from the generic-erased store (TData = unknown, see core store.tsx's InternalSyncProps comment); re-widened here to this dialog's own TData. allColumns is already TData-typed via useDataGridAllColumns<TData>() above.
    const built = buildImportedRows<TData>({
      dataRows: importRows,
      mapping: preview.mapping,
      columns: allColumns,
      cellTypes: state.cellTypes as never,
      createRow,
      signal: abortController.signal,
    });
    if (!(built instanceof Promise)) {
      finish(built.rows, built.rejected);
      return;
    }
    // A large import (chunked so the dialog can repaint and Cancel can land) or an async schema on a
    // mapped column: the dialog stays open with Import disabled — the existing pending affordance —
    // and closes when the last verdict is in, or drops the batch silently if Cancel/close beat it.
    const token = ++confirmTokenRef.current;
    setIsValidating(true);
    void built.then(
      (result) => {
        if (confirmTokenRef.current !== token) return; // superseded by a newer confirm, or the dialog reset
        setIsValidating(false);
        finish(result.rows, result.rejected);
      },
      () => {
        // cancelled (signal aborted) or a validator threw outside runValidateBatch's own guard — either way, no rows to import.
        if (confirmTokenRef.current !== token) return;
        setIsValidating(false);
      },
    );
  }, [preview, importRows, allColumns, storeApi, createRow, finish]);

  const onCancel = useCallback(() => {
    abortControllerRef.current?.abort();
    onOpenChange(false);
  }, [onOpenChange]);

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="max-h-[80vh] min-w-0 overflow-y-auto sm:max-w-2xl">
        <DialogHeader>
          <DialogTitle>{labels.io.importDialogTitle}</DialogTitle>
          <DialogDescription>{labels.io.importDialogDescription}</DialogDescription>
        </DialogHeader>

        <div className="flex min-w-0 flex-col gap-3">
          {preview ? (
            <Dropzone compact accept={IMPORT_ACCEPT} maxFiles={1} onDrop={onDropFiles} aria-label={labels.io.chooseFile}>
              <Paperclip className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
              <span className="min-w-0 flex-1 truncate text-sm text-foreground">{preview.fileName}</span>
              <span className="shrink-0 text-xs text-muted-foreground">{labels.io.replaceFile}</span>
            </Dropzone>
          ) : (
            <Dropzone accept={IMPORT_ACCEPT} maxFiles={1} onDrop={onDropFiles} aria-label={labels.io.chooseFile}>
              <DropzoneEmptyState title={labels.io.chooseFile} description={labels.io.noFileChosen} />
            </Dropzone>
          )}

          {error && <p className="text-sm text-destructive">{labels.io[error]}</p>}

          {rejectedCount > 0 && <p role="alert" className="text-sm text-destructive">{labels.io.importRejectedCells(rejectedCount)}</p>}

          {mergeFailed && <p role="alert" className="text-sm text-destructive">{labels.io.importMergeFailed}</p>}

          {preview && (
            <>
              <div className="flex items-center gap-4">
                <label className="flex items-center gap-2 text-sm text-foreground">
                  <input
                    type="checkbox"
                    checked={preview.hasHeaderRow}
                    onChange={(e) => setHasHeaderRow(e.target.checked)}
                  />
                  {labels.io.hasHeaderRow}
                </label>
                {preview.delimiter !== undefined && (
                  <label className="flex items-center gap-2 text-sm text-foreground">
                    {labels.io.delimiter}
                    <Select value={preview.delimiter} onValueChange={(value) => void setDelimiter(value as CsvDelimiter)}>
                      <SelectTrigger className="w-36" size="sm" aria-label={labels.io.delimiter}>
                        <SelectValue />
                      </SelectTrigger>
                      <SelectContent>
                        {DELIMITER_OPTIONS.map((option) => (
                          <SelectItem key={option.value} value={option.value}>
                            {labels.io[option.labelKey]}
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  </label>
                )}
                {preview.sheetNames !== undefined && preview.sheetNames.length > 1 && (
                  <label className="flex items-center gap-2 text-sm text-foreground">
                    {labels.io.sheet}
                    <Select value={preview.sheetName ?? preview.sheetNames[0]} onValueChange={(value) => void setSheetName(value as string)}>
                      <SelectTrigger className="w-40" size="sm" aria-label={labels.io.sheet}>
                        <SelectValue />
                      </SelectTrigger>
                      <SelectContent>
                        {preview.sheetNames.map((name) => (
                          <SelectItem key={name} value={name} title={name}>
                            {name}
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  </label>
                )}
              </div>

              <div className="flex min-w-0 flex-col gap-1">
                <span className="text-sm font-medium text-foreground">{labels.io.mapColumns}</span>
                <div className="min-w-0 overflow-x-auto rounded-md border border-border">
                  {/* one grid for header+selects+preview so every column shares an identical track — keeps selects and preview cells aligned */}
                  <div
                    role="table"
                    className="grid text-sm"
                    style={{ gridTemplateColumns: `repeat(${preview.importHeaders.length}, minmax(9rem, 1fr))` }}
                  >
                    {preview.importHeaders.map((header, importColumnIndex) => {
                      const currentGridColumnId = preview.mapping[importColumnIndex]?.gridColumnId ?? null;
                      // a grid column already claimed by another row can't be picked again — one source per grid column (v1 rule)
                      const takenElsewhere = new Set(
                        preview.mapping
                          .filter((m) => m.importColumnIndex !== importColumnIndex && m.gridColumnId !== null)
                          .map((m) => m.gridColumnId),
                      );
                      return (
                        <div
                          key={importColumnIndex}
                          role="columnheader"
                          className="min-w-0 border-b border-border bg-muted/50 p-1.5 text-start font-medium text-foreground"
                        >
                          <div className="mb-1 truncate" title={header}>
                            {header}
                          </div>
                          <div className="flex items-center gap-1">
                            <Select
                              value={currentGridColumnId ?? "__skip__"}
                              onValueChange={(value) =>
                                setMapping(importColumnIndex, value === "__skip__" || value === null ? null : value)
                              }
                            >
                              <SelectTrigger className="w-full" size="sm" aria-label={labels.io.mapColumnAriaLabel(header)}>
                                <SelectValue />
                              </SelectTrigger>
                              <SelectContent>
                                <SelectItem value="__skip__">{labels.io.skipColumn}</SelectItem>
                                {allColumns.map((column) => (
                                  <SelectItem key={column.id} value={column.id} disabled={takenElsewhere.has(column.id)}>
                                    {columnLabel(column)}
                                  </SelectItem>
                                ))}
                              </SelectContent>
                            </Select>
                            {currentGridColumnId !== null && (
                              <Button
                                type="button"
                                variant="ghost"
                                size="icon"
                                className="size-7 shrink-0"
                                aria-label={labels.io.skipColumnQuick}
                                onClick={() => setMapping(importColumnIndex, null)}
                              >
                                <X className="size-3.5" />
                              </Button>
                            )}
                          </div>
                        </div>
                      );
                    })}
                    {previewRows.map((row, rowIndex) =>
                      preview.importHeaders.map((_, columnIndex) => (
                        <div
                          key={`${rowIndex}-${columnIndex}`}
                          role="cell"
                          className={cn(
                            "min-w-0 truncate p-1.5 text-muted-foreground",
                            rowIndex < previewRows.length - 1 && "border-b border-border",
                          )}
                          title={row[columnIndex] ?? ""}
                        >
                          {row[columnIndex] ?? ""}
                        </div>
                      )),
                    )}
                  </div>
                </div>
                {preview.importHeaders.length > 1 && (
                  <span className="text-xs text-muted-foreground">{labels.io.mapColumnsHint}</span>
                )}
                <span className="text-xs text-muted-foreground">
                  {labels.io.previewTruncated(previewRows.length, importRows.length)}
                </span>
              </div>
            </>
          )}
        </div>

        <DialogFooter>
          <Button type="button" variant="outline" onClick={onCancel}>
            {labels.io.cancel}
          </Button>
            <Button type="button" onClick={onConfirm} disabled={!preview || importRows.length === 0 || isParsing || isValidating || rejectedCount > 0}>
              {labels.io.import}
            </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
registry/default/blocks/data-grid-io/import-button.tsx
"use client";

import { useState, type ReactNode } from "react";
import { Upload } from "lucide-react";
import { useDataGridLabels } from "@/registry/default/components/data-grid/data-grid";
import { Button } from "@/components/ui/button";
import { DataGridImportDialog, type ImportDefaults } from "./import-dialog";

export type { ImportDefaults };

/** Props for {@link DataGridImportButton}. */
export type DataGridImportButtonProps<TData> = {
  className?: string;
  /** Builds a fresh row for import row `index`; required — the dialog never writes to the grid store directly. */
  createRow: (index: number) => TData;
  /** Called with the fully-built rows on confirm; the consumer decides how to merge (replace/append). */
  onImport: (rows: TData[]) => void | Promise<void>;
  /** Consumer-configurable preselection defaults (delimiter, header row, skip columns, mapping). Omit for today's behavior unchanged. */
  importDefaults?: ImportDefaults;
};

/**
 * Toolbar-slot import trigger: a ghost icon button that opens {@link DataGridImportDialog}.
 * `size-8` + ghost variant matches the toolbar's other icon buttons.
 */
export function DataGridImportButton<TData>(props: DataGridImportButtonProps<TData>): ReactNode {
  const { className, createRow, onImport, importDefaults } = props;
  const labels = useDataGridLabels();
  const [open, setOpen] = useState(false);

  return (
    <>
      <Button type="button" variant="ghost" size="icon" className={className} aria-label={labels.io.importButton} onClick={() => setOpen(true)}>
        <Upload />
      </Button>
      <DataGridImportDialog open={open} onOpenChange={setOpen} createRow={createRow} onImport={onImport} importDefaults={importDefaults} />
    </>
  );
}

More from gridcn

Browse all components in this library →