fix(grid): preserve async context filter targets
This commit is contained in:
2 files changed
+288
-52
No files matched your search
@@ -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() {
|
||||
|
||||
+217
-26
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user