mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
fix(grid): persist visual table filters across restart
This commit is contained in:
@@ -1496,9 +1496,11 @@ function loadStructuredFilterStateForScope() {
|
||||
const cacheKey = structuredFilterCacheKey.value;
|
||||
const scopeKey = structuredFilterScopeKey.value;
|
||||
structuredFilterRules.value = cloneDataGridStructuredFilterRules(cached.rules);
|
||||
whereFilterInput.value = cached.manualWhereInput;
|
||||
appliedStructuredWhereInput.value = cached.appliedWhereInput;
|
||||
serverColumnFilters.value = structuredClone(cached.serverColumnFilters ?? {});
|
||||
appliedStructuredWhereInput.value = "";
|
||||
// Restore the applied SQL before the manual input so the whereInput watcher
|
||||
// emits the combined condition instead of a brief empty WHERE (#8831).
|
||||
whereFilterInput.value = cached.manualWhereInput;
|
||||
void buildStructuredWhereFromRules(structuredFilterRules.value)
|
||||
.then((whereInput) => {
|
||||
if (requestId !== structuredFilterHydrationRequestId || structuredFilterCacheKey.value !== cacheKey || structuredFilterScopeKey.value !== scopeKey) return;
|
||||
@@ -1521,7 +1523,6 @@ function loadStructuredFilterStateForScope() {
|
||||
appliedStructuredWhereInput.value = "";
|
||||
serverColumnFilters.value = {};
|
||||
structuredFilterRules.value = filterBuilderColumnOptions.value.length > 0 ? [defaultStructuredFilterRule()] : [];
|
||||
persistStructuredFilterState();
|
||||
markConditionInputsApplied();
|
||||
structuredFilterHydrationReady.value = true;
|
||||
}
|
||||
|
||||
+119
-3
@@ -1,13 +1,14 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { clearDataGridStructuredFilterStatesForTab, loadDataGridStructuredFilterState, saveDataGridStructuredFilterState } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
|
||||
import { clearDataGridStructuredFilterStates, clearDataGridStructuredFilterStatesForTab, dropDataGridStructuredFilterMemoryCache, loadDataGridStructuredFilterState, saveDataGridStructuredFilterState } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
|
||||
|
||||
describe("data grid structured filter persistence", () => {
|
||||
const cacheKey = "issue-436-filter-view";
|
||||
const scopeKey = "mysql\0demo\0users";
|
||||
|
||||
beforeEach(() => {
|
||||
clearDataGridStructuredFilterStatesForTab("issue-436-filter-view");
|
||||
clearDataGridStructuredFilterStatesForTab("lru-tab");
|
||||
clearDataGridStructuredFilterStates();
|
||||
saveDataGridStructuredFilterState(cacheKey, {
|
||||
scopeKey,
|
||||
manualWhereInput: "tenant_id = 7",
|
||||
@@ -78,4 +79,119 @@ describe("data grid structured filter persistence", () => {
|
||||
expect(loadDataGridStructuredFilterState("lru-tab-0", scopeKey)).toBeDefined();
|
||||
expect(loadDataGridStructuredFilterState("lru-tab-1", scopeKey)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("restores visual filter rules after a process restart instead of the combined WHERE (#8831)", () => {
|
||||
saveDataGridStructuredFilterState("data-tab-orders", {
|
||||
scopeKey,
|
||||
manualWhereInput: "",
|
||||
rules: [{ id: "r-id", columnName: "id", mode: "equals", rawValue: "1", rawEndValue: "", conjunction: "AND" }],
|
||||
appliedWhereInput: "`id` = 1",
|
||||
serverColumnFilters: {},
|
||||
});
|
||||
|
||||
dropDataGridStructuredFilterMemoryCache();
|
||||
|
||||
const restored = loadDataGridStructuredFilterState("data-tab-orders", scopeKey);
|
||||
expect(restored).toMatchObject({
|
||||
manualWhereInput: "",
|
||||
appliedWhereInput: "`id` = 1",
|
||||
rules: [{ columnName: "id", mode: "equals", rawValue: "1" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a closed tab's filters off disk after restart", () => {
|
||||
saveDataGridStructuredFilterState("tab-1-run-1-0", {
|
||||
scopeKey,
|
||||
manualWhereInput: "id > 1",
|
||||
rules: [],
|
||||
appliedWhereInput: "id > 1",
|
||||
serverColumnFilters: {},
|
||||
});
|
||||
clearDataGridStructuredFilterStatesForTab("tab-1");
|
||||
dropDataGridStructuredFilterMemoryCache();
|
||||
|
||||
expect(loadDataGridStructuredFilterState("tab-1-run-1-0", scopeKey)).toBeUndefined();
|
||||
expect(loadDataGridStructuredFilterState(cacheKey, scopeKey)?.manualWhereInput).toBe("tenant_id = 7");
|
||||
});
|
||||
|
||||
it("does not let a scope-miss empty builder overwrite visual rules (#8831)", () => {
|
||||
saveDataGridStructuredFilterState("data-tab-orders", {
|
||||
scopeKey,
|
||||
manualWhereInput: "",
|
||||
rules: [{ id: "r-id", columnName: "id", mode: "equals", rawValue: "1", rawEndValue: "", conjunction: "AND" }],
|
||||
appliedWhereInput: "`id` = 1",
|
||||
serverColumnFilters: {},
|
||||
});
|
||||
|
||||
saveDataGridStructuredFilterState("data-tab-orders", {
|
||||
scopeKey: `${scopeKey}\0pending-columns`,
|
||||
manualWhereInput: "`id` = 1",
|
||||
rules: [{ id: "empty", columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
|
||||
appliedWhereInput: "",
|
||||
serverColumnFilters: {},
|
||||
});
|
||||
|
||||
dropDataGridStructuredFilterMemoryCache();
|
||||
|
||||
expect(loadDataGridStructuredFilterState("data-tab-orders", scopeKey)).toMatchObject({
|
||||
manualWhereInput: "",
|
||||
appliedWhereInput: "`id` = 1",
|
||||
rules: [{ columnName: "id", mode: "equals", rawValue: "1" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("still replaces visual rules when the same scope is cleared", () => {
|
||||
saveDataGridStructuredFilterState(cacheKey, {
|
||||
scopeKey,
|
||||
manualWhereInput: "",
|
||||
rules: [{ id: "empty", columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
|
||||
appliedWhereInput: "",
|
||||
serverColumnFilters: {},
|
||||
});
|
||||
|
||||
dropDataGridStructuredFilterMemoryCache();
|
||||
|
||||
expect(loadDataGridStructuredFilterState(cacheKey, scopeKey)).toMatchObject({
|
||||
manualWhereInput: "",
|
||||
appliedWhereInput: "",
|
||||
rules: [{ columnName: "" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores corrupt stored payloads", () => {
|
||||
localStorage.setItem("dbx-data-grid-structured-filters", "{not json");
|
||||
dropDataGridStructuredFilterMemoryCache();
|
||||
expect(loadDataGridStructuredFilterState(cacheKey, scopeKey)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps valid filter rules when a stored rule is invalid", () => {
|
||||
localStorage.setItem(
|
||||
"dbx-data-grid-structured-filters",
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
entries: [
|
||||
[
|
||||
cacheKey,
|
||||
{
|
||||
scopeKey,
|
||||
manualWhereInput: "",
|
||||
appliedWhereInput: "`id` = 1",
|
||||
rules: [
|
||||
{ id: "bad", columnName: "status", mode: "not-a-mode", rawValue: "open", rawEndValue: "", conjunction: "AND" },
|
||||
{ id: "r-id", columnName: "id", mode: "equals", rawValue: "1", rawEndValue: "", conjunction: "AND" },
|
||||
],
|
||||
serverColumnFilters: { x: { condition: "bad" }, 0: { condition: "`id` = 1", keys: ["1"], labels: ["1"] } },
|
||||
},
|
||||
],
|
||||
],
|
||||
}),
|
||||
);
|
||||
dropDataGridStructuredFilterMemoryCache();
|
||||
|
||||
expect(loadDataGridStructuredFilterState(cacheKey, scopeKey)).toMatchObject({
|
||||
appliedWhereInput: "`id` = 1",
|
||||
rules: [{ id: "r-id", columnName: "id", rawValue: "1" }],
|
||||
serverColumnFilters: { 0: { condition: "`id` = 1", keys: ["1"], labels: ["1"] } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
|
||||
import { safeLocalStorageGet, safeLocalStorageRemove, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
|
||||
|
||||
export type DataGridCachedServerColumnFilter = {
|
||||
condition: string;
|
||||
@@ -15,41 +17,177 @@ export type DataGridStructuredFilterCacheState = {
|
||||
};
|
||||
|
||||
const STRUCTURED_FILTER_STATE_CACHE_MAX_ENTRIES = 128;
|
||||
const STORAGE_KEY = "dbx-data-grid-structured-filters";
|
||||
const STORAGE_VERSION = 1;
|
||||
const FILTER_MODE_LIST = [
|
||||
"equals",
|
||||
"not-equals",
|
||||
"is-null",
|
||||
"is-not-null",
|
||||
"is-blank",
|
||||
"is-not-blank",
|
||||
"like",
|
||||
"not-like",
|
||||
"begins-with",
|
||||
"ends-with",
|
||||
"less-than",
|
||||
"less-than-or-equal",
|
||||
"greater-than",
|
||||
"greater-than-or-equal",
|
||||
"in",
|
||||
"not-in",
|
||||
"between",
|
||||
"not-between",
|
||||
] as const satisfies readonly DataGridContextFilterMode[];
|
||||
// Type-level guard: breaks compilation when DataGridContextFilterMode gains a member missing from FILTER_MODE_LIST.
|
||||
type _MissingFilterModes = Exclude<DataGridContextFilterMode, (typeof FILTER_MODE_LIST)[number]>;
|
||||
const FILTER_MODES: Set<DataGridContextFilterMode> & ([_MissingFilterModes] extends [never] ? unknown : never) = new Set<DataGridContextFilterMode>(FILTER_MODE_LIST);
|
||||
|
||||
const structuredFilterStateCache = new Map<string, DataGridStructuredFilterCacheState>();
|
||||
let hydrated = false;
|
||||
|
||||
export function cloneDataGridStructuredFilterRules(rules: readonly DataGridStructuredFilterRule[]): DataGridStructuredFilterRule[] {
|
||||
return rules.map((rule) => ({ ...rule }));
|
||||
}
|
||||
|
||||
function isFilterMode(value: unknown): value is DataGridContextFilterMode {
|
||||
return typeof value === "string" && FILTER_MODES.has(value as DataGridContextFilterMode);
|
||||
}
|
||||
|
||||
function sanitizeRule(value: unknown): DataGridStructuredFilterRule | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
const rule = value as Partial<DataGridStructuredFilterRule>;
|
||||
if (typeof rule.id !== "string" || typeof rule.columnName !== "string") return undefined;
|
||||
if (!isFilterMode(rule.mode)) return undefined;
|
||||
if (typeof rule.rawValue !== "string" || typeof rule.rawEndValue !== "string") return undefined;
|
||||
if (rule.conjunction !== "AND" && rule.conjunction !== "OR") return undefined;
|
||||
return {
|
||||
id: rule.id,
|
||||
columnName: rule.columnName,
|
||||
mode: rule.mode,
|
||||
rawValue: rule.rawValue,
|
||||
rawEndValue: rule.rawEndValue,
|
||||
conjunction: rule.conjunction,
|
||||
...(rule.disabled === true ? { disabled: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeServerColumnFilter(value: unknown): DataGridCachedServerColumnFilter | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
const filter = value as Partial<DataGridCachedServerColumnFilter>;
|
||||
if (typeof filter.condition !== "string" || !Array.isArray(filter.keys) || !Array.isArray(filter.labels)) return undefined;
|
||||
const keys = filter.keys.filter((key): key is string => typeof key === "string");
|
||||
const labels = filter.labels.filter((label): label is string => typeof label === "string");
|
||||
if (keys.length !== filter.keys.length || labels.length !== filter.labels.length) return undefined;
|
||||
return { condition: filter.condition, keys, labels };
|
||||
}
|
||||
|
||||
function hasNamedFilterRules(state: Pick<DataGridStructuredFilterCacheState, "rules">): boolean {
|
||||
return state.rules.some((rule) => rule.columnName);
|
||||
}
|
||||
|
||||
function sanitizeState(value: unknown): DataGridStructuredFilterCacheState | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
const state = value as Partial<DataGridStructuredFilterCacheState>;
|
||||
if (typeof state.scopeKey !== "string" || typeof state.manualWhereInput !== "string" || typeof state.appliedWhereInput !== "string") return undefined;
|
||||
if (!Array.isArray(state.rules)) return undefined;
|
||||
const rules = state.rules.map(sanitizeRule).filter((rule): rule is DataGridStructuredFilterRule => !!rule);
|
||||
const serverColumnFilters: Record<number, DataGridCachedServerColumnFilter> = {};
|
||||
if (state.serverColumnFilters && typeof state.serverColumnFilters === "object" && !Array.isArray(state.serverColumnFilters)) {
|
||||
for (const [key, filter] of Object.entries(state.serverColumnFilters)) {
|
||||
if (!/^\d+$/.test(key)) continue;
|
||||
const sanitized = sanitizeServerColumnFilter(filter);
|
||||
if (sanitized) serverColumnFilters[Number(key)] = sanitized;
|
||||
}
|
||||
}
|
||||
return {
|
||||
scopeKey: state.scopeKey,
|
||||
manualWhereInput: state.manualWhereInput,
|
||||
rules,
|
||||
appliedWhereInput: state.appliedWhereInput,
|
||||
serverColumnFilters,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneState(state: DataGridStructuredFilterCacheState): DataGridStructuredFilterCacheState {
|
||||
return {
|
||||
...state,
|
||||
rules: cloneDataGridStructuredFilterRules(state.rules),
|
||||
serverColumnFilters: structuredClone(state.serverColumnFilters),
|
||||
};
|
||||
}
|
||||
|
||||
function persistToStorage() {
|
||||
const entries = [...structuredFilterStateCache.entries()];
|
||||
if (entries.length === 0) {
|
||||
safeLocalStorageRemove(STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
safeLocalStorageSet(STORAGE_KEY, JSON.stringify({ version: STORAGE_VERSION, entries }));
|
||||
}
|
||||
|
||||
function ensureHydrated() {
|
||||
if (hydrated) return;
|
||||
hydrated = true;
|
||||
const raw = safeLocalStorageGet(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { version?: unknown; entries?: unknown };
|
||||
if (parsed.version !== STORAGE_VERSION || !Array.isArray(parsed.entries)) return;
|
||||
for (const entry of parsed.entries) {
|
||||
if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") continue;
|
||||
const state = sanitizeState(entry[1]);
|
||||
if (!state) continue;
|
||||
structuredFilterStateCache.set(entry[0], state);
|
||||
if (structuredFilterStateCache.size >= STRUCTURED_FILTER_STATE_CACHE_MAX_ENTRIES) break;
|
||||
}
|
||||
} catch {
|
||||
structuredFilterStateCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function loadDataGridStructuredFilterState(cacheKey: string, scopeKey: string): DataGridStructuredFilterCacheState | undefined {
|
||||
ensureHydrated();
|
||||
const cached = structuredFilterStateCache.get(cacheKey);
|
||||
if (!cached || cached.scopeKey !== scopeKey) return undefined;
|
||||
structuredFilterStateCache.delete(cacheKey);
|
||||
structuredFilterStateCache.set(cacheKey, cached);
|
||||
return {
|
||||
...cached,
|
||||
rules: cloneDataGridStructuredFilterRules(cached.rules),
|
||||
serverColumnFilters: structuredClone(cached.serverColumnFilters),
|
||||
};
|
||||
return cloneState(cached);
|
||||
}
|
||||
|
||||
export function saveDataGridStructuredFilterState(cacheKey: string, state: DataGridStructuredFilterCacheState) {
|
||||
ensureHydrated();
|
||||
const existing = structuredFilterStateCache.get(cacheKey);
|
||||
// A scope miss hydrates the empty default builder while initialWhereInput still
|
||||
// holds the combined SQL. Do not let that write-through erase visual rules (#8831).
|
||||
if (existing && existing.scopeKey !== state.scopeKey && hasNamedFilterRules(existing) && !hasNamedFilterRules(state)) return;
|
||||
structuredFilterStateCache.delete(cacheKey);
|
||||
structuredFilterStateCache.set(cacheKey, {
|
||||
...state,
|
||||
rules: cloneDataGridStructuredFilterRules(state.rules),
|
||||
serverColumnFilters: structuredClone(state.serverColumnFilters),
|
||||
});
|
||||
structuredFilterStateCache.set(cacheKey, cloneState(state));
|
||||
while (structuredFilterStateCache.size > STRUCTURED_FILTER_STATE_CACHE_MAX_ENTRIES) {
|
||||
const oldest = structuredFilterStateCache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
structuredFilterStateCache.delete(oldest);
|
||||
}
|
||||
persistToStorage();
|
||||
}
|
||||
|
||||
export function clearDataGridStructuredFilterStatesForTab(tabId: string) {
|
||||
ensureHydrated();
|
||||
structuredFilterStateCache.delete(tabId);
|
||||
for (const cacheKey of structuredFilterStateCache.keys()) {
|
||||
if (cacheKey.startsWith(`${tabId}-`)) structuredFilterStateCache.delete(cacheKey);
|
||||
}
|
||||
persistToStorage();
|
||||
}
|
||||
|
||||
export function clearDataGridStructuredFilterStates() {
|
||||
structuredFilterStateCache.clear();
|
||||
hydrated = true;
|
||||
persistToStorage();
|
||||
}
|
||||
|
||||
/** Drop the in-memory cache without touching disk, as if the process restarted. */
|
||||
export function dropDataGridStructuredFilterMemoryCache() {
|
||||
structuredFilterStateCache.clear();
|
||||
hydrated = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user