feat(grid): export selections with data extractors

Closes #10360
This commit is contained in:
t8y2
2026-09-26 16:45:22 +00:00
parent 5a272565bb
commit 749d72d874
5 changed files with 175 additions and 22 deletions
+11 -6
View File
@@ -7731,6 +7731,7 @@ const {
copyWithPreference,
previewWithPreference,
canCopyWithExtractor,
exportWithExtractor,
exportCsv,
exportCurrentPageCsv,
exportJson,
@@ -11771,7 +11772,7 @@ function filterSubmenu(): ContextMenuItem {
});
}
function buildExtractorContextItems(): ContextMenuItem[] {
function buildExtractorContextItems(destination: "copy" | "export" = "copy"): ContextMenuItem[] {
const items: ContextMenuItem[] = [];
let separatorPending = false;
for (const extractor of DATA_GRID_COPY_EXTRACTOR_IDS) {
@@ -11784,21 +11785,21 @@ function buildExtractorContextItems(): ContextMenuItem[] {
const extractorItems: ContextMenuItem[] = [];
if (extractor === "sql-inserts") {
for (const excludePrimaryKeysFromInsert of [false, true]) {
if (selected && settingsStore.editorSettings.dataGridExtractorOptions.sql.excludePrimaryKeysFromInsert === excludePrimaryKeysFromInsert) continue;
if (destination === "copy" && selected && settingsStore.editorSettings.dataGridExtractorOptions.sql.excludePrimaryKeysFromInsert === excludePrimaryKeysFromInsert) continue;
const options = sqlInsertExtractorOptions(excludePrimaryKeysFromInsert);
extractorItems.push({
label: t(excludePrimaryKeysFromInsert ? "grid.copyExtractorSqlInsertsWithoutPrimaryKeys" : "grid.copyExtractorSqlInsertsWithPrimaryKeys"),
action: () => void copyWithExtractor(extractor, options),
action: () => void (destination === "copy" ? copyWithExtractor(extractor, options) : exportWithExtractor(extractor, options)),
disabled: !canCopyWithExtractor(extractor, options),
});
}
} else if (!selected) {
} else if (destination === "export" || !selected) {
extractorItems.push({
label: copyExtractorLabel(extractor),
action: () => {
// One-off copy as the chosen format; do NOT persist it as the default —
// One-off use of the chosen format; do NOT persist it as the default —
// the saved default stays controlled by the toolbar/settings dialog.
void copyWithExtractor(extractor);
void (destination === "copy" ? copyWithExtractor(extractor) : exportWithExtractor(extractor));
},
disabled: !canCopyWithExtractor(extractor),
});
@@ -11902,6 +11903,10 @@ function exportSubmenu(): ContextMenuItem {
{ label: t("grid.exportSelectedRowsTxt"), action: exportSelectedRowsTxt },
);
}
const extractorItems = buildExtractorContextItems("export");
if (extractorItems.length > 0) {
items.push({ label: "", separator: true }, ...extractorItems);
}
return { label: t("grid.export"), icon: Upload, children: items };
}
@@ -353,6 +353,35 @@ describe("DataGrid context menu target lifecycle", () => {
expect(contextMenuLabels()).toContain("Filter");
});
it("offers extractor file exports for the selected cell", async () => {
const { host } = mountGrid(hydratedResult(1, "value"));
await settle();
openContextMenu(gridCell(host));
await settle();
contextMenuButton("Export").dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
await settle();
expect(contextMenuButton("Cell Text").disabled).toBe(false);
expect(contextMenuButton("JSON Array").disabled).toBe(false);
expect(contextMenuButton("SQL Select").disabled).toBe(false);
});
it("keeps unsupported extractor exports disabled for a whole-column selection", async () => {
const { host } = mountGrid(hydratedResult(1, "value"));
await settle();
columnHeader(host, 1).click();
await settle();
openContextMenu(columnHeader(host, 1));
await settle();
contextMenuButton("Export").dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
await settle();
expect(contextMenuButton("JSON Array").disabled).toBe(false);
expect(contextMenuButton("SQL Select").disabled).toBe(true);
});
async function openGridSearchAndType(host: HTMLElement, query: string) {
await settle();
const gridRoot = host.querySelector<HTMLElement>("[data-grid-root]");
@@ -12,6 +12,7 @@ import { extractDataGridSelection, exportQueryResultCsv, exportQueryResultJson }
import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS } from "@/lib/dataGrid/dataGridCopyExtractor";
import { clearDataGridClipboardCopy, parseDataGridClipboard } from "@/lib/dataGrid/dataGridClipboard";
import { MONGO_DOCUMENT_GRID_NULL, mongoDocumentGridExternalValue } from "@/lib/mongo/mongoDocumentValues";
import { saveTextFile } from "@/lib/export/saveTextFile";
const toast = vi.fn();
@@ -53,6 +54,14 @@ vi.mock("@/lib/backend/api", async (importOriginal) => {
};
});
vi.mock("@/lib/export/saveTextFile", async (importOriginal) => {
const original = await importOriginal<typeof import("@/lib/export/saveTextFile")>();
return {
...original,
saveTextFile: vi.fn(),
};
});
function row(data: unknown[]) {
return {
id: 1,
@@ -179,6 +188,7 @@ describe("useDataGridExport prepared row statements", () => {
beforeEach(() => {
vi.clearAllMocks();
clearDataGridClipboardCopy();
vi.mocked(saveTextFile).mockResolvedValue(true);
});
it("disables row copy when the result has no rows", () => {
@@ -1155,6 +1165,81 @@ describe("useDataGridExport prepared row statements", () => {
expect(toast).toHaveBeenCalledWith("grid.copyExtractorUnsupportedSelection", 5000);
});
it("saves exactly the same configured extractor output used by copy and preview", async () => {
const matrix: CellSelectionMatrix = {
rowIndexes: [0, 1],
columnIndexes: [0, 1],
columns: ["id", "name"],
rows: [
[1, "Ada"],
[2, "Grace"],
],
};
const extractorOptions = {
...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS,
dsv: {
...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.dsv,
columnSeparator: "::",
rowSeparator: "\r\n",
includeColumnHeader: true,
},
};
const text = "id::name\r\n1::Ada\r\n2::Grace";
vi.mocked(extractDataGridSelection).mockImplementation(async (request) => ({
text,
mimeType: "text/plain",
fileExtension: "txt",
rowCount: request.rows.length,
columnCount: request.selectedColumnIndexes.length,
}));
const state = createExportState(editableTable, ["id", "name"], matrix, undefined, undefined, matrix.rows, [], extractorOptions);
await expect(state.copyWithExtractor("dsv", extractorOptions)).resolves.toBe(true);
const preview = await state.previewWithExtractor("dsv", extractorOptions);
await expect(state.exportWithExtractor("dsv", extractorOptions)).resolves.toBe(true);
expect(copyToClipboard).toHaveBeenCalledWith(text);
expect(preview.text).toBe(text);
expect(saveTextFile).toHaveBeenCalledWith(text, expect.stringMatching(/^users_selected_\d{12}\.txt$/), "TXT", "txt", { operation: "selection-extractor-dsv" });
expect(vi.mocked(extractDataGridSelection).mock.calls.map(([request]) => request.options)).toEqual([extractorOptions, extractorOptions, extractorOptions]);
});
it("does not report success when an extractor export save is cancelled", async () => {
const matrix: CellSelectionMatrix = { rowIndexes: [0], columnIndexes: [1], columns: ["name"], rows: [["Ada"]] };
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: '[{"name":"Ada"}]', mimeType: "application/json", fileExtension: "json", rowCount: 1, columnCount: 1 });
vi.mocked(saveTextFile).mockResolvedValueOnce(false);
const state = createExportState(editableTable, ["id", "name"], matrix, [1, "Ada"]);
await expect(state.exportWithExtractor("json")).resolves.toBe(false);
expect(saveTextFile).toHaveBeenCalledOnce();
expect(toast).not.toHaveBeenCalledWith("grid.exported");
});
it("does not save extractor output for an unsupported discrete selection", async () => {
const state = createExportState(editableTable, ["id", "name"], undefined, undefined, {
columns: ["id", "name"],
rows: [[1], ["Grace"]],
});
expect(state.canCopyWithExtractor("json")).toBe(false);
await expect(state.exportWithExtractor("json")).resolves.toBe(false);
expect(extractDataGridSelection).not.toHaveBeenCalled();
expect(saveTextFile).not.toHaveBeenCalled();
});
it("reports extractor save failures through the existing export error path", async () => {
const matrix: CellSelectionMatrix = { rowIndexes: [0], columnIndexes: [1], columns: ["name"], rows: [["Ada"]] };
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "Ada", mimeType: "text/plain", fileExtension: "txt", rowCount: 1, columnCount: 1 });
vi.mocked(saveTextFile).mockRejectedValueOnce(new Error("disk full"));
const state = createExportState(editableTable, ["id", "name"], matrix, [1, "Ada"]);
await expect(state.exportWithExtractor("pretty")).resolves.toBe(false);
expect(toast).toHaveBeenCalledWith("grid.exportFailed: disk full", 5000);
});
it("uses the right-clicked cell for SQL predicates despite an irregular discrete selection", async () => {
const state = createExportState(
editableTable,
@@ -5,7 +5,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { saveTextFile, sanitizeExportBaseName, compactLocalTimestamp } from "@/lib/export/saveTextFile";
import * as api from "@/lib/backend/api";
import { type CellSelectionMatrix, type CellSelectionRange, type SelectionData } from "@/lib/dataGrid/gridSelection";
import type { DataGridExtractRequest, DataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor";
import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, type DataGridCopyExtractorId, type DataGridExtractRequest, type DataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor";
import { useToast } from "@/composables/useToast";
import { useExportTracker } from "@/composables/useExportTracker";
import { clipboardCellValue, type CellValue } from "@/lib/dataGrid/cellValue";
@@ -764,7 +764,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
});
}
const { copyWithExtractor, copyWithPreference, previewWithExtractor, previewWithPreference, canCopyWithExtractor } = useDataGridExtractor({
const { extractWithExtractor, copyWithExtractor, copyWithPreference, previewWithExtractor, previewWithPreference, canCopyWithExtractor } = useDataGridExtractor({
columns,
displayItems,
allColumns,
@@ -818,6 +818,28 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
}
async function exportWithExtractor(extractor: DataGridCopyExtractorId, extractorOptions: DataGridExtractorOptions = extractorOptionsOption?.value ?? DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS): Promise<boolean> {
let exported = false;
await runExclusiveExport(async () => {
if (!canCopyWithExtractor(extractor, extractorOptions)) return;
try {
const extraction = await extractWithExtractor(extractor, extractorOptions);
if (!extraction) return;
const extension = extraction.result.fileExtension.replace(/^\.+/, "") || "txt";
const baseName = `${exportFileBaseName?.value || tableMeta.value?.tableName || "export"}_selected`;
const saved = await saveTextFile(extraction.result.text, exportFileName(baseName, extension, { preferFallback: true }), extension.toUpperCase(), extension, {
operation: `selection-extractor-${extractor}`,
});
if (!saved) return;
toast(t("grid.exported"));
exported = true;
} catch (e: any) {
toast(t("grid.exportFailed", { message: translateBackendError(t, e) }), 5000);
}
});
return exported;
}
async function exportCsv(rowIds?: number[]) {
await runExclusiveExport(async () => {
try {
@@ -1709,6 +1731,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
previewWithExtractor,
previewWithPreference,
canCopyWithExtractor,
exportWithExtractor,
exportCsv,
exportCurrentPageCsv,
exportJson,
@@ -15,6 +15,7 @@ import {
type DataGridCopyPreference,
type DataGridExtractPreview,
type DataGridExtractRequest,
type DataGridExtractResult,
type DataGridExtractorOptions,
type DataGridExtractWarningCode,
} from "@/lib/dataGrid/dataGridCopyExtractor";
@@ -41,6 +42,12 @@ interface ExtractorRequestSource {
rawRows: unknown[][];
}
interface ResolvedDataGridExtraction {
initialRequest: DataGridExtractRequest;
request: DataGridExtractRequest;
result: DataGridExtractResult;
}
interface UseDataGridExtractorOptions {
columns: ComputedRef<string[]>;
displayItems: ComputedRef<ExtractorRowItem[]>;
@@ -376,19 +383,27 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
return { text, mimeType: "application/javascript", fileExtension: "js", rowCount: rowLimit ?? request.rows.length, columnCount: request.selectedColumnIndexes.length, warnings: undefined, omittedColumns: undefined };
}
async function extractWithExtractor(extractor: DataGridCopyExtractorId, extractorOptions: DataGridExtractorOptions = options.extractorOptions?.value ?? DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, maxRows?: number): Promise<ResolvedDataGridExtraction | null> {
const initialRequest = buildRequest(extractor, extractorOptions);
if (!initialRequest) return null;
const rowLimit = maxRows === undefined ? undefined : Math.min(initialRequest.rows.length, maxRows);
const request = await resolveRequestSourceValues(initialRequest, rowLimit);
const mongoResult = await resolveMongoExtractorResult(extractor, request, rowLimit);
const result = mongoResult ?? (await api.extractDataGridSelection(request));
if (!result.text && !extractorAllowsEmptyOutput(extractor)) return null;
return { initialRequest, request, result };
}
async function copyWithExtractor(extractor: DataGridCopyExtractorId, extractorOptions: DataGridExtractorOptions = options.extractorOptions?.value ?? DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS): Promise<boolean> {
if (hasUnsupportedDiscreteSelection.value && !hasContextPredicateTarget(extractor)) {
toast(t("grid.copyExtractorUnsupportedSelection"), 5000);
return false;
}
if (!canCopyWithExtractor(extractor, extractorOptions)) return false;
const initialRequest = buildRequest(extractor, extractorOptions);
if (!initialRequest) return false;
try {
const request = await resolveRequestSourceValues(initialRequest);
const mongoResult = await resolveMongoExtractorResult(extractor, request);
const result = mongoResult ?? (await api.extractDataGridSelection(request));
if (!result.text && !extractorAllowsEmptyOutput(extractor)) return false;
const extraction = await extractWithExtractor(extractor, extractorOptions);
if (!extraction) return false;
const { request, result } = extraction;
// 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.
@@ -418,14 +433,10 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
async function previewWithExtractor(extractor: DataGridCopyExtractorId, extractorOptions: DataGridExtractorOptions): Promise<DataGridExtractPreview> {
if (hasUnsupportedDiscreteSelection.value && !hasContextPredicateTarget(extractor)) throw new Error(t("grid.copyExtractorUnsupportedSelection"));
const initialRequest = buildRequest(extractor, extractorOptions);
if (!initialRequest) throw new Error(t("grid.copyExtractorEmptySelection"));
const extraction = await extractWithExtractor(extractor, extractorOptions, DATA_GRID_EXTRACTOR_PREVIEW_MAX_ROWS);
if (!extraction) throw new Error(t("grid.copyExtractorEmptySelection"));
const { initialRequest, result } = extraction;
const sourceRowCount = initialRequest.rows.length;
const previewRowCount = Math.min(sourceRowCount, DATA_GRID_EXTRACTOR_PREVIEW_MAX_ROWS);
const request = await resolveRequestSourceValues(initialRequest, previewRowCount);
const mongoResult = await resolveMongoExtractorResult(extractor, request, previewRowCount);
const result = mongoResult ?? (await api.extractDataGridSelection(request));
if (!result.text && !extractorAllowsEmptyOutput(extractor)) throw new Error(t("grid.copyExtractorEmptySelection"));
return { ...result, sourceRowCount, truncated: sourceRowCount > result.rowCount };
}
@@ -453,7 +464,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
}
}
return { copyWithExtractor, copyWithPreference, previewWithExtractor, previewWithPreference, canCopyWithExtractor };
return { extractWithExtractor, copyWithExtractor, copyWithPreference, previewWithExtractor, previewWithPreference, canCopyWithExtractor };
}
function extractorAllowsEmptyOutput(extractor: DataGridCopyExtractorId): boolean {