feat(grid): persist column layouts and improve drag-to-reorder

This commit is contained in:
Ashton Lin authored and GitHub committed 2026-07-31 02:01:46 +08:00
1 parent f5c29f08f1
commit 44f748d3a1
24 files changed
+1532 -276

No files matched your search

@@ -1,5 +1,17 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loadTableDataGridColumnOrder, notifyTableDataGridColumnOrderChanged, removeTableDataGridColumnOrder, saveTableDataGridColumnOrder, TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
import {
documentDataGridColumnLayoutScopeKey,
loadDataGridColumnLayout,
loadDataGridColumnOrder,
loadTableDataGridColumnOrder,
notifyTableDataGridColumnOrderChanged,
removeDataGridColumnOrder,
removeTableDataGridColumnOrder,
saveDataGridColumnLayout,
saveTableDataGridColumnOrder,
TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT,
tableDataGridColumnOrderScopeKey,
} from "@/lib/dataGrid/dataGridColumnLayoutStorage";
function installLocalStorage() {
const data = new Map<string, string>();
@@ -57,3 +69,55 @@ describe("table data grid column order storage", () => {
expect(event.detail).toEqual({ scopeKey: "table-scope" });
});
});
describe("data grid column layout storage", () => {
beforeEach(installLocalStorage);
afterEach(() => vi.unstubAllGlobals());
it("uses a stable document scope without query or result column signatures", () => {
expect(
documentDataGridColumnLayoutScopeKey({
databaseType: "elasticsearch",
connectionId: "connection-1",
database: "database-name",
collection: "order_index_v1",
}),
).toBe(["document", "elasticsearch", "connection-1", "database-name", "order_index_v1"].join("\u0001"));
});
it("stores order and hidden keys without dropping fields absent from the current page", () => {
const layout = {
orderKeys: ["_id", "status", "orderNo", "goodsList"],
hiddenKeys: ["goodsList"],
};
saveDataGridColumnLayout("document-layout", layout);
expect(loadDataGridColumnLayout("document-layout", ["_id", "orderNo", "status", "createTime"])).toEqual(layout);
});
it("keeps hidden keys when only the saved order is reset", () => {
saveDataGridColumnLayout("combined-layout", { orderKeys: ["status", "_id"], hiddenKeys: ["goodsList"] });
removeDataGridColumnOrder("combined-layout");
expect(loadDataGridColumnOrder("combined-layout", [])).toEqual([]);
expect(loadDataGridColumnLayout("combined-layout")).toEqual({ orderKeys: [], hiddenKeys: ["goodsList"] });
});
it("loads the previous order-only payload format", () => {
localStorage.setItem(
"dbx-data-grid-column-layout:legacy-layout",
JSON.stringify({
version: 1,
columnSignature: "id\0name",
order: ["name", "id"],
}),
);
expect(loadDataGridColumnLayout("legacy-layout", ["id", "name"])).toEqual({
orderKeys: ["name", "id"],
hiddenKeys: [],
});
});
});
@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loadDataGridColumnLayout } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
import { documentGridColumnVisibilityScopeKey, loadDocumentGridHiddenColumnKeys, migrateDocumentGridColumnVisibilityToLayout, saveDocumentGridHiddenColumnKeys } from "@/lib/document/documentGridColumnVisibilityStorage";
const STORAGE_PREFIX = "dbx-document-grid-column-visibility:v1:";
let storedValues: Map<string, string>;
function installLocalStorage() {
storedValues = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (key: string) => storedValues.get(key) ?? null,
setItem: (key: string, value: string) => storedValues.set(key, value),
removeItem: (key: string) => storedValues.delete(key),
});
}
function scopeKey(collection: string) {
return documentGridColumnVisibilityScopeKey({
databaseType: "elasticsearch",
connectionId: "connection-1",
database: "",
collection,
});
}
describe("document grid column visibility storage", () => {
beforeEach(installLocalStorage);
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("stores normalized hidden field names", () => {
const ordersScope = scopeKey("orders");
saveDocumentGridHiddenColumnKeys(ordersScope, ["goodsList", "logisticsInfo", "goodsList"]);
expect(loadDocumentGridHiddenColumnKeys(ordersScope)).toEqual(["goodsList", "logisticsInfo"]);
});
it("isolates indexes and removes the persisted selection when all fields are shown", () => {
const ordersScope = scopeKey("orders");
const productsScope = scopeKey("products");
saveDocumentGridHiddenColumnKeys(ordersScope, ["goodsList"]);
expect(loadDocumentGridHiddenColumnKeys(productsScope)).toEqual([]);
saveDocumentGridHiddenColumnKeys(ordersScope, []);
expect(storedValues.has(`${STORAGE_PREFIX}${ordersScope}`)).toBe(false);
expect(loadDocumentGridHiddenColumnKeys(ordersScope)).toEqual([]);
});
it("ignores invalid persisted values without hiding unrelated fields", () => {
const ordersScope = scopeKey("orders");
storedValues.set(`${STORAGE_PREFIX}${ordersScope}`, JSON.stringify(["goodsList", 42, "goodsList", null]));
expect(loadDocumentGridHiddenColumnKeys(ordersScope)).toEqual(["goodsList"]);
});
it("reports malformed persisted JSON and falls back to all fields visible", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const ordersScope = scopeKey("orders");
storedValues.set(`${STORAGE_PREFIX}${ordersScope}`, "{");
expect(loadDocumentGridHiddenColumnKeys(ordersScope)).toEqual([]);
expect(warn).toHaveBeenCalledWith(expect.stringContaining("[DBX][document-grid-column-visibility:parse]"), expect.any(SyntaxError));
});
it("migrates legacy hidden fields into the unified layout once", () => {
const ordersScope = scopeKey("orders");
saveDocumentGridHiddenColumnKeys(ordersScope, ["goodsList"]);
migrateDocumentGridColumnVisibilityToLayout(ordersScope, "document-layout");
migrateDocumentGridColumnVisibilityToLayout(ordersScope, "document-layout");
expect(loadDataGridColumnLayout("document-layout")).toEqual({
orderKeys: [],
hiddenKeys: ["goodsList"],
});
expect(storedValues.has(`${STORAGE_PREFIX}${ordersScope}`)).toBe(false);
});
});
@@ -1,4 +1,5 @@
import { safeLocalStorageGet, safeLocalStorageRemove, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import type { DatabaseType } from "@/types/database";
const STORAGE_PREFIX = "dbx-data-grid-column-layout:";
const TABLE_STORAGE_PREFIX = "dbx-data-grid-table-column-order:";
@@ -22,12 +23,28 @@ export interface DataGridColumnLayoutScope {
sourceColumns?: readonly (string | undefined)[];
}
interface StoredDataGridColumnLayout {
export interface DataGridColumnLayout {
orderKeys: string[];
hiddenKeys: string[];
}
interface StoredDataGridColumnLayout extends DataGridColumnLayout {
version: number;
columnSignature: string;
}
interface LegacyStoredDataGridColumnLayout {
version: number;
columnSignature?: string;
order: string[];
}
export interface DocumentDataGridColumnLayoutScope {
databaseType: DatabaseType;
connectionId: string;
database: string;
collection: string;
}
export interface TableDataGridColumnOrderScope {
connectionId: string;
database: string;
@@ -50,30 +67,61 @@ export function dataGridColumnLayoutScopeKey(scope: DataGridColumnLayoutScope):
return [scope.connectionId ?? "", scope.database ?? "", scope.schema ?? "", scope.context ?? "", scope.tableSchema ?? "", scope.tableName ?? "", scope.tableName ? "" : normalizeSql(scope.sql), columnSignature, sourceSignature].join("\u0001");
}
export function loadDataGridColumnOrder(scopeKey: string, columnKeys: readonly string[]): string[] {
export function documentDataGridColumnLayoutScopeKey(scope: DocumentDataGridColumnLayoutScope): string {
return ["document", scope.databaseType, scope.connectionId, scope.database, scope.collection].join("\u0001");
}
function normalizedStringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value.filter((key): key is string => typeof key === "string"))];
}
export function loadDataGridColumnLayout(scopeKey: string, columnKeys: readonly string[] = []): DataGridColumnLayout | null {
const raw = safeLocalStorageGet(`${STORAGE_PREFIX}${scopeKey}`);
if (!raw) return [];
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<StoredDataGridColumnLayout>;
if (parsed.version !== STORAGE_VERSION || !Array.isArray(parsed.order)) return [];
if (parsed.columnSignature && parsed.columnSignature !== columnKeys.join("\0")) return [];
return parsed.order.filter((key): key is string => typeof key === "string");
} catch {
return [];
const parsed = JSON.parse(raw) as Partial<StoredDataGridColumnLayout & LegacyStoredDataGridColumnLayout>;
if (parsed.version !== STORAGE_VERSION) return null;
if (Array.isArray(parsed.orderKeys) || Array.isArray(parsed.hiddenKeys)) {
return {
orderKeys: normalizedStringList(parsed.orderKeys),
hiddenKeys: normalizedStringList(parsed.hiddenKeys),
};
}
if (!Array.isArray(parsed.order)) return null;
if (parsed.columnSignature && parsed.columnSignature !== columnKeys.join("\0")) return null;
return { orderKeys: normalizedStringList(parsed.order), hiddenKeys: [] };
} catch (error) {
console.warn(`[DBX][data-grid-column-layout:parse] ${scopeKey}`, error);
return null;
}
}
export function saveDataGridColumnOrder(scopeKey: string, columnKeys: readonly string[], order: readonly string[]) {
export function saveDataGridColumnLayout(scopeKey: string, layout: DataGridColumnLayout) {
const payload: StoredDataGridColumnLayout = {
version: STORAGE_VERSION,
columnSignature: columnKeys.join("\0"),
order: [...order],
orderKeys: normalizedStringList(layout.orderKeys),
hiddenKeys: normalizedStringList(layout.hiddenKeys),
};
safeLocalStorageSet(`${STORAGE_PREFIX}${scopeKey}`, JSON.stringify(payload));
}
export function loadDataGridColumnOrder(scopeKey: string, columnKeys: readonly string[]): string[] {
return loadDataGridColumnLayout(scopeKey, columnKeys)?.orderKeys ?? [];
}
export function saveDataGridColumnOrder(scopeKey: string, columnKeys: readonly string[], order: readonly string[]) {
const hiddenKeys = loadDataGridColumnLayout(scopeKey, columnKeys)?.hiddenKeys ?? [];
saveDataGridColumnLayout(scopeKey, { orderKeys: [...order], hiddenKeys });
}
export function removeDataGridColumnOrder(scopeKey: string) {
safeLocalStorageRemove(`${STORAGE_PREFIX}${scopeKey}`);
const layout = loadDataGridColumnLayout(scopeKey);
if (!layout) {
safeLocalStorageRemove(`${STORAGE_PREFIX}${scopeKey}`);
return;
}
saveDataGridColumnLayout(scopeKey, { orderKeys: [], hiddenKeys: layout.hiddenKeys });
}
export function tableDataGridColumnOrderScopeKey(scope: TableDataGridColumnOrderScope): string {
@@ -46,7 +46,7 @@ export function buildDataGridColumnLookupItems(options: DataGridColumnLookupOpti
});
}
export function filterDataGridColumnLookupItems(items: readonly DataGridColumnLookupItem[], query: string): DataGridColumnLookupItem[] {
export function filterDataGridColumnLookupItems<ColumnLookupItem extends DataGridColumnLookupItem>(items: readonly ColumnLookupItem[], query: string): ColumnLookupItem[] {
const normalizedQuery = normalizedSearchText(query);
if (!normalizedQuery) return [...items];
return items.filter((item) => [item.name, item.sourceName, item.comment].filter((value): value is string => !!value).some((value) => value.toLocaleLowerCase().includes(normalizedQuery)));
@@ -57,6 +57,48 @@ export function moveVisibleColumnIndex(options: { orderedIndexes: readonly numbe
return next;
}
export function moveDisplayableColumnIndex(options: { orderedIndexes: readonly number[]; fromDisplayableIndex: number; toDisplayableIndex: number }): number[] {
const next = [...options.orderedIndexes];
if (next.length === 0) return next;
const from = Math.max(0, Math.min(options.fromDisplayableIndex, next.length - 1));
const to = Math.max(0, Math.min(options.toDisplayableIndex, next.length - 1));
const [moved] = next.splice(from, 1);
if (moved === undefined) return next;
next.splice(to, 0, moved);
return next;
}
export function mergeUnavailableColumnOrderKeys(currentKeys: readonly string[], previousKeys: readonly string[]): string[] {
const normalizedCurrentKeys = [...new Set(currentKeys)];
const currentKeySet = new Set(normalizedCurrentKeys);
const missingBeforeCurrent = new Set<string>();
const missingAfterCurrent = new Map<string, Set<string>>();
let precedingCurrentKey: string | undefined;
for (const key of previousKeys) {
if (currentKeySet.has(key)) {
precedingCurrentKey = key;
continue;
}
if (!precedingCurrentKey) {
missingBeforeCurrent.add(key);
continue;
}
const anchoredKeys = missingAfterCurrent.get(precedingCurrentKey) ?? new Set<string>();
anchoredKeys.add(key);
missingAfterCurrent.set(precedingCurrentKey, anchoredKeys);
}
const mergedKeys = [...missingBeforeCurrent];
for (const key of normalizedCurrentKeys) {
mergedKeys.push(key);
mergedKeys.push(...(missingAfterCurrent.get(key) ?? []));
}
return mergedKeys;
}
export function columnOrderKeysForIndexes(indexes: readonly number[], columnKeys: readonly string[]): string[] {
return indexes.map((index) => columnKeys[index]).filter((key): key is string => !!key);
}
@@ -0,0 +1,50 @@
import { safeLocalStorageGet, safeLocalStorageRemove, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { loadDataGridColumnLayout, saveDataGridColumnLayout } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
const STORAGE_PREFIX = "dbx-document-grid-column-visibility:v1:";
export interface DocumentGridColumnVisibilityScope {
databaseType?: string;
connectionId: string;
database: string;
collection: string;
}
export function documentGridColumnVisibilityScopeKey(scope: DocumentGridColumnVisibilityScope): string {
return ["document-column-visibility", scope.databaseType ?? "", scope.connectionId, scope.database, scope.collection].join("\u0001");
}
export function loadDocumentGridHiddenColumnKeys(scopeKey: string): string[] {
const raw = safeLocalStorageGet(`${STORAGE_PREFIX}${scopeKey}`);
if (!raw) return [];
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return [...new Set(parsed.filter((key): key is string => typeof key === "string"))];
} catch (error) {
console.warn(`[DBX][document-grid-column-visibility:parse] ${scopeKey}`, error);
return [];
}
}
export function saveDocumentGridHiddenColumnKeys(scopeKey: string, hiddenColumnKeys: readonly string[]) {
const normalizedKeys = [...new Set(hiddenColumnKeys)];
if (normalizedKeys.length === 0) {
safeLocalStorageRemove(`${STORAGE_PREFIX}${scopeKey}`);
return;
}
safeLocalStorageSet(`${STORAGE_PREFIX}${scopeKey}`, JSON.stringify(normalizedKeys));
}
export function migrateDocumentGridColumnVisibilityToLayout(legacyScopeKey: string, layoutScopeKey: string) {
const legacyStorageKey = `${STORAGE_PREFIX}${legacyScopeKey}`;
if (loadDataGridColumnLayout(layoutScopeKey)) {
safeLocalStorageRemove(legacyStorageKey);
return;
}
const hiddenKeys = loadDocumentGridHiddenColumnKeys(legacyScopeKey);
if (hiddenKeys.length === 0) return;
saveDataGridColumnLayout(layoutScopeKey, { orderKeys: [], hiddenKeys });
if (loadDataGridColumnLayout(layoutScopeKey)) safeLocalStorageRemove(legacyStorageKey);
}