Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
120 changes: 106 additions & 14 deletions src/components/sidebar/ConnectionsList.tsx
Original file line number Diff line number Diff line change
@@ -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<unknown>(ORDER_COLLECTION);
return JSON.stringify(Array.isArray(stored) ? stored.filter((id) => typeof id === "string") : []);
}
const serverOrderSnapshot = () => "[]";

interface ConnectionsListProps {
connections: DatabaseConnection[];
Expand All @@ -22,37 +47,104 @@ export function ConnectionsList({
onDuplicateConnection,
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<string | null>(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 (
<section>
<div className="px-3 mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Connections</span>
<div className="h-[1px] flex-1 bg-border/30 ml-3" />
</div>
<p id={instructionsId} className="sr-only">
Drag to reorder connections, or focus a reorder handle and press Alt+Arrow Up or Alt+Arrow Down.
</p>

<div className="space-y-0.5">
<ul aria-label="Saved connections" className="space-y-0.5">
{connections.length === 0 ? (
<div className="px-3 py-6 text-center border border-dashed border-border/50 rounded-lg mx-2">
<li className="px-3 py-6 text-center border border-dashed border-border/50 rounded-lg mx-2">
<p className="text-xs text-muted-foreground mb-3 leading-relaxed">
No database connections established yet.
</p>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={onAddConnection}>
Add Connection
</Button>
</div>
</li>
) : (
connections.map((conn) => (
<ConnectionItem
orderedConnections.map((conn, index) => (
// The row accepts pointer drops; its handle provides equivalent keyboard reordering.
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<li
key={conn.id}
connection={conn}
isActive={activeConnection?.id === conn.id}
onSelect={onSelectConnection}
onDelete={onDeleteConnection}
onEdit={onEditConnection}
onDuplicate={onDuplicateConnection}
/>
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;
}}
>
<button
type="button"
draggable
aria-label={`Reorder ${conn.name}`}
aria-describedby={instructionsId}
title="Drag to reorder (Alt+↑ / Alt+↓)"
className="shrink-0 p-1 ml-1 rounded text-fg-muted hover:text-fg cursor-grab active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-brand-tint"
onDragStart={(event) => {
draggedId.current = conn.id;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", conn.id);
}}
onDragEnd={() => {
draggedId.current = null;
}}
onKeyDown={(event) => {
if (!event.altKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
event.preventDefault();
moveConnection(conn.id, index + (event.key === "ArrowUp" ? -1 : 1));
}}
>
<GripVertical strokeWidth={1.5} className="w-3 h-3" />
</button>
<div className="flex-1 min-w-0">
<ConnectionItem
connection={conn}
isActive={activeConnection?.id === conn.id}
onSelect={onSelectConnection}
onDelete={onDeleteConnection}
onEdit={onEditConnection}
onDuplicate={onDuplicateConnection}
/>
</div>
</li>
))
)}
</div>
</ul>
</section>
);
}
110 changes: 105 additions & 5 deletions tests/components/sidebar/ConnectionsList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -95,12 +95,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(<ConnectionsList {...props} />);
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(<ConnectionsList {...props} />);
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(<ConnectionsList {...orderedProps()} />);
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(<ConnectionsList {...orderedProps()} />);
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(<ConnectionsList {...orderedProps()} />);
expect(getAllByRole("listitem")[0].textContent).toContain("Test MySQL");
unmount();
localStorage.setItem("libredb_connection_order", JSON.stringify({ invalid: true }));
const fallback = render(<ConnectionsList {...orderedProps()} />);
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(<ConnectionsList {...orderedProps()} />);
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(<ConnectionsList {...orderedProps()} />);
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(
<ConnectionsList
Expand Down Expand Up @@ -225,7 +325,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[1]!);

expect(defaultOnDelete).toHaveBeenCalledTimes(1);
Expand All @@ -247,7 +347,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);
Expand All @@ -267,7 +367,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);
Expand Down
Loading