diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index c795f099a3..3a823cb952 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -9957,6 +9957,19 @@ function fillSelectionWithValue(value: string | null, options: { preserveEmptySt const allowDraftSelectionValue = selectedRangeTargetsOnlyDraftRow(); beginBatch(); try { + if (selectedCellKeys.value.size > 0) { + for (const key of selectedCellKeys.value) { + const [rStr, cStr] = key.split(":"); + const rowIndex = Number(rStr); + const visibleCol = Number(cStr); + if (Number.isNaN(rowIndex) || Number.isNaN(visibleCol)) continue; + const item = displayItemAt(rowIndex); + if (!item) continue; + if (cellIsSelected(rowIndex, visibleCol)) applied = applyVisibleSelectedCellValue(item, visibleCol, value, allowDraftSelectionValue, options) || applied; + } + return applied; + } + if (range) { for (let rowIndex = range.startRow; rowIndex <= range.endRow; rowIndex++) { const item = displayItemAt(rowIndex); @@ -9985,6 +9998,19 @@ function fillSelectionWithValue(value: string | null, options: { preserveEmptySt } function selectionHasEditableCells(): boolean { + if (selectedCellKeys.value.size > 0) { + for (const key of selectedCellKeys.value) { + const [rStr, cStr] = key.split(":"); + const rowIndex = Number(rStr); + const visibleCol = Number(cStr); + if (Number.isNaN(rowIndex) || Number.isNaN(visibleCol)) continue; + const item = displayItemAt(rowIndex); + if (!item) continue; + if (transposeIncludesRow(item.id) && cellIsSelected(rowIndex, visibleCol) && canEditCellItem(item, actualColumnIndex(visibleCol))) return true; + } + return false; + } + const range = selectedRange.value; if (range) { for (let rowIndex = range.startRow; rowIndex <= range.endRow; rowIndex++) { @@ -10943,9 +10969,24 @@ async function onGridKeydown(event: KeyboardEvent) { event.preventDefault(); return; } - if (isDeleteCurrentRowShortcut(event, settingsStore.editorSettings.shortcuts) && deleteCurrentRow()) { - event.preventDefault(); - return; + const isBareDelete = !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && (event.key === "Delete" || event.key === "Backspace"); + if (isBareDelete) { + if (hasRowSelection.value && selectedRowCount.value > 0) { + if (deleteCurrentRow()) { + event.preventDefault(); + } + return; + } + if (hasCellSelection.value || hasColumnSelection.value || !!selectedRange.value || selectedCellCount.value > 0 || !!currentSelectedCellPosition()) { + event.preventDefault(); + setSelectionNull(); + return; + } + } else if (isDeleteCurrentRowShortcut(event, settingsStore.editorSettings.shortcuts)) { + if (deleteCurrentRow()) { + event.preventDefault(); + return; + } } if (isToggleTransposeShortcut(event, settingsStore.editorSettings.shortcuts) && toggleKeyboardTranspose()) { event.preventDefault(); @@ -13571,6 +13612,12 @@ defineExpose({ focusWhere, editSelectedCell, selectSingleCell, + selectRow, + hasRowSelection, + displayItems, + hasPendingChanges, + showDeleteRowConfirm, + pendingDeleteRowIds, openGoToColumn, visibleColumnCount, displayableColumnCount, diff --git a/apps/desktop/src/components/grid/__tests__/DataGridDeleteShortcut.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridDeleteShortcut.spec.ts new file mode 100644 index 0000000000..b2973be6c9 --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/DataGridDeleteShortcut.spec.ts @@ -0,0 +1,275 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, markRaw, nextTick, ref, type App, type PropType } from "vue"; +import { createPinia, setActivePinia } from "pinia"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import type { ColumnInfo, DatabaseType, QueryResult } from "@/types/database"; +import { TooltipProvider } from "@/components/ui/tooltip"; + +vi.mock("@/composables/useDataGridColumnResize", async (importOriginal) => { + const actual = await importOriginal(); + const { ref } = await import("vue"); + return { + ...actual, + useDataGridColumnResize: () => ({ + initColumnWidths: vi.fn(), + onResizeStart: vi.fn(), + autoFitColumn: vi.fn(), + renderedColumnWidths: ref([120, 120]), + totalWidth: ref(240), + columnVars: ref({ "--total-w": "240px" }), + getIsResizing: () => false, + }), + }; +}); + +import DataGrid from "../DataGrid.vue"; +import { useSettingsStore } from "@/stores/settingsStore"; + +const mountedApps: Array<{ app: App; host: HTMLElement }> = []; + +const RecycleScroller = defineComponent({ + props: { + items: { + type: Array as PropType, + default: () => [], + }, + }, + setup(props, { attrs, slots }) { + return () => + h( + "div", + attrs, + props.items.map((item) => slots.default?.({ item })), + ); + }, +}); + +interface TableMeta { + catalog?: string; + schema?: string; + tableName: string; + columns: ColumnInfo[]; + primaryKeys: string[]; +} + +function mountGrid( + options: { + context?: "results" | "table-data"; + databaseType?: DatabaseType; + connectionId?: string; + database?: string; + tableMeta?: TableMeta | null; + deleteRowShortcut?: string; + editable?: boolean; + } = {}, +) { + const pinia = createPinia(); + setActivePinia(pinia); + const settingsStore = useSettingsStore(); + settingsStore.updateEditorSettings({ + dataGridRenderMode: "canvas", + shortcuts: { + ...settingsStore.editorSettings.shortcuts, + ...(options.deleteRowShortcut ? { deleteCurrentRow: options.deleteRowShortcut } : {}), + }, + }); + const result = markRaw({ + columns: ["id", "name"], + rows: [ + [1, "Alice"], + [2, "Bob"], + ], + affected_rows: 0, + execution_time_ms: 0, + }); + const defaultTableMeta: TableMeta = { + catalog: "warehouse", + schema: "public", + tableName: "users", + columns: [ + { name: "id", data_type: "int", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "name", data_type: "varchar", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, + ], + primaryKeys: ["id"], + }; + + const host = document.createElement("div"); + const grid = ref<{ + selectSingleCell: (row: number, col: number) => void; + selectRow: (rowIndex: number) => void; + hasRowSelection: boolean; + displayItems: Array<{ id: number; data: unknown[]; isDirtyCol: boolean[] }>; + hasPendingChanges: boolean; + showDeleteRowConfirm: boolean; + pendingDeleteRowIds: number[]; + }>(); + document.body.append(host); + const Root = defineComponent({ + setup() { + return () => + h( + TooltipProvider, + { delayDuration: 0 }, + { + default: () => + h(DataGrid, { + ref: grid, + result, + databaseType: options.databaseType ?? "mysql", + context: options.context ?? "table-data", + editable: options.editable ?? true, + connectionId: options.connectionId ?? "connection-1", + database: options.database ?? "app", + tableMeta: options.tableMeta === null ? undefined : (options.tableMeta ?? defaultTableMeta), + }), + }, + ); + }, + }); + const app = createApp(Root); + app.use(pinia); + app.use(i18n); + app.component("RecycleScroller", RecycleScroller); + app.mount(host); + mountedApps.push({ app, host }); + return { host, grid }; +} + +async function settle() { + await nextTick(); + await Promise.resolve(); + await nextTick(); +} + +function gridRoot(host: HTMLElement): HTMLElement { + const root = host.querySelector("[data-grid-root]"); + if (!root) throw new Error("Data grid root not found"); + return root; +} + +function dispatchKey(target: HTMLElement, key: string, modifiers: { ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean; shiftKey?: boolean } = {}): KeyboardEvent { + const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key, ...modifiers }); + target.dispatchEvent(event); + return event; +} + +afterEach(() => { + window.getSelection()?.removeAllRanges(); + for (const { app, host } of mountedApps.splice(0)) { + app.unmount(); + host.remove(); + } +}); + +describe("DataGrid delete key cell null and row delete behavior", () => { + it("sets selected cell value to null when Delete key is pressed without deleting the row (#11958)", async () => { + const { host, grid } = mountGrid(); + await settle(); + const root = gridRoot(host); + grid.value?.selectSingleCell(0, 1); + await settle(); + + expect(grid.value?.displayItems[0]?.data[1]).toBe("Alice"); + expect(grid.value?.hasRowSelection).toBe(false); + + const event = dispatchKey(root, "Delete"); + await settle(); + + expect(event.defaultPrevented).toBe(true); + expect(grid.value?.displayItems[0]?.data[1]).toBeNull(); + expect(grid.value?.displayItems[0]?.isDirtyCol[1]).toBe(true); + expect(grid.value?.displayItems.length).toBe(2); + expect(grid.value?.showDeleteRowConfirm).toBe(false); + }); + + it("sets selected cell value to null when Backspace key is pressed (macOS delete key) (#11958)", async () => { + const { host, grid } = mountGrid(); + await settle(); + const root = gridRoot(host); + grid.value?.selectSingleCell(1, 1); + await settle(); + + expect(grid.value?.displayItems[1]?.data[1]).toBe("Bob"); + expect(grid.value?.hasRowSelection).toBe(false); + + const event = dispatchKey(root, "Backspace"); + await settle(); + + expect(event.defaultPrevented).toBe(true); + expect(grid.value?.displayItems[1]?.data[1]).toBeNull(); + expect(grid.value?.displayItems[1]?.isDirtyCol[1]).toBe(true); + expect(grid.value?.displayItems.length).toBe(2); + expect(grid.value?.showDeleteRowConfirm).toBe(false); + }); + + it("triggers row deletion when a whole row is selected and Delete is pressed", async () => { + const { host, grid } = mountGrid(); + await settle(); + const root = gridRoot(host); + grid.value?.selectRow(0); + await settle(); + + expect(grid.value?.hasRowSelection).toBe(true); + + const event = dispatchKey(root, "Delete"); + await settle(); + + expect(event.defaultPrevented).toBe(true); + expect(grid.value?.showDeleteRowConfirm).toBe(true); + expect(grid.value?.pendingDeleteRowIds.length).toBeGreaterThan(0); + // Cell value is not set to null + expect(grid.value?.displayItems[0]?.data[1]).toBe("Alice"); + }); + + it("does not modify cell or delete row when the grid is read-only", async () => { + const { host, grid } = mountGrid({ editable: false }); + await settle(); + const root = gridRoot(host); + grid.value?.selectSingleCell(0, 1); + await settle(); + + const event = dispatchKey(root, "Delete"); + await settle(); + + expect(event.defaultPrevented).toBe(true); + expect(grid.value?.displayItems[0]?.data[1]).toBe("Alice"); + expect(grid.value?.displayItems.length).toBe(2); + expect(grid.value?.showDeleteRowConfirm).toBe(false); + }); + + it("leaves editable input targets untouched on Delete key", async () => { + const { host } = mountGrid(); + await settle(); + const root = gridRoot(host); + const input = document.createElement("input"); + root.append(input); + + const event = dispatchKey(input, "Delete"); + await settle(); + + expect(event.defaultPrevented).toBe(false); + }); + + it("supports custom deleteCurrentRow shortcut while still allowing Delete key on cells", async () => { + const { host, grid } = mountGrid({ deleteRowShortcut: "Mod+Shift+K" }); + await settle(); + const root = gridRoot(host); + grid.value?.selectSingleCell(0, 1); + await settle(); + + // Plain Delete still clears cell to null + const deleteEvent = dispatchKey(root, "Delete"); + await settle(); + expect(deleteEvent.defaultPrevented).toBe(true); + expect(grid.value?.displayItems[0]?.data[1]).toBeNull(); + expect(grid.value?.showDeleteRowConfirm).toBe(false); + + // Custom shortcut Mod+Shift+K triggers row deletion + const customEvent = dispatchKey(root, "K", { ctrlKey: true, shiftKey: true }); + await settle(); + expect(customEvent.defaultPrevented).toBe(true); + expect(grid.value?.showDeleteRowConfirm).toBe(true); + }); +});