feat(dashboard): add sidebar pinned items shortcut section (#12891)

* feat(dashboard): add sidebar pinned items shortcut section

* docs(changelog): add fragment for sidebar pinned items feature

* docs(changelog): update pr number in changelog fragment

* fix(dashboard): scale down sidebar pinned item icons to match text proportions

* fix(dashboard): remove redundant pin icon from PINNED category header

---------

Co-authored-by: ZaimMarzuki <ZaimMarzuki@users.noreply.github.com>
This commit is contained in:
ZaimMarzuki
2026-09-18 22:22:42 +07:00
committed by GitHub
parent 9eb7c2f17e
commit 7f1b4a5eb7
6 changed files with 426 additions and 48 deletions

View File

@@ -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))

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<Set<SidebarSectionId>>(new Set());
const [pinnedItems, setPinnedItems] = useState<Set<string>>(new Set());
const [pinnedSectionCollapsed, setPinnedSectionCollapsed] = useState(false);
const [sidebarExpansionLoaded, setSidebarExpansionLoaded] = useState(false);
const [skipInitialActiveExpansion, setSkipInitialActiveExpansion] = useState(false);
const [hoveredItem, setHoveredItem] = useState<HoveredItem>(null);
@@ -168,6 +178,11 @@ export default function Sidebar({
readStoredPinnedRaw,
getServerSnapshotNull
);
const storedPinnedItemsRaw = useSyncExternalStore(
noopSubscribe,
readStoredPinnedItemsRaw,
getServerSnapshotNull
);
if (hydrated && !sidebarExpansionLoaded) {
const storedExpanded = parseStoredArray<SidebarSectionId[]>(storedExpandedRaw, [
DEFAULT_EXPANDED,
@@ -176,6 +191,7 @@ export default function Sidebar({
storedPinnedRaw !== null
? parseStoredArray<SidebarSectionId[]>(storedPinnedRaw, [])
: (SIDEBAR_SECTIONS.filter((s) => s.defaultPinned).map((s) => s.id) as SidebarSectionId[]);
const storedPinnedItems = parseStoredArray<string[]>(storedPinnedItemsRaw, []);
const initialPinned = new Set<SidebarSectionId>(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}
</span>
{!collapsed && (
<div className="flex min-w-0 flex-col">
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">{item.label}</span>
{item.subtitle && (
<span className="truncate text-[10px] text-text-muted/60">{item.subtitle}</span>
@@ -477,33 +532,105 @@ export default function Sidebar({
onMouseLeave: handleMouseLeave,
};
if (item.external) {
if (collapsed) {
if (item.external) {
return (
<a
key={itemKey}
href={item.href}
target="_blank"
rel="noopener noreferrer"
onClick={onClose}
className={className}
{...sharedProps}
>
{content}
</a>
);
}
return (
<a
key={item.href}
<Link
key={itemKey}
href={item.href}
target="_blank"
rel="noopener noreferrer"
prefetch={false}
onClick={onClose}
className={className}
{...sharedProps}
>
{content}
</a>
</Link>
);
}
const pinButton = item.id !== "home" && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
togglePinItem(item.id);
}}
title={isItemPinned ? t("unpinItem") : t("pinItem")}
aria-label={isItemPinned ? t("unpinItem") : t("pinItem")}
className={cn(
"mr-1.5 p-0.5 rounded transition-all shrink-0",
isItemPinned
? "text-primary opacity-100 hover:text-primary/80"
: "text-text-muted/30 opacity-0 group-hover/nav-item:opacity-100 hover:text-text-muted/80"
)}
>
<span
className="material-symbols-outlined text-[13px]"
style={{
fontSize: "13px",
...(isItemPinned ? { fontVariationSettings: "'FILL' 1" } : {}),
}}
>
push_pin
</span>
</button>
);
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 (
<div key={itemKey} className={containerClassName}>
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
onClick={onClose}
className={innerLinkClassName}
{...sharedProps}
>
{content}
</a>
{pinButton}
</div>
);
}
return (
<Link
key={item.href}
href={item.href}
prefetch={false}
onClick={onClose}
className={className}
{...sharedProps}
>
{content}
</Link>
<div key={itemKey} className={containerClassName}>
<Link
href={item.href}
prefetch={false}
onClick={onClose}
className={innerLinkClassName}
{...sharedProps}
>
{content}
</Link>
{pinButton}
</div>
);
};
@@ -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 && (
<div className="border-t border-black/5 dark:border-white/5 my-1.5" />
)}
{sectionItems.map(renderNavLink)}
{sectionItems.map((item: any) =>
renderNavLink(item, section.id === "pinned" ? "pinned" : undefined)
)}
</div>
);
}
@@ -639,7 +770,9 @@ export default function Sidebar({
if (section.showTitle === false) {
return (
<div key={section.id} className={cn("space-y-0.5", !isFirst && "mt-1")}>
{sectionItems.map(renderNavLink)}
{sectionItems.map((item: any) =>
renderNavLink(item, section.id === "pinned" ? "pinned" : undefined)
)}
</div>
);
}
@@ -657,30 +790,32 @@ export default function Sidebar({
{section.title}
</span>
{/* Pin button — right side near chevron */}
<button
onClick={(e) => {
e.stopPropagation();
togglePin(sectionId);
}}
title={isPinned ? t("unpinSection") : t("pinSectionOpen")}
className={cn(
"p-0.5 rounded transition-all shrink-0",
isPinned
? "text-primary opacity-100"
: "text-text-muted/30 opacity-0 group-hover/header:opacity-100 hover:text-text-muted/70"
)}
>
<span
className="material-symbols-outlined"
style={{
fontSize: "10px",
...(isPinned ? { fontVariationSettings: "'FILL' 1" } : {}),
{/* Pin button — right side near chevron (only for standard sections) */}
{sectionId !== "pinned" && (
<button
onClick={(e) => {
e.stopPropagation();
togglePin(sectionId);
}}
title={isPinned ? t("unpinSection") : t("pinSectionOpen")}
className={cn(
"p-0.5 rounded transition-all shrink-0",
isPinned
? "text-primary opacity-100"
: "text-text-muted/30 opacity-0 group-hover/header:opacity-100 hover:text-text-muted/70"
)}
>
push_pin
</span>
</button>
<span
className="material-symbols-outlined"
style={{
fontSize: "10px",
...(isPinned ? { fontVariationSettings: "'FILL' 1" } : {}),
}}
>
push_pin
</span>
</button>
)}
<span
className={cn(
@@ -708,11 +843,13 @@ export default function Sidebar({
</span>
</div>
)}
{child.items.map(renderNavLink)}
{child.items.map((item: any) =>
renderNavLink(item, section.id === "pinned" ? "pinned" : undefined)
)}
</div>
);
}
return renderNavLink(child);
return renderNavLink(child, section.id === "pinned" ? "pinned" : undefined);
})}
</div>
)}

View File

@@ -124,6 +124,7 @@ export type SidebarItemId = HideableSidebarItemId | AlwaysVisibleSidebarItemId;
export type SidebarSectionId =
| "home"
| "pinned"
| "omni-proxy"
| "analytics"
| "costs"

View File

@@ -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(<Sidebar />);
});
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(<Sidebar />);
});
// 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(<Sidebar />);
});
// 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(<Sidebar collapsed />);
});
// 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(<Sidebar />);
});
// 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(<Sidebar />);
});
// 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();
});
});