diff --git a/docs/STORAGE.md b/docs/STORAGE.md index b2caa3148..a34b01bfd 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -580,7 +580,7 @@ CREATE INDEX IF NOT EXISTS idx_user_storage_user_id ON user_storage (user_id); | Column | Type | Description | |--------|------|-------------| | `user_id` | TEXT | User's email from JWT token (e.g., `admin@libredb.org`) | -| `collection` | TEXT | Data category: `connections`, `history`, `saved_queries`, `schema_snapshots`, `saved_charts`, `active_connection_id`, `audit_log`, `masking_config`, `threshold_config`, `dismissed_seeds` | +| `collection` | TEXT | Data category: `connections`, `history`, `saved_queries`, `schema_snapshots`, `saved_charts`, `active_connection_id`, `audit_log`, `masking_config`, `threshold_config`, `dismissed_seeds`, `favorite_connections` | | `data` | TEXT | JSON-serialized collection data | | `updated_at` | TEXT / TIMESTAMPTZ | Last modification timestamp | @@ -647,7 +647,7 @@ This part describes the internals of the storage abstraction layer: design goals ### 3.1 Collections -All application state is organized into **10 collections**, each stored as a JSON blob: +All application state is organized into **11 collections**, each stored as a JSON blob: | Collection | Type | Description | Max Items | |-----------|------|-------------|-----------| @@ -661,6 +661,7 @@ All application state is organized into **10 collections**, each stored as a JSO | `masking_config` | `MaskingConfig` | Data masking rules and RBAC | — | | `threshold_config` | `ThresholdConfig[]` | Monitoring alert thresholds | — | | `dismissed_seeds` | `string[]` | Seed IDs the user dismissed (deleted a `managed: false` seed copy) so it is not re-added | — | +| `favorite_connections` | `string[]` | Connection ids the user has starred | — | **A snapshot taken before the object model has no kind and no path.** `schema_snapshots` holds what the schema list held when the snapshot was taken, and a live reading now always carries an object's @@ -707,6 +708,7 @@ audit_log → libredb_audit_log masking_config → libredb_masking_config threshold_config → libredb_threshold_config dismissed_seeds → libredb_dismissed_seeds +favorite_connections → libredb_favorite_connections ``` --- @@ -779,7 +781,7 @@ storage.saveConnection(conn); | Category | Methods | |----------|---------| -| **Connections** | `getConnections()`, `saveConnection(conn)`, `deleteConnection(id)`, `getDismissedSeeds()` | +| **Connections** | `getConnections()`, `saveConnection(conn)`, `deleteConnection(id)`, `getDismissedSeeds()`, `getFavoriteConnectionIds()`, `toggleFavoriteConnection(id)` | | **History** | `getHistory()`, `addToHistory(item)`, `clearHistory()` | | **Saved Queries** | `getSavedQueries()`, `saveQuery(query)`, `deleteSavedQuery(id)` | | **Schema Snapshots** | `getSchemaSnapshots(connId?)`, `saveSchemaSnapshot(snap)`, `deleteSchemaSnapshot(id)` | @@ -990,7 +992,7 @@ When a user first enables server mode (or a new user logs in for the first time) 1. Hook detects serverMode = true 2. Checks localStorage('libredb_server_migrated') flag 3. If not migrated: - a. Reads whichever of the 10 collections exist in localStorage (a fresh browser with none simply sets the flag and skips) + a. Reads whichever of the 11 collections exist in localStorage (a fresh browser with none simply sets the flag and skips) b. POST /api/storage/migrate with the collected payload c. Server calls provider.mergeData() — upserts each collection as a whole blob in one transaction d. Sets 'libredb_server_migrated' flag in localStorage diff --git a/src/components/Studio.tsx b/src/components/Studio.tsx index aafbef3b7..d3901af0a 100644 --- a/src/components/Studio.tsx +++ b/src/components/Studio.tsx @@ -59,6 +59,7 @@ import { useTransactionControl } from "@/hooks/use-transaction-control"; import { useQueryExecution } from "@/hooks/use-query-execution"; import { useInlineEditing } from "@/hooks/use-inline-editing"; import { useStorageSync } from "@/hooks/use-storage-sync"; +import { useFavoriteConnections } from "@/hooks/use-favorite-connections"; import { storage } from "@/lib/storage"; import { type MaskingConfig, @@ -110,6 +111,7 @@ export default function Studio() { // 2. Connection Manager + Provider Metadata const conn = useConnectionManager(storageReady); const { metadata, error: metadataError, retry: retryMetadata } = useProviderMetadata(conn.activeConnection); + const { favoriteIds, toggleFavorite } = useFavoriteConnections(storageReady); // 3. Tab Manager const tabMgr = useTabManager({ @@ -791,6 +793,8 @@ export default function Studio() { setIsConnectionModalOpen(true); }} onDuplicateConnection={handleDuplicateConnection} + favoriteConnectionIds={favoriteIds} + onToggleFavoriteConnection={toggleFavorite} onAddConnection={() => setIsConnectionModalOpen(true)} onObjectClick={onObjectClick} objectActions={objectActions} @@ -914,6 +918,8 @@ export default function Studio() { }} onDeleteConnection={requestDeleteConnection} onDuplicateConnection={handleDuplicateConnection} + favoriteConnectionIds={favoriteIds} + onToggleFavoriteConnection={toggleFavorite} onAddConnection={() => setIsConnectionModalOpen(true)} /> diff --git a/src/components/sidebar/ConnectionItem.tsx b/src/components/sidebar/ConnectionItem.tsx index 4a043f0aa..3595ff2a9 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, Copy, Star } from "lucide-react"; import { getDBIcon } from "@/lib/db-ui-config"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -13,6 +13,8 @@ interface ConnectionItemProps { onDelete: (id: string) => void; onEdit?: (conn: DatabaseConnection) => void; onDuplicate?: (conn: DatabaseConnection) => void; + isFavorite?: boolean; + onToggleFavorite?: (id: string) => void; } export const ConnectionItem = React.memo(function ConnectionItem({ @@ -22,6 +24,8 @@ export const ConnectionItem = React.memo(function ConnectionItem({ onDelete, onEdit, onDuplicate, + isFavorite = false, + onToggleFavorite, }: ConnectionItemProps) { return (
+ {onToggleFavorite && ( + + )} {conn.managed && (
void; onEditConnection?: (conn: DatabaseConnection) => void; onDuplicateConnection?: (conn: DatabaseConnection) => void; + /** Connection ids the user has starred. Renders as a "Favorites" group above the rest. */ + favoriteConnectionIds?: Set; + onToggleFavoriteConnection?: (id: string) => void; onAddConnection: () => void; } +/** Section header matching the "Connections" label + divider style already used below. */ +function SectionHeader({ label }: { label: string }) { + return ( +
+ {label} +
+
+ ); +} + export function ConnectionsList({ connections, activeConnection, @@ -20,39 +33,61 @@ export function ConnectionsList({ onDeleteConnection, onEditConnection, onDuplicateConnection, + favoriteConnectionIds, + onToggleFavoriteConnection, onAddConnection, }: ConnectionsListProps) { + // Favorited connections render together, above the rest, in their existing relative + // order. The non-favorited group keeps exactly the order and behaviour it always has - + // this only ever pulls entries out of it, never reorders or filters what remains. + const favorites = favoriteConnectionIds?.size ? connections.filter((conn) => favoriteConnectionIds.has(conn.id)) : []; + const rest = favoriteConnectionIds?.size + ? connections.filter((conn) => !favoriteConnectionIds.has(conn.id)) + : connections; + + const renderItem = (conn: DatabaseConnection) => ( + + ); + return ( -
-
- Connections -
-
+ <> + {favorites.length > 0 && ( +
+ +
{favorites.map(renderItem)}
+
+ )} + + {(rest.length > 0 || connections.length === 0) && ( +
+ -
- {connections.length === 0 ? ( -
-

- No database connections established yet. -

- +
+ {connections.length === 0 ? ( +
+

+ No database connections established yet. +

+ +
+ ) : ( + rest.map(renderItem) + )}
- ) : ( - connections.map((conn) => ( - - )) - )} -
-
+
+ )} + ); } diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 6180c9ba1..20b797916 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -19,6 +19,9 @@ interface SidebarProps { onDeleteConnection: (id: string) => void; onEditConnection?: (conn: DatabaseConnection) => void; onDuplicateConnection?: (conn: DatabaseConnection) => void; + /** Connection ids the user has starred. Renders as a "Favorites" group above the rest. */ + favoriteConnectionIds?: Set; + onToggleFavoriteConnection?: (id: string) => void; onAddConnection: () => void; /** A row the reader activated, handed over whole: path, kind and the fields the tree loaded. */ onObjectClick?: (object: DatabaseObject) => void; @@ -75,6 +78,8 @@ export function Sidebar({ onDeleteConnection, onEditConnection, onDuplicateConnection, + favoriteConnectionIds, + onToggleFavoriteConnection, onAddConnection, onObjectClick, onShowDiagram, @@ -134,6 +139,8 @@ export function Sidebar({ onDeleteConnection={onDeleteConnection} onEditConnection={onEditConnection} onDuplicateConnection={onDuplicateConnection} + favoriteConnectionIds={favoriteConnectionIds} + onToggleFavoriteConnection={onToggleFavoriteConnection} onAddConnection={onAddConnection} /> diff --git a/src/hooks/use-favorite-connections.ts b/src/hooks/use-favorite-connections.ts new file mode 100644 index 000000000..3fe6bcb71 --- /dev/null +++ b/src/hooks/use-favorite-connections.ts @@ -0,0 +1,65 @@ +"use client"; + +import { useCallback, useMemo, useSyncExternalStore } from "react"; +import { storage } from "@/lib/storage"; +import type { StorageChangeDetail } from "@/lib/storage"; + +const EMPTY_FAVORITE_IDS = new Set(); + +function subscribe(callback: () => void) { + const handleStorageChange = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.collection === "favorite_connections") callback(); + }; + window.addEventListener("libredb-storage-change", handleStorageChange); + return () => window.removeEventListener("libredb-storage-change", handleStorageChange); +} + +/** + * A JSON string rather than the raw array: `useSyncExternalStore` treats any snapshot that + * is not `Object.is`-equal to the previous one as a change, and `storage.getFavoriteConnectionIds()` + * returns a fresh array on every call. Without this, two renders with identical favorites would + * still look like a change on every render, which can trigger the "getSnapshot should be cached" + * infinite-loop warning. + */ +function getSnapshot(): string { + return JSON.stringify(storage.getFavoriteConnectionIds()); +} + +function getServerSnapshot(): string { + return "[]"; +} + +/** + * Tracks which connection ids the user has starred, backed by the storage facade's + * `favorite_connections` collection. + * + * Deliberately a separate id set rather than a field read off each `DatabaseConnection` — + * see the comment on `StorageData["favorite_connections"]` for why a field on the connection + * itself would not survive reload for the connections a user is most likely to favorite. + * + * Built on `useSyncExternalStore` (favorite_connections is exactly that: state that lives + * outside React, in localStorage, mutated by the storage facade) rather than an effect that + * reads storage and calls setState, so a favorite toggled from another mounted instance of + * this hook is reflected here without a synchronous setState-in-effect render cascade. A + * favorite pulled down from the server takes a different path to the same result: the pull + * writes localStorage directly and dispatches no change event, but `useSyncExternalStore` + * re-reads `getSnapshot` on every render regardless of cause, and the pull's own + * `storageReady` flip is what supplies that render. + */ +export function useFavoriteConnections(storageReady: boolean) { + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + const favoriteIds = useMemo(() => { + if (!storageReady) return EMPTY_FAVORITE_IDS; + return new Set(JSON.parse(snapshot) as string[]); + }, [snapshot, storageReady]); + + const toggleFavorite = useCallback((id: string) => { + // useSyncExternalStore re-renders via the subscription above once the facade + // dispatches its change event. + storage.toggleFavoriteConnection(id); + }, []); + + return { favoriteIds, toggleFavorite }; +} diff --git a/src/hooks/use-storage-sync.ts b/src/hooks/use-storage-sync.ts index 36177533d..bcdaa9ef1 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); + if (data.favorite_connections) writeCollectionToLocal("favorite_connections", data.favorite_connections); setLastSyncedAt(new Date()); setSyncError(null); @@ -331,6 +332,8 @@ function getCollectionData(collection: string): unknown { return storage.getThresholdConfig(); case "dismissed_seeds": return storage.getDismissedSeeds(); + case "favorite_connections": + return storage.getFavoriteConnectionIds(); default: return null; } diff --git a/src/lib/storage/connection-secrets.ts b/src/lib/storage/connection-secrets.ts index 04e2df313..9fae622f8 100644 --- a/src/lib/storage/connection-secrets.ts +++ b/src/lib/storage/connection-secrets.ts @@ -213,7 +213,7 @@ export interface ConnectionReadResult { /** * Every read goes through this. An unreadable field is OMITTED and the record kept: * - * - Throwing would empty all ten collections for a rotated key, taking the user's query history, + * - Throwing would empty all eleven collections for a rotated key, taking the user's query history, * saved queries, charts and snapshots down with the passwords. * - Dropping the record would be worse. useStorageSync is a write-through cache, so a connection * missing from a read is persisted as a deletion on the next push - destroying ciphertext that a diff --git a/src/lib/storage/encrypting-provider.ts b/src/lib/storage/encrypting-provider.ts index d7e68a34b..47cfad7c9 100644 --- a/src/lib/storage/encrypting-provider.ts +++ b/src/lib/storage/encrypting-provider.ts @@ -14,7 +14,7 @@ import type { DatabaseConnection } from "@/lib/types"; * * Only `connections` is touched. No other collection carries a credential field: history and * saved_queries hold SQL text (the product's data, not its secrets), audit_log is already - * sanitized by src/lib/audit.ts, and the remaining six hold metadata. + * sanitized by src/lib/audit.ts, and the remaining seven hold metadata. */ const CONNECTIONS: StorageCollection = "connections"; diff --git a/src/lib/storage/storage-facade.ts b/src/lib/storage/storage-facade.ts index 43cb53eb6..5f723606e 100644 --- a/src/lib/storage/storage-facade.ts +++ b/src/lib/storage/storage-facade.ts @@ -81,6 +81,26 @@ export const storage = { const filtered = connections.filter((c) => c.id !== id); writeJSON("connections", filtered); dispatchChange("connections", filtered); + + const favorites = storage.getFavoriteConnectionIds(); + if (favorites.includes(id)) { + const nextFavorites = favorites.filter((favId) => favId !== id); + writeJSON("favorite_connections", nextFavorites); + dispatchChange("favorite_connections", nextFavorites); + } + }, + + getFavoriteConnectionIds: (): string[] => { + return readJSON("favorite_connections") ?? []; + }, + + /** Flips the connection's favorite state and returns the updated id list. */ + toggleFavoriteConnection: (id: string): string[] => { + const current = storage.getFavoriteConnectionIds(); + const next = current.includes(id) ? current.filter((favId) => favId !== id) : [...current, id]; + writeJSON("favorite_connections", next); + dispatchChange("favorite_connections", next); + return next; }, // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/lib/storage/types.ts b/src/lib/storage/types.ts index 01c8144a7..6b26fd9fb 100644 --- a/src/lib/storage/types.ts +++ b/src/lib/storage/types.ts @@ -19,6 +19,16 @@ export interface StorageData { threshold_config: ThresholdConfig[]; /** seedIds the user dismissed (deleted a managed:false seed copy) so it is not re-added. */ dismissed_seeds: string[]; + /** + * Connection ids the user has starred, kept separate from `connections` rather than as a + * field on `DatabaseConnection`: a `managed:true` connection is always taken fresh from the + * server on every load (see `mergeManagedConnections` in `use-connection-manager.ts`), so a + * field on the connection object itself would be silently discarded on reload for exactly the + * connections a user is most likely to want to favorite. A separate id list favorites + * correctly regardless of who owns the connection, and a duplicated connection (which gets a + * new id) does not inherit the original's favorite status for free. + */ + favorite_connections: string[]; } /** Collection names that can be synced to server storage */ @@ -36,6 +46,7 @@ export const STORAGE_COLLECTIONS: StorageCollection[] = [ "masking_config", "threshold_config", "dismissed_seeds", + "favorite_connections", ]; /** diff --git a/tests/components/Studio.test.tsx b/tests/components/Studio.test.tsx index df6f3a9a7..03fcf3535 100644 --- a/tests/components/Studio.test.tsx +++ b/tests/components/Studio.test.tsx @@ -80,6 +80,8 @@ const mockStorageSaveConnection = mock(() => {}); const mockStorageGetConnections = mock(() => [] as unknown[]); const mockStorageDeleteConnection = mock(() => {}); const mockStorageSaveQuery = mock(() => {}); +const mockStorageGetFavoriteConnectionIds = mock(() => [] as string[]); +const mockStorageToggleFavoriteConnection = mock(() => [] as string[]); // Data Masking const mockSaveMaskingConfig = mock(() => {}); // URL (for export tests) @@ -239,6 +241,8 @@ mock.module("@/lib/storage", () => ({ deleteConnection: mockStorageDeleteConnection, saveQuery: mockStorageSaveQuery, getActiveConnectionId: mock(() => null), + getFavoriteConnectionIds: mockStorageGetFavoriteConnectionIds, + toggleFavoriteConnection: mockStorageToggleFavoriteConnection, }, })); @@ -576,6 +580,9 @@ describe("Studio", () => { mockStorageGetConnections.mockReturnValue([]); mockStorageDeleteConnection.mockClear(); mockStorageSaveQuery.mockClear(); + mockStorageGetFavoriteConnectionIds.mockClear(); + mockStorageGetFavoriteConnectionIds.mockReturnValue([]); + mockStorageToggleFavoriteConnection.mockClear(); mockSaveMaskingConfig.mockClear(); // Set rather than restored: one test turns masking on, and `mockRestore` in bun // drops the implementation entirely instead of returning it to this default. @@ -1111,6 +1118,32 @@ describe("Studio", () => { expect(source).toEqual(original); }); + test("loads favoriteConnectionIds from storage and forwards them to Sidebar", () => { + mockStorageGetFavoriteConnectionIds.mockReturnValue(["fav-1", "fav-2"]); + + render(); + + expect(mockStorageGetFavoriteConnectionIds).toHaveBeenCalled(); + const favoriteIds = capturedSidebarProps.favoriteConnectionIds as Set; + expect(favoriteIds.has("fav-1")).toBe(true); + expect(favoriteIds.has("fav-2")).toBe(true); + }); + + test.each(["desktop", "mobile"] as const)( + "onToggleFavoriteConnection (%s) calls storage.toggleFavoriteConnection with the connection id", + (surface) => { + render(); + if (surface === "mobile") { + act(() => (capturedMobileNavProps.onTabChange as (tab: string) => void)("database")); + } + const props = surface === "mobile" ? capturedConnectionsListProps : capturedSidebarProps; + + act(() => (props.onToggleFavoriteConnection as (id: string) => void)("conn-1")); + + expect(mockStorageToggleFavoriteConnection).toHaveBeenCalledWith("conn-1"); + }, + ); + test("onAddConnection opens connection modal", () => { render(); const fn = capturedSidebarProps.onAddConnection as () => void; diff --git a/tests/components/sidebar/ConnectionItem.test.tsx b/tests/components/sidebar/ConnectionItem.test.tsx index a7fb8ab0f..862cfb7fe 100644 --- a/tests/components/sidebar/ConnectionItem.test.tsx +++ b/tests/components/sidebar/ConnectionItem.test.tsx @@ -243,4 +243,80 @@ describe("ConnectionItem", () => { // onSelect should NOT have been called (stopPropagation) expect(defaultOnSelect).toHaveBeenCalledTimes(0); }); + + describe("favorite toggle", () => { + test("no star button when onToggleFavorite is not passed", () => { + const { queryByLabelText } = render( + , + ); + + expect(queryByLabelText("Add to favorites")).toBeNull(); + expect(queryByLabelText("Remove from favorites")).toBeNull(); + }); + + test("shows an unfavorited star labeled to add, and toggles it with stopPropagation", () => { + const onToggleFavorite = mock(() => {}); + const { getByLabelText } = render( + , + ); + + const star = getByLabelText("Add to favorites"); + expect(star.getAttribute("aria-pressed")).toBe("false"); + + fireEvent.click(star); + + expect(onToggleFavorite).toHaveBeenCalledTimes(1); + expect(onToggleFavorite).toHaveBeenCalledWith(mockPostgresConnection.id); + expect(defaultOnSelect).not.toHaveBeenCalled(); + }); + + test("a favorited connection shows a filled star labeled to remove", () => { + const onToggleFavorite = mock(() => {}); + const { getByLabelText } = render( + , + ); + + const star = getByLabelText("Remove from favorites"); + expect(star.getAttribute("aria-pressed")).toBe("true"); + + fireEvent.click(star); + + expect(onToggleFavorite).toHaveBeenCalledWith(mockPostgresConnection.id); + }); + + test("star toggle is available for managed connections", () => { + const onToggleFavorite = mock(() => {}); + const { getByLabelText } = render( + , + ); + + fireEvent.click(getByLabelText("Add to favorites")); + expect(onToggleFavorite).toHaveBeenCalledWith(mockPostgresConnection.id); + }); + }); }); diff --git a/tests/components/sidebar/ConnectionsList.test.tsx b/tests/components/sidebar/ConnectionsList.test.tsx index 093109904..27af08388 100644 --- a/tests/components/sidebar/ConnectionsList.test.tsx +++ b/tests/components/sidebar/ConnectionsList.test.tsx @@ -286,4 +286,183 @@ describe("ConnectionsList", () => { expect(queryByText("No database connections established yet.")).toBeNull(); }); + + describe("favorites", () => { + const defaultOnToggleFavorite = mock(() => {}); + + beforeEach(() => { + defaultOnToggleFavorite.mockClear(); + }); + + test("no Favorites section when favoriteConnectionIds is not passed", () => { + const { queryByText } = render( + , + ); + + expect(queryByText("Favorites")).toBeNull(); + }); + + test("no Favorites section when favoriteConnectionIds is empty", () => { + const { queryByText } = render( + , + ); + + expect(queryByText("Favorites")).toBeNull(); + }); + + test("renders a Favorites section above Connections when a connection is favorited", () => { + const { getByText } = render( + , + ); + + const favoritesHeader = getByText("Favorites"); + const connectionsHeader = getByText("Connections"); + // DOM order: Favorites section precedes the Connections section + expect( + favoritesHeader.compareDocumentPosition(connectionsHeader) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + test("a favorited connection renders once, under Favorites, not duplicated under Connections", () => { + const { container } = render( + , + ); + + const matches = Array.from(container.querySelectorAll("span")).filter((el) => el.textContent === "Test MySQL"); + expect(matches.length).toBe(1); + }); + + test("non-favorited connections keep rendering under Connections", () => { + const { queryByText } = render( + , + ); + + expect(queryByText("Test PostgreSQL")).not.toBeNull(); + }); + + test("clicking the star toggle calls onToggleFavoriteConnection with the connection id", () => { + const { getByLabelText } = render( + , + ); + + fireEvent.click(getByLabelText("Add to favorites")); + + expect(defaultOnToggleFavorite).toHaveBeenCalledTimes(1); + expect(defaultOnToggleFavorite).toHaveBeenCalledWith(mockPostgresConnection.id); + // stopPropagation: the item itself must not be selected + expect(defaultOnSelect).not.toHaveBeenCalled(); + }); + + test("a favorited connection's star toggle is labeled to remove it", () => { + const { getByLabelText } = render( + , + ); + + fireEvent.click(getByLabelText("Remove from favorites")); + + expect(defaultOnToggleFavorite).toHaveBeenCalledWith(mockPostgresConnection.id); + }); + + test("shows the Connections empty state only when there are truly no connections, not when all are favorited", () => { + const { queryByText } = render( + , + ); + + expect(queryByText("No database connections established yet.")).toBeNull(); + }); + + test("hides the Connections section entirely when every connection is favorited", () => { + const { queryByText } = render( + , + ); + + // Both are under Favorites; the "Connections" header has nothing left to sit above. + expect(queryByText("Connections")).toBeNull(); + }); + + test("keeps the Connections section when at least one connection is not favorited", () => { + const { queryByText } = render( + , + ); + + expect(queryByText("Connections")).not.toBeNull(); + }); + }); }); diff --git a/tests/components/sidebar/Sidebar.test.tsx b/tests/components/sidebar/Sidebar.test.tsx index a6d13d616..3ae79afba 100644 --- a/tests/components/sidebar/Sidebar.test.tsx +++ b/tests/components/sidebar/Sidebar.test.tsx @@ -4,11 +4,15 @@ import "../../helpers/mock-navigation"; import { mock } from "bun:test"; let capturedDuplicateHandler: unknown; +let capturedFavoriteIds: unknown; +let capturedToggleFavoriteHandler: unknown; // Mock child components to isolate Sidebar logic mock.module("@/components/sidebar/ConnectionsList", () => ({ ConnectionsList: (props: Record) => { capturedDuplicateHandler = props.onDuplicateConnection; + capturedFavoriteIds = props.favoriteConnectionIds; + capturedToggleFavoriteHandler = props.onToggleFavoriteConnection; // eslint-disable-next-line @typescript-eslint/no-require-imports const React = require("react"); const connections = props.connections as Array> | undefined; @@ -337,6 +341,17 @@ describe("Sidebar", () => { expect(capturedDuplicateHandler).toBe(onDuplicateConnection); }); + test("passes favoriteConnectionIds and onToggleFavoriteConnection through to ConnectionsList", () => { + const favoriteConnectionIds = new Set([mockPostgresConnection.id]); + const onToggleFavoriteConnection = mock(() => {}); + const props = createDefaultProps({ favoriteConnectionIds, onToggleFavoriteConnection }); + + render(); + + expect(capturedFavoriteIds).toBe(favoriteConnectionIds); + expect(capturedToggleFavoriteHandler).toBe(onToggleFavoriteConnection); + }); + /** * The footer used to print a hardcoded "v1.2.5" while the package had long * moved on, so the sidebar told users a version the build never was. It now diff --git a/tests/components/studio-agent-ask.test.tsx b/tests/components/studio-agent-ask.test.tsx index dfe16a416..123cf7311 100644 --- a/tests/components/studio-agent-ask.test.tsx +++ b/tests/components/studio-agent-ask.test.tsx @@ -200,6 +200,8 @@ mock.module("@/lib/storage", () => ({ // Read by the REAL command palette when it opens. getSavedQueries: mock(() => [] as unknown[]), getHistory: mock(() => [] as unknown[]), + getFavoriteConnectionIds: mock(() => [] as string[]), + toggleFavoriteConnection: mock(() => [] as string[]), }, })); diff --git a/tests/components/studio/source-tab.test.tsx b/tests/components/studio/source-tab.test.tsx index f9aa0b622..ca5792522 100644 --- a/tests/components/studio/source-tab.test.tsx +++ b/tests/components/studio/source-tab.test.tsx @@ -228,6 +228,8 @@ mock.module("@/lib/storage", () => ({ deleteConnection: () => {}, saveQuery: () => {}, getActiveConnectionId: () => null, + getFavoriteConnectionIds: () => [] as string[], + toggleFavoriteConnection: () => [] as string[], }, })); diff --git a/tests/hooks/use-favorite-connections.test.ts b/tests/hooks/use-favorite-connections.test.ts new file mode 100644 index 000000000..7d1145021 --- /dev/null +++ b/tests/hooks/use-favorite-connections.test.ts @@ -0,0 +1,130 @@ +import "../setup-dom"; + +import { describe, test, expect, beforeEach } from "bun:test"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import React from "react"; +import ReactDOMServer from "react-dom/server"; + +import { useFavoriteConnections } from "@/hooks/use-favorite-connections"; +import { storage } from "@/lib/storage"; + +describe("useFavoriteConnections", () => { + beforeEach(() => { + localStorage.clear(); + }); + + test("starts with an empty set before storage is ready", () => { + const { result } = renderHook(() => useFavoriteConnections(false)); + expect(result.current.favoriteIds.size).toBe(0); + }); + + test("does not read from storage before storage is ready", () => { + storage.toggleFavoriteConnection("conn-1"); + const { result } = renderHook(() => useFavoriteConnections(false)); + expect(result.current.favoriteIds.has("conn-1")).toBe(false); + }); + + test("loads existing favorites once storage becomes ready", () => { + storage.toggleFavoriteConnection("conn-1"); + storage.toggleFavoriteConnection("conn-2"); + + const { result, rerender } = renderHook(({ ready }) => useFavoriteConnections(ready), { + initialProps: { ready: false }, + }); + expect(result.current.favoriteIds.size).toBe(0); + + rerender({ ready: true }); + + expect(result.current.favoriteIds.has("conn-1")).toBe(true); + expect(result.current.favoriteIds.has("conn-2")).toBe(true); + }); + + test("toggleFavorite adds an id and updates favoriteIds", async () => { + const { result } = renderHook(() => useFavoriteConnections(true)); + + act(() => { + result.current.toggleFavorite("conn-1"); + }); + + await waitFor(() => { + expect(result.current.favoriteIds.has("conn-1")).toBe(true); + }); + expect(storage.getFavoriteConnectionIds()).toEqual(["conn-1"]); + }); + + test("toggleFavorite removes an id already favorited", async () => { + const { result } = renderHook(() => useFavoriteConnections(true)); + + act(() => { + result.current.toggleFavorite("conn-1"); + }); + await waitFor(() => { + expect(result.current.favoriteIds.has("conn-1")).toBe(true); + }); + + act(() => { + result.current.toggleFavorite("conn-1"); + }); + await waitFor(() => { + expect(result.current.favoriteIds.has("conn-1")).toBe(false); + }); + }); + + test("ignores libredb-storage-change events for other collections", () => { + const { result } = renderHook(() => useFavoriteConnections(true)); + + act(() => { + window.dispatchEvent( + new CustomEvent("libredb-storage-change", { detail: { collection: "connections", data: [] } }), + ); + }); + + expect(result.current.favoriteIds.size).toBe(0); + }); + + test("picks up a favorite_connections change dispatched by another consumer of the facade", async () => { + const { result } = renderHook(() => useFavoriteConnections(true)); + + act(() => { + // Simulates a second mounted instance (or the storage-sync pull) writing through + // the facade directly, rather than through this hook's own toggleFavorite. + storage.toggleFavoriteConnection("conn-remote"); + }); + + await waitFor(() => { + expect(result.current.favoriteIds.has("conn-remote")).toBe(true); + }); + }); + + /** + * There is no localStorage on the server, so `useSyncExternalStore` must take the + * server snapshot rather than one that read the (jsdom-provided, in this suite) + * localStorage. Seeding a real favorite first is what makes the assertion able to + * tell the two snapshots apart: only `getSnapshot` would see it. + */ + test("reports no favorites during server rendering, even with favorites already stored", () => { + storage.toggleFavoriteConnection("conn-should-not-appear"); + + function Probe() { + const { favoriteIds } = useFavoriteConnections(true); + return React.createElement("span", null, String(favoriteIds.has("conn-should-not-appear"))); + } + + expect(ReactDOMServer.renderToString(React.createElement(Probe))).toContain("false"); + }); + + test("removes its event listener on unmount", () => { + const originalRemove = window.removeEventListener.bind(window); + let removedCollectionListener = false; + window.removeEventListener = ((...args: Parameters) => { + if (args[0] === "libredb-storage-change") removedCollectionListener = true; + return originalRemove(...args); + }) as typeof window.removeEventListener; + + const { unmount } = renderHook(() => useFavoriteConnections(true)); + unmount(); + + expect(removedCollectionListener).toBe(true); + window.removeEventListener = originalRemove; + }); +}); diff --git a/tests/isolated/use-storage-sync.test.ts b/tests/isolated/use-storage-sync.test.ts index b8d6cf0ab..87e4b459c 100644 --- a/tests/isolated/use-storage-sync.test.ts +++ b/tests/isolated/use-storage-sync.test.ts @@ -21,6 +21,7 @@ const mockStorage = { })), getThresholdConfig: mock(() => []), getDismissedSeeds: mock(() => ["seed-1"]), + getFavoriteConnectionIds: mock(() => ["fav-1"]), }; const ALL_COLLECTIONS = [ @@ -34,6 +35,7 @@ const ALL_COLLECTIONS = [ "masking_config", "threshold_config", "dismissed_seeds", + "favorite_connections", ]; mock.module("@/lib/storage", () => ({ @@ -289,6 +291,7 @@ describe("useStorageSync", () => { expect(mockStorage.getMaskingConfig).toHaveBeenCalled(); expect(mockStorage.getThresholdConfig).toHaveBeenCalled(); expect(mockStorage.getDismissedSeeds).toHaveBeenCalled(); + expect(mockStorage.getFavoriteConnectionIds).toHaveBeenCalled(); }); }); @@ -380,6 +383,23 @@ describe("useStorageSync", () => { expect(localStorage.getItem("libredb_active_connection_id")).toBe("conn-1"); }); + test("writes favorite_connections to localStorage on pull", async () => { + localStorage.setItem("libredb_server_migrated", "true"); + setupServerMode({ + "/api/storage": { ok: true, status: 200, json: { favorite_connections: ["fav-1", "fav-2"] } }, + }); + + const { result } = renderHook(() => useStorageSync()); + + await waitFor(() => { + expect(result.current.lastSyncedAt).not.toBeNull(); + }); + + const stored = localStorage.getItem("libredb_favorite_connections"); + expect(stored).not.toBeNull(); + expect(JSON.parse(stored!)).toEqual(["fav-1", "fav-2"]); + }); + test("removes active_connection_id from localStorage when server returns null", async () => { localStorage.setItem("libredb_server_migrated", "true"); localStorage.setItem("libredb_active_connection_id", "stale"); diff --git a/tests/unit/lib/storage/storage-facade.test.ts b/tests/unit/lib/storage/storage-facade.test.ts index edde331b2..e664797e4 100644 --- a/tests/unit/lib/storage/storage-facade.test.ts +++ b/tests/unit/lib/storage/storage-facade.test.ts @@ -186,3 +186,84 @@ describe("storage facade: threshold config", () => { expect(result[0].metric).toBe("custom"); }); }); + +// ── Favorite connections ───────────────────────────────────────────────────── + +describe("storage facade: favorite connections", () => { + beforeEach(() => { + localStorage.clear(); + }); + + test("getFavoriteConnectionIds returns an empty array when nothing stored", () => { + expect(storage.getFavoriteConnectionIds()).toEqual([]); + }); + + test("toggleFavoriteConnection adds an id not already favorited", () => { + const result = storage.toggleFavoriteConnection("conn-1"); + expect(result).toEqual(["conn-1"]); + expect(storage.getFavoriteConnectionIds()).toEqual(["conn-1"]); + }); + + test("toggleFavoriteConnection removes an id already favorited", () => { + storage.toggleFavoriteConnection("conn-1"); + const result = storage.toggleFavoriteConnection("conn-1"); + expect(result).toEqual([]); + expect(storage.getFavoriteConnectionIds()).toEqual([]); + }); + + test("toggleFavoriteConnection preserves other favorited ids", () => { + storage.toggleFavoriteConnection("conn-1"); + storage.toggleFavoriteConnection("conn-2"); + const result = storage.toggleFavoriteConnection("conn-1"); + expect(result).toEqual(["conn-2"]); + }); + + test("toggleFavoriteConnection dispatches libredb-storage-change with collection favorite_connections", () => { + let captured: CustomEvent | null = null; + const handler = (e: Event) => { + captured = e as CustomEvent; + }; + window.addEventListener("libredb-storage-change", handler); + + storage.toggleFavoriteConnection("conn-1"); + + window.removeEventListener("libredb-storage-change", handler); + expect(captured).not.toBeNull(); + expect((captured as unknown as CustomEvent).detail.collection).toBe("favorite_connections"); + expect((captured as unknown as CustomEvent).detail.data).toEqual(["conn-1"]); + }); + + test("deleteConnection prunes the deleted id out of favorite_connections", () => { + storage.saveConnection(makeConnection({ id: "conn-1" })); + storage.toggleFavoriteConnection("conn-1"); + storage.toggleFavoriteConnection("conn-2"); // a favorite for a connection that isn't this one + + storage.deleteConnection("conn-1"); + + expect(storage.getFavoriteConnectionIds()).toEqual(["conn-2"]); + }); + + test("deleteConnection does not touch favorite_connections when the deleted id wasn't favorited", () => { + storage.saveConnection(makeConnection({ id: "conn-1" })); + storage.toggleFavoriteConnection("conn-2"); + + storage.deleteConnection("conn-1"); + + expect(storage.getFavoriteConnectionIds()).toEqual(["conn-2"]); + }); + + test("deleteConnection dispatches a favorite_connections change only when the deleted id was favorited", () => { + storage.saveConnection(makeConnection({ id: "conn-1" })); + storage.toggleFavoriteConnection("conn-1"); + const collections: string[] = []; + const handler = (e: Event) => { + collections.push((e as CustomEvent).detail.collection); + }; + window.addEventListener("libredb-storage-change", handler); + + storage.deleteConnection("conn-1"); + + expect(collections).toContain("favorite_connections"); + window.removeEventListener("libredb-storage-change", handler); + }); +});