diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index d644341255..ba084ca4ec 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -724,12 +724,25 @@ const contextSelectionIsSynthetic = ref(false); const contextHeaderColumn = ref(null); const contextHeaderColumnIndex = ref(null); const contextHeaderVisibleColIdx = ref(null); +let contextMenuLifecycle = 0; function invalidateSyntheticContextSelection() { contextSelectionIsSynthetic.value = false; contextCell.value = null; } +function onGridContextMenuOpen() { + contextMenuLifecycle += 1; +} + +function onGridContextMenuClose() { + const lifecycle = ++contextMenuLifecycle; + queueMicrotask(() => { + if (lifecycle !== contextMenuLifecycle) return; + invalidateSyntheticContextSelection(); + }); +} + const bulkEditDialogOpen = ref(false); const bulkEditValue = ref(""); const copyColumnNamesDialogOpen = ref(false); @@ -10328,7 +10341,7 @@ const gridContextMenuItems = computed(() => { @paste="onGridPaste" @focusin="onGridFocusIn" > - +
diff --git a/apps/desktop/src/components/grid/__tests__/DataGridContextExtractor.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridContextExtractor.spec.ts new file mode 100644 index 0000000000..cee52d5afa --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/DataGridContextExtractor.spec.ts @@ -0,0 +1,13 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8"); + +describe("DataGrid context extractor lifecycle", () => { + it("clears the right-click target after the context-menu action has started", () => { + expect(source).toContain('@open="onGridContextMenuOpen"'); + expect(source).toContain('@close="onGridContextMenuClose"'); + expect(source).toContain("queueMicrotask(() => {"); + expect(source).toContain("invalidateSyntheticContextSelection();"); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts index 6b2a5accb7..bd05193514 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts @@ -112,6 +112,7 @@ function createExportState( visibleColumnIndexes?: number[], isSyntheticContext = false, contextRowId?: number | null, + contextColumn?: number, ) { const rows = (rowDataList ?? [rowData ?? columns.map((column, index) => (column === "id" ? 1 : `value-${index}`))]).map((data, index) => ({ ...row(data), id: index + 1 })); const resolvedContextRowId = contextRowId === undefined ? (rows[0]?.id ?? null) : contextRowId; @@ -137,7 +138,7 @@ function createExportState( selectedCells: computed(() => selectedCellMatrix ?? selectedCellsOverride ?? { columns: [], rows: [] }), selectedCellMatrix: computed(() => selectedCellMatrix ?? null), selectedRange: computed(() => null), - contextCell: ref(resolvedContextRowId === null ? null : { rowId: resolvedContextRowId, rowIndex: 0, col: isSyntheticContext ? 0 : -1 }), + contextCell: ref(resolvedContextRowId === null ? null : { rowId: resolvedContextRowId, rowIndex: 0, col: contextColumn ?? (isSyntheticContext ? 0 : -1) }), contextSelectionIsSynthetic: ref(isSyntheticContext), getRowItem: (rowId) => rows.find((candidate) => candidate.id === rowId), selectedRowIds, @@ -288,6 +289,42 @@ describe("useDataGridExport prepared row statements", () => { ); }); + it("uses the right-clicked cell for SQL SELECT even when its row is selected", async () => { + const state = createExportState(editableTable, ["id", "name"], undefined, [7, "Ada"], undefined, undefined, [1], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, false, 1, 1); + vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "SELECT * FROM users WHERE name = 'Ada';", mimeType: "application/sql", fileExtension: "sql", rowCount: 1, columnCount: 1 }); + + expect(state.canCopyWithExtractor("sql-select")).toBe(true); + await state.copyWithExtractor("sql-select"); + + expect(extractDataGridSelection).toHaveBeenCalledWith( + expect.objectContaining({ + extractor: "sql-select", + columns: [expect.objectContaining({ sourceName: "name" })], + selectedColumnIndexes: [0], + rows: [["Ada"]], + selectionKind: "cells", + }), + ); + }); + + it("uses only the right-clicked cell for a WHERE clause", async () => { + const state = createExportState(editableTable, ["id", "name"], undefined, [7, "Ada"], undefined, undefined, [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, true, 1, 1); + vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "name = 'Ada'", mimeType: "application/sql", fileExtension: "sql", rowCount: 1, columnCount: 1 }); + + expect(state.canCopyWithExtractor("where-clause")).toBe(true); + await state.copyWithExtractor("where-clause"); + + expect(extractDataGridSelection).toHaveBeenCalledWith( + expect.objectContaining({ + extractor: "where-clause", + columns: [expect.objectContaining({ sourceName: "name" })], + selectedColumnIndexes: [0], + rows: [["Ada"]], + selectionKind: "cells", + }), + ); + }); + it("includes hidden identity support columns in a selected-row SELECT request", async () => { const item = row([7, "Ada"]); const options: UseDataGridExportOptions = { @@ -403,6 +440,17 @@ describe("useDataGridExport prepared row statements", () => { }); expect(state.canCopyWithExtractor("sql-select")).toBe(true); + vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "SELECT * FROM users WHERE id = 7;", mimeType: "application/sql", fileExtension: "sql", rowCount: 1, columnCount: 1 }); + + return state.copyWithExtractor("sql-select").then(() => { + expect(extractDataGridSelection).toHaveBeenCalledWith( + expect.objectContaining({ + columns: [expect.objectContaining({ displayName: "id", sourceName: "id" })], + selectedColumnIndexes: [0], + rows: [[7]], + }), + ); + }); }); it("enables INSERT/UPDATE when right-clicking a previously selected primary-key cell", () => { @@ -854,6 +902,40 @@ describe("useDataGridExport prepared row statements", () => { expect(toast).toHaveBeenCalledWith("grid.copyExtractorUnsupportedSelection", 5000); }); + it("uses the right-clicked cell for SQL predicates despite an irregular discrete selection", async () => { + const state = createExportState( + editableTable, + ["id", "name"], + undefined, + undefined, + { columns: ["id", "name"], rows: [[1], ["Grace"]] }, + [ + [1, "Ada"], + [2, "Grace"], + ], + [], + DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, + false, + undefined, + false, + 2, + 1, + ); + vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "name = 'Grace'", mimeType: "application/sql", fileExtension: "sql", rowCount: 1, columnCount: 1 }); + + expect(state.canCopyWithExtractor("where-clause")).toBe(true); + await expect(state.copyWithExtractor("where-clause")).resolves.toBe(true); + + expect(extractDataGridSelection).toHaveBeenCalledWith( + expect.objectContaining({ + extractor: "where-clause", + columns: [expect.objectContaining({ sourceName: "name" })], + selectedColumnIndexes: [0], + rows: [["Grace"]], + }), + ); + }); + it("limits live extractor previews to the first 100 selected rows", async () => { const rows = Array.from({ length: 101 }, (_, index) => [index + 1, `name-${index + 1}`]); const matrix: CellSelectionMatrix = { diff --git a/apps/desktop/src/composables/useDataGridExtractor.ts b/apps/desktop/src/composables/useDataGridExtractor.ts index aa542c55de..e6a16091a6 100644 --- a/apps/desktop/src/composables/useDataGridExtractor.ts +++ b/apps/desktop/src/composables/useDataGridExtractor.ts @@ -79,6 +79,10 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { return options.hasCellSelection.value ? options.selectedCells.value : null; } + function hasContextPredicateTarget(extractor: DataGridCopyExtractorId): boolean { + return (extractor === "sql-select" || extractor === "where-clause") && (options.contextCell.value?.col ?? -1) >= 0; + } + function buildRequest(extractor: DataGridCopyExtractorId, extractorOptions: DataGridExtractorOptions = options.extractorOptions?.value ?? DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS): DataGridExtractRequest | null { const visibleIndexes = options.visibleColumnIndexes.value; const sourceNames = options.allSourceColumns.value; @@ -91,9 +95,14 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { const matrix = options.selectedCellMatrix.value; const isMultiCellSelection = !!matrix && (matrix.rowIndexes.length > 1 || matrix.columnIndexes.length > 1); + const contextCell = options.contextCell.value; // A right-click sets contextCell.col to the clicked column (≥ 0); the test // harness and non-right-click paths leave it at -1. - const hasRightClickContext = !!options.contextCell.value && options.contextSelectionIsSynthetic.value; + const hasRightClickContext = !!contextCell && options.contextSelectionIsSynthetic.value; + // SQL predicates generated from the context menu must describe the cell the + // user right-clicked, even when an existing row or range selection remains + // active underneath that menu. + const contextPredicateCell = hasContextPredicateTarget(extractor) ? contextCell : null; // When the user already has a single-cell selection and right-clicks the same // cell, contextSelectionIsSynthetic is false but we still have a valid context // cell. For INSERT/UPDATE extractors, we include all visible non-PK columns @@ -102,7 +111,15 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { // the selected cell via the matrix branch below. const hasNonSyntheticContextCell = !!options.contextCell.value && options.contextCell.value.col >= 0 && !options.contextSelectionIsSynthetic.value && !!matrix && !isMultiCellSelection; - if (options.hasRowSelection.value && options.selectedRowIds.value.size > 0) { + if (contextPredicateCell) { + const item = fullItemsById.get(contextPredicateCell.rowId); + const sourceIndex = visibleIndexes[contextPredicateCell.col]; + if (item && !item.isDraft && sourceIndex !== undefined) { + sourceRows = [item.data]; + sourceRowIds = [item.id]; + selectedSourceIndexes = [sourceIndex]; + } + } else if (options.hasRowSelection.value && options.selectedRowIds.value.size > 0) { const items = options.displayItems.value.filter((item) => options.selectedRowIds.value.has(item.id) && !item.isDraft); sourceRows = items.map((item) => (fullItemsById.get(item.id) ?? item).data); sourceRowIds = items.map((item) => item.id); @@ -156,7 +173,10 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { const columnTypesBySource = new Map(visibleIndexes.map((sourceIndex, visibleIndex) => [sourceIndex, options.columnTypes.value?.[visibleIndex]])); const columns = requiredSourceIndexes.map((sourceIndex, compactIndex) => ({ displayName: fullColumns[sourceIndex], - sourceName: sourceNames?.[sourceIndex], + // Query results without source metadata still expose their database column + // names as display names. Keep the request mapping aligned with the + // availability check so SQL SELECT can use that safe fallback. + sourceName: sourceNames?.[sourceIndex] ?? fullColumns[sourceIndex], sourceIndex: compactIndex, })); const descriptor = DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor]; @@ -227,7 +247,8 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { } function canCopyWithExtractor(extractor: DataGridCopyExtractorId, extractorOptions: DataGridExtractorOptions = options.extractorOptions?.value ?? DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS): boolean { - if (hasUnsupportedDiscreteSelection.value) return false; + const contextPredicateTarget = hasContextPredicateTarget(extractor); + if (hasUnsupportedDiscreteSelection.value && !contextPredicateTarget) return false; if (!options.hasRowSelection.value && !options.hasCellSelection.value && !options.contextCell.value) return false; if (extractorUnavailableForDatabase(extractor, options.databaseType.value)) return false; if (extractor === "sql-inserts") { @@ -244,7 +265,9 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { } if (extractor === "sql-select") { const matrix = options.selectedCellMatrix.value; - if (options.hasRowSelection.value) { + if (contextPredicateTarget) { + // The context-menu target is validated by buildRequest below. + } else if (options.hasRowSelection.value) { if (options.selectedRowIds.value.size !== 1) return false; } else if (matrix) { if (matrix.rowIndexes.length !== 1 || matrix.columnIndexes.length !== 1) return false; @@ -257,6 +280,9 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { if (request.selectionKind === "cells" && request.selectedColumnIndexes.length !== 1) return false; return request.columns.length > 0 && request.columns.every((column) => !!(column.sourceName ?? column.displayName)?.trim()); } + if (extractor === "where-clause" && contextPredicateTarget) { + return buildRequest(extractor, extractorOptions) !== null; + } if (extractor === "raw") { const request = buildRequest(extractor, extractorOptions); return request !== null && request.rows.length * request.selectedColumnIndexes.length === 1; @@ -278,7 +304,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { } async function copyWithExtractor(extractor: DataGridCopyExtractorId, extractorOptions: DataGridExtractorOptions = options.extractorOptions?.value ?? DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS): Promise { - if (hasUnsupportedDiscreteSelection.value) { + if (hasUnsupportedDiscreteSelection.value && !hasContextPredicateTarget(extractor)) { toast(t("grid.copyExtractorUnsupportedSelection"), 5000); return false; } @@ -317,7 +343,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { } async function previewWithExtractor(extractor: DataGridCopyExtractorId, extractorOptions: DataGridExtractorOptions): Promise { - if (hasUnsupportedDiscreteSelection.value) throw new Error(t("grid.copyExtractorUnsupportedSelection")); + 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 sourceRowCount = initialRequest.rows.length;