fix(grid): copy NULL cells as empty fields

This commit is contained in:
李子凡 authored and GitHub committed 2026-08-31 16:57:33 +08:00
1 parent 27f62fe6e2
commit 0e9f92c917
11 files changed
+113 -16

No files matched your search

@@ -730,6 +730,66 @@ describe("useDataGridExport prepared row statements", () => {
expect(copyToClipboard).toHaveBeenCalledWith(text);
});
it("copies a NULL cell as an empty clipboard value", async () => {
const state = createExportState(editableTable, ["id", "name"], undefined, [1, null], undefined, undefined, [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, false, 1, 1);
await state.copyCell();
expect(copyToClipboard).toHaveBeenCalledWith("");
});
it("copies all rows with empty fields for NULL cells", async () => {
const text = "id\tname\n1\t\n2\tAda";
const state = createExportState(editableTable, ["id", "name"], undefined, undefined, undefined, [
[1, null],
[2, "Ada"],
]);
await state.copyAll();
expect(copyToClipboard).toHaveBeenCalledWith(text);
expect(parseDataGridClipboard(text)).toEqual([
["id", "name"],
["1", null],
["2", "Ada"],
]);
});
it("copies a NULL smart single-cell selection as an empty value", async () => {
const matrix: CellSelectionMatrix = {
rowIndexes: [0],
columnIndexes: [1],
columns: ["name"],
rows: [[null]],
};
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "", mimeType: "text/plain", fileExtension: "txt", rowCount: 1, columnCount: 1 });
const state = createExportState(editableTable, ["id", "name"], matrix, [1, null]);
await expect(state.copyWithPreference("smart")).resolves.toBe(true);
expect(copyToClipboard).toHaveBeenCalledWith("");
});
it("uses an empty default NULL text for TSV clipboard output", async () => {
const matrix: CellSelectionMatrix = {
rowIndexes: [0],
columnIndexes: [0, 1],
columns: ["id", "name"],
rows: [[1, null]],
};
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "1\t", mimeType: "text/tab-separated-values", fileExtension: "tsv", rowCount: 1, columnCount: 2 });
const state = createExportState(editableTable, ["id", "name"], matrix, [1, null]);
await expect(state.copyWithExtractor("tsv")).resolves.toBe(true);
expect(extractDataGridSelection).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({ dsv: expect.objectContaining({ nullText: "" }) }),
}),
);
expect(copyToClipboard).toHaveBeenCalledWith("1\t");
});
it("preserves JSON-column text in a single-cell smart copy", async () => {
const tableMeta: DataGridTableMeta = {
tableName: "events",
@@ -8,7 +8,7 @@ import { type CellSelectionMatrix, type CellSelectionRange, type SelectionData }
import type { DataGridExtractRequest, DataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor";
import { useToast } from "@/composables/useToast";
import { useExportTracker } from "@/composables/useExportTracker";
import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue";
import { clipboardCellValue, type CellValue } from "@/lib/dataGrid/cellValue";
import { tryStartExclusiveActivation, type ActionActivationGuard } from "@/lib/connection/actionActivation";
import { copyToClipboard } from "@/lib/common/clipboard";
import { clearDataGridClipboardCopy, rememberDataGridClipboardCopy } from "@/lib/dataGrid/dataGridClipboard";
@@ -529,7 +529,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
const sourceIndex = visibleColumnIndexes.value[contextCell.value.col] ?? contextCell.value.col;
const [resolvedItem] = await resolveVisibleRowValues([item], [sourceIndex]);
const val = resolvedItem?.data[contextCell.value.col] ?? null;
await copyText(displayCellValue(val), { rows: [[val]] });
await copyText(clipboardCellValue(val), { rows: [[val]] });
}
async function copyRow() {
@@ -671,10 +671,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
});
async function copyAll() {
const header = columns.value.join("\t");
const rows = (await resolveVisibleRowValues(displayItems.value.filter((item) => !item.isDraft))).map((item) => item.data);
const body = rows.map((row) => row.map((cell) => displayCellValue(cell)).join("\t")).join("\n");
await copyText(`${header}\n${body}`, { rows, header: columns.value });
await copyText(formatTsv(columns.value, rows), { rows, header: columns.value });
}
// --- Export functions ---
@@ -329,7 +329,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
const request = await resolveRequestSourceValues(initialRequest);
const mongoResult = await resolveMongoExtractorResult(extractor, request);
const result = mongoResult ?? (await api.extractDataGridSelection(request));
if (!result.text) return false;
if (!result.text && !extractorAllowsEmptyOutput(extractor)) return false;
// Derive the grid paste-back payload from the effective request schema so
// hidden support columns, row headers, NULLs, tabs, and newlines keep the
// same shape and values as the rendered raw text or TSV.
@@ -365,7 +365,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
const request = await resolveRequestSourceValues(initialRequest, previewRowCount);
const mongoResult = await resolveMongoExtractorResult(extractor, request, previewRowCount);
const result = mongoResult ?? (await api.extractDataGridSelection(request));
if (!result.text) throw new Error(t("grid.copyExtractorEmptySelection"));
if (!result.text && !extractorAllowsEmptyOutput(extractor)) throw new Error(t("grid.copyExtractorEmptySelection"));
return { ...result, sourceRowCount, truncated: sourceRowCount > result.rowCount };
}
@@ -396,6 +396,10 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
return { copyWithExtractor, copyWithPreference, previewWithExtractor, previewWithPreference, canCopyWithExtractor };
}
function extractorAllowsEmptyOutput(extractor: DataGridCopyExtractorId): boolean {
return DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor].category === "raw" || DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor].category === "delimited";
}
function compactTableMeta(tableMeta: DataGridTableMeta | undefined, requiredColumns: string[]): DataGridTableMeta | undefined {
if (!tableMeta?.columns) return tableMeta;
const requiredNames = new Set(requiredColumns.map(normalizeName));