fix(grid): 复制页签时保留独立筛选与排序规则 (#11628)

This commit is contained in:
zipg authored and GitHub committed 2026-10-11 12:48:02 +08:00
1 parent c80ef798e5
commit 8e52e61c39
9 files changed
+150 -11

No files matched your search

@@ -1,6 +1,6 @@
import { nextTick, ref } from "vue";
import { beforeEach, describe, expect, it } from "vitest";
import { buildDataGridStructuredOrderBy, clearDataGridSortBuilderMemoryCache, combineDataGridOrderByInputs, moveDataGridStructuredSortRule, useDataGridSortBuilder, type DataGridStructuredSortRule } from "@/composables/useDataGridSortBuilder";
import { buildDataGridStructuredOrderBy, clearDataGridSortBuilderMemoryCache, combineDataGridOrderByInputs, copyDataGridSortBuilderStatesForTab, moveDataGridStructuredSortRule, useDataGridSortBuilder, type DataGridStructuredSortRule } from "@/composables/useDataGridSortBuilder";
function idFactory() {
let index = 0;
@@ -60,6 +60,32 @@ describe("structured data-grid ORDER BY", () => {
});
describe("useDataGridSortBuilder", () => {
it("copies each scope owned by a tab without copying another tab's rules", async () => {
const scopeKey = ref("scope-1");
const source = useDataGridSortBuilder({ columns: ["id", "name"], cacheKey: "tab-1", scopeKey, createId: idFactory() });
source.setOpen(true);
source.updateRule(source.rules.value[0].id, { columnName: "id", direction: "desc" });
source.markApplied('"id" DESC');
await nextTick();
scopeKey.value = "scope-2";
await nextTick();
source.updateRule(source.rules.value[0].id, { columnName: "name" });
source.markApplied('"name" ASC');
const other = useDataGridSortBuilder({ columns: ["id"], cacheKey: "tab-10", scopeKey: "scope-1" });
other.setOpen(true);
other.updateRule(other.rules.value[0].id, { columnName: "id" });
other.markApplied('"id" ASC');
await nextTick();
copyDataGridSortBuilderStatesForTab("tab-1", "copy");
const copy = useDataGridSortBuilder({ columns: ["id", "name"], cacheKey: "copy", scopeKey: "scope-1" });
const copyOtherScope = useDataGridSortBuilder({ columns: ["id", "name"], cacheKey: "copy", scopeKey: "scope-2" });
expect(copy.appliedOrderByInput.value).toBe('"id" DESC');
expect(copyOtherScope.appliedOrderByInput.value).toBe('"name" ASC');
expect(useDataGridSortBuilder({ columns: ["id"], cacheKey: "copy0", scopeKey: "scope-1" }).rules.value).toEqual([]);
});
it("keeps disabled draft rules when the same tab is remounted", async () => {
const columns = ref(["id", "name"]);
const first = useDataGridSortBuilder({ columns, cacheKey: "tab-1", scopeKey: "scope-1", createId: idFactory() });
@@ -24,6 +24,26 @@ type UseDataGridSortBuilderOptions = {
const SORT_BUILDER_CACHE_MAX_ENTRIES = 128;
const sortBuilderCache = new Map<string, DataGridSortBuilderCacheEntry>();
function saveCacheEntry(key: string, entry: DataGridSortBuilderCacheEntry) {
sortBuilderCache.delete(key);
sortBuilderCache.set(key, { appliedOrderBy: entry.appliedOrderBy, rules: cloneRules(entry.rules) });
while (sortBuilderCache.size > SORT_BUILDER_CACHE_MAX_ENTRIES) {
const oldest = sortBuilderCache.keys().next().value;
if (oldest === undefined) break;
sortBuilderCache.delete(oldest);
}
}
export function copyDataGridSortBuilderStatesForTab(sourceTabId: string, targetTabId: string) {
if (sourceTabId === targetTabId) return;
for (const [key, entry] of [...sortBuilderCache.entries()]) {
const { cacheKey, scopeKey } = JSON.parse(key) as { cacheKey: string; scopeKey: string };
if (cacheKey === sourceTabId || cacheKey.startsWith(`${sourceTabId}-`)) {
saveCacheEntry(JSON.stringify({ cacheKey: `${targetTabId}${cacheKey.slice(sourceTabId.length)}`, scopeKey }), entry);
}
}
}
function cloneRules(rules: readonly DataGridStructuredSortRule[]): DataGridStructuredSortRule[] {
return rules.map((rule) => ({ ...rule }));
}
@@ -72,13 +92,7 @@ export function useDataGridSortBuilder(options: UseDataGridSortBuilderOptions) {
function persist() {
const key = entryKey();
if (!key) return;
sortBuilderCache.delete(key);
sortBuilderCache.set(key, { appliedOrderBy: appliedOrderByInput.value, rules: cloneRules(rules.value) });
while (sortBuilderCache.size > SORT_BUILDER_CACHE_MAX_ENTRIES) {
const oldest = sortBuilderCache.keys().next().value;
if (oldest === undefined) break;
sortBuilderCache.delete(oldest);
}
saveCacheEntry(key, { appliedOrderBy: appliedOrderByInput.value, rules: rules.value });
}
function restore() {
@@ -1,7 +1,7 @@
/** @vitest-environment happy-dom */
import { beforeEach, describe, expect, it } from "vitest";
import { clearDataGridStructuredFilterStates, clearDataGridStructuredFilterStatesForTab, dropDataGridStructuredFilterMemoryCache, loadDataGridStructuredFilterState, saveDataGridStructuredFilterState } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
import { clearDataGridStructuredFilterStates, clearDataGridStructuredFilterStatesForTab, copyDataGridStructuredFilterStatesForTab, dropDataGridStructuredFilterMemoryCache, loadDataGridStructuredFilterState, saveDataGridStructuredFilterState } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
describe("data grid structured filter persistence", () => {
const cacheKey = "issue-436-filter-view";
@@ -25,6 +25,25 @@ describe("data grid structured filter persistence", () => {
});
});
it("copies only the source tab's grids and persists separate manual and quick conditions across a restart", () => {
const state = loadDataGridStructuredFilterState(cacheKey, scopeKey)!;
saveDataGridStructuredFilterState("tab-1", state);
saveDataGridStructuredFilterState("tab-1-run-1-0", { ...state, manualWhereInput: "tenant_id = 8" });
saveDataGridStructuredFilterState("tab-10", { ...state, manualWhereInput: "tenant_id = 9" });
dropDataGridStructuredFilterMemoryCache();
copyDataGridStructuredFilterStatesForTab("tab-1", "copy");
dropDataGridStructuredFilterMemoryCache();
expect(loadDataGridStructuredFilterState("copy", scopeKey)).toEqual(state);
expect(loadDataGridStructuredFilterState("copy-run-1-0", scopeKey)?.manualWhereInput).toBe("tenant_id = 8");
expect(loadDataGridStructuredFilterState("copy0", scopeKey)).toBeUndefined();
expect(loadDataGridStructuredFilterState("copy", "other-scope")).toBeUndefined();
clearDataGridStructuredFilterStatesForTab("copy");
expect(loadDataGridStructuredFilterState("tab-1", scopeKey)).toEqual(state);
expect(loadDataGridStructuredFilterState("tab-10", scopeKey)?.manualWhereInput).toBe("tenant_id = 9");
});
it("does not leak filters into another table scope", () => {
expect(loadDataGridStructuredFilterState(cacheKey, `${scopeKey}\0archive`)).toBeUndefined();
});
@@ -171,6 +171,15 @@ export function saveDataGridStructuredFilterState(cacheKey: string, state: DataG
persistToStorage();
}
export function copyDataGridStructuredFilterStatesForTab(sourceTabId: string, targetTabId: string) {
if (sourceTabId === targetTabId) return;
ensureHydrated();
const entries = [...structuredFilterStateCache.entries()].filter(([key]) => key === sourceTabId || key.startsWith(`${sourceTabId}-`));
for (const [key, state] of entries) {
saveDataGridStructuredFilterState(`${targetTabId}${key.slice(sourceTabId.length)}`, state);
}
}
export function clearDataGridStructuredFilterStatesForTab(tabId: string) {
ensureHydrated();
structuredFilterStateCache.delete(tabId);
@@ -1,5 +1,8 @@
import { createPinia, setActivePinia } from "pinia";
import { effectScope, nextTick } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { clearDataGridStructuredFilterStates, loadDataGridStructuredFilterState, saveDataGridStructuredFilterState } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
import { clearDataGridSortBuilderMemoryCache, useDataGridSortBuilder } from "@/composables/useDataGridSortBuilder";
const mocks = vi.hoisted(() => ({
buildTableSelectSql: vi.fn(),
@@ -57,6 +60,8 @@ describe("queryStore table data refresh", () => {
vi.clearAllMocks();
vi.unstubAllGlobals();
installLocalStorage();
clearDataGridStructuredFilterStates();
clearDataGridSortBuilderMemoryCache();
setActivePinia(createPinia());
mocks.metadataGeneration = 0;
mocks.getConnectionConfig.mockReturnValue({
@@ -124,6 +129,66 @@ describe("queryStore table data refresh", () => {
expect(copy.result).not.toBe(original.result);
});
it.each(["filter", "sort"] as const)("duplicates the %s builder state separately from manual SQL inputs (#11609)", async (kind) => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const id = store.createTab("pg-1", "app", "users", "data", "public");
store.setTableMeta(id, {
database: "app",
schema: "public",
tableName: "users",
tableType: "TABLE",
columns: [
{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "status", data_type: "text", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
],
primaryKeys: ["id"],
});
const original = store.tabs.find((tab) => tab.id === id)!;
original.whereInput = "(status = 'ACTIVE') AND (id > 10) AND (status <> 'DELETED')";
original.orderByInput = "status ASC";
original.structuredOrderByInput = "id DESC";
const filterState = {
scopeKey: "filter-scope",
manualWhereInput: "status = 'ACTIVE'",
appliedWhereInput: "id > 10",
rules: [{ id: "filter-1", columnName: "id", mode: "greater-than" as const, rawValue: "10", rawEndValue: "", conjunction: "AND" as const }],
serverColumnFilters: { 1: { condition: "status <> 'DELETED'", keys: ["active"], labels: ["ACTIVE"] } },
};
saveDataGridStructuredFilterState(id, filterState);
const scope = effectScope();
try {
const source = scope.run(() => useDataGridSortBuilder({ columns: ["id", "status"], cacheKey: id, scopeKey: "sort-scope" }))!;
source.setOpen(true);
source.updateRule(source.rules.value[0].id, { columnName: "id", direction: "desc" });
source.addRule();
source.updateRule(source.rules.value[1].id, { columnName: "status", direction: "asc", disabled: true });
source.markApplied("id DESC");
await nextTick();
store.duplicateTab(id);
const copy = store.tabs[1];
await vi.waitFor(() => expect(copy.result).toBeDefined());
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ whereInput: original.whereInput, orderBy: "status ASC, id DESC" }));
if (kind === "filter") {
const copied = loadDataGridStructuredFilterState(copy.id, "filter-scope");
expect(copied).toEqual(filterState);
copied!.rules[0].rawValue = "100";
copied!.serverColumnFilters[1].labels.push("CHANGED");
saveDataGridStructuredFilterState(copy.id, copied!);
expect(loadDataGridStructuredFilterState(id, "filter-scope")).toEqual(filterState);
} else {
const target = scope.run(() => useDataGridSortBuilder({ columns: ["id", "status"], cacheKey: copy.id, scopeKey: "sort-scope" }))!;
expect(target.rules.value).toEqual(source.rules.value);
expect(target.appliedOrderByInput.value).toBe("id DESC");
target.updateRule(target.rules.value[0].id, { direction: "asc" });
await nextTick();
expect(source.rules.value[0].direction).toBe("desc");
}
} finally {
scope.stop();
}
});
it("refreshes only matching data tabs after a table mutation", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
+8 -2
View File
@@ -108,10 +108,10 @@ import { replaceSqlServerLeadingUseQuery, sqlServerLeadingUseScript, switchesDat
import { classifySqlRisk } from "@/lib/sql/sqlRisk";
import { externalSqlFileDisplayTitles, normalizeExternalSqlPath } from "@/lib/sql/sqlFileOpen";
import { clearDataGridPendingSnapshot, clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor";
import { combineDataGridOrderByInputs } from "@/composables/useDataGridSortBuilder";
import { combineDataGridOrderByInputs, copyDataGridSortBuilderStatesForTab } from "@/composables/useDataGridSortBuilder";
import { beginClosingDataGridViewSnapshotsForTab, clearDataGridViewSnapshot, clearDataGridViewSnapshotsForTab } from "@/lib/dataGrid/dataGridViewStateCache";
import { beginClosingBrowserState } from "@/lib/tabs/documentBrowserStateCache";
import { clearDataGridStructuredFilterStatesForTab } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
import { clearDataGridStructuredFilterStatesForTab, copyDataGridStructuredFilterStatesForTab } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
import { clearDataGridSearchStatesForTab } from "@/lib/dataGrid/dataGridSearchStatePersistence";
import { buildTabResultSnapshot, deleteTabResultSnapshot, pruneTabResultSnapshots, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
import { estimateQueryResultsBytes, selectInactiveResultEvictions } from "@/lib/tabs/queryResultSize";
@@ -4824,6 +4824,12 @@ export const useQueryStore = defineStore("query", () => {
previewSql: original.previewSql,
};
initializeResultAutoSave(newTab);
if (newTab.mode === "data") {
// Seed builder state before the new grid mounts; the combined WHERE alone
// cannot recover which conditions belong to the manual input or quick UI.
copyDataGridStructuredFilterStatesForTab(id, newId);
copyDataGridSortBuilderStatesForTab(id, newId);
}
tabs.value.splice(idx + 1, 0, newTab);
const owner = groupForTab(id);
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB