From 8947de38448016a637d974a9f50590c4ea574209 Mon Sep 17 00:00:00 2001 From: 2160039878-cyber <285580214+2160039878-cyber@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:38:21 +0800 Subject: [PATCH] feat(connections): persist custom sidebar ordering --- docs/FEATURES.md | 1 + src/components/sidebar/ConnectionsList.tsx | 118 ++++++++++++++++-- .../sidebar/ConnectionsList.test.tsx | 110 +++++++++++++++- 3 files changed, 211 insertions(+), 18 deletions(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index fd9069f7c..001ba2332 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -18,6 +18,7 @@ * **Workspace Tabs:** Open multiple queries simultaneously in separate tabs. * **Independent Results:** Each tab maintains its own execution state and results grid. * **Persistent Tabs:** Switch between tasks without losing your work. +* **Connection Order:** Drag a sidebar connection by its handle, or focus the handle and use `Alt+↑` / `Alt+↓`. The order is saved in the current browser, including for administrator-managed connections. New connections appear after the saved order; reordering does not change connection settings or switch the active connection. ### 3. Pro Data Grid (Excel-Style) * **High Performance:** Virtualized rendering using TanStack Virtual for smooth scrolling through millions of rows. diff --git a/src/components/sidebar/ConnectionsList.tsx b/src/components/sidebar/ConnectionsList.tsx index a91bb53b0..72a503e63 100644 --- a/src/components/sidebar/ConnectionsList.tsx +++ b/src/components/sidebar/ConnectionsList.tsx @@ -1,7 +1,32 @@ -import React from "react"; +import React, { useId, useRef, useSyncExternalStore } from "react"; import { DatabaseConnection } from "@/lib/types"; import { Button } from "@/components/ui/button"; import { ConnectionItem } from "./ConnectionItem"; +import { GripVertical } from "lucide-react"; +import { toast } from "sonner"; +import { getKey, readJSON, writeJSON } from "@/lib/storage/local-storage"; + +const ORDER_COLLECTION = "connection_order"; +const ORDER_CHANGE_EVENT = "libredb-connection-order-change"; + +function subscribeToOrder(onChange: () => void) { + const onStorage = (event: StorageEvent) => { + if (event.key === null || event.key === getKey(ORDER_COLLECTION)) onChange(); + }; + window.addEventListener(ORDER_CHANGE_EVENT, onChange); + window.addEventListener("storage", onStorage); + return () => { + window.removeEventListener(ORDER_CHANGE_EVENT, onChange); + window.removeEventListener("storage", onStorage); + }; +} + +// A string snapshot stays stable even though the stored array is parsed on every read. +function orderSnapshot() { + const stored = readJSON(ORDER_COLLECTION); + return JSON.stringify(Array.isArray(stored) ? stored.filter((id) => typeof id === "string") : []); +} +const serverOrderSnapshot = () => "[]"; interface ConnectionsListProps { connections: DatabaseConnection[]; @@ -20,36 +45,103 @@ export function ConnectionsList({ onEditConnection, onAddConnection, }: ConnectionsListProps) { + const order: string[] = JSON.parse(useSyncExternalStore(subscribeToOrder, orderSnapshot, serverOrderSnapshot)); + const positions = new Map(order.map((id, index) => [id, index])); + const orderedConnections = [...connections].sort( + (a, b) => (positions.get(a.id) ?? order.length) - (positions.get(b.id) ?? order.length), + ); + const draggedId = useRef(null); + const instructionsId = useId(); + + const moveConnection = (id: string, targetIndex: number) => { + const sourceIndex = orderedConnections.findIndex((conn) => conn.id === id); + if (sourceIndex < 0 || targetIndex < 0 || targetIndex >= orderedConnections.length || sourceIndex === targetIndex) + return; + const next = orderedConnections.map((conn) => conn.id); + next.splice(sourceIndex, 1); + next.splice(targetIndex, 0, id); + if (!writeJSON(ORDER_COLLECTION, next)) { + toast.error("Could not save the connection order."); + return; + } + window.dispatchEvent(new Event(ORDER_CHANGE_EVENT)); + }; + return (
Connections
+

+ Drag to reorder connections, or focus a reorder handle and press Alt+Arrow Up or Alt+Arrow Down. +

-
+
    {connections.length === 0 ? ( -
    +
  • No database connections established yet.

    -
  • + ) : ( - connections.map((conn) => ( - ( + // The row accepts pointer drops; its handle provides equivalent keyboard reordering. + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions +
  • + className="flex items-center" + onDragOver={(event) => { + if (!draggedId.current) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }} + onDrop={(event) => { + if (!draggedId.current) return; + event.preventDefault(); + moveConnection(draggedId.current, index); + draggedId.current = null; + }} + > + +
    + +
    +
  • )) )} -
+
); } diff --git a/tests/components/sidebar/ConnectionsList.test.tsx b/tests/components/sidebar/ConnectionsList.test.tsx index a27d7c44e..61d1f5a4b 100644 --- a/tests/components/sidebar/ConnectionsList.test.tsx +++ b/tests/components/sidebar/ConnectionsList.test.tsx @@ -58,8 +58,8 @@ mock.module("@/lib/db-ui-config", () => ({ getDBColor: () => "text-hue-blue", })); -import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import { render, fireEvent, cleanup } from "@testing-library/react"; +import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"; +import { render, fireEvent, cleanup, act } from "@testing-library/react"; import React from "react"; import { ConnectionsList } from "@/components/sidebar/ConnectionsList"; @@ -80,12 +80,112 @@ describe("ConnectionsList", () => { }); beforeEach(() => { + localStorage.clear(); defaultOnSelect.mockClear(); defaultOnDelete.mockClear(); defaultOnEdit.mockClear(); defaultOnAdd.mockClear(); }); + const orderedProps = () => ({ + connections: [mockPostgresConnection, mockMySQLConnection], + activeConnection: mockPostgresConnection, + onSelectConnection: defaultOnSelect, + onDeleteConnection: defaultOnDelete, + onEditConnection: defaultOnEdit, + onAddConnection: defaultOnAdd, + }); + + test("dragging connections persists the order without selecting or changing a connection", () => { + const props = orderedProps(); + const { getByRole, getAllByRole, unmount } = render(); + const handle = getByRole("button", { name: "Reorder Test PostgreSQL" }); + const transfer = { setData: mock(() => {}), effectAllowed: "", dropEffect: "" }; + fireEvent.dragStart(handle, { dataTransfer: transfer }); + fireEvent.dragOver(getAllByRole("listitem")[1], { dataTransfer: transfer }); + fireEvent.drop(getAllByRole("listitem")[1], { dataTransfer: transfer }); + expect(getAllByRole("listitem")[0].textContent).toContain("Test MySQL"); + expect(JSON.parse(localStorage.getItem("libredb_connection_order")!)).toEqual([ + mockMySQLConnection.id, + mockPostgresConnection.id, + ]); + expect(defaultOnSelect).not.toHaveBeenCalled(); + expect(defaultOnDelete).not.toHaveBeenCalled(); + expect(localStorage.getItem("libredb_connections")).toBeNull(); + unmount(); + const restored = render(); + expect(restored.getAllByRole("listitem")[0].textContent).toContain("Test MySQL"); + }); + + test("supports keyboard ordering and keeps focus on the moved drag handle", () => { + const { getByRole, getAllByRole } = render(); + const handle = getByRole("button", { name: "Reorder Test PostgreSQL" }); + handle.focus(); + fireEvent.keyDown(handle, { key: "ArrowDown" }); + expect(localStorage.getItem("libredb_connection_order")).toBeNull(); + fireEvent.keyDown(handle, { key: "ArrowUp", altKey: true }); + expect(localStorage.getItem("libredb_connection_order")).toBeNull(); + fireEvent.keyDown(handle, { key: "ArrowDown", altKey: true }); + expect(getAllByRole("listitem")[1].textContent).toContain("Test PostgreSQL"); + expect(document.activeElement).toBe(handle); + fireEvent.keyDown(handle, { key: "ArrowDown", altKey: true }); + fireEvent.keyDown(handle, { key: "ArrowUp", altKey: true }); + expect(getAllByRole("listitem")[0].textContent).toContain("Test PostgreSQL"); + fireEvent.click(handle); + expect(defaultOnSelect).not.toHaveBeenCalled(); + }); + + test("ignores external drops, cancelled drags and dropping onto the same connection", () => { + const { getByRole, getAllByRole } = render(); + const transfer = { setData: mock(() => {}), effectAllowed: "", dropEffect: "" }; + const handle = getByRole("button", { name: "Reorder Test PostgreSQL" }); + fireEvent.dragOver(getAllByRole("listitem")[1], { dataTransfer: transfer }); + fireEvent.drop(getAllByRole("listitem")[1], { dataTransfer: transfer }); + fireEvent.dragStart(handle, { dataTransfer: transfer }); + fireEvent.drop(getAllByRole("listitem")[0], { dataTransfer: transfer }); + fireEvent.dragStart(handle, { dataTransfer: transfer }); + fireEvent.dragEnd(handle); + fireEvent.drop(getAllByRole("listitem")[1], { dataTransfer: transfer }); + expect(localStorage.getItem("libredb_connection_order")).toBeNull(); + }); + + test("ignores invalid or missing stored IDs and appends new connections in their original order", () => { + localStorage.setItem("libredb_connection_order", JSON.stringify(["missing", 42, mockMySQLConnection.id])); + const { getAllByRole, unmount } = render(); + expect(getAllByRole("listitem")[0].textContent).toContain("Test MySQL"); + unmount(); + localStorage.setItem("libredb_connection_order", JSON.stringify({ invalid: true })); + const fallback = render(); + expect(fallback.getAllByRole("listitem")[0].textContent).toContain("Test PostgreSQL"); + }); + + test("updates mounted lists after another browser tab changes or clears the order", () => { + const { getAllByRole, unmount } = render(); + localStorage.setItem("libredb_connection_order", JSON.stringify([mockMySQLConnection.id])); + act(() => window.dispatchEvent(new window.StorageEvent("storage", { key: "unrelated" }))); + expect(getAllByRole("listitem")[0].textContent).toContain("Test PostgreSQL"); + act(() => window.dispatchEvent(new window.StorageEvent("storage", { key: "libredb_connection_order" }))); + expect(getAllByRole("listitem")[0].textContent).toContain("Test MySQL"); + localStorage.clear(); + act(() => window.dispatchEvent(new window.StorageEvent("storage", { key: null }))); + expect(getAllByRole("listitem")[0].textContent).toContain("Test PostgreSQL"); + unmount(); + act(() => window.dispatchEvent(new window.StorageEvent("storage", { key: null }))); + }); + + test("keeps the visible order when storage cannot save it", () => { + const { getByRole, getAllByRole } = render(); + const write = spyOn(localStorage, "setItem").mockImplementation(() => { + throw new Error("Storage unavailable"); + }); + try { + fireEvent.keyDown(getByRole("button", { name: "Reorder Test PostgreSQL" }), { key: "ArrowDown", altKey: true }); + expect(getAllByRole("listitem")[0].textContent).toContain("Test PostgreSQL"); + } finally { + write.mockRestore(); + } + }); + test('renders "Connections" header', () => { const { queryByText } = render( { ); // First button is edit (Pencil), second is delete (Trash2) - const buttons = container.querySelectorAll("button"); + const buttons = container.querySelectorAll("button:not([draggable])"); fireEvent.click(buttons[1]!); expect(defaultOnDelete).toHaveBeenCalledTimes(1); @@ -232,7 +332,7 @@ describe("ConnectionsList", () => { ); // First button is edit (Pencil), second is delete (Trash2) - const buttons = container.querySelectorAll("button"); + const buttons = container.querySelectorAll("button:not([draggable])"); fireEvent.click(buttons[0]!); expect(defaultOnEdit).toHaveBeenCalledTimes(1); @@ -252,7 +352,7 @@ describe("ConnectionsList", () => { ); // Only the delete button remains when onEdit is not passed down - const buttons = container.querySelectorAll("button"); + const buttons = container.querySelectorAll("button:not([draggable])"); expect(buttons.length).toBe(1); fireEvent.click(buttons[0]!); expect(defaultOnDelete).toHaveBeenCalledTimes(1);