From f3d2b6cb01b30af2ac8a4f87fc28eef8dc713baf Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 22 Aug 2026 03:40:43 +0000 Subject: [PATCH] fix(grid): clear stale header context menu targets Closes #6869 --- apps/desktop/src/components/grid/DataGrid.vue | 36 +++---- .../DataGridContextExtractor.spec.ts | 96 +++++++++++++++++++ .../app-tests/dataGridContextMenu.test.ts | 24 +++++ 3 files changed, 135 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 3526045fcb..fb8b1c022a 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -802,9 +802,12 @@ const contextHeaderColumnIndex = ref(null); const contextHeaderVisibleColIdx = ref(null); let contextMenuLifecycle = 0; -function invalidateSyntheticContextSelection() { +function invalidateContextMenuTarget() { contextSelectionIsSynthetic.value = false; contextCell.value = null; + contextHeaderColumn.value = null; + contextHeaderColumnIndex.value = null; + contextHeaderVisibleColIdx.value = null; } function onGridContextMenuOpen() { @@ -842,7 +845,7 @@ function onGridContextMenuClose() { const targetId = target?.id; queueMicrotask(() => { if (lifecycle !== contextMenuLifecycle) return; - invalidateSyntheticContextSelection(); + invalidateContextMenuTarget(); // CustomContextMenu closes before starting an item action. Its action runs // in this turn, so only a menu dismissal leaves this target unclaimed. if (targetId !== undefined && contextFilterTarget.value?.id === targetId && !activeContextFilterActions.has(targetId)) contextFilterTarget.value = null; @@ -5091,7 +5094,7 @@ const selection = useDataGridSelection({ getScrollElement: dataGridSelectionScroller, cellFromClientPoint: dataGridCellFromClientPoint, rowFromClientPoint: dataGridRowFromClientPoint, - onUserCellSelection: invalidateSyntheticContextSelection, + onUserCellSelection: invalidateContextMenuTarget, // Canvas schedules its draw before the document-level mousemove handler runs, // so its row state must be current before that frame is painted. shouldUpdateDraggedRowsImmediately: () => useCanvasGridRows.value, @@ -7669,7 +7672,7 @@ function showCellDetails(rowIndex: number, colIndex: number) { function showCellDetailsForVisibleCell(rowIndex: number, visibleColIdx: number, actualColIdx: number) { clearRowSelection(); - invalidateSyntheticContextSelection(); + invalidateContextMenuTarget(); selectSingleCell(rowIndex, visibleColIdx); showCellDetails(rowIndex, actualColIdx); } @@ -7754,11 +7757,8 @@ function onTransposeCellMouseenter(rowIndex: number, actualColIdx: number) { function selectTransposeCell(rowIndex: number, actualColIdx: number, event: MouseEvent) { const visibleColIdx = visibleColumnIndexes.value.indexOf(actualColIdx); if (visibleColIdx < 0) return; - contextHeaderColumn.value = null; - contextHeaderColumnIndex.value = null; - contextHeaderVisibleColIdx.value = null; clearRowSelection(); - invalidateSyntheticContextSelection(); + invalidateContextMenuTarget(); if (event.shiftKey || event.metaKey || event.ctrlKey) { extendCellSelectionTo(rowIndex, visibleColIdx); } else { @@ -7771,11 +7771,8 @@ function selectTransposeCell(rowIndex: number, actualColIdx: number, event: Mous function showTransposeCellDetails(rowIndex: number, actualColIdx: number) { const visibleColIdx = visibleColumnIndexes.value.indexOf(actualColIdx); if (visibleColIdx < 0) return; - contextHeaderColumn.value = null; - contextHeaderColumnIndex.value = null; - contextHeaderVisibleColIdx.value = null; clearRowSelection(); - invalidateSyntheticContextSelection(); + invalidateContextMenuTarget(); selectSingleCell(rowIndex, visibleColIdx); transposeRowIndex.value = rowIndex; showCellDetails(rowIndex, actualColIdx); @@ -8502,7 +8499,7 @@ function toggleKeyboardTranspose(): boolean { // Shared path that selects or extends to nextPosition and scrolls it into view. // Used by both moveSelectedCell (relative steps) and navigateSelectedCell (absolute/page jumps). function applyCellNavigation(nextPosition: CellPosition, extend = false, block: DataGridScrollAlignment = "nearest", previousPageRowIndex?: number): boolean { - invalidateSyntheticContextSelection(); + invalidateContextMenuTarget(); if (extend) extendCellSelectionTo(nextPosition.rowIndex, nextPosition.colIndex); else selectSingleCell(nextPosition.rowIndex, nextPosition.colIndex); clearRowSelection(); @@ -9560,7 +9557,7 @@ watch( closeReadonlyCellTextSelection(); clearCellSelection(); clearRowSelection(); - invalidateSyntheticContextSelection(); + invalidateContextMenuTarget(); closeCellDetails(); closeDetailDialogs(); if (shouldPreserveTranspose) { @@ -9576,7 +9573,7 @@ watch( // --- Context menu handlers --- function onHeaderContext(col: string, columnIndex: number) { - contextCell.value = null; + invalidateContextMenuTarget(); const visibleColIdx = visibleColumnIndexes.value.indexOf(columnIndex); if (visibleColIdx >= 0 && !columnIsSelected(visibleColIdx)) { selectColumn(visibleColIdx); @@ -9671,9 +9668,7 @@ function clearNativeTextSelection() { function onCellContext(rowId: number, rowIndex: number, colIdx: number, visibleColIdx: number, event?: MouseEvent) { event?.preventDefault(); clearNativeTextSelection(); - contextHeaderColumn.value = null; - contextHeaderColumnIndex.value = null; - contextHeaderVisibleColIdx.value = null; + invalidateContextMenuTarget(); contextCell.value = { rowId, rowIndex, col: colIdx }; if (hasRowSelection.value && isRowSelected(rowId)) { contextSelectionIsSynthetic.value = false; @@ -9821,9 +9816,7 @@ watch(editValue, scheduleActiveCellEditTextareaResize); function onRowContext(rowId: number, rowIndex: number) { batchAppendPasteRowId.value = null; - contextHeaderColumn.value = null; - contextHeaderColumnIndex.value = null; - contextHeaderVisibleColIdx.value = null; + invalidateContextMenuTarget(); contextCell.value = { rowId, rowIndex, col: -1 }; if (!isRowSelected(rowId)) { clearCellSelection(); @@ -11778,6 +11771,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] { 'data-grid-header-cell--selected': isSelectingAll, }" @click="selectAllCells" + @contextmenu="invalidateContextMenuTarget" > # diff --git a/apps/desktop/src/components/grid/__tests__/DataGridContextExtractor.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridContextExtractor.spec.ts index 2c82571a68..338b3647e9 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridContextExtractor.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridContextExtractor.spec.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ buildTableSelectSql: vi.fn(async ({ whereInput }: { whereInput?: string }) => `SELECT payload FROM events${whereInput ? ` WHERE ${whereInput}` : ""}`), executeMulti: vi.fn(), cancelQuery: vi.fn(), + copyToClipboard: vi.fn(), toast: vi.fn(), })); @@ -26,6 +27,14 @@ vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }), })); +vi.mock("@/lib/common/clipboard", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + copyToClipboard: mocks.copyToClipboard, + }; +}); + vi.mock("@/composables/useDataGridColumnResize", async (importOriginal) => { const actual = await importOriginal(); const { ref } = await import("vue"); @@ -200,6 +209,26 @@ function contextMenuButton(label: string): HTMLButtonElement { return button; } +function contextMenuLabels(): string[] { + return [...document.querySelectorAll("[data-dbx-context-menu] button")].map((button) => button.textContent?.trim() ?? ""); +} + +function columnHeader(host: HTMLElement, index: number): HTMLElement { + const header = host.querySelector(`[data-grid-column-index="${index}"]`); + if (!header) throw new Error(`Column header not found: ${index}`); + return header; +} + +function selectAllHeader(host: HTMLElement): HTMLElement { + const header = host.querySelector(".data-grid-header-cell:not([data-grid-column-index])"); + if (!header) throw new Error("Select-all header not found"); + return header; +} + +function openContextMenu(target: HTMLElement) { + target.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 12, clientY: 12 })); +} + async function startEqualsFilter(host: HTMLElement) { await settle(); const cell = host.querySelector('[data-row-index="0"] [data-visible-col-index="1"]'); @@ -224,6 +253,73 @@ afterEach(() => { document.querySelectorAll("[data-dbx-context-menu]").forEach((menu) => menu.remove()); }); +describe("DataGrid context menu target lifecycle", () => { + it("does not reuse a closed header target for a later select-all menu", async () => { + const { host } = mountGrid(hydratedResult(1, "value")); + await settle(); + + openContextMenu(columnHeader(host, 0)); + await settle(); + expect(contextMenuLabels()).toContain("Copy Column Name"); + + const cell = gridCell(host); + cell.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, cancelable: true, button: 0 })); + cell.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 })); + window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 })); + await settle(); + + selectAllHeader(host).click(); + openContextMenu(selectAllHeader(host)); + await settle(); + + expect(contextMenuLabels()).toContain("Copy"); + expect(contextMenuLabels()).not.toContain("Copy Column Name"); + expect(contextMenuLabels()).not.toContain("Freeze to This Column"); + }); + + it("keeps single- and multi-column header menus targeted", async () => { + const { host } = mountGrid(hydratedResult(1, "value")); + await settle(); + + openContextMenu(columnHeader(host, 0)); + await settle(); + expect(contextMenuLabels()).toContain("Copy Column Name"); + + document.body.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, cancelable: true, button: 0 })); + await settle(); + columnHeader(host, 1).dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, shiftKey: true })); + openContextMenu(columnHeader(host, 1)); + await settle(); + + expect(contextMenuLabels()).toContain("Copy Selected Column Names (2)"); + expect(contextMenuLabels()).toContain("Freeze Selected Columns"); + }); + + it("preserves a newly opened header target and same-turn menu actions", async () => { + const { host } = mountGrid(hydratedResult(1, "value")); + await settle(); + + openContextMenu(columnHeader(host, 0)); + openContextMenu(columnHeader(host, 1)); + await settle(); + contextMenuButton("Copy Column Name").click(); + + await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith("payload")); + }); + + it("keeps the initial select-all menu on the data selection target", async () => { + const { host } = mountGrid(hydratedResult(1, "value")); + await settle(); + + selectAllHeader(host).click(); + openContextMenu(selectAllHeader(host)); + await settle(); + + expect(contextMenuLabels()).toContain("Copy"); + expect(contextMenuLabels()).not.toContain("Copy Column Name"); + }); +}); + describe("DataGrid context filter lifecycle", () => { it("keeps the right-click target through menu close and large-value hydration", async () => { const hydration = deferred(); diff --git a/packages/app-tests/dataGridContextMenu.test.ts b/packages/app-tests/dataGridContextMenu.test.ts index e2e95d43fe..09eb42f0fe 100644 --- a/packages/app-tests/dataGridContextMenu.test.ts +++ b/packages/app-tests/dataGridContextMenu.test.ts @@ -35,6 +35,30 @@ test("column-header context menus defer copy statement generation", () => { assert.doesNotMatch(handler, /prefetchCopyStatements/); }); +test("context-menu invalidation clears cell and header targets together", () => { + const handler = dataGridSource.match(/function invalidateContextMenuTarget\(\) \{[^]*?\n\}/)?.[0] ?? ""; + + assert.match(handler, /contextSelectionIsSynthetic\.value = false;/); + assert.match(handler, /contextCell\.value = null;/); + assert.match(handler, /contextHeaderColumn\.value = null;/); + assert.match(handler, /contextHeaderColumnIndex\.value = null;/); + assert.match(handler, /contextHeaderVisibleColIdx\.value = null;/); +}); + +test("select-all context menus invalidate a stale specialized target", () => { + const header = dataGridSource.match(//u)?.[0] ?? ""; + + assert.match(header, /@contextmenu="invalidateContextMenuTarget"/); +}); + +test("menu-close invalidation is deferred and guarded from a newer open", () => { + const handler = dataGridSource.match(/function onGridContextMenuClose\(\) \{[^]*?\n\}/)?.[0] ?? ""; + + assert.match(handler, /queueMicrotask\(\(\) => \{/); + assert.match(handler, /if \(lifecycle !== contextMenuLifecycle\) return;/); + assert.match(handler, /invalidateContextMenuTarget\(\);/); +}); + test("editable cell selections expose generation after bulk edit", () => { const icon = {}; const action = () => {};