fix(grid): clear stale header context menu targets

Closes #6869
This commit is contained in:
t8y2
2026-08-22 03:50:15 +00:00
parent 62657446f3
commit f3d2b6cb01
3 changed files with 135 additions and 21 deletions
+15 -21
View File
@@ -802,9 +802,12 @@ const contextHeaderColumnIndex = ref<number | null>(null);
const contextHeaderVisibleColIdx = ref<number | null>(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"
>
#
</div>
@@ -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<typeof import("@/lib/common/clipboard")>();
return {
...actual,
copyToClipboard: mocks.copyToClipboard,
};
});
vi.mock("@/composables/useDataGridColumnResize", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/composables/useDataGridColumnResize")>();
const { ref } = await import("vue");
@@ -200,6 +209,26 @@ function contextMenuButton(label: string): HTMLButtonElement {
return button;
}
function contextMenuLabels(): string[] {
return [...document.querySelectorAll<HTMLButtonElement>("[data-dbx-context-menu] button")].map((button) => button.textContent?.trim() ?? "");
}
function columnHeader(host: HTMLElement, index: number): HTMLElement {
const header = host.querySelector<HTMLElement>(`[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<HTMLElement>(".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<HTMLElement>('[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<QueryResult[]>();
+24
View File
@@ -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(/<div\s+class="data-grid-header-cell shrink-0 px-2 py-1\.5[^]*?@click="selectAllCells"[^]*?>/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 = () => {};