fix(document): keep collection filter and sort across tab switches
Refs: #8035
This commit is contained in:
5 files changed
+458
-6
No files matched your search
@@ -69,6 +69,7 @@ import {
|
||||
documentStoreValueForGrid,
|
||||
} from "@/lib/app/documentJsonValues";
|
||||
import { applyDocumentStoreIdentityPlan, formatMeilisearchDocumentOperationPreview, insertDocumentStoreDocument as insertDocumentStoreDocumentCore } from "@/lib/app/documentStoreSave";
|
||||
import { restoreDocumentBrowserState, saveDocumentBrowserState } from "@/lib/tabs/documentBrowserStateCache";
|
||||
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
|
||||
import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
|
||||
import {
|
||||
@@ -110,12 +111,23 @@ const props = defineProps<{
|
||||
collection: string;
|
||||
databaseType?: DatabaseType;
|
||||
tableMeta?: NonNullable<QueryTab["tableMeta"]>;
|
||||
/** Tab id; query conditions are cached per tab and restored on remount. */
|
||||
stateKey?: string;
|
||||
}>();
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type ViewMode = "document" | "table";
|
||||
const DYNAMODB_DEFAULT_EXPORT_ROW_LIMIT = 10_000;
|
||||
|
||||
// This component is keyed by tab in ContentArea and unmounted on every tab
|
||||
// switch; without restoring from the per-tab cache, coming back to the tab
|
||||
// would silently drop the user's filter/sort conditions. Page position only
|
||||
// survives for skip-based paging: cursor stores (DynamoDB/Elasticsearch)
|
||||
// cannot resume a page without their cursor stacks, and infinite scroll
|
||||
// always restarts from the first segment.
|
||||
const restoredDocumentBrowserState = props.stateKey ? restoreDocumentBrowserState(props.stateKey) : undefined;
|
||||
const restoresSkipBasedPage = !!restoredDocumentBrowserState && !settingsStore.editorSettings.infiniteScroll && (documentStoreProviderFor(props.databaseType).kind === "mongodb" || documentStoreProviderFor(props.databaseType).kind === "meilisearch");
|
||||
|
||||
const documents = ref<JsonRecord[]>([]);
|
||||
const copyDocuments = ref<JsonRecord[]>([]);
|
||||
const gridRows = shallowRef<QueryResult["rows"]>([]);
|
||||
@@ -135,7 +147,7 @@ const loading = ref(false);
|
||||
const documentLoadExecutionId = ref("");
|
||||
const documentLoadCancelling = ref(false);
|
||||
const documentLoadingElapsedSeconds = ref("0.0");
|
||||
const page = ref(0);
|
||||
const page = ref(restoresSkipBasedPage ? Math.max(0, Math.trunc(restoredDocumentBrowserState!.page)) : 0);
|
||||
const pageSize = ref(normalizeResultPageSize(settingsStore.editorSettings.tableOpenPageSize));
|
||||
const selectedIdx = ref<number | null>(null);
|
||||
const editJson = ref("");
|
||||
@@ -155,8 +167,8 @@ const viewMode = computed<ViewMode>({
|
||||
get: () => settingsStore.editorSettings.mongoViewMode,
|
||||
set: (value) => settingsStore.updateEditorSettings({ mongoViewMode: value }),
|
||||
});
|
||||
const filterInput = ref("");
|
||||
const sortInput = ref("");
|
||||
const filterInput = ref(restoredDocumentBrowserState?.filterInput ?? "");
|
||||
const sortInput = ref(restoredDocumentBrowserState?.sortInput ?? "");
|
||||
const filterInputRef = ref<HTMLTextAreaElement>();
|
||||
const sortInputRef = ref<HTMLTextAreaElement>();
|
||||
const dataGridRef = ref<InstanceType<typeof DataGrid>>();
|
||||
@@ -284,8 +296,21 @@ type DocumentGridChanges = {
|
||||
const documentFilterBuilderOpen = ref(false);
|
||||
const documentFilterFieldPopoverOpen = ref<Record<string, boolean>>({});
|
||||
const documentFilterFieldSearch = ref<Record<string, string>>({});
|
||||
const documentFilterRules = ref<DocumentFilterRule[]>([]);
|
||||
const appliedDocumentFilter = ref<Record<string, unknown> | null>(null);
|
||||
const documentFilterRules = ref<DocumentFilterRule[]>(restoredDocumentBrowserState?.documentFilterRules ?? []);
|
||||
const appliedDocumentFilter = ref<Record<string, unknown> | null>(restoredDocumentBrowserState?.appliedDocumentFilter ?? null);
|
||||
|
||||
function persistDocumentBrowserState() {
|
||||
if (!props.stateKey) return;
|
||||
saveDocumentBrowserState(props.stateKey, {
|
||||
filterInput: filterInput.value,
|
||||
sortInput: sortInput.value,
|
||||
appliedDocumentFilter: appliedDocumentFilter.value,
|
||||
documentFilterRules: documentFilterRules.value,
|
||||
page: page.value,
|
||||
});
|
||||
}
|
||||
|
||||
watch([filterInput, sortInput, appliedDocumentFilter, documentFilterRules, page], persistDocumentBrowserState, { deep: true });
|
||||
const elasticsearchMappingFields = ref<ColumnInfo[]>([]);
|
||||
function elasticsearchGridColumnTypesFor(columns: readonly string[]): string[] {
|
||||
const mappingTypes = elasticsearchFieldTypes.value;
|
||||
@@ -2120,6 +2145,7 @@ onMounted(async () => {
|
||||
void nextTick(resizeDocumentQueryInputs);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
persistDocumentBrowserState();
|
||||
window.removeEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
|
||||
unsubscribeElasticsearchIndexCleared?.();
|
||||
unsubscribeElasticsearchIndexCleared = undefined;
|
||||
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
documentFindDocuments: vi.fn(),
|
||||
documentCountDocuments: vi.fn(),
|
||||
cancelQuery: vi.fn(),
|
||||
ensureConnected: vi.fn(),
|
||||
documentInsertDocument: vi.fn(),
|
||||
documentUpdateDocument: vi.fn(),
|
||||
documentDeleteDocument: vi.fn(),
|
||||
documentSaveMeilisearchBatch: vi.fn(),
|
||||
}));
|
||||
|
||||
const dataGrid = vi.hoisted(() => ({
|
||||
paginate: undefined as ((offset: number, limit: number) => Promise<void>) | undefined,
|
||||
sort: undefined as ((column: string, columnIndex: number, direction: "asc" | "desc" | null) => void) | undefined,
|
||||
pageOffset: undefined as number | undefined,
|
||||
}));
|
||||
|
||||
const settings = vi.hoisted(() => ({
|
||||
editorSettings: {
|
||||
pageSize: 2,
|
||||
tableOpenPageSize: 2,
|
||||
infiniteScroll: false,
|
||||
mongoViewMode: "table" as "document" | "table",
|
||||
columnWidthDensity: "standard" as "compact" | "standard" | "comfortable",
|
||||
dataGridRenderMode: "canvas" as "canvas" | "dom",
|
||||
tableFontFamily: "system-ui",
|
||||
tableFontSize: 12,
|
||||
numericColumnRightAlign: true,
|
||||
confirmDangerousSqlExecution: true,
|
||||
exportBatchSize: 2,
|
||||
exportRowLimitEnabled: false,
|
||||
exportRowLimit: 100_000,
|
||||
},
|
||||
updateEditorSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("vue-i18n")>()),
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
getColumns: vi.fn(),
|
||||
documentFindDocuments: backend.documentFindDocuments,
|
||||
documentCountDocuments: backend.documentCountDocuments,
|
||||
dynamodbDescribeTable: vi.fn(),
|
||||
cancelQuery: backend.cancelQuery,
|
||||
documentInsertDocument: backend.documentInsertDocument,
|
||||
documentUpdateDocument: backend.documentUpdateDocument,
|
||||
documentDeleteDocument: backend.documentDeleteDocument,
|
||||
documentSaveMeilisearchBatch: backend.documentSaveMeilisearchBatch,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({ ensureConnected: backend.ensureConnected }),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/settingsStore", () => ({
|
||||
TABLE_FONT_SIZE_MIN: 8,
|
||||
TABLE_FONT_SIZE_MAX: 16,
|
||||
useSettingsStore: () => settings,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/grid/DataGrid.vue", () => {
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: "DataGridStub",
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
result: { type: Object, required: true },
|
||||
pageOffset: { type: Number, required: false },
|
||||
pageLimit: { type: Number, required: false },
|
||||
pageSizePreference: { type: String, required: false },
|
||||
},
|
||||
setup(props, { attrs, expose, slots }) {
|
||||
dataGrid.paginate = attrs.onPaginate as typeof dataGrid.paginate;
|
||||
dataGrid.sort = attrs.onSort as typeof dataGrid.sort;
|
||||
expose({
|
||||
visibleColumnCount: 2,
|
||||
displayableColumnCount: 2,
|
||||
hiddenColumnCount: 0,
|
||||
orderedColumnLayoutOptions: [],
|
||||
filteredColumnLayoutOptions: () => [],
|
||||
toggleColumnVisibility: vi.fn(),
|
||||
showAllColumns: vi.fn(),
|
||||
invertColumnVisibility: vi.fn(),
|
||||
hasCustomColumnOrder: false,
|
||||
moveDisplayableColumn: vi.fn(),
|
||||
resetColumnOrder: vi.fn(),
|
||||
nullColumnsHidden: false,
|
||||
canToggleAllNullColumns: false,
|
||||
allNullColumnCount: 0,
|
||||
toggleAllNullColumns: vi.fn(),
|
||||
multiRowTranspose: false,
|
||||
setMultiRowTranspose: vi.fn(),
|
||||
resetInfiniteScrollState: vi.fn(),
|
||||
});
|
||||
return () => {
|
||||
dataGrid.pageOffset = props.pageOffset;
|
||||
return h("div", [
|
||||
// Rendering the real search-bar slot exposes the filter/sort inputs
|
||||
// the same way the actual grid toolbar does.
|
||||
h("div", slots["search-bar"]?.({ localFilterCount: 0, hasLocalColumnFilters: false, localFilterSummaries: [], clearLocalFilter: () => undefined })),
|
||||
h("div", { "data-testid": "data-grid" }, (props.result as { rows: unknown[] }).rows.length),
|
||||
]);
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/redis/RedisJsonEditor.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
default: defineComponent({
|
||||
props: { modelValue: { type: String, required: true } },
|
||||
setup(props) {
|
||||
return () => h("div", {}, props.modelValue);
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/popover", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const passthrough = (name: string) =>
|
||||
defineComponent({
|
||||
name,
|
||||
setup(_, { slots }) {
|
||||
return () => h("div", slots.default?.());
|
||||
},
|
||||
});
|
||||
return { Popover: passthrough("PopoverStub"), PopoverTrigger: passthrough("PopoverTriggerStub"), PopoverContent: passthrough("PopoverContentStub") };
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/select", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const passthrough = (name: string) =>
|
||||
defineComponent({
|
||||
name,
|
||||
setup(_, { slots }) {
|
||||
return () => h("div", slots.default?.());
|
||||
},
|
||||
});
|
||||
return { Select: passthrough("SelectStub"), SelectContent: passthrough("SelectContentStub"), SelectItem: passthrough("SelectItemStub"), SelectTrigger: passthrough("SelectTriggerStub"), SelectValue: passthrough("SelectValueStub") };
|
||||
});
|
||||
|
||||
import DocumentBrowser from "@/components/document/DocumentBrowser.vue";
|
||||
|
||||
let app: App<Element> | null = null;
|
||||
let root: HTMLDivElement | null = null;
|
||||
|
||||
async function flushUi() {
|
||||
for (let index = 0; index < 4; index++) {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
async function mountBrowser(options: { databaseType?: "mongodb" | "elasticsearch"; stateKey?: string } = {}) {
|
||||
app = createApp(DocumentBrowser, {
|
||||
connectionId: "conn-1",
|
||||
database: "test",
|
||||
collection: "docs",
|
||||
databaseType: options.databaseType ?? "mongodb",
|
||||
stateKey: options.stateKey,
|
||||
});
|
||||
app.mount(root!);
|
||||
await flushUi();
|
||||
}
|
||||
|
||||
function documentResult(start: number, count: number, total: number, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
documents: Array.from({ length: count }, (_, index) => {
|
||||
const id = start + index;
|
||||
return { _id: String(id), name: `row_${id}` };
|
||||
}),
|
||||
total,
|
||||
total_is_exact: true,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function queryTextareas(): HTMLTextAreaElement[] {
|
||||
return [...root!.querySelectorAll("textarea.document-query-input")];
|
||||
}
|
||||
|
||||
function typeInTextarea(textarea: HTMLTextAreaElement, value: string) {
|
||||
textarea.value = value;
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function pressEnter(textarea: HTMLTextAreaElement) {
|
||||
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
});
|
||||
backend.documentFindDocuments.mockReset();
|
||||
backend.documentCountDocuments.mockReset();
|
||||
backend.cancelQuery.mockReset();
|
||||
backend.ensureConnected.mockReset();
|
||||
backend.ensureConnected.mockResolvedValue(undefined);
|
||||
backend.documentCountDocuments.mockResolvedValue(0);
|
||||
backend.documentFindDocuments.mockResolvedValue(documentResult(1, 2, 2));
|
||||
dataGrid.paginate = undefined;
|
||||
dataGrid.sort = undefined;
|
||||
dataGrid.pageOffset = undefined;
|
||||
settings.editorSettings.infiniteScroll = false;
|
||||
|
||||
root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
root?.remove();
|
||||
app = null;
|
||||
root = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("DocumentBrowser tab state (tab switch persistence)", () => {
|
||||
it("restores filter and sort conditions after unmount/remount", async () => {
|
||||
await mountBrowser({ stateKey: "tab-filter-sort" });
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [filterTextarea] = queryTextareas();
|
||||
typeInTextarea(filterTextarea, '{"name":"row_1"}');
|
||||
await flushUi();
|
||||
pressEnter(filterTextarea);
|
||||
await flushUi();
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(2);
|
||||
expect(backend.documentFindDocuments.mock.calls[1]![5]).toBe(JSON.stringify({ name: "row_1" }));
|
||||
|
||||
dataGrid.sort!("name", 1, "asc");
|
||||
await flushUi();
|
||||
expect(backend.documentFindDocuments.mock.calls[2]![7]).toBe(JSON.stringify({ name: 1 }));
|
||||
|
||||
// Switch away (unmount) and back (remount) — what ContentArea does per tab.
|
||||
app!.unmount();
|
||||
app = null;
|
||||
root!.replaceChildren();
|
||||
await flushUi();
|
||||
await mountBrowser({ stateKey: "tab-filter-sort" });
|
||||
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(4);
|
||||
const restoredCall = backend.documentFindDocuments.mock.calls[3]!;
|
||||
expect(restoredCall[5]).toBe(JSON.stringify({ name: "row_1" }));
|
||||
expect(restoredCall[7]).toBe(JSON.stringify({ name: 1 }));
|
||||
|
||||
const [restoredFilter, restoredSort] = queryTextareas();
|
||||
expect(restoredFilter.value).toBe('{"name":"row_1"}');
|
||||
expect(restoredSort.value).toBe(JSON.stringify({ name: 1 }));
|
||||
});
|
||||
|
||||
it("restores the page position for skip-based stores", async () => {
|
||||
backend.documentFindDocuments.mockReset();
|
||||
backend.documentFindDocuments.mockResolvedValue(documentResult(1, 2, 6));
|
||||
|
||||
await mountBrowser({ stateKey: "tab-page" });
|
||||
expect(backend.documentFindDocuments.mock.calls[0]![3]).toBe(0);
|
||||
|
||||
await dataGrid.paginate!(2, 2);
|
||||
await flushUi();
|
||||
expect(backend.documentFindDocuments.mock.calls[1]![3]).toBe(2);
|
||||
|
||||
app!.unmount();
|
||||
app = null;
|
||||
root!.replaceChildren();
|
||||
await flushUi();
|
||||
await mountBrowser({ stateKey: "tab-page" });
|
||||
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(3);
|
||||
expect(backend.documentFindDocuments.mock.calls[2]![3]).toBe(2);
|
||||
expect(dataGrid.pageOffset).toBe(2);
|
||||
});
|
||||
|
||||
it("restarts from the first page for cursor-based stores (elasticsearch)", async () => {
|
||||
backend.documentFindDocuments.mockReset();
|
||||
backend.documentFindDocuments.mockResolvedValueOnce(documentResult(1, 2, 6, { next_cursor: "cursor-1" })).mockResolvedValue(documentResult(3, 2, 6));
|
||||
|
||||
await mountBrowser({ databaseType: "elasticsearch", stateKey: "tab-es" });
|
||||
|
||||
await dataGrid.paginate!(2, 2);
|
||||
await flushUi();
|
||||
expect(backend.documentFindDocuments.mock.calls[1]![3]).toBe(0);
|
||||
expect(backend.documentFindDocuments.mock.calls[1]![10]).toBe("cursor-1");
|
||||
|
||||
app!.unmount();
|
||||
app = null;
|
||||
root!.replaceChildren();
|
||||
await flushUi();
|
||||
await mountBrowser({ databaseType: "elasticsearch", stateKey: "tab-es" });
|
||||
|
||||
const restoredCall = backend.documentFindDocuments.mock.calls.at(-1)!;
|
||||
expect(restoredCall[3]).toBe(0);
|
||||
expect(restoredCall[10]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps tab states isolated from each other", async () => {
|
||||
await mountBrowser({ stateKey: "tab-a" });
|
||||
dataGrid.sort!("name", 1, "asc");
|
||||
await flushUi();
|
||||
app!.unmount();
|
||||
app = null;
|
||||
root!.replaceChildren();
|
||||
|
||||
await mountBrowser({ stateKey: "tab-b" });
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(3);
|
||||
expect(backend.documentFindDocuments.mock.calls[2]![7]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -2402,7 +2402,7 @@ defineExpose({
|
||||
<!-- Document mode: MongoDB collections and Elasticsearch indices -->
|
||||
<template v-else-if="activeTab.mode === 'mongo'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<DocumentBrowser ref="documentBrowserRef" :key="`${activeTab.id}:${activeTab.sql}`" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :database-type="activeEffectiveDatabaseType" :table-meta="activeTab.tableMeta" />
|
||||
<DocumentBrowser ref="documentBrowserRef" :key="`${activeTab.id}:${activeTab.sql}`" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :database-type="activeEffectiveDatabaseType" :table-meta="activeTab.tableMeta" :state-key="activeTab.id" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clearDocumentBrowserState, restoreDocumentBrowserState, saveDocumentBrowserState, type DocumentBrowserStateSnapshot } from "@/lib/tabs/documentBrowserStateCache";
|
||||
|
||||
function snapshot(page: number): DocumentBrowserStateSnapshot {
|
||||
return {
|
||||
filterInput: `{"page":${page}}`,
|
||||
sortInput: `{"f":${page}}`,
|
||||
appliedDocumentFilter: page % 2 === 0 ? { status: "active" } : null,
|
||||
documentFilterRules: page % 2 === 0 ? [{ id: `rule-${page}`, fieldName: "status", mode: "equals", rawValue: "active", conjunction: "AND" }] : [],
|
||||
page,
|
||||
};
|
||||
}
|
||||
|
||||
describe("documentBrowserStateCache", () => {
|
||||
it("round-trips a snapshot for the same key", () => {
|
||||
saveDocumentBrowserState("round-trip", snapshot(3));
|
||||
|
||||
expect(restoreDocumentBrowserState("round-trip")).toEqual(snapshot(3));
|
||||
});
|
||||
|
||||
it("returns undefined for an unknown key", () => {
|
||||
expect(restoreDocumentBrowserState("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the most recent write for a key", () => {
|
||||
saveDocumentBrowserState("overwrite", snapshot(1));
|
||||
saveDocumentBrowserState("overwrite", snapshot(2));
|
||||
|
||||
expect(restoreDocumentBrowserState("overwrite")).toEqual(snapshot(2));
|
||||
});
|
||||
|
||||
it("evicts the oldest entry beyond 32 keys", () => {
|
||||
for (let index = 0; index < 33; index += 1) {
|
||||
saveDocumentBrowserState(`evict-${index}`, snapshot(index));
|
||||
}
|
||||
|
||||
expect(restoreDocumentBrowserState("evict-0")).toBeUndefined();
|
||||
expect(restoreDocumentBrowserState("evict-1")).toBeDefined();
|
||||
expect(restoreDocumentBrowserState("evict-32")).toBeDefined();
|
||||
});
|
||||
|
||||
it("treats a restore as recency so a touched entry survives eviction", () => {
|
||||
for (let index = 0; index < 32; index += 1) {
|
||||
saveDocumentBrowserState(`touch-${index}`, snapshot(index));
|
||||
}
|
||||
expect(restoreDocumentBrowserState("touch-0")).toEqual(snapshot(0));
|
||||
saveDocumentBrowserState("touch-new", snapshot(99));
|
||||
|
||||
expect(restoreDocumentBrowserState("touch-0")).toBeDefined();
|
||||
expect(restoreDocumentBrowserState("touch-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears a key on demand", () => {
|
||||
saveDocumentBrowserState("clear-me", snapshot(4));
|
||||
clearDocumentBrowserState("clear-me");
|
||||
|
||||
expect(restoreDocumentBrowserState("clear-me")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { DocumentFilterRule } from "@/lib/app/documentStoreProvider";
|
||||
|
||||
export interface DocumentBrowserStateSnapshot {
|
||||
/** Manual JSON filter text from the collection filter input. */
|
||||
filterInput: string;
|
||||
/** Manual JSON sort text from the collection sort input. */
|
||||
sortInput: string;
|
||||
/** Structured-filter contribution applied on top of the manual filter. */
|
||||
appliedDocumentFilter: Record<string, unknown> | null;
|
||||
/** Rules backing the structured filter builder. */
|
||||
documentFilterRules: DocumentFilterRule[];
|
||||
/** Zero-based page position; only meaningful for skip-based stores. */
|
||||
page: number;
|
||||
}
|
||||
|
||||
// ContentArea renders only the active tab, so DocumentBrowser is unmounted on
|
||||
// every tab switch and remounted when the user comes back. Query conditions
|
||||
// survive that round trip through this session-scoped cache keyed by tab id —
|
||||
// the same role tab.whereInput/tab.orderByInput play for SQL data tabs. The
|
||||
// bound keeps state for long tab sessions from accumulating forever.
|
||||
const MAX_ENTRIES = 32;
|
||||
const cache = new Map<string, DocumentBrowserStateSnapshot>();
|
||||
|
||||
export function restoreDocumentBrowserState(stateKey: string): DocumentBrowserStateSnapshot | undefined {
|
||||
const snapshot = cache.get(stateKey);
|
||||
if (!snapshot) return undefined;
|
||||
// Refresh LRU recency.
|
||||
cache.delete(stateKey);
|
||||
cache.set(stateKey, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function saveDocumentBrowserState(stateKey: string, snapshot: DocumentBrowserStateSnapshot): void {
|
||||
cache.delete(stateKey);
|
||||
cache.set(stateKey, snapshot);
|
||||
while (cache.size > MAX_ENTRIES) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
cache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDocumentBrowserState(stateKey: string): void {
|
||||
cache.delete(stateKey);
|
||||
}
|
||||
Reference in new issue
Block a user