mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user