fix(grid): preserve async context filter targets

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-20 02:16:25 +08:00
1 parent 15d73f3020
commit c2b7e9f269
2 files changed
+288 -52

No files matched your search

+71 -26
View File
@@ -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<ContextFilterTarget | null>(null);
let nextContextFilterTargetId = 0;
const activeContextFilterActions = new Set<number>();
// 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<string | null> {
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<string | null> {
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<string | null>
}
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() {
@@ -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<typeof import("@/composables/useDataGridColumnResize")>();
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<unknown[]>,
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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<HTMLButtonElement>("[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<HTMLElement>('[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<QueryResult[]>();
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<QueryResult[]>();
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<string | undefined>();
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();
});
});