mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(grid): set selected cell to null on bare delete or backspace
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<typeof import("@/composables/useDataGridColumnResize")>();
|
||||
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<unknown[]>,
|
||||
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<QueryResult>({
|
||||
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<HTMLElement>("[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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user