feat(grid): persist column layouts and improve drag-to-reorder
This commit is contained in:
1 parent
f5c29f08f1
commit
44f748d3a1
24 files changed
+1532
-276
No files matched your search
+65
-1
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
+83
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user