diff --git a/README.md b/README.md index c36e3cb19..43ca902b1 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ And nothing is held back. Single sign-on, ER diagrams, the AI features and the N - **Monaco Engine**: Powered by the same core as VS Code. - **Smart Autocomplete**: Schema-aware suggestions for tables, columns, and SQL keywords. - **Command Palette**: Quick access to tables, connections, saved queries, and actions with `Cmd/Ctrl+K`. +- **Favorite Connections**: Star a connection to keep it in a Favorites group above the remaining connections. Favorites preserve the original order within each group and persist as user preferences, including for administrator-managed connections. Server storage synchronizes these preferences when enabled. - **Multi-Tab Workspace**: Handle parallel tasks with independent execution states. - **Saved Query Backups**: Export the complete saved-query library as JSON. Import validates the file, preserves query metadata and merges new entries, reporting duplicate IDs while keeping existing queries intact. - **Duplicate Connections**: Open an independent `(copy)` of an editable saved connection in the connection editor, adjust its settings and save. Cancelling leaves the saved connections unchanged; administrator-managed connections cannot be duplicated. diff --git a/src/components/sidebar/ConnectionItem.tsx b/src/components/sidebar/ConnectionItem.tsx index 4a043f0aa..5f85fa829 100644 --- a/src/components/sidebar/ConnectionItem.tsx +++ b/src/components/sidebar/ConnectionItem.tsx @@ -1,6 +1,6 @@ import React from "react"; import { DatabaseConnection, ENVIRONMENT_LABELS } from "@/lib/types"; -import { Lock, Trash2, Pencil, Copy } from "lucide-react"; +import { Lock, Trash2, Pencil, Star, Copy } from "lucide-react"; import { getDBIcon } from "@/lib/db-ui-config"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -12,6 +12,8 @@ interface ConnectionItemProps { onSelect: (conn: DatabaseConnection) => void; onDelete: (id: string) => void; onEdit?: (conn: DatabaseConnection) => void; + isFavorite?: boolean; + onToggleFavorite?: (id: string) => void; onDuplicate?: (conn: DatabaseConnection) => void; } @@ -21,6 +23,8 @@ export const ConnectionItem = React.memo(function ConnectionItem({ onSelect, onDelete, onEdit, + isFavorite = false, + onToggleFavorite, onDuplicate, }: ConnectionItemProps) { return ( @@ -108,6 +112,20 @@ export const ConnectionItem = React.memo(function ConnectionItem({ )} + {onToggleFavorite && ( + + )} ); diff --git a/src/components/sidebar/ConnectionsList.tsx b/src/components/sidebar/ConnectionsList.tsx index 5fc3d7fdb..787a2529f 100644 --- a/src/components/sidebar/ConnectionsList.tsx +++ b/src/components/sidebar/ConnectionsList.tsx @@ -1,7 +1,21 @@ -import React from "react"; +import React, { useSyncExternalStore } from "react"; import { DatabaseConnection } from "@/lib/types"; import { Button } from "@/components/ui/button"; import { ConnectionItem } from "./ConnectionItem"; +import { storage, type StorageChangeDetail } from "@/lib/storage"; +import { toast } from "sonner"; + +function subscribeToFavorites(onChange: () => void) { + const listener = (event: Event) => { + if ((event as CustomEvent).detail.collection === "favorite_connections") onChange(); + }; + window.addEventListener("libredb-storage-change", listener); + return () => window.removeEventListener("libredb-storage-change", listener); +} + +// A primitive snapshot stays stable across reads; the stored array is parsed afresh. +const favoriteSnapshot = () => JSON.stringify(storage.getFavoriteConnectionIds()); +const serverFavoriteSnapshot = () => "[]"; interface ConnectionsListProps { connections: DatabaseConnection[]; @@ -22,37 +36,63 @@ export function ConnectionsList({ onDuplicateConnection, onAddConnection, }: ConnectionsListProps) { + const favorites = new Set( + JSON.parse(useSyncExternalStore(subscribeToFavorites, favoriteSnapshot, serverFavoriteSnapshot)), + ); + const groups = [ + { label: "Favorites", items: connections.filter((conn) => favorites.has(conn.id)) }, + { label: "Connections", items: connections.filter((conn) => !favorites.has(conn.id)) }, + ].filter((group) => group.items.length > 0 || (group.label === "Connections" && connections.length === 0)); + + const toggleFavorite = (id: string) => { + if (!storage.toggleConnectionFavorite(id)) toast.error("Could not save the connection favorite."); + }; + return ( -
-
- Connections -
-
+
+ {groups.map(({ label, items }) => ( +
+
+ + {label} + +
+
-
- {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();