feat(sidebar): persist custom pinned item ordering
This commit is contained in:
1 parent
b635ea1466
commit
406b7acdbb
16 files changed
+1019
-82
No files matched your search
+121
-1
@@ -1,6 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { reactive } from "vue";
|
||||
import { inheritNaturalTreeNodeOrder, migrateLegacyPinnedTreeNodeIds, syncPinnedTreeNodeStateInPlace, treeNodePinKey, updatePinnedTreeNodeInPlace } from "@/lib/app/pinnedItems";
|
||||
import {
|
||||
inheritNaturalTreeNodeOrder,
|
||||
migrateLegacyPinnedTreeNodeIds,
|
||||
migrateLegacyPinnedTreeNodeOrder,
|
||||
removePinnedTreeNodesFromOrder,
|
||||
reorderPinnedTreeNodeOrder,
|
||||
replacePinnedTreeNodeInOrder,
|
||||
syncPinnedTreeNodeStateInPlace,
|
||||
treeNodePinKey,
|
||||
updatePinnedTreeNodeInPlace,
|
||||
} from "@/lib/app/pinnedItems";
|
||||
import { buildTreeNodesFromLayout } from "@/lib/sidebar/sidebarLayout";
|
||||
import type { ConnectionConfig, SidebarLayout, TreeNode } from "@/types/database";
|
||||
|
||||
@@ -146,6 +156,31 @@ describe("sidebar pinned tree nodes", () => {
|
||||
expect(tableB.pinned).toBe(false);
|
||||
});
|
||||
|
||||
it("removes a deleted table pin so a recreated or renamed table does not inherit it", () => {
|
||||
const deletedTable: TreeNode = { id: "conn:db:public:users", label: "users", type: "table", connectionId: "conn", database: "db", schema: "public", tableName: "users" };
|
||||
const anotherTable: TreeNode = { id: "conn:db:public:orders", label: "orders", type: "table", connectionId: "conn", database: "db", schema: "public", tableName: "orders" };
|
||||
const pinOrder = [treeNodePinKey(deletedTable), treeNodePinKey(anotherTable)];
|
||||
|
||||
const remainingOrder = removePinnedTreeNodesFromOrder(pinOrder, [{ ...deletedTable, id: "object-browser-row-id" }]);
|
||||
const recreatedTable: TreeNode = { ...deletedTable };
|
||||
const renamedTable: TreeNode = { ...anotherTable, id: deletedTable.id, label: deletedTable.label, tableName: deletedTable.tableName };
|
||||
|
||||
expect(remainingOrder).toEqual([treeNodePinKey(anotherTable)]);
|
||||
expect(remainingOrder).not.toContain(treeNodePinKey(recreatedTable));
|
||||
expect(remainingOrder).not.toContain(treeNodePinKey(renamedTable));
|
||||
});
|
||||
|
||||
it("moves a renamed pin to the new identity without retaining the old name", () => {
|
||||
const users: TreeNode = { id: "conn:db:public:users", label: "users", type: "table", connectionId: "conn", database: "db", schema: "public", tableName: "users" };
|
||||
const orders: TreeNode = { id: "conn:db:public:orders", label: "orders", type: "table", connectionId: "conn", database: "db", schema: "public", tableName: "orders" };
|
||||
const accounts: TreeNode = { ...users, id: "conn:db:public:accounts", label: "accounts", tableName: "accounts" };
|
||||
|
||||
const renamedOrder = replacePinnedTreeNodeInOrder([treeNodePinKey(users), treeNodePinKey(orders)], users, accounts);
|
||||
|
||||
expect(renamedOrder).toEqual([treeNodePinKey(accounts), treeNodePinKey(orders)]);
|
||||
expect(renamedOrder).not.toContain(treeNodePinKey(users));
|
||||
});
|
||||
|
||||
it("migrates a legacy id once instead of pinning every colliding node", () => {
|
||||
const tableA: TreeNode = { id: "duplicate-table-id", label: "users", type: "table", connectionId: "conn", database: "a" };
|
||||
const tableB: TreeNode = { id: "duplicate-table-id", label: "users", type: "table", connectionId: "conn", database: "b" };
|
||||
@@ -156,6 +191,91 @@ describe("sidebar pinned tree nodes", () => {
|
||||
expect(migrated.ids).toEqual(new Set([treeNodePinKey(tableA)]));
|
||||
});
|
||||
|
||||
it("uses the persisted order for pinned siblings", () => {
|
||||
const nodeA: TreeNode = { id: "db-a", label: "A", type: "database", connectionId: "conn", database: "a" };
|
||||
const nodeB: TreeNode = { id: "db-b", label: "B", type: "database", connectionId: "conn", database: "b" };
|
||||
const nodeC: TreeNode = { id: "db-c", label: "C", type: "database", connectionId: "conn", database: "c" };
|
||||
const nodes = [nodeA, nodeB, nodeC];
|
||||
const order = [treeNodePinKey(nodeC), treeNodePinKey(nodeA)];
|
||||
|
||||
syncPinnedTreeNodeStateInPlace(nodes, new Set(order), order);
|
||||
|
||||
expect(nodes.map((node) => node.id)).toEqual(["db-c", "db-a", "db-b"]);
|
||||
});
|
||||
|
||||
it("keeps the fixed default database before manually pinned siblings", () => {
|
||||
const nodeA: TreeNode = { id: "db-a", label: "A", type: "database", connectionId: "conn", database: "a" };
|
||||
const defaultNode: TreeNode = { id: "db-default", label: "Default", type: "database", connectionId: "conn", database: "default" };
|
||||
const nodeC: TreeNode = { id: "db-c", label: "C", type: "database", connectionId: "conn", database: "c" };
|
||||
const nodes = [nodeA, defaultNode, nodeC];
|
||||
const order = [treeNodePinKey(nodeC), treeNodePinKey(nodeA)];
|
||||
|
||||
syncPinnedTreeNodeStateInPlace(nodes, new Set(order), order, (node) => node.id === defaultNode.id);
|
||||
|
||||
expect(nodes.map((node) => node.id)).toEqual(["db-default", "db-c", "db-a"]);
|
||||
expect(defaultNode.pinned).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the fixed default database first even when it is also manually pinned", () => {
|
||||
const nodeA: TreeNode = { id: "db-a", label: "A", type: "database", connectionId: "conn", database: "a" };
|
||||
const defaultNode: TreeNode = { id: "db-default", label: "Default", type: "database", connectionId: "conn", database: "default" };
|
||||
const nodeC: TreeNode = { id: "db-c", label: "C", type: "database", connectionId: "conn", database: "c" };
|
||||
const nodes = [nodeA, defaultNode, nodeC];
|
||||
const order = [treeNodePinKey(nodeC), treeNodePinKey(nodeA), treeNodePinKey(defaultNode)];
|
||||
|
||||
syncPinnedTreeNodeStateInPlace(nodes, new Set(order), order, (node) => node.id === defaultNode.id);
|
||||
|
||||
expect(nodes.map((node) => node.id)).toEqual(["db-default", "db-c", "db-a"]);
|
||||
expect(defaultNode.pinned).toBe(true);
|
||||
});
|
||||
|
||||
it("reorders pinned keys before and after a sibling without dropping unrelated keys", () => {
|
||||
const initial = ["scope:a", "other:x", "scope:b", "other:y", "scope:c"];
|
||||
|
||||
const before = reorderPinnedTreeNodeOrder(initial, "scope:c", "scope:a", "before");
|
||||
expect(before).toEqual(["scope:c", "scope:a", "other:x", "scope:b", "other:y"]);
|
||||
|
||||
const after = reorderPinnedTreeNodeOrder(before, "scope:c", "scope:b", "after");
|
||||
expect(after).toEqual(["scope:a", "other:x", "scope:b", "scope:c", "other:y"]);
|
||||
expect(after.filter((key) => key.startsWith("other:"))).toEqual(["other:x", "other:y"]);
|
||||
});
|
||||
|
||||
it("places a newly appended pin last within its sibling pin section", () => {
|
||||
const nodeA: TreeNode = { id: "db-a", label: "A", type: "database", connectionId: "conn", database: "a" };
|
||||
const nodeB: TreeNode = { id: "db-b", label: "B", type: "database", connectionId: "conn", database: "b" };
|
||||
const nodeC: TreeNode = { id: "db-c", label: "C", type: "database", connectionId: "conn", database: "c" };
|
||||
const nodes = [nodeA, nodeB, nodeC];
|
||||
const order = [treeNodePinKey(nodeC), treeNodePinKey(nodeA), treeNodePinKey(nodeB)];
|
||||
|
||||
syncPinnedTreeNodeStateInPlace(nodes, new Set(order), order);
|
||||
|
||||
expect(nodes.map((node) => node.id)).toEqual(["db-c", "db-a", "db-b"]);
|
||||
});
|
||||
|
||||
it("migrates a legacy pin key in place without changing persisted order", () => {
|
||||
const node: TreeNode = { id: "legacy-table", label: "users", type: "table", connectionId: "conn", database: "db" };
|
||||
|
||||
const migrated = migrateLegacyPinnedTreeNodeOrder([node], ["before", node.id, "after"]);
|
||||
|
||||
expect(migrated.changed).toBe(true);
|
||||
expect(migrated.order).toEqual(["before", treeNodePinKey(node), "after"]);
|
||||
});
|
||||
|
||||
it("removes explicitly supplied legacy keys for an unloaded node", () => {
|
||||
const node: TreeNode = { id: "object-browser:app:events", label: "events", type: "table", connectionId: "conn", database: "app", schema: "public" };
|
||||
const legacyKey = "conn:app:public:__tables:public:events";
|
||||
|
||||
expect(removePinnedTreeNodesFromOrder([legacyKey, "unrelated"], [node], undefined, [legacyKey])).toEqual(["unrelated"]);
|
||||
});
|
||||
|
||||
it("replaces an explicitly supplied legacy key without losing its position", () => {
|
||||
const oldNode: TreeNode = { id: "object-browser:app:events", label: "events", type: "table", connectionId: "conn", database: "app", schema: "public" };
|
||||
const newNode: TreeNode = { id: "conn:app:public:__tables:public:renamed", label: "renamed", type: "table", connectionId: "conn", database: "app", schema: "public" };
|
||||
const legacyKey = "conn:app:public:__tables:public:events";
|
||||
|
||||
expect(replacePinnedTreeNodeInOrder(["before", legacyKey, "after"], oldNode, newNode, undefined, [legacyKey])).toEqual(["before", treeNodePinKey(newNode), "after"]);
|
||||
});
|
||||
|
||||
it("applies pinned state to connection groups when rebuilding from layout", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildObjectBrowserRows } from "@/lib/table/objectBrowserRows";
|
||||
import { orderItemsByPinnedTreeNodeOrder, removePinnedTreeNodesFromOrder, treeNodePinIdentity, treeNodePinKey } from "@/lib/app/pinnedItems";
|
||||
import { buildObjectBrowserRows, canonicalizeObjectBrowserPinnedTreeNodeIdentity, objectBrowserRowLegacyPinnedTreeNodeIds, objectBrowserRowMatchesPinnedTreeNode, sortObjectBrowserRows, type ObjectBrowserRow } from "@/lib/table/objectBrowserRows";
|
||||
import type { TreeNode } from "@/types/database";
|
||||
|
||||
describe("buildObjectBrowserRows", () => {
|
||||
it("preserves a resolved SQLite attached-database alias on every row", () => {
|
||||
@@ -24,3 +26,96 @@ describe("buildObjectBrowserRows", () => {
|
||||
expect(rows[0]?.schema).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Object Browser pinned ordering", () => {
|
||||
const context = { connectionId: "conn", database: "app", schema: "public" };
|
||||
|
||||
function tableNode(name: string, schema = "public"): TreeNode {
|
||||
return {
|
||||
id: `conn:app:${schema}:tables:${name}`,
|
||||
label: name,
|
||||
type: "table",
|
||||
connectionId: "conn",
|
||||
database: "app",
|
||||
schema,
|
||||
};
|
||||
}
|
||||
|
||||
function orderRows(rows: ObjectBrowserRow[], pinnedNodes: TreeNode[], extraPinnedKeys: string[] = []): ObjectBrowserRow[] {
|
||||
const pinnedOrder = [...extraPinnedKeys, ...pinnedNodes.map(treeNodePinKey)];
|
||||
return orderItemsByPinnedTreeNodeOrder(rows, pinnedOrder, (row, identity) => objectBrowserRowMatchesPinnedTreeNode(row, identity, context));
|
||||
}
|
||||
|
||||
it("uses the sidebar's persisted custom pin order before the selected name sort", () => {
|
||||
const rows = buildObjectBrowserRows({
|
||||
objects: ["a1", "a2", "a3", "a4", "a5", "aaa"].map((name) => ({ name, object_type: "TABLE", schema: "public" })),
|
||||
database: "app",
|
||||
fallbackSchema: "public",
|
||||
rowSchema: "public",
|
||||
});
|
||||
const sorted = sortObjectBrowserRows(rows, "name", "asc");
|
||||
const pinnedNodes = ["aaa", "a5", "a4", "a3", "a2", "a1"].map((name) => tableNode(name));
|
||||
|
||||
const ordered = orderRows(sorted, pinnedNodes, [treeNodePinKey({ ...tableNode("other"), connectionId: "other-connection" })]);
|
||||
|
||||
expect(ordered.map((row) => row.name)).toEqual(["aaa", "a5", "a4", "a3", "a2", "a1"]);
|
||||
});
|
||||
|
||||
it("keeps unpinned rows in the Object Browser's selected column order", () => {
|
||||
const rows: ObjectBrowserRow[] = [
|
||||
{ id: "a1", name: "a1", displayName: "a1", schema: "public", type: "TABLE", totalBytes: 30 },
|
||||
{ id: "a2", name: "a2", displayName: "a2", schema: "public", type: "TABLE", totalBytes: 40 },
|
||||
{ id: "a3", name: "a3", displayName: "a3", schema: "public", type: "TABLE", totalBytes: 10 },
|
||||
{ id: "a4", name: "a4", displayName: "a4", schema: "public", type: "TABLE", totalBytes: 20 },
|
||||
];
|
||||
const sorted = sortObjectBrowserRows(rows, "totalBytes", "desc");
|
||||
|
||||
const ordered = orderRows(sorted, [tableNode("a3"), tableNode("a1")]);
|
||||
|
||||
expect(ordered.map((row) => row.name)).toEqual(["a3", "a1", "a2", "a4"]);
|
||||
});
|
||||
|
||||
it("matches a sidebar database child when Object Browser reports the database name as its schema", () => {
|
||||
const row: ObjectBrowserRow = { id: "a1", name: "a1", displayName: "a1", schema: "app", type: "TABLE" };
|
||||
const sidebarNode = tableNode("a1", "");
|
||||
|
||||
expect(objectBrowserRowMatchesPinnedTreeNode(row, treeNodePinIdentity(sidebarNode), { connectionId: "conn", database: "app", schema: "app" })).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the database-as-schema alias when an Object Browser object is deleted", () => {
|
||||
const sidebarNode = tableNode("events", "");
|
||||
const objectBrowserNode: TreeNode = { ...sidebarNode, id: "object-browser:app:events", schema: "app" };
|
||||
const canonicalize = canonicalizeObjectBrowserPinnedTreeNodeIdentity({ connectionId: "conn", database: "app" });
|
||||
|
||||
const remainingOrder = removePinnedTreeNodesFromOrder([treeNodePinKey(sidebarNode)], [objectBrowserNode], canonicalize);
|
||||
|
||||
expect(remainingOrder).toEqual([]);
|
||||
});
|
||||
|
||||
it("reconstructs legacy sidebar IDs for an unloaded Object Browser row", () => {
|
||||
const row: ObjectBrowserRow = { id: "object-browser:events:0", name: "events", displayName: "events", schema: "public", type: "TABLE" };
|
||||
const ids = objectBrowserRowLegacyPinnedTreeNodeIds(row, { connectionId: "conn", database: "app", schema: "public", sidebarParentId: "conn:app:public" });
|
||||
|
||||
expect(ids).toEqual(expect.arrayContaining(["conn:app:public:events", "conn:app:public:__tables:public:events"]));
|
||||
});
|
||||
|
||||
it("does not confuse same-name objects across schemas or routine overloads", () => {
|
||||
const tableRow: ObjectBrowserRow = { id: "orders", name: "orders", displayName: "orders", schema: "public", type: "TABLE" };
|
||||
expect(objectBrowserRowMatchesPinnedTreeNode(tableRow, treeNodePinIdentity(tableNode("orders", "archive")), context)).toBe(false);
|
||||
expect(objectBrowserRowMatchesPinnedTreeNode(tableRow, treeNodePinIdentity(tableNode("orders", "public")), context)).toBe(true);
|
||||
|
||||
const routineRow: ObjectBrowserRow = { id: "run-int", name: "run", displayName: "run(integer)", schema: "public", type: "FUNCTION", signature: "integer" };
|
||||
const routineNode: TreeNode = {
|
||||
id: "conn:app:public:functions:run:text",
|
||||
label: "run(text)",
|
||||
objectName: "run",
|
||||
signature: "text",
|
||||
type: "function",
|
||||
connectionId: "conn",
|
||||
database: "app",
|
||||
schema: "public",
|
||||
};
|
||||
expect(objectBrowserRowMatchesPinnedTreeNode(routineRow, treeNodePinIdentity(routineNode), context)).toBe(false);
|
||||
expect(objectBrowserRowMatchesPinnedTreeNode(routineRow, treeNodePinIdentity({ ...routineNode, id: "conn:app:public:functions:run:integer", label: "run(integer)", signature: "integer" }), context)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user