mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
@@ -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
@@ -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 = () => {};
|
||||
|
||||
Reference in New Issue
Block a user