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 · lightxlsxpapaparse
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} />
</>
);
}