mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 13:52:28 +03:00
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:
1
changelog.d/features/sidebar-pinned-items.md
Normal file
1
changelog.d/features/sidebar-pinned-items.md
Normal 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))
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -124,6 +124,7 @@ export type SidebarItemId = HideableSidebarItemId | AlwaysVisibleSidebarItemId;
|
||||
|
||||
export type SidebarSectionId =
|
||||
| "home"
|
||||
| "pinned"
|
||||
| "omni-proxy"
|
||||
| "analytics"
|
||||
| "costs"
|
||||
|
||||
233
tests/unit/sidebar-pinned-items.test.tsx
Normal file
233
tests/unit/sidebar-pinned-items.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user