- {connections.length === 0 ? (
-
-
- No database connections established yet.
-
-
+
+ {connections.length === 0 ? (
+
+
+ No database connections established yet.
+
+
+
+ ) : (
+ items.map((conn) => (
+
+ ))
+ )}
- ) : (
- connections.map((conn) => (
-
- ))
- )}
-
+
+ ))}
);
}
diff --git a/src/hooks/use-storage-sync.ts b/src/hooks/use-storage-sync.ts
index 36177533d..6551fba82 100644
--- a/src/hooks/use-storage-sync.ts
+++ b/src/hooks/use-storage-sync.ts
@@ -176,6 +176,7 @@ export function useStorageSync(): StorageSyncState {
if (data.masking_config) writeCollectionToLocal("masking_config", data.masking_config);
if (data.threshold_config) writeCollectionToLocal("threshold_config", data.threshold_config);
if (data.dismissed_seeds) writeCollectionToLocal("dismissed_seeds", data.dismissed_seeds);
+ writeCollectionToLocal("favorite_connections", data.favorite_connections ?? []);
setLastSyncedAt(new Date());
setSyncError(null);
@@ -313,6 +314,8 @@ function getCollectionData(collection: string): unknown {
switch (collection) {
case "connections":
return storage.getConnections();
+ case "favorite_connections":
+ return storage.getFavoriteConnectionIds();
case "history":
return storage.getHistory();
case "saved_queries":
diff --git a/src/lib/storage/storage-facade.ts b/src/lib/storage/storage-facade.ts
index 43cb53eb6..b52c25f25 100644
--- a/src/lib/storage/storage-facade.ts
+++ b/src/lib/storage/storage-facade.ts
@@ -67,6 +67,19 @@ export const storage = {
return readJSON
("dismissed_seeds") ?? [];
},
+ getFavoriteConnectionIds: (): string[] => {
+ const data = readJSON("favorite_connections");
+ return Array.isArray(data) ? data.filter((id): id is string => typeof id === "string") : [];
+ },
+
+ toggleConnectionFavorite: (id: string): boolean => {
+ const favorites = storage.getFavoriteConnectionIds();
+ const next = favorites.includes(id) ? favorites.filter((favorite) => favorite !== id) : [...favorites, id];
+ if (!writeJSON("favorite_connections", next)) return false;
+ dispatchChange("favorite_connections", next);
+ return true;
+ },
+
deleteConnection: (id: string) => {
const connections = storage.getConnections();
const target = connections.find((c) => c.id === id);
diff --git a/src/lib/storage/types.ts b/src/lib/storage/types.ts
index 01c8144a7..dbee1e2da 100644
--- a/src/lib/storage/types.ts
+++ b/src/lib/storage/types.ts
@@ -9,6 +9,8 @@ import type { ThresholdConfig } from "../monitoring-thresholds";
*/
export interface StorageData {
connections: DatabaseConnection[];
+ /** User preferences, including favorites for administrator-managed connections. */
+ favorite_connections: string[];
history: QueryHistoryItem[];
saved_queries: SavedQuery[];
schema_snapshots: SchemaSnapshot[];
@@ -27,6 +29,7 @@ export type StorageCollection = keyof StorageData;
/** All persistable collection names */
export const STORAGE_COLLECTIONS: StorageCollection[] = [
"connections",
+ "favorite_connections",
"history",
"saved_queries",
"schema_snapshots",
diff --git a/tests/api/storage/storage-routes.test.ts b/tests/api/storage/storage-routes.test.ts
index 754745e73..c4166f469 100644
--- a/tests/api/storage/storage-routes.test.ts
+++ b/tests/api/storage/storage-routes.test.ts
@@ -27,20 +27,6 @@ mock.module("@/lib/storage/factory", () => ({
getStorageProvider: async () => (providerEnabled ? mockProvider : null),
}));
-mock.module("@/lib/storage/types", () => ({
- STORAGE_COLLECTIONS: [
- "connections",
- "history",
- "saved_queries",
- "schema_snapshots",
- "saved_charts",
- "active_connection_id",
- "audit_log",
- "masking_config",
- "threshold_config",
- ],
-}));
-
// ── Import routes ────────────────────────────────────────────────────────────
import { GET } from "@/app/api/storage/route";
@@ -128,6 +114,13 @@ describe("PUT /api/storage/[collection]", () => {
expect(mockProvider.setCollection).toHaveBeenCalledWith("admin@test.com", "connections", data);
});
+ test("stores favorites under the authenticated user's identity", async () => {
+ mockSession = { username: "reader@test.com", role: "user" };
+ const res = await makeRequest("favorite_connections", ["managed"]);
+ expect(res.status).toBe(200);
+ expect(mockProvider.setCollection).toHaveBeenCalledWith("reader@test.com", "favorite_connections", ["managed"]);
+ });
+
test("returns 400 when data field is missing", async () => {
const request = new NextRequest("http://localhost/api/storage/connections", {
method: "PUT",
diff --git a/tests/components/sidebar/ConnectionsList.test.tsx b/tests/components/sidebar/ConnectionsList.test.tsx
index 093109904..6d4cd8b13 100644
--- a/tests/components/sidebar/ConnectionsList.test.tsx
+++ b/tests/components/sidebar/ConnectionsList.test.tsx
@@ -59,8 +59,11 @@ mock.module("@/lib/db-ui-config", () => ({
}));
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
-import { render, fireEvent, cleanup } from "@testing-library/react";
+import { render, fireEvent, cleanup, within, act } from "@testing-library/react";
import React from "react";
+import ReactDOMServer from "react-dom/server";
+import { storage } from "@/lib/storage";
+import { mockToastError } from "../../helpers/mock-sonner";
import { ConnectionsList } from "@/components/sidebar/ConnectionsList";
import { mockPostgresConnection, mockMySQLConnection } from "../../fixtures/connections";
@@ -95,12 +98,93 @@ describe("ConnectionsList", () => {
});
beforeEach(() => {
+ localStorage.clear();
+ mockToastError.mockClear();
defaultOnSelect.mockClear();
defaultOnDelete.mockClear();
defaultOnEdit.mockClear();
defaultOnAdd.mockClear();
});
+ const favoriteProps = () => ({
+ connections: [
+ mockPostgresConnection,
+ mockMySQLConnection,
+ { ...mockPostgresConnection, id: "managed", name: "Managed DB", managed: true },
+ ],
+ activeConnection: mockPostgresConnection,
+ onSelectConnection: defaultOnSelect,
+ onDeleteConnection: defaultOnDelete,
+ onEditConnection: defaultOnEdit,
+ onAddConnection: defaultOnAdd,
+ });
+
+ test("favorites persist across remount and preserve the order and settings of other connections", () => {
+ const props = favoriteProps();
+ const original = structuredClone(props.connections);
+ const view = render();
+ fireEvent.click(view.getByRole("button", { name: "Add Test MySQL to favorites" }));
+ expect(defaultOnSelect).not.toHaveBeenCalled();
+ expect(within(view.getByRole("group", { name: "Favorites" })).getByText("Test MySQL") !== null).toBe(true);
+ expect(view.container.textContent!.indexOf("Favorites")).toBeLessThan(
+ view.container.textContent!.indexOf("Connections"),
+ );
+ const remaining = view.getByRole("group", { name: "Connections" });
+ expect(remaining.textContent!.indexOf("Test PostgreSQL")).toBeLessThan(
+ remaining.textContent!.indexOf("Managed DB"),
+ );
+ view.unmount();
+ const restored = render();
+ expect(
+ restored.getByRole("button", { name: "Remove Test MySQL from favorites" }).getAttribute("aria-pressed"),
+ ).toBe("true");
+ fireEvent.click(restored.getByRole("button", { name: "Remove Test MySQL from favorites" }));
+ expect(restored.queryByRole("group", { name: "Favorites" }) === null).toBe(true);
+ expect(storage.getFavoriteConnectionIds()).toEqual([]);
+ expect(props.connections).toEqual(original);
+ expect(storage.getConnections()).toEqual([]);
+ });
+
+ test("managed connections can be favorited and every mounted list sees the toggle", () => {
+ const props = favoriteProps();
+ const first = render();
+ const second = render();
+ fireEvent.click(within(first.container).getByRole("button", { name: "Add Managed DB to favorites" }));
+ expect(within(second.container).getByRole("button", { name: "Remove Managed DB from favorites" }) !== null).toBe(
+ true,
+ );
+ expect(storage.getFavoriteConnectionIds()).toEqual(["managed"]);
+ expect(storage.getConnections()).toEqual([]);
+ act(() => storage.saveConnection(mockPostgresConnection));
+ expect(within(second.container).getByRole("group", { name: "Favorites" }).textContent).toContain("Managed DB");
+ });
+
+ test("all-favorite lists have no empty connections group and server rendering uses an empty preference", () => {
+ const props = favoriteProps();
+ localStorage.setItem("libredb_favorite_connections", JSON.stringify(props.connections.map((conn) => conn.id)));
+ const html = ReactDOMServer.renderToString();
+ expect(html).not.toContain('aria-label="Favorites"');
+ const view = render();
+ expect(view.queryByRole("group", { name: "Connections" }) === null).toBe(true);
+ expect(view.queryByText("No database connections established yet.") === null).toBe(true);
+ expect(view.getByRole("group", { name: "Favorites" }).querySelectorAll('[aria-pressed="true"]').length).toBe(3);
+ });
+
+ test("a failed favorite write leaves the list unchanged and reports the failure", () => {
+ const view = render();
+ const original = localStorage.setItem;
+ localStorage.setItem = () => {
+ throw new Error("Storage full");
+ };
+ try {
+ fireEvent.click(view.getByRole("button", { name: "Add Test MySQL to favorites" }));
+ expect(view.queryByRole("group", { name: "Favorites" }) === null).toBe(true);
+ expect(mockToastError).toHaveBeenCalledWith("Could not save the connection favorite.");
+ } finally {
+ localStorage.setItem = original;
+ }
+ });
+
test('renders "Connections" header', () => {
const { queryByText } = render(
{
/>,
);
- // Only the delete button remains when onEdit is not passed down
+ // Delete and favorite remain when onEdit is not passed down.
const buttons = container.querySelectorAll("button");
- expect(buttons.length).toBe(1);
+ expect(buttons.length).toBe(2);
fireEvent.click(buttons[0]!);
expect(defaultOnDelete).toHaveBeenCalledTimes(1);
});
diff --git a/tests/isolated/use-storage-sync.test.ts b/tests/isolated/use-storage-sync.test.ts
index b8d6cf0ab..02e7dc08c 100644
--- a/tests/isolated/use-storage-sync.test.ts
+++ b/tests/isolated/use-storage-sync.test.ts
@@ -21,10 +21,12 @@ const mockStorage = {
})),
getThresholdConfig: mock(() => []),
getDismissedSeeds: mock(() => ["seed-1"]),
+ getFavoriteConnectionIds: mock(() => ["favorite-1"]),
};
const ALL_COLLECTIONS = [
"connections",
+ "favorite_connections",
"history",
"saved_queries",
"schema_snapshots",
@@ -289,12 +291,24 @@ describe("useStorageSync", () => {
expect(mockStorage.getMaskingConfig).toHaveBeenCalled();
expect(mockStorage.getThresholdConfig).toHaveBeenCalled();
expect(mockStorage.getDismissedSeeds).toHaveBeenCalled();
+ expect(mockStorage.getFavoriteConnectionIds).toHaveBeenCalled();
});
});
// ── Pull from server ──────────────────────────────────────────────────
describe("pull from server", () => {
+ test.each([{ favorites: [] }, { favorites: ["server-favorite"] }, { favorites: undefined }])(
+ "restores favorite preferences from the server",
+ async ({ favorites }) => {
+ localStorage.setItem("libredb_server_migrated", "true");
+ localStorage.setItem("libredb_favorite_connections", '["old"]');
+ setupServerMode({ "/api/storage": { ok: true, status: 200, json: { favorite_connections: favorites } } });
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => expect(result.current.isReady).toBe(true));
+ expect(JSON.parse(localStorage.getItem("libredb_favorite_connections")!)).toEqual(favorites ?? []);
+ },
+ );
test("pulls data from server on mount in server mode", async () => {
localStorage.setItem("libredb_server_migrated", "true");
const fetchMock = setupServerMode();
@@ -400,6 +414,24 @@ describe("useStorageSync", () => {
// ── Push to server (debounced) ────────────────────────────────────────
describe("push to server", () => {
+ test("pushes favorite IDs through the existing authenticated storage path", async () => {
+ localStorage.setItem("libredb_server_migrated", "true");
+ const fetchMock = setupServerMode({
+ "/api/storage/favorite_connections": { ok: true, status: 200, json: { ok: true } },
+ });
+ const { result } = renderHook(() => useStorageSync());
+ await waitFor(() => expect(result.current.isReady).toBe(true));
+ act(() =>
+ window.dispatchEvent(
+ new CustomEvent("libredb-storage-change", { detail: { collection: "favorite_connections" } }),
+ ),
+ );
+ await waitFor(() => expect(calledPaths(fetchMock)).toContain("/api/storage/favorite_connections"));
+ const call = (fetchMock.mock.calls as unknown[][]).find(([url]) =>
+ String(url).endsWith("/api/storage/favorite_connections"),
+ )!;
+ expect(JSON.parse((call[1] as RequestInit).body as string)).toEqual({ data: ["favorite-1"] });
+ });
test("pushes collection to server on storage-change event", async () => {
localStorage.setItem("libredb_server_migrated", "true");
const fetchMock = mockGlobalFetch({
diff --git a/tests/run-components.sh b/tests/run-components.sh
index 9676ca7ae..54e957825 100755
--- a/tests/run-components.sh
+++ b/tests/run-components.sh
@@ -273,7 +273,6 @@ run_group "Group 15/16: Remaining components" \
tests/components/schema-explorer/SchemaExplorer.test.tsx \
tests/components/schema-explorer/ColumnList.test.tsx \
tests/components/sidebar/ConnectionItem.test.tsx \
- tests/components/sidebar/ConnectionsList.test.tsx \
tests/components/studio/QueryToolbar.test.tsx \
tests/components/studio/StudioTabBar.test.tsx \
tests/components/admin/OverviewTab.test.tsx \
@@ -328,6 +327,11 @@ run_group "Group 20: WireCompatibilityHint" \
run_group "Group 21: ui/scroll-area" \
tests/components/ui/scroll-area.test.tsx
+# ConnectionsList reads real stored preferences. The admin suites in Group 15/16
+# replace the storage facade, so it must run in its own process.
+run_group "Group 22: ConnectionsList preferences" \
+ tests/components/sidebar/ConnectionsList.test.tsx
+
# Summary
echo ""
echo "========================================"
diff --git a/tests/unit/lib/storage/storage-facade.test.ts b/tests/unit/lib/storage/storage-facade.test.ts
index edde331b2..1b64ae721 100644
--- a/tests/unit/lib/storage/storage-facade.test.ts
+++ b/tests/unit/lib/storage/storage-facade.test.ts
@@ -41,6 +41,42 @@ function makeAuditEvent(overrides: Partial = {}): AuditEvent {
// ── CustomEvent dispatch ─────────────────────────────────────────────────────
+describe("storage facade: connection favorites", () => {
+ beforeEach(() => localStorage.clear());
+
+ test.each([
+ ["null", []],
+ ["{}", []],
+ ["invalid JSON", []],
+ ['[null,1,"a",false,"b"]', ["a", "b"]],
+ ])("ignores invalid favorite preference data %s", (data, expected) => {
+ localStorage.setItem("libredb_favorite_connections", data);
+ expect(storage.getFavoriteConnectionIds()).toEqual(expected);
+ });
+
+ test("toggles IDs without changing stored connection settings and publishes the preference", () => {
+ storage.saveConnection(makeConnection());
+ const original = localStorage.getItem("libredb_connections");
+ const listener = mock((_event: Event) => {});
+ window.addEventListener("libredb-storage-change", listener);
+ try {
+ expect(storage.getFavoriteConnectionIds()).toEqual([]);
+ expect(storage.toggleConnectionFavorite("conn-1")).toBe(true);
+ expect(storage.toggleConnectionFavorite("managed")).toBe(true);
+ expect(storage.getFavoriteConnectionIds()).toEqual(["conn-1", "managed"]);
+ expect(storage.toggleConnectionFavorite("conn-1")).toBe(true);
+ expect(storage.getFavoriteConnectionIds()).toEqual(["managed"]);
+ expect((listener.mock.calls.at(-1)![0] as CustomEvent).detail).toEqual({
+ collection: "favorite_connections",
+ data: ["managed"],
+ });
+ expect(localStorage.getItem("libredb_connections")).toBe(original);
+ } finally {
+ window.removeEventListener("libredb-storage-change", listener);
+ }
+ });
+});
+
describe("storage facade: CustomEvent dispatch", () => {
beforeEach(() => {
localStorage.clear();