diff --git a/changelog.d/features/sidebar-pinned-items.md b/changelog.d/features/sidebar-pinned-items.md new file mode 100644 index 0000000000..2075fe16e0 --- /dev/null +++ b/changelog.d/features/sidebar-pinned-items.md @@ -0,0 +1 @@ +- **feat(dashboard):** add sidebar pinned items shortcut section with individual item pin toggle and localStorage persistence ([#12891](https://github.com/diegosouzapw/OmniRoute/pull/12891)) diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 84b9418b8c..f0d08510c3 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1293,6 +1293,9 @@ "mainNavigation": "Main navigation", "unpinSection": "Unpin section", "pinSectionOpen": "Pin section open", + "pinItem": "Pin item", + "unpinItem": "Unpin item", + "pinnedSection": "Pinned", "reloadPage": "Reload Page", "dragReorderSection": "Drag to reorder section", "dragReorderItem": "Drag to reorder", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 3686818932..e10e9c2f16 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -1293,6 +1293,9 @@ "mainNavigation": "Navigasi utama", "unpinSection": "Lepas sematan bagian", "pinSectionOpen": "Sematkan bagian agar tetap terbuka", + "pinItem": "Sematkan item", + "unpinItem": "Lepas sematan item", + "pinnedSection": "Disematkan", "reloadPage": "Muat Ulang Halaman", "dragReorderSection": "Seret untuk menyusun ulang bagian", "dragReorderItem": "Seret untuk menyusun ulang", diff --git a/src/shared/components/Sidebar.tsx b/src/shared/components/Sidebar.tsx index 27ab62e0d0..d89d5c1825 100644 --- a/src/shared/components/Sidebar.tsx +++ b/src/shared/components/Sidebar.tsx @@ -51,6 +51,7 @@ const isE2EMode = process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE === "1"; const DEFAULT_EXPANDED: SidebarSectionId = "omni-proxy"; const EXPANDED_SECTIONS_KEY = "sidebar-expanded-sections"; const PINNED_SECTIONS_KEY = "sidebar-pinned-sections"; +const PINNED_ITEMS_KEY = "sidebar-pinned-items"; type SidebarGlyphStyle = CSSProperties & { "--sidebar-icon-accent": string; @@ -104,6 +105,13 @@ function readStoredPinnedRaw() { return null; } } +function readStoredPinnedItemsRaw() { + try { + return localStorage.getItem(PINNED_ITEMS_KEY); + } catch { + return null; + } +} export default function Sidebar({ onClose, @@ -143,6 +151,8 @@ export default function Sidebar({ new Set([DEFAULT_EXPANDED]) ); const [pinnedSections, setPinnedSections] = useState>(new Set()); + const [pinnedItems, setPinnedItems] = useState>(new Set()); + const [pinnedSectionCollapsed, setPinnedSectionCollapsed] = useState(false); const [sidebarExpansionLoaded, setSidebarExpansionLoaded] = useState(false); const [skipInitialActiveExpansion, setSkipInitialActiveExpansion] = useState(false); const [hoveredItem, setHoveredItem] = useState(null); @@ -168,6 +178,11 @@ export default function Sidebar({ readStoredPinnedRaw, getServerSnapshotNull ); + const storedPinnedItemsRaw = useSyncExternalStore( + noopSubscribe, + readStoredPinnedItemsRaw, + getServerSnapshotNull + ); if (hydrated && !sidebarExpansionLoaded) { const storedExpanded = parseStoredArray(storedExpandedRaw, [ DEFAULT_EXPANDED, @@ -176,6 +191,7 @@ export default function Sidebar({ storedPinnedRaw !== null ? parseStoredArray(storedPinnedRaw, []) : (SIDEBAR_SECTIONS.filter((s) => s.defaultPinned).map((s) => s.id) as SidebarSectionId[]); + const storedPinnedItems = parseStoredArray(storedPinnedItemsRaw, []); const initialPinned = new Set(storedPinned); const initialExpanded = hydrateExpandedSections(storedExpanded, initialPinned); @@ -183,6 +199,7 @@ export default function Sidebar({ setSkipInitialActiveExpansion(storedExpanded.length === 0); setExpandedSections(initialExpanded); setPinnedSections(initialPinned); + setPinnedItems(new Set(storedPinnedItems)); setSidebarExpansionLoaded(true); } @@ -326,12 +343,31 @@ export default function Sidebar({ section.children.flatMap((child: any) => (child.type === "group" ? child.items : [child])) ); + const pinnedItemList = Array.from(pinnedItems) + .map((id) => allVisibleItems.find((item) => item.id === id)) + .filter(Boolean) as (SidebarItemDefinition & { label: string; subtitle?: string })[]; + + const homeIndex = visibleSections.findIndex((s) => s.id === "home"); + const insertIndex = homeIndex >= 0 ? homeIndex + 1 : 0; + const sectionsWithPinned = + pinnedItemList.length > 0 + ? [ + ...visibleSections.slice(0, insertIndex), + { + id: "pinned" as SidebarSectionId, + title: getSidebarLabel("pinnedSection", "Pinned"), + children: pinnedItemList, + }, + ...visibleSections.slice(insertIndex), + ] + : visibleSections; + const activeHref = getActiveSidebarHref(pathname, allVisibleItems); const isSearching = searchQuery.trim().length > 0; const displaySections = isSearching - ? filterSidebarSectionsByQuery(visibleSections, searchQuery) - : visibleSections; + ? filterSidebarSectionsByQuery(sectionsWithPinned, searchQuery) + : sectionsWithPinned; // Keep the active page visible while preserving accordion semantics for // unpinned sections. Render-time adjustment (react.dev "You Might Not Need @@ -377,6 +413,10 @@ export default function Sidebar({ // Accordion toggle: opening a section closes all non-pinned sections const toggleSection = useCallback( (sectionId: SidebarSectionId) => { + if (sectionId === "pinned") { + setPinnedSectionCollapsed((prev) => !prev); + return; + } setExpandedSections((prev) => toggleExpandedSection(prev, pinnedSections, sectionId)); }, [pinnedSections] @@ -402,6 +442,19 @@ export default function Sidebar({ }); }, []); + const togglePinItem = useCallback((itemId: string) => { + setPinnedItems((prev) => { + const next = new Set(prev); + if (next.has(itemId)) { + next.delete(itemId); + } else { + next.add(itemId); + } + saveToStorage(PINNED_ITEMS_KEY, [...next]); + return next; + }); + }, []); + const handleShutdown = async () => { setIsShuttingDown(true); try { @@ -444,8 +497,10 @@ export default function Sidebar({ const handleMouseLeave = useCallback(() => setHoveredItem(null), []); - const renderNavLink = (item) => { + const renderNavLink = (item: any, keyPrefix?: string) => { const active = !item.external && activeHref === item.href; + const isItemPinned = pinnedItems.has(item.id); + const itemKey = keyPrefix ? `${keyPrefix}-${item.href}` : item.href; const className = cn( "flex items-center gap-3 rounded-lg transition-all group", collapsed ? "justify-center px-2 py-2.5" : "px-3 py-1.5", @@ -455,7 +510,7 @@ export default function Sidebar({ ); const iconClassName = cn( "material-symbols-outlined text-[18px] shrink-0", - active ? "fill-1" : "group-hover:text-primary transition-colors" + active ? "fill-1" : "group-hover/nav-item:text-primary transition-colors" ); const content = ( <> @@ -463,7 +518,7 @@ export default function Sidebar({ {item.icon} {!collapsed && ( -
+
{item.label} {item.subtitle && ( {item.subtitle} @@ -477,33 +532,105 @@ export default function Sidebar({ onMouseLeave: handleMouseLeave, }; - if (item.external) { + if (collapsed) { + if (item.external) { + return ( + + {content} + + ); + } + return ( - {content} - + + ); + } + + const pinButton = item.id !== "home" && ( + + ); + + const containerClassName = cn( + "group/nav-item flex items-center rounded-lg transition-all", + active + ? "bg-primary/10 text-primary" + : "text-text-muted hover:bg-surface/50 hover:text-text-main" + ); + const innerLinkClassName = "flex min-w-0 flex-1 items-center gap-3 px-3 py-1.5"; + + if (item.external) { + return ( +
+ + {content} + + {pinButton} +
); } return ( - - {content} - +
+ + {content} + + {pinButton} +
); }; @@ -616,7 +743,9 @@ export default function Sidebar({ )} {displaySections.map((section, idx) => { const sectionId = section.id as SidebarSectionId; - const isExpanded = isSearching || expandedSections.has(sectionId); + const isExpanded = + isSearching || + (sectionId === "pinned" ? !pinnedSectionCollapsed : expandedSections.has(sectionId)); const isPinned = pinnedSections.has(sectionId); const isFirst = idx === 0; const sectionItems = section.children.flatMap((child: any) => @@ -630,7 +759,9 @@ export default function Sidebar({ {!isFirst && (
)} - {sectionItems.map(renderNavLink)} + {sectionItems.map((item: any) => + renderNavLink(item, section.id === "pinned" ? "pinned" : undefined) + )}
); } @@ -639,7 +770,9 @@ export default function Sidebar({ if (section.showTitle === false) { return (
- {sectionItems.map(renderNavLink)} + {sectionItems.map((item: any) => + renderNavLink(item, section.id === "pinned" ? "pinned" : undefined) + )}
); } @@ -657,30 +790,32 @@ export default function Sidebar({ {section.title} - {/* Pin button — right side near chevron */} - + + push_pin + + + )}
)} - {child.items.map(renderNavLink)} + {child.items.map((item: any) => + renderNavLink(item, section.id === "pinned" ? "pinned" : undefined) + )}
); } - return renderNavLink(child); + return renderNavLink(child, section.id === "pinned" ? "pinned" : undefined); })} )} diff --git a/src/shared/constants/sidebarVisibility/types.ts b/src/shared/constants/sidebarVisibility/types.ts index 792cd46dee..a7d1480c93 100644 --- a/src/shared/constants/sidebarVisibility/types.ts +++ b/src/shared/constants/sidebarVisibility/types.ts @@ -124,6 +124,7 @@ export type SidebarItemId = HideableSidebarItemId | AlwaysVisibleSidebarItemId; export type SidebarSectionId = | "home" + | "pinned" | "omni-proxy" | "analytics" | "costs" diff --git a/tests/unit/sidebar-pinned-items.test.tsx b/tests/unit/sidebar-pinned-items.test.tsx new file mode 100644 index 0000000000..5032ff69da --- /dev/null +++ b/tests/unit/sidebar-pinned-items.test.tsx @@ -0,0 +1,233 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE = "1"; + +vi.mock("next-intl", () => ({ + useTranslations: () => { + const translate = (key: string) => { + if (key === "pinnedSection") return "Pinned"; + if (key === "pinItem") return "Pin item"; + if (key === "unpinItem") return "Unpin item"; + if (key === "usage") return "Usage"; + if (key === "logs") return "Logs"; + return key; + }; + translate.has = (key: string) => + ["pinnedSection", "pinItem", "unpinItem", "usage", "logs"].includes(key); + return translate; + }, +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/dashboard/analytics", +})); + +const cleanupCallbacks: Array<() => void> = []; + +function makeContainer(): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + cleanupCallbacks.push(() => container.remove()); + return container; +} + +function jsonResponse(body: unknown) { + return { ok: true, status: 200, json: async () => body } as Response; +} + +describe("Sidebar pinned items shortcut (#pinned-items)", () => { + let root: Root | undefined; + + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + localStorage.clear(); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (String(url).includes("/api/settings")) return jsonResponse({}); + return jsonResponse({}); + }) + ); + }); + + afterEach(() => { + if (root) { + act(() => root!.unmount()); + root = undefined; + } + while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.(); + document.body.innerHTML = ""; + localStorage.clear(); + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("does not render PINNED section when no items are pinned", async () => { + const { default: Sidebar } = await import("@/shared/components/Sidebar"); + const container = makeContainer(); + root = createRoot(container); + await act(async () => { + root!.render(); + }); + + const headers = Array.from(container.querySelectorAll("span")).map((s) => s.textContent); + expect(headers).not.toContain("Pinned"); + }); + + it("hydrates and renders PINNED section when items are stored in localStorage", async () => { + localStorage.setItem("sidebar-pinned-items", JSON.stringify(["analytics", "logs"])); + + const { default: Sidebar } = await import("@/shared/components/Sidebar"); + const container = makeContainer(); + root = createRoot(container); + await act(async () => { + root!.render(); + }); + + // Pinned section should now appear + const headers = Array.from(container.querySelectorAll("span")).map((s) => s.textContent); + expect(headers).toContain("Pinned"); + + // The pinned shortcuts should have links to /dashboard/analytics and /dashboard/logs + const links = Array.from(container.querySelectorAll('a[href="/dashboard/analytics"]')); + // Since it exists in PINNED and in its original section (ANALYTICS), there should be 2 links + expect(links.length).toBe(2); + }); + + it("allows pinning and unpinning an item, persisting to localStorage", async () => { + const { default: Sidebar } = await import("@/shared/components/Sidebar"); + const container = makeContainer(); + root = createRoot(container); + await act(async () => { + root!.render(); + }); + + // Expand ANALYTICS if not expanded + const analyticsHeader = Array.from(container.querySelectorAll('div[role="button"]')).find( + (el) => el.textContent?.includes("analyticsSection") + ); + if (analyticsHeader) { + await act(async () => { + analyticsHeader.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + } + + // Find the pin button for analytics item + const pinButtons = Array.from( + container.querySelectorAll('button[title*="Pin item"], button[aria-label*="Pin item"]') + ); + expect(pinButtons.length).toBeGreaterThan(0); + + // Click pin on the first item + await act(async () => { + pinButtons[0].dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + // PINNED section should now appear + const headers = Array.from(container.querySelectorAll("span")).map((s) => s.textContent); + expect(headers).toContain("Pinned"); + + // Check localStorage was updated + const stored = JSON.parse(localStorage.getItem("sidebar-pinned-items") || "[]"); + expect(stored.length).toBe(1); + + // Now unpin the item by clicking its pin button + const unpinButtons = Array.from( + container.querySelectorAll('button[title*="Unpin item"], button[aria-label*="Unpin item"]') + ); + expect(unpinButtons.length).toBeGreaterThan(0); + + await act(async () => { + unpinButtons[0].dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + // PINNED section should disappear + const headersAfter = Array.from(container.querySelectorAll("span")).map((s) => s.textContent); + expect(headersAfter).not.toContain("Pinned"); + expect(JSON.parse(localStorage.getItem("sidebar-pinned-items") || "[]")).toEqual([]); + }); + + it("renders pinned shortcuts in collapsed mode", async () => { + localStorage.setItem("sidebar-pinned-items", JSON.stringify(["analytics"])); + + const { default: Sidebar } = await import("@/shared/components/Sidebar"); + const container = makeContainer(); + root = createRoot(container); + await act(async () => { + root!.render(); + }); + + // In collapsed mode, the link should still exist + const links = Array.from(container.querySelectorAll('a[href="/dashboard/analytics"]')); + expect(links.length).toBeGreaterThanOrEqual(1); + }); + + it("allows collapsing and expanding the PINNED section", async () => { + localStorage.setItem("sidebar-pinned-items", JSON.stringify(["analytics"])); + + const { default: Sidebar } = await import("@/shared/components/Sidebar"); + const container = makeContainer(); + root = createRoot(container); + await act(async () => { + root!.render(); + }); + + // Find the Pinned section header button + const pinnedHeader = Array.from(container.querySelectorAll('div[role="button"]')).find((el) => + el.textContent?.includes("Pinned") + ); + expect(pinnedHeader).toBeDefined(); + + // Click to collapse + await act(async () => { + pinnedHeader!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + // The shortcut item inside Pinned section should now be collapsed (hidden from DOM) + // Only 1 link (in Analytics section) remains in DOM + const linksCollapsed = Array.from(container.querySelectorAll('a[href="/dashboard/analytics"]')); + expect(linksCollapsed.length).toBe(1); + + // Click to re-expand + await act(async () => { + pinnedHeader!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const linksExpanded = Array.from(container.querySelectorAll('a[href="/dashboard/analytics"]')); + expect(linksExpanded.length).toBe(2); + }); + + it("renders proportional pin icon sizing on nav items and clean text header for PINNED", async () => { + localStorage.setItem("sidebar-pinned-items", JSON.stringify(["analytics"])); + + const { default: Sidebar } = await import("@/shared/components/Sidebar"); + const container = makeContainer(); + root = createRoot(container); + await act(async () => { + root!.render(); + }); + + // Nav item pin icon should have 13px fontSize (proportional to 14px item label) + const navPinBtn = container.querySelector( + 'button[title*="Unpin item"], button[aria-label*="Unpin item"]' + ); + expect(navPinBtn).toBeDefined(); + const navPinIcon = navPinBtn?.querySelector(".material-symbols-outlined"); + expect((navPinIcon as HTMLElement)?.style.fontSize).toBe("13px"); + + // Section header PINNED should be clean text without redundant leading icon, consistent with other category headers + const pinnedHeader = Array.from(container.querySelectorAll('div[role="button"]')).find((el) => + el.textContent?.includes("Pinned") + ); + expect(pinnedHeader).toBeDefined(); + const headerPinIcon = pinnedHeader?.querySelector( + ".material-symbols-outlined:not(:last-child)" + ); + expect(headerPinIcon).toBeNull(); + }); +});