diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 0c781d044e..b2d3a1eaf3 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -10154,6 +10154,7 @@ function onCellContext(rowId: number, rowIndex: number, colIdx: number, visibleC } clearRowSelection(); if (!cellIsSelected(rowIndex, visibleColIdx)) { + clearCellSelection(); selectSingleCell(rowIndex, visibleColIdx); contextSelectionIsSynthetic.value = true; } else { diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts index 1846bfa5eb..74c05a8620 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts @@ -66,6 +66,8 @@ function createMongoExportState(options: { selectedCellMatrix?: CellSelectionMatrix; selectedRowIds?: Set; mongoUpdateTarget?: false; + contextColumn?: number; + syntheticContext?: boolean; }) { const items = options.items ?? [options.item]; const selectedRowIds = options.selectedRowIds ?? new Set(); @@ -90,8 +92,8 @@ function createMongoExportState(options: { selectedCells: computed(() => options.selectedCellMatrix ?? { columns: [], rows: [] }), selectedCellMatrix: computed(() => options.selectedCellMatrix ?? null), selectedRange: computed(() => null), - contextCell: ref({ rowId: options.item.id, rowIndex: 0, col: -1 }), - contextSelectionIsSynthetic: ref(false), + contextCell: ref({ rowId: options.item.id, rowIndex: 0, col: options.contextColumn ?? -1 }), + contextSelectionIsSynthetic: ref(options.syntheticContext ?? false), getRowItem: (rowId) => items.find((item) => item.id === rowId), selectedRowIds: ref(selectedRowIds), hasRowSelection: computed(() => selectedRowIds.size > 0), @@ -637,8 +639,8 @@ describe("useDataGridExport prepared row statements", () => { ); }); - it("copies the full row when right-clicking a cell with a synthetic single-cell selection", async () => { - vi.mocked(extractDataGridSelection).mockResolvedValue({ text: "x", mimeType: "text/csv", fileExtension: "csv", rowCount: 1, columnCount: 3 }); + it("copies only the clicked cell as TSV when right-clicking with a synthetic single-cell selection", async () => { + vi.mocked(extractDataGridSelection).mockResolvedValue({ text: "x", mimeType: "text/tab-separated-values", fileExtension: "tsv", rowCount: 1, columnCount: 1 }); // A synthetic 1×1 selection (what right-click creates) — only column 0 of row 0. const matrix: CellSelectionMatrix = { rowIndexes: [0], @@ -647,12 +649,14 @@ describe("useDataGridExport prepared row statements", () => { rows: [[1]], }; const state = createExportState(editableTable, ["id", "name", "note"], matrix, undefined, undefined, undefined, [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, [0, 1, 2], true); - await state.copyWithExtractor("csv"); + await state.copyWithExtractor("tsv"); - // Despite the 1×1 matrix, the context-cell fallback should produce a full-row request (3 columns). expect(extractDataGridSelection).toHaveBeenCalledWith( expect.objectContaining({ - columns: [expect.objectContaining({ displayName: "id" }), expect.objectContaining({ displayName: "name" }), expect.objectContaining({ displayName: "note" })], + columns: [expect.objectContaining({ displayName: "id" })], + selectedColumnIndexes: [0], + rows: [[1]], + selectionKind: "cells", }), ); }); @@ -1329,6 +1333,8 @@ describe("useDataGridExport prepared row statements", () => { columns: ["name"], rows: [[item.data[1]]], }, + contextColumn: 1, + syntheticContext: true, }); expect(state.canCopyWithExtractor("sql-updates")).toBe(true); diff --git a/apps/desktop/src/composables/useDataGridExtractor.ts b/apps/desktop/src/composables/useDataGridExtractor.ts index 2361b5ee10..327ec1efda 100644 --- a/apps/desktop/src/composables/useDataGridExtractor.ts +++ b/apps/desktop/src/composables/useDataGridExtractor.ts @@ -116,6 +116,10 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { // happens to be the primary key. sql-select intentionally keeps using only // the selected cell via the matrix branch below. const hasNonSyntheticContextCell = !!options.contextCell.value && options.contextCell.value.col >= 0 && !options.contextSelectionIsSynthetic.value && !!matrix && !isMultiCellSelection; + // INSERT and UPDATE need the complete record to retain key columns and + // writable values. Other extractors should respect the actual cell range + // that a right-click creates, just like keyboard copy does. + const requiresFullRowContext = extractor === "sql-inserts" || (extractor === "sql-updates" && options.databaseType.value !== "mongodb"); if (contextPredicateCell) { const item = fullItemsById.get(contextPredicateCell.rowId); @@ -137,20 +141,15 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { sourceRowIds = items.map((item) => item.id); selectedSourceIndexes = dedupeColumnIndexes(matrix.columnIndexes.map((index) => visibleIndexes[index] ?? index)).filter((index) => index < fullColumns.length); if (options.hasColumnSelection.value) selectionKind = "columns"; - } else if ((hasRightClickContext || (hasNonSyntheticContextCell && extractor !== "sql-select")) && options.contextCell.value) { - // A SELECT targets the clicked cell; other extractors use the full row - // so that right-clicking a selected PK cell still enables INSERT/UPDATE. + } else if (requiresFullRowContext && (hasRightClickContext || hasNonSyntheticContextCell) && options.contextCell.value) { + // SQL INSERT/UPDATE needs the full row so that right-clicking a selected + // primary-key cell still includes writable columns. const item = fullItemsById.get(options.contextCell.value.rowId); if (item && !item.isDraft) { sourceRows = [item.data]; sourceRowIds = [item.id]; - if (extractor === "sql-select") { - const sourceIndex = visibleIndexes[options.contextCell.value.col]; - selectedSourceIndexes = sourceIndex === undefined ? [] : [sourceIndex]; - } else { - selectedSourceIndexes = dedupeColumnIndexes(visibleIndexes).filter((index) => index < fullColumns.length); - selectionKind = "rows"; - } + selectedSourceIndexes = dedupeColumnIndexes(visibleIndexes).filter((index) => index < fullColumns.length); + selectionKind = "rows"; } } else if (matrix) { // Single-cell matrix without a context cell — still honor the explicit selection. diff --git a/packages/app-tests/dataGridContextMenu.test.ts b/packages/app-tests/dataGridContextMenu.test.ts index 09eb42f0fe..6914913259 100644 --- a/packages/app-tests/dataGridContextMenu.test.ts +++ b/packages/app-tests/dataGridContextMenu.test.ts @@ -51,6 +51,15 @@ test("select-all context menus invalidate a stale specialized target", () => { assert.match(header, /@contextmenu="invalidateContextMenuTarget"/); }); +test("right-clicking outside the old cell or column selection resets it before selecting the target", () => { + const handler = dataGridSource.match(/function onCellContext\([^]*?\n\}/)?.[0] ?? ""; + const clear = handler.indexOf("clearCellSelection();"); + const select = handler.indexOf("selectSingleCell(rowIndex, visibleColIdx);"); + + assert.ok(clear >= 0); + assert.ok(clear < select); +}); + test("menu-close invalidation is deferred and guarded from a newer open", () => { const handler = dataGridSource.match(/function onGridContextMenuClose\(\) \{[^]*?\n\}/)?.[0] ?? "";