From c2b7e9f269f4584b0e1032f03329502c45c7908b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Thu, 20 Aug 2026 02:16:25 +0800 Subject: [PATCH] fix(grid): preserve async context filter targets --- apps/desktop/src/components/grid/DataGrid.vue | 97 +++++-- .../DataGridContextExtractor.spec.ts | 243 ++++++++++++++++-- 2 files changed, 288 insertions(+), 52 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index d4bd023072..287a0cc1a7 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -739,6 +739,23 @@ const contextCell = ref<{ rowIndex: number; col: number; } | null>(null); +type ContextFilterTarget = { + id: number; + rowId: number; + rowIndex: number; + col: number; + columnName: string; + columnInfo?: ColumnInfo; + sourceResult: QueryResult; + sourceIndex?: number; + sourceValue: CellValue; + requiresHydration: boolean; +}; +// The context menu closes before invoking its action. Keep an immutable target +// for asynchronous filter actions instead of reading the cleared selection. +const contextFilterTarget = ref(null); +let nextContextFilterTargetId = 0; +const activeContextFilterActions = new Set(); // True when onCellContext created a synthetic single-cell selection (the cell // was not previously selected). Lets buildRequest distinguish a genuine 1×1 // selection (user Ctrl+click) from a synthetic one (right-click on unselected). @@ -755,13 +772,43 @@ function invalidateSyntheticContextSelection() { function onGridContextMenuOpen() { contextMenuLifecycle += 1; + const cell = contextCell.value; + const columnName = cell ? props.result.columns[cell.col] : undefined; + const sourceItem = cell ? getRowItem(cell.rowId) : undefined; + contextFilterTarget.value = + cell && columnName && sourceItem + ? { + id: ++nextContextFilterTargetId, + rowId: cell.rowId, + rowIndex: cell.rowIndex, + col: cell.col, + columnName, + columnInfo: props.tableMeta?.columns.find((column) => column.name === columnName), + sourceResult: props.result, + sourceIndex: sourceItem.sourceIndex, + sourceValue: sourceItem.data[cell.col] ?? null, + requiresHydration: sourceItem.sourceIndex !== undefined && isLargeValuePreview(sourceItem, cell.col), + } + : null; } +watch( + () => props.result, + (result) => { + if (contextFilterTarget.value?.sourceResult !== result) contextFilterTarget.value = null; + }, +); + function onGridContextMenuClose() { const lifecycle = ++contextMenuLifecycle; + const target = contextFilterTarget.value; + const targetId = target?.id; queueMicrotask(() => { if (lifecycle !== contextMenuLifecycle) return; invalidateSyntheticContextSelection(); + // 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; }); } @@ -5638,17 +5685,8 @@ function applyContextSort(direction: "asc" | "desc" | null, mode: DataGridSortMo applyColumnSort(contextColumn.value, contextCell.value.col, direction, mode); } -async function contextFilterCondition(mode: FilterMode): Promise { - const target = contextCell.value; - if (!target) return null; - const columnName = props.result.columns[target.col]; - if (!columnName) return null; - const sourceResult = props.result; - const sourceItem = getRowItem(target.rowId); - if (!sourceItem) return null; - const sourceIndex = sourceItem.sourceIndex; - const requiresHydration = sourceIndex !== undefined && isLargeValuePreview(sourceItem, target.col); - const sourceValue = sourceItem.data[target.col] ?? null; +async function contextFilterCondition(target: ContextFilterTarget, mode: FilterMode): Promise { + const { columnName, sourceResult, sourceIndex, sourceValue, requiresHydration } = target; // CustomContextMenu closes before invoking its action. Keep the target // stable across hydration after the close lifecycle clears contextCell. if (mode !== "is-null" && mode !== "is-not-null") { @@ -5656,25 +5694,32 @@ async function contextFilterCondition(mode: FilterMode): Promise } if (props.result !== sourceResult) return null; const value = requiresHydration && sourceIndex !== undefined ? (sourceResult.rows[sourceIndex]?.[target.col] ?? null) : sourceValue; - return ( - (await buildDataGridContextFilterCondition({ - databaseType: resolvedDatabaseType.value, - identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId), - columnName, - columnInfo: props.tableMeta?.columns.find((column) => column.name === columnName), - mode, - value, - })) ?? null - ); + const condition = await buildDataGridContextFilterCondition({ + databaseType: resolvedDatabaseType.value, + identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId), + columnName, + columnInfo: target.columnInfo, + mode, + value, + }); + return props.result === sourceResult ? (condition ?? null) : null; } async function applyContextFilter(mode: FilterMode) { if (!canUseWhereSearch.value) return; - const condition = await contextFilterCondition(mode); - if (!condition) return; - const existing = whereFilterInput.value.trim(); - whereFilterInput.value = existing ? `(${existing}) AND (${condition})` : condition; - await applyWhereFilter(); + const target = contextFilterTarget.value; + if (!target) return; + activeContextFilterActions.add(target.id); + try { + const condition = await contextFilterCondition(target, mode); + if (!condition || props.result !== target.sourceResult) return; + const existing = whereFilterInput.value.trim(); + whereFilterInput.value = existing ? `(${existing}) AND (${condition})` : condition; + await applyWhereFilter(); + } finally { + activeContextFilterActions.delete(target.id); + if (contextFilterTarget.value?.id === target.id) contextFilterTarget.value = null; + } } async function clearContextFilter() { diff --git a/apps/desktop/src/components/grid/__tests__/DataGridContextExtractor.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridContextExtractor.spec.ts index 6598d6c0ba..fe84a5609b 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridContextExtractor.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridContextExtractor.spec.ts @@ -1,35 +1,226 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; +// @vitest-environment happy-dom -const source = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8"); +import { createApp, defineComponent, h, markRaw, nextTick, shallowRef, type App, type PropType } from "vue"; +import { createPinia, setActivePinia } from "pinia"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import type { QueryResult } from "@/types/database"; +import { TooltipProvider } from "@/components/ui/tooltip"; -describe("DataGrid context extractor lifecycle", () => { - it("clears the right-click target after the context-menu action has started", () => { - expect(source).toContain('@open="onGridContextMenuOpen"'); - expect(source).toContain('@close="onGridContextMenuClose"'); - expect(source).toContain("queueMicrotask(() => {"); - expect(source).toContain("invalidateSyntheticContextSelection();"); +const mocks = vi.hoisted(() => ({ + buildDataGridContextFilterCondition: vi.fn(async ({ columnName, value }: { columnName: string; value: unknown }) => `\`${columnName}\` = '${String(value)}'`), + buildTableSelectSql: vi.fn(async ({ whereInput }: { whereInput?: string }) => `SELECT payload FROM events${whereInput ? ` WHERE ${whereInput}` : ""}`), + executeMulti: vi.fn(), + toast: vi.fn(), +})); + +vi.mock("@/lib/backend/api", () => ({ + buildDataGridContextFilterCondition: mocks.buildDataGridContextFilterCondition, + buildTableSelectSql: mocks.buildTableSelectSql, + executeMulti: mocks.executeMulti, +})); + +vi.mock("@/composables/useToast", () => ({ + useToast: () => ({ toast: mocks.toast }), +})); + +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, 180]), + totalWidth: ref(300), + columnVars: ref({ "--total-w": "300px" }), + getIsResizing: () => false, + }), + }; +}); + +import DataGrid from "../DataGrid.vue"; +import { useSettingsStore } from "@/stores/settingsStore"; + +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 })), + ); + }, +}); + +const mountedApps: Array<{ app: App; host: HTMLElement }> = []; + +function largeValueResult(id = 1, value = "preview"): QueryResult { + return { + columns: ["id", "payload"], + rows: [[id, value]], + affected_rows: 0, + execution_time_ms: 0, + large_value_cells: [{ row_index: 0, column_index: 1, original_bytes: 4096 }], + }; +} + +function hydratedResult(id: number, value: string): QueryResult { + return { + columns: ["id", "payload"], + rows: [[id, value]], + affected_rows: 0, + execution_time_ms: 0, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function mountGrid(initialResult = largeValueResult()) { + const result = shallowRef(markRaw(initialResult)); + const onExecuteSql = vi.fn().mockResolvedValue(undefined); + const pinia = createPinia(); + setActivePinia(pinia); + const settingsStore = useSettingsStore(); + settingsStore.updateEditorSettings({ dataGridRenderMode: "canvas" }); + + const host = document.createElement("div"); + document.body.append(host); + const Root = defineComponent({ + setup() { + return () => + h( + TooltipProvider, + { delayDuration: 0 }, + { + default: () => + h(DataGrid, { + result: result.value, + databaseType: "mysql", + connectionId: "connection-1", + database: "dbx", + context: "table-data", + editable: true, + tableMeta: { + tableName: "events", + columns: [ + { name: "id", data_type: "int" }, + { name: "payload", data_type: "text" }, + ], + primaryKeys: ["id"], + }, + onExecuteSql, + }), + }, + ); + }, + }); + const app = createApp(Root); + app.use(pinia); + app.use(i18n); + app.component("RecycleScroller", RecycleScroller); + app.mount(host); + // Mount once with the production-default Canvas mode so DataGrid finishes + // setting up its column-width dependencies, then exercise the DOM grid. + settingsStore.updateEditorSettings({ dataGridRenderMode: "dom" }); + mountedApps.push({ app, host }); + return { host, onExecuteSql, replaceResult: (nextResult: QueryResult) => (result.value = markRaw(nextResult)) }; +} + +async function settle() { + await nextTick(); + await Promise.resolve(); + await nextTick(); +} + +function contextMenuButton(label: string): HTMLButtonElement { + const button = [...document.querySelectorAll("[data-dbx-context-menu] button")].find((candidate) => candidate.textContent?.trim() === label); + if (!button) throw new Error(`Context menu item not found: ${label}`); + return button; +} + +async function startEqualsFilter(host: HTMLElement) { + await settle(); + const cell = host.querySelector('[data-row-index="0"] [data-visible-col-index="1"]'); + if (!cell) throw new Error("Large-value grid cell not found"); + cell.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 12, clientY: 12 })); + await settle(); + contextMenuButton("Filter").dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + await settle(); + contextMenuButton("Filter by This Value").click(); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + for (const { app, host } of mountedApps.splice(0)) { + app.unmount(); + host.remove(); + } + document.querySelectorAll("[data-dbx-context-menu]").forEach((menu) => menu.remove()); +}); + +describe("DataGrid context filter lifecycle", () => { + it("keeps the right-click target through menu close and large-value hydration", async () => { + const hydration = deferred(); + mocks.executeMulti.mockReturnValue(hydration.promise); + const { host, onExecuteSql } = mountGrid(); + + await startEqualsFilter(host); + await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(1)); + expect(document.querySelector("[data-dbx-context-menu]")).toBeNull(); + + hydration.resolve([hydratedResult(1, "full original value")]); + await vi.waitFor(() => expect(onExecuteSql).toHaveBeenCalledTimes(1)); + + const sql = onExecuteSql.mock.calls[0]?.[0] as string; + expect(sql).toContain("payload"); + expect(sql).toContain("full original value"); }); - it("resolves menu items after the right-click target is updated", () => { - expect(source).toContain(':items="currentGridContextMenuItems"'); - expect(source).toContain("return gridContextMenuItems.value;"); + it("does not apply a completed hydration from a previous result set", async () => { + const hydration = deferred(); + mocks.executeMulti.mockReturnValue(hydration.promise); + const { host, onExecuteSql, replaceResult } = mountGrid(); + + await startEqualsFilter(host); + await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(1)); + replaceResult(largeValueResult(2, "new preview")); + await settle(); + hydration.resolve([hydratedResult(1, "old value")]); + await settle(); + + expect(onExecuteSql).not.toHaveBeenCalled(); }); - it("snapshots the filter target before asynchronous value hydration", () => { - const start = source.indexOf("async function contextFilterCondition"); - const end = source.indexOf("async function applyContextFilter", start); - const filterSource = source.slice(start, end); + it("does not apply a delayed condition after the result set changes", async () => { + const condition = deferred(); + mocks.buildDataGridContextFilterCondition.mockReturnValue(condition.promise); + const { host, onExecuteSql, replaceResult } = mountGrid(hydratedResult(1, "old value")); - expect(start).toBeGreaterThanOrEqual(0); - expect(end).toBeGreaterThan(start); - expect(filterSource).toContain("const target = contextCell.value;"); - expect(filterSource).toContain("const sourceResult = props.result;"); - expect(filterSource).toContain("const sourceIndex = sourceItem.sourceIndex;"); - expect(filterSource).toContain("await hydrateLargeValueCell(target.rowId, target.col)"); - expect(filterSource).toContain("sourceResult.rows[sourceIndex]?.[target.col]"); - expect(filterSource).not.toContain("getRowItem(target.rowId);\n if (!item)"); - expect(filterSource).not.toContain("contextColumn.value"); - expect(filterSource).not.toContain("contextCellValue.value"); + await startEqualsFilter(host); + await vi.waitFor(() => expect(mocks.buildDataGridContextFilterCondition).toHaveBeenCalledTimes(1)); + replaceResult(hydratedResult(2, "new value")); + await settle(); + condition.resolve("`payload` = 'old value'"); + await settle(); + + expect(onExecuteSql).not.toHaveBeenCalled(); }); });