mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 10:49:35 +00:00
feat(ui): add Classic, Smart, and Compact desktop navigation styles (#1642)
* feat(ui): add Classic, Smart, and Compact desktop navigation styles Introduce a shared app-nav registry and reachable model so TopBar, the command palette, and mobile menus share one destination source. Smart bar is the default; Appearance gains a Navigation subsection with quick links. * fix(e2e): stop clearing top-nav prefs on every reload The desktop-navigation suite used addInitScript to wipe mode storage, which re-ran on reload and undid the classic/compact values under test. * fix(ui): harden nav PR docs and Compact quick-link coverage Drop the partial docs-refresh import that left missing image assets and a stale Display screenshot, keep navigation-scoped operator docs against main, and add an E2E path that proves Compact quick-link add, persist, and render after reload. * fix(ui): polish Compact quick links and menu mastheads Align Smart/Compact menus with Theme chrome, keep pin labels always visible, and drive add capacity from persisted pins (max five) with a trailing + picker and per-pin remove. * test(e2e): exact-match Compact Networking pin locator Avoid Playwright strict-mode clash with Actions for Networking. * fix(ui): simplify Compact quick link removal and fix + button trailing Remove the (...) dropdown per quick link in Compact mode. Right-click context menu remains as the sole on-bar removal affordance. Fix the + add-button to trail quick links rather than pinning to the far right by removing flex-1 from the quick-link rail.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Plus, Loader2, ChevronLeft, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { UserProfileDropdown } from './UserProfileDropdown';
|
||||
@@ -44,6 +44,9 @@ import type { SidebarActivityAction } from '@/components/sidebar/SidebarActivity
|
||||
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
|
||||
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
|
||||
import { useTopNavAlign } from '@/hooks/use-top-nav-align';
|
||||
import { useTopNavMode } from '@/hooks/use-top-nav-mode';
|
||||
import { useTopNavQuickLinks } from '@/hooks/use-top-nav-quick-links';
|
||||
import { getAppNavItem } from '@/lib/navigation/appNavRegistry';
|
||||
import { useStackMuteActions } from '@/hooks/useMuteRuleActions';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
@@ -195,6 +198,8 @@ export default function EditorLayout() {
|
||||
const [diffPreviewEnabled] = useComposeDiffPreviewEnabled();
|
||||
const [topNavLabels] = useTopNavLabels();
|
||||
const [topNavAlign] = useTopNavAlign();
|
||||
const [topNavMode] = useTopNavMode();
|
||||
const { persistedIds: quickLinkIds, addQuickLink, removeQuickLink } = useTopNavQuickLinks();
|
||||
|
||||
// Use a ref to break the circular dependency:
|
||||
// useViewNavigationState needs onNavigateToDashboard -> resetEditorState
|
||||
@@ -220,10 +225,22 @@ export default function EditorLayout() {
|
||||
handleMutePrefillConsumed,
|
||||
handleNavigate,
|
||||
navItems,
|
||||
navModel,
|
||||
openMuteRulesWithPrefill,
|
||||
reachCtx,
|
||||
} = navState;
|
||||
|
||||
const visibleQuickLinks = useMemo(() => {
|
||||
const candidateSet = new Set(navModel.quickLinkCandidates.map((item) => item.value));
|
||||
return quickLinkIds
|
||||
.filter((id) => candidateSet.has(id))
|
||||
.map((id) => {
|
||||
const item = getAppNavItem(id);
|
||||
return item ? { value: item.value, label: item.label, icon: item.icon } : null;
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null);
|
||||
}, [quickLinkIds, navModel.quickLinkCandidates]);
|
||||
|
||||
const {
|
||||
notifications,
|
||||
tickerConnected,
|
||||
@@ -908,6 +925,13 @@ export default function EditorLayout() {
|
||||
userMenu={userMenuEl}
|
||||
showLabels={topNavLabels}
|
||||
navAlign={topNavAlign}
|
||||
navMode={topNavMode}
|
||||
navModel={navModel}
|
||||
quickLinks={visibleQuickLinks}
|
||||
persistedQuickLinkIds={quickLinkIds}
|
||||
onAddQuickLink={(value) => addQuickLink(value as typeof quickLinkIds[number])}
|
||||
onRemoveQuickLink={(value) => removeQuickLink(value as typeof quickLinkIds[number])}
|
||||
onOpenSettings={() => openSettings()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -962,6 +986,7 @@ export default function EditorLayout() {
|
||||
stackUpdates={stackUpdates}
|
||||
urlHydratingStack={urlHydratingStack}
|
||||
isFileLoading={isFileLoading}
|
||||
quickLinkCandidates={navModel.quickLinkCandidates}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1033,6 +1058,7 @@ export default function EditorLayout() {
|
||||
headerActions={mobileMastheadActions}
|
||||
selectedSection={mobileSettingsSection}
|
||||
onSelectedSectionChange={setMobileSettingsSection}
|
||||
quickLinkCandidates={navModel.quickLinkCandidates}
|
||||
/>
|
||||
);
|
||||
case 'security':
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { ActiveView } from './hooks/useViewNavigationState';
|
||||
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||
import type { SecurityTab, FleetTab } from '@/lib/events';
|
||||
import { isStackEditorDeepLink } from '@/lib/router/readUrlRouteState';
|
||||
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
|
||||
|
||||
// Paid-tier views are loaded on demand. Their internal PaidGate /
|
||||
// CapabilityGate wrappers render
|
||||
@@ -109,6 +110,7 @@ export interface ViewRouterProps {
|
||||
stackUpdates: Record<string, StackUpdateInfo>;
|
||||
urlHydratingStack: string | null;
|
||||
isFileLoading: boolean;
|
||||
quickLinkCandidates?: NavDestination[];
|
||||
}
|
||||
|
||||
export function ViewRouter({
|
||||
@@ -142,6 +144,7 @@ export function ViewRouter({
|
||||
stackUpdates,
|
||||
urlHydratingStack,
|
||||
isFileLoading,
|
||||
quickLinkCandidates,
|
||||
}: ViewRouterProps): ReactNode {
|
||||
const { can } = useAuth();
|
||||
const { experimental, experimentalReady } = useExperimental();
|
||||
@@ -153,6 +156,7 @@ export function ViewRouter({
|
||||
muteRulePrefill={muteRulePrefill}
|
||||
onMutePrefillConsumed={onMutePrefillConsumed}
|
||||
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
|
||||
quickLinkCandidates={quickLinkCandidates}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Terminal, CloudDownload, Home, HardDrive, ScrollText,
|
||||
Activity, Radar, RefreshCw, Clock, ShieldCheck, Network,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
@@ -18,20 +13,18 @@ import { HUB_ONLY_VIEWS } from '@/lib/router/routeTypes';
|
||||
import { readUrlRouteState } from '@/lib/router/readUrlRouteState';
|
||||
import {
|
||||
authzReady,
|
||||
isViewHidden,
|
||||
normalizeHiddenView,
|
||||
type ReachabilityContext,
|
||||
} from '@/lib/routing/reachability';
|
||||
import { useExperimental } from '@/hooks/useExperimental';
|
||||
import { buildNavigationModel } from '@/lib/navigation/buildNavigationModel';
|
||||
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
|
||||
|
||||
export type { ActiveView };
|
||||
export { HUB_ONLY_VIEWS };
|
||||
|
||||
export interface NavItem {
|
||||
value: ActiveView;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
/** @deprecated Prefer NavDestination from appNavRegistry; alias kept for mobile/palette imports. */
|
||||
export type NavItem = NavDestination;
|
||||
|
||||
interface UseViewNavigationStateOptions {
|
||||
onNavigateToDashboard?: () => void;
|
||||
@@ -124,39 +117,8 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
return () => window.removeEventListener(SENCHO_NAVIGATE_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
const navItems = useMemo((): NavItem[] => {
|
||||
const items: NavItem[] = [
|
||||
{ value: 'dashboard', label: 'Home', icon: Home },
|
||||
];
|
||||
if (!isViewHidden('fleet', reachCtx)) {
|
||||
items.push({ value: 'fleet', label: 'Fleet', icon: Radar });
|
||||
}
|
||||
items.push(
|
||||
{ value: 'resources', label: 'Resources', icon: HardDrive },
|
||||
{ value: 'networking', label: 'Networking', icon: Network },
|
||||
{ value: 'security', label: 'Security', icon: ShieldCheck },
|
||||
{ value: 'templates', label: 'App Store', icon: CloudDownload },
|
||||
);
|
||||
if (!isViewHidden('global-observability', reachCtx)) {
|
||||
items.push({ value: 'global-observability', label: 'Logs', icon: Activity });
|
||||
}
|
||||
if (!isViewHidden('auto-updates', reachCtx)) {
|
||||
items.push({ value: 'auto-updates', label: 'Update', icon: RefreshCw });
|
||||
}
|
||||
if (!isViewHidden('scheduled-ops', reachCtx)) {
|
||||
items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock });
|
||||
}
|
||||
// Visual discovery fail-closed: omit Console until /meta settles and the
|
||||
// flag is on. URL normalization still waits on experimentalReady inside
|
||||
// isViewHidden so enabled deep links are not rewritten during cold load.
|
||||
if (experimentalReady && experimental && !isViewHidden('host-console', reachCtx)) {
|
||||
items.push({ value: 'host-console', label: 'Console', icon: Terminal });
|
||||
}
|
||||
if (!isViewHidden('audit-log', reachCtx)) {
|
||||
items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText });
|
||||
}
|
||||
return items;
|
||||
}, [reachCtx, experimentalReady, experimental]);
|
||||
const navModel = useMemo(() => buildNavigationModel(reachCtx), [reachCtx]);
|
||||
const navItems = navModel.allPageItems;
|
||||
|
||||
useEffect(() => {
|
||||
if (!authzReady(reachCtx)) return;
|
||||
@@ -183,6 +145,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
openMuteRulesWithPrefill,
|
||||
handleNavigate,
|
||||
navItems,
|
||||
navModel,
|
||||
reachCtx,
|
||||
} as const;
|
||||
}
|
||||
|
||||
+593
-140
@@ -1,161 +1,614 @@
|
||||
import { Fragment, type ReactNode } from 'react';
|
||||
import { Fragment, type ReactNode, useMemo } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Menu } from 'lucide-react';
|
||||
import { Menu, MoreHorizontal, Plus } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from './ui/sheet';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from './ui/dropdown-menu';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from './ui/context-menu';
|
||||
import type { TopNavAlign } from '@/hooks/use-top-nav-align';
|
||||
import type { TopNavMode } from '@/hooks/use-top-nav-mode';
|
||||
import { MAX_QUICK_LINKS } from '@/hooks/use-top-nav-quick-links';
|
||||
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
|
||||
import type { NavGroupBucket, ReachableNavigationModel } from '@/lib/navigation/buildNavigationModel';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface TopBarNavItem {
|
||||
value: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
value: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
interface TopBarProps {
|
||||
activeView: string;
|
||||
navItems: TopBarNavItem[];
|
||||
onNavigate: (value: string) => void;
|
||||
mobileNavOpen: boolean;
|
||||
onMobileNavOpenChange: (open: boolean) => void;
|
||||
search?: ReactNode;
|
||||
themeSwitch?: ReactNode;
|
||||
notifications: ReactNode;
|
||||
userMenu: ReactNode;
|
||||
/** Show text labels beside the desktop nav icons. When false, the bar is icon-only. */
|
||||
showLabels?: boolean;
|
||||
/** Desktop nav placement in icon-only mode. Ignored while labels are shown (always left). */
|
||||
navAlign?: TopNavAlign;
|
||||
activeView: string;
|
||||
/** Flat page destinations for Classic strip and the mobile sheet. */
|
||||
navItems: TopBarNavItem[];
|
||||
onNavigate: (value: string) => void;
|
||||
mobileNavOpen: boolean;
|
||||
onMobileNavOpenChange: (open: boolean) => void;
|
||||
search?: ReactNode;
|
||||
themeSwitch?: ReactNode;
|
||||
notifications: ReactNode;
|
||||
userMenu: ReactNode;
|
||||
showLabels?: boolean;
|
||||
navAlign?: TopNavAlign;
|
||||
navMode?: TopNavMode;
|
||||
navModel?: ReachableNavigationModel;
|
||||
/** Visible (reachable) quick links for Compact mode. */
|
||||
quickLinks?: NavDestination[];
|
||||
/** Persisted pin IDs (including temporarily unreachable). Capacity is length. */
|
||||
persistedQuickLinkIds?: readonly string[];
|
||||
onAddQuickLink?: (value: string) => void;
|
||||
onRemoveQuickLink?: (value: string) => void;
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
const navButtonClass = (isActive: boolean) =>
|
||||
cn(
|
||||
'relative inline-flex h-full shrink-0 items-center gap-2 px-4',
|
||||
'font-mono text-[10px] uppercase tracking-[0.18em] transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
|
||||
isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
);
|
||||
|
||||
function ActiveUnderline({ active }: { active: boolean }) {
|
||||
if (!active) return null;
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 -bottom-px h-[2px] bg-brand"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBarMenuMasthead({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-brand/[0.05] via-transparent to-transparent" />
|
||||
<div className="absolute inset-y-0 left-0 w-[2px] bg-brand/60" />
|
||||
<div className="relative flex items-center px-[var(--density-row-x)] py-[var(--density-tile-y)]">
|
||||
<span className="font-heading text-xl leading-none text-stat-value">{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelMenuContent({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuContent align="start" sideOffset={8} className="w-56 overflow-hidden rounded-md p-0">
|
||||
<TopBarMenuMasthead title={title} />
|
||||
<div className="border-t border-card-border/60 p-1">{children}</div>
|
||||
</DropdownMenuContent>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopNavButton({
|
||||
item,
|
||||
isActive,
|
||||
showLabels,
|
||||
onNavigate,
|
||||
}: {
|
||||
item: TopBarNavItem;
|
||||
isActive: boolean;
|
||||
showLabels: boolean;
|
||||
onNavigate: (value: string) => void;
|
||||
}) {
|
||||
const Icon = item.icon;
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(item.value)}
|
||||
aria-label={item.label}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={navButtonClass(isActive)}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" strokeWidth={1.5} />
|
||||
{showLabels && <span className="hidden xl:inline">{item.label}</span>}
|
||||
<ActiveUnderline active={isActive} />
|
||||
</button>
|
||||
);
|
||||
if (showLabels) return <Fragment>{button}</Fragment>;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{item.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupedMenuItems({
|
||||
groups,
|
||||
activeView,
|
||||
onSelect,
|
||||
onAddQuickLink,
|
||||
persistedIds,
|
||||
atCapacity,
|
||||
}: {
|
||||
groups: NavGroupBucket[];
|
||||
activeView: string;
|
||||
onSelect: (value: string) => void;
|
||||
/** When set, Compact launcher rows get a context Add action. */
|
||||
onAddQuickLink?: (value: string) => void;
|
||||
persistedIds?: ReadonlySet<string>;
|
||||
atCapacity?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{groups.map((group, index) => (
|
||||
<Fragment key={group.group}>
|
||||
{index > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
{group.label}
|
||||
</DropdownMenuLabel>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeView === item.value;
|
||||
const canContextAdd =
|
||||
Boolean(onAddQuickLink)
|
||||
&& item.value !== 'settings'
|
||||
&& !persistedIds?.has(item.value)
|
||||
&& !atCapacity;
|
||||
|
||||
const menuItem = (
|
||||
<DropdownMenuItem
|
||||
key={item.value}
|
||||
onSelect={() => onSelect(item.value)}
|
||||
data-active={isActive ? 'true' : undefined}
|
||||
className={cn(
|
||||
'gap-2 font-mono text-[11px] uppercase tracking-[0.14em]',
|
||||
isActive && 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" strokeWidth={1.5} />
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
if (!canContextAdd) return menuItem;
|
||||
|
||||
return (
|
||||
<ContextMenu key={item.value}>
|
||||
<ContextMenuTrigger asChild>{menuItem}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onSelect={() => onAddQuickLink?.(item.value)}
|
||||
>
|
||||
Add to quick links
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassicStrip({
|
||||
navItems,
|
||||
activeView,
|
||||
showLabels,
|
||||
onNavigate,
|
||||
}: {
|
||||
navItems: TopBarNavItem[];
|
||||
activeView: string;
|
||||
showLabels: boolean;
|
||||
onNavigate: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{navItems.map((item) => (
|
||||
<DesktopNavButton
|
||||
key={item.value}
|
||||
item={item}
|
||||
isActive={activeView === item.value}
|
||||
showLabels={showLabels}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SmartStrip({
|
||||
primaryItems,
|
||||
overflowGroups,
|
||||
activeView,
|
||||
showLabels,
|
||||
onNavigate,
|
||||
}: {
|
||||
primaryItems: TopBarNavItem[];
|
||||
overflowGroups: NavGroupBucket[];
|
||||
activeView: string;
|
||||
showLabels: boolean;
|
||||
onNavigate: (value: string) => void;
|
||||
}) {
|
||||
const overflowValues = useMemo(
|
||||
() => new Set<string>(overflowGroups.flatMap((g) => g.items.map((i) => i.value))),
|
||||
[overflowGroups],
|
||||
);
|
||||
const moreActive = overflowValues.has(activeView);
|
||||
const hasOverflow = overflowGroups.some((g) => g.items.length > 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
{primaryItems.map((item) => (
|
||||
<DesktopNavButton
|
||||
key={item.value}
|
||||
item={item}
|
||||
isActive={activeView === item.value}
|
||||
showLabels={showLabels}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
{hasOverflow && (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="More navigation"
|
||||
aria-current={moreActive ? 'page' : undefined}
|
||||
className={navButtonClass(moreActive)}
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4 shrink-0" strokeWidth={1.5} />
|
||||
<span>More</span>
|
||||
<ActiveUnderline active={moreActive} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<PanelMenuContent title="More">
|
||||
<GroupedMenuItems
|
||||
groups={overflowGroups}
|
||||
activeView={activeView}
|
||||
onSelect={onNavigate}
|
||||
/>
|
||||
</PanelMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactQuickLink({
|
||||
item,
|
||||
isActive,
|
||||
onNavigate,
|
||||
onRemove,
|
||||
}: {
|
||||
item: NavDestination;
|
||||
isActive: boolean;
|
||||
onNavigate: (value: string) => void;
|
||||
onRemove: (value: string) => void;
|
||||
}) {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(item.value)}
|
||||
aria-label={item.label}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn('relative inline-flex h-full shrink-0 items-stretch', navButtonClass(isActive))}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" strokeWidth={1.5} />
|
||||
<span className="inline">{item.label}</span>
|
||||
<ActiveUnderline active={isActive} />
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={() => onRemove(item.value)}>Remove</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactStrip({
|
||||
launcherGroups,
|
||||
quickLinks,
|
||||
quickLinkCandidates,
|
||||
persistedQuickLinkIds,
|
||||
activeView,
|
||||
onNavigate,
|
||||
onAddQuickLink,
|
||||
onRemoveQuickLink,
|
||||
onOpenSettings,
|
||||
}: {
|
||||
launcherGroups: NavGroupBucket[];
|
||||
quickLinks: NavDestination[];
|
||||
quickLinkCandidates: NavDestination[];
|
||||
persistedQuickLinkIds: readonly string[];
|
||||
activeView: string;
|
||||
onNavigate: (value: string) => void;
|
||||
onAddQuickLink?: (value: string) => void;
|
||||
onRemoveQuickLink?: (value: string) => void;
|
||||
onOpenSettings?: () => void;
|
||||
}) {
|
||||
const launcherValues = useMemo(
|
||||
() => new Set<string>(launcherGroups.flatMap((g) => g.items.map((i) => i.value))),
|
||||
[launcherGroups],
|
||||
);
|
||||
const quickValues = useMemo(
|
||||
() => new Set<string>(quickLinks.map((i) => i.value)),
|
||||
[quickLinks],
|
||||
);
|
||||
const persistedSet = useMemo(
|
||||
() => new Set<string>(persistedQuickLinkIds),
|
||||
[persistedQuickLinkIds],
|
||||
);
|
||||
const atCapacity = persistedQuickLinkIds.length >= MAX_QUICK_LINKS;
|
||||
const unpinnedCandidates = useMemo(
|
||||
() => quickLinkCandidates.filter((item) => !persistedSet.has(item.value)),
|
||||
[quickLinkCandidates, persistedSet],
|
||||
);
|
||||
const addEnabled = !atCapacity && unpinnedCandidates.length > 0;
|
||||
const addDisabledReason = atCapacity
|
||||
? 'Remove a quick link to free a slot'
|
||||
: 'No more destinations available';
|
||||
|
||||
const launcherActive =
|
||||
launcherValues.has(activeView) && !quickValues.has(activeView);
|
||||
|
||||
const selectDestination = (value: string) => {
|
||||
if (value === 'settings') {
|
||||
onOpenSettings?.();
|
||||
return;
|
||||
}
|
||||
onNavigate(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 self-stretch items-stretch">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open navigation launcher"
|
||||
aria-current={launcherActive ? 'page' : undefined}
|
||||
data-sn-launcher-active={launcherActive ? 'true' : 'false'}
|
||||
className={navButtonClass(launcherActive)}
|
||||
>
|
||||
<Menu className="w-4 h-4 shrink-0" strokeWidth={1.5} />
|
||||
<ActiveUnderline active={launcherActive} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<PanelMenuContent title="Navigate">
|
||||
<GroupedMenuItems
|
||||
groups={launcherGroups}
|
||||
activeView={activeView}
|
||||
onSelect={selectDestination}
|
||||
onAddQuickLink={onAddQuickLink}
|
||||
persistedIds={persistedSet}
|
||||
atCapacity={atCapacity}
|
||||
/>
|
||||
</PanelMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div
|
||||
data-sn-quick-link-rail
|
||||
className="flex min-w-0 self-stretch items-stretch overflow-x-auto [scrollbar-width:none]"
|
||||
>
|
||||
{quickLinks.map((item) => (
|
||||
<CompactQuickLink
|
||||
key={item.value}
|
||||
item={item}
|
||||
isActive={activeView === item.value}
|
||||
onNavigate={onNavigate}
|
||||
onRemove={(value) => onRemoveQuickLink?.(value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{addEnabled ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add quick link"
|
||||
className={navButtonClass(false)}
|
||||
>
|
||||
<Plus className="w-4 h-4 shrink-0" strokeWidth={1.5} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<PanelMenuContent title="Add quick link">
|
||||
{unpinnedCandidates.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isCurrent = item.value === activeView;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.value}
|
||||
onSelect={() => onAddQuickLink?.(item.value)}
|
||||
className={cn(
|
||||
'gap-2 font-mono text-[11px] uppercase tracking-[0.14em]',
|
||||
isCurrent && 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" strokeWidth={1.5} />
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</PanelMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
tabIndex={0}
|
||||
className="inline-flex h-full shrink-0 items-stretch"
|
||||
title={addDisabledReason}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add quick link"
|
||||
aria-disabled="true"
|
||||
disabled
|
||||
className={cn(navButtonClass(false), 'pointer-events-none opacity-40')}
|
||||
>
|
||||
<Plus className="w-4 h-4 shrink-0" strokeWidth={1.5} />
|
||||
</button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{addDisabledReason}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopBar({
|
||||
activeView,
|
||||
navItems,
|
||||
onNavigate,
|
||||
mobileNavOpen,
|
||||
onMobileNavOpenChange,
|
||||
search,
|
||||
themeSwitch,
|
||||
notifications,
|
||||
userMenu,
|
||||
showLabels = true,
|
||||
navAlign = 'left',
|
||||
activeView,
|
||||
navItems,
|
||||
onNavigate,
|
||||
mobileNavOpen,
|
||||
onMobileNavOpenChange,
|
||||
search,
|
||||
themeSwitch,
|
||||
notifications,
|
||||
userMenu,
|
||||
showLabels = true,
|
||||
navAlign = 'left',
|
||||
navMode = 'smart',
|
||||
navModel,
|
||||
quickLinks = [],
|
||||
persistedQuickLinkIds = [],
|
||||
onAddQuickLink,
|
||||
onRemoveQuickLink,
|
||||
onOpenSettings,
|
||||
}: TopBarProps) {
|
||||
// Centering applies only to the icon-only bar; with labels on the nav stays
|
||||
// left so the long labels read from the edge.
|
||||
const centered = !showLabels && navAlign === 'center';
|
||||
return (
|
||||
<div
|
||||
data-sn-chrome="topbar"
|
||||
className={cn(
|
||||
'relative flex h-14 items-center gap-3 px-4',
|
||||
'border-b border-glass-border bg-sidebar backdrop-blur-md',
|
||||
'shadow-chrome-top',
|
||||
)}
|
||||
const stripLabels = navMode !== 'compact' && showLabels;
|
||||
const centered = navMode !== 'compact' && !stripLabels && navAlign === 'center';
|
||||
|
||||
const primaryItems = navModel?.primaryItems ?? navItems;
|
||||
const overflowGroups = navModel?.overflowGroups ?? [];
|
||||
const launcherGroups = navModel?.launcherGroups ?? [];
|
||||
const quickLinkCandidates = navModel?.quickLinkCandidates ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-sn-chrome="topbar"
|
||||
data-sn-nav-mode={navMode}
|
||||
className={cn(
|
||||
'relative flex h-14 items-center gap-3 px-4',
|
||||
'border-b border-glass-border bg-sidebar backdrop-blur-md',
|
||||
'shadow-chrome-top',
|
||||
)}
|
||||
>
|
||||
{centered && <div className="flex-1 min-w-0" />}
|
||||
|
||||
<TooltipProvider delayDuration={300} disableHoverableContent>
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className={cn(
|
||||
'hidden md:flex self-stretch items-stretch',
|
||||
stripLabels && 'min-w-0 flex-1 overflow-x-auto [scrollbar-width:none]',
|
||||
navMode === 'compact' && 'min-w-0 flex-1 overflow-x-visible',
|
||||
!stripLabels && centered && 'shrink-0',
|
||||
)}
|
||||
>
|
||||
{/* LEFT SPACER: balances the right utilities so the nav centers. */}
|
||||
{centered && <div className="flex-1 min-w-0" />}
|
||||
{navMode === 'classic' && (
|
||||
<ClassicStrip
|
||||
navItems={navItems}
|
||||
activeView={activeView}
|
||||
showLabels={stripLabels}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
{navMode === 'smart' && (
|
||||
<SmartStrip
|
||||
primaryItems={primaryItems}
|
||||
overflowGroups={overflowGroups}
|
||||
activeView={activeView}
|
||||
showLabels={stripLabels}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
{navMode === 'compact' && (
|
||||
<CompactStrip
|
||||
launcherGroups={launcherGroups}
|
||||
quickLinks={quickLinks}
|
||||
quickLinkCandidates={quickLinkCandidates}
|
||||
persistedQuickLinkIds={persistedQuickLinkIds}
|
||||
activeView={activeView}
|
||||
onNavigate={onNavigate}
|
||||
onAddQuickLink={onAddQuickLink}
|
||||
onRemoveQuickLink={onRemoveQuickLink}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* NAV ZONE: Navigation (hidden on mobile) */}
|
||||
<TooltipProvider delayDuration={300} disableHoverableContent>
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className={cn(
|
||||
'hidden md:flex self-stretch items-stretch',
|
||||
showLabels && 'min-w-0 flex-1 overflow-x-auto [scrollbar-width:none]',
|
||||
!showLabels && centered && 'shrink-0',
|
||||
)}
|
||||
>
|
||||
{navItems.map(({ value, label, icon: Icon }) => {
|
||||
const isActive = activeView === value;
|
||||
const button = (
|
||||
<button
|
||||
onClick={() => onNavigate(value)}
|
||||
aria-label={label}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'relative inline-flex h-full shrink-0 items-center gap-2 px-4',
|
||||
'font-mono text-[10px] uppercase tracking-[0.18em] transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
|
||||
isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" strokeWidth={1.5} />
|
||||
{showLabels && <span className="hidden xl:inline">{label}</span>}
|
||||
{isActive && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 -bottom-px h-[2px] bg-brand"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
// Icon-only mode: a tooltip names the destination on hover/focus. With
|
||||
// labels on, the visible text carries it, so the button renders bare.
|
||||
return showLabels ? (
|
||||
<Fragment key={value}>{button}</Fragment>
|
||||
) : (
|
||||
<Tooltip key={value}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</TooltipProvider>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-end gap-2',
|
||||
centered ? 'flex-1 min-w-0' : 'relative z-10 shrink-0',
|
||||
!centered && !stripLabels && navMode !== 'compact' && 'flex-1 min-w-0',
|
||||
)}
|
||||
>
|
||||
{search}
|
||||
{themeSwitch}
|
||||
{notifications}
|
||||
{userMenu}
|
||||
|
||||
{/* RIGHT ZONE: Utilities + identity pin */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-end gap-2',
|
||||
centered ? 'flex-1 min-w-0' : showLabels ? 'relative z-10 shrink-0' : 'flex-1 min-w-0',
|
||||
)}
|
||||
<Sheet open={mobileNavOpen} onOpenChange={onMobileNavOpenChange}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Open navigation menu"
|
||||
className="h-8 w-8 rounded-lg md:hidden"
|
||||
>
|
||||
{search}
|
||||
{themeSwitch}
|
||||
{notifications}
|
||||
{userMenu}
|
||||
|
||||
{/* Mobile nav trigger */}
|
||||
<Sheet open={mobileNavOpen} onOpenChange={onMobileNavOpenChange}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Open navigation menu"
|
||||
className="h-8 w-8 rounded-lg md:hidden"
|
||||
>
|
||||
<Menu className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-64 p-0">
|
||||
<div className="p-4 border-b">
|
||||
<p className="text-sm font-medium">Navigation</p>
|
||||
</div>
|
||||
<nav className="flex flex-col p-2 gap-1">
|
||||
{navItems.map(({ value, label, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => {
|
||||
onNavigate(value);
|
||||
onMobileNavOpenChange(false);
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors',
|
||||
activeView === value
|
||||
? 'bg-glass-highlight font-medium text-foreground'
|
||||
: 'text-muted-foreground hover:bg-glass-highlight hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" strokeWidth={1.5} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<Menu className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-64 p-0">
|
||||
<div className="p-4 border-b">
|
||||
<p className="text-sm font-medium">Navigation</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<nav className="flex flex-col p-2 gap-1">
|
||||
{navItems.map(({ value, label, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onNavigate(value);
|
||||
onMobileNavOpenChange(false);
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors',
|
||||
activeView === value
|
||||
? 'bg-glass-highlight font-medium text-foreground'
|
||||
: 'text-muted-foreground hover:bg-glass-highlight hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" strokeWidth={1.5} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Home, Radar } from 'lucide-react';
|
||||
import { TopBar, type TopBarNavItem } from '../TopBar';
|
||||
|
||||
@@ -87,3 +88,215 @@ describe('TopBar showLabels', () => {
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' }).previousElementSibling).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TopBar smart and compact modes', () => {
|
||||
const overflowGroups = [
|
||||
{
|
||||
group: 'operations' as const,
|
||||
label: 'Operations',
|
||||
items: [{ value: 'global-observability' as const, label: 'Logs', icon: Home }],
|
||||
},
|
||||
];
|
||||
const launcherGroups = [
|
||||
{
|
||||
group: 'overview' as const,
|
||||
label: 'Overview',
|
||||
items: [{ value: 'dashboard' as const, label: 'Home', icon: Home }],
|
||||
},
|
||||
{
|
||||
group: 'settings' as const,
|
||||
label: 'Settings',
|
||||
items: [{ value: 'settings' as const, label: 'Settings', icon: Radar }],
|
||||
},
|
||||
];
|
||||
const emptyModel = {
|
||||
allPageItems: [
|
||||
{ value: 'dashboard' as const, label: 'Home', icon: Home },
|
||||
{ value: 'fleet' as const, label: 'Fleet', icon: Radar },
|
||||
],
|
||||
primaryItems: [
|
||||
{ value: 'dashboard' as const, label: 'Home', icon: Home },
|
||||
{ value: 'fleet' as const, label: 'Fleet', icon: Radar },
|
||||
],
|
||||
overflowGroups: [] as typeof overflowGroups,
|
||||
launcherGroups: [] as typeof launcherGroups,
|
||||
quickLinkCandidates: [
|
||||
{ value: 'dashboard' as const, label: 'Home', icon: Home },
|
||||
{ value: 'fleet' as const, label: 'Fleet', icon: Radar },
|
||||
],
|
||||
};
|
||||
|
||||
it('marks More with aria-current when the active page is in overflow', () => {
|
||||
renderTopBar({
|
||||
navMode: 'smart',
|
||||
activeView: 'global-observability',
|
||||
navModel: {
|
||||
...emptyModel,
|
||||
overflowGroups,
|
||||
},
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'More navigation' })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'More navigation' })).toHaveTextContent('More');
|
||||
});
|
||||
|
||||
it('opens the More menu with masthead chrome and keeps overflow labels', async () => {
|
||||
const onNavigate = vi.fn();
|
||||
renderTopBar({
|
||||
navMode: 'smart',
|
||||
onNavigate,
|
||||
navModel: {
|
||||
...emptyModel,
|
||||
overflowGroups,
|
||||
},
|
||||
});
|
||||
const more = screen.getByRole('button', { name: 'More navigation' });
|
||||
more.focus();
|
||||
fireEvent.keyDown(more, { key: 'Enter' });
|
||||
expect(await screen.findByText('More', { selector: '.font-heading' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('menuitem', { name: /Logs/i })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Logs/i }));
|
||||
expect(onNavigate).toHaveBeenCalledWith('global-observability');
|
||||
});
|
||||
|
||||
it('renders Compact pins with always-inline labels and a trailing Add control', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onNavigate = vi.fn();
|
||||
const onAddQuickLink = vi.fn();
|
||||
const onRemoveQuickLink = vi.fn();
|
||||
const onOpenSettings = vi.fn();
|
||||
renderTopBar({
|
||||
navMode: 'compact',
|
||||
activeView: 'dashboard',
|
||||
onNavigate,
|
||||
onAddQuickLink,
|
||||
onRemoveQuickLink,
|
||||
onOpenSettings,
|
||||
persistedQuickLinkIds: ['dashboard'],
|
||||
quickLinks: [{ value: 'dashboard', label: 'Home', icon: Home }],
|
||||
navModel: {
|
||||
...emptyModel,
|
||||
launcherGroups,
|
||||
quickLinkCandidates: [
|
||||
{ value: 'dashboard' as const, label: 'Home', icon: Home },
|
||||
{ value: 'fleet' as const, label: 'Fleet', icon: Radar },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const home = screen.getByRole('button', { name: 'Home' });
|
||||
expect(home.querySelector('span.inline')).toBeTruthy();
|
||||
expect(home.querySelector('span.hidden')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Add quick link' }));
|
||||
await user.click(await screen.findByRole('menuitem', { name: /Fleet/i }));
|
||||
expect(onAddQuickLink).toHaveBeenCalledWith('fleet');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Home' }));
|
||||
expect(onNavigate).toHaveBeenCalledWith('dashboard');
|
||||
|
||||
await user.pointer({ keys: '[MouseRight]', target: screen.getByRole('button', { name: 'Home' }) });
|
||||
await user.click(await screen.findByRole('menuitem', { name: /^Remove$/i }));
|
||||
expect(onRemoveQuickLink).toHaveBeenCalledWith('dashboard');
|
||||
expect(onNavigate).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Open navigation launcher' }));
|
||||
expect(await screen.findByText('Navigate', { selector: '.font-heading' })).toBeInTheDocument();
|
||||
await user.click(await screen.findByRole('menuitem', { name: /Settings/i }));
|
||||
expect(onOpenSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables Add when persisted capacity is full even if fewer pins are visible', () => {
|
||||
renderTopBar({
|
||||
navMode: 'compact',
|
||||
persistedQuickLinkIds: ['dashboard', 'fleet', 'resources', 'security', 'networking'],
|
||||
quickLinks: [{ value: 'dashboard', label: 'Home', icon: Home }],
|
||||
navModel: {
|
||||
...emptyModel,
|
||||
launcherGroups,
|
||||
quickLinkCandidates: emptyModel.quickLinkCandidates,
|
||||
},
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Add quick link' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('offers Compact launcher context Add for unpinned destinations', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAddQuickLink = vi.fn();
|
||||
const compactLauncher = [
|
||||
{
|
||||
group: 'overview' as const,
|
||||
label: 'Overview',
|
||||
items: [
|
||||
{ value: 'dashboard' as const, label: 'Home', icon: Home },
|
||||
{ value: 'fleet' as const, label: 'Fleet', icon: Radar },
|
||||
],
|
||||
},
|
||||
];
|
||||
renderTopBar({
|
||||
navMode: 'compact',
|
||||
onAddQuickLink,
|
||||
persistedQuickLinkIds: ['dashboard'],
|
||||
quickLinks: [{ value: 'dashboard', label: 'Home', icon: Home }],
|
||||
navModel: {
|
||||
...emptyModel,
|
||||
launcherGroups: compactLauncher,
|
||||
quickLinkCandidates: [
|
||||
{ value: 'dashboard' as const, label: 'Home', icon: Home },
|
||||
{ value: 'fleet' as const, label: 'Fleet', icon: Radar },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Open navigation launcher' }));
|
||||
fireEvent.contextMenu(await screen.findByRole('menuitem', { name: /Fleet/i }));
|
||||
await user.click(await screen.findByRole('menuitem', { name: /Add to quick links/i }));
|
||||
expect(onAddQuickLink).toHaveBeenCalledWith('fleet');
|
||||
});
|
||||
|
||||
it('hides Compact launcher context Add for already-pinned destinations', async () => {
|
||||
const user = userEvent.setup();
|
||||
const compactLauncher = [
|
||||
{
|
||||
group: 'overview' as const,
|
||||
label: 'Overview',
|
||||
items: [
|
||||
{ value: 'dashboard' as const, label: 'Home', icon: Home },
|
||||
{ value: 'fleet' as const, label: 'Fleet', icon: Radar },
|
||||
],
|
||||
},
|
||||
];
|
||||
renderTopBar({
|
||||
navMode: 'compact',
|
||||
onAddQuickLink: vi.fn(),
|
||||
persistedQuickLinkIds: ['dashboard'],
|
||||
quickLinks: [{ value: 'dashboard', label: 'Home', icon: Home }],
|
||||
navModel: {
|
||||
...emptyModel,
|
||||
launcherGroups: compactLauncher,
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Open navigation launcher' }));
|
||||
fireEvent.contextMenu(await screen.findByRole('menuitem', { name: /Home/i }));
|
||||
expect(screen.queryByRole('menuitem', { name: /Add to quick links/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('does not offer Add to quick links on Smart More', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTopBar({
|
||||
navMode: 'smart',
|
||||
onAddQuickLink: vi.fn(),
|
||||
navModel: {
|
||||
...emptyModel,
|
||||
primaryItems: [{ value: 'dashboard' as const, label: 'Home', icon: Home }],
|
||||
overflowGroups,
|
||||
},
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: 'More navigation' }));
|
||||
fireEvent.contextMenu(await screen.findByRole('menuitem', { name: /Logs/i }));
|
||||
expect(screen.queryByRole('menuitem', { name: /Add to quick links/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,11 +14,13 @@ import {
|
||||
import type { SectionId } from '@/components/settings';
|
||||
import { SettingsSectionContent } from '@/components/settings/SettingsSectionContent';
|
||||
import { BackChip, Kicker, Masthead } from './mobile-ui';
|
||||
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
|
||||
|
||||
interface MobileSettingsProps {
|
||||
headerActions: ReactNode;
|
||||
selectedSection: SectionId | null;
|
||||
onSelectedSectionChange: (section: SectionId | null) => void;
|
||||
quickLinkCandidates?: NavDestination[];
|
||||
}
|
||||
|
||||
const NOOP = () => {};
|
||||
@@ -27,6 +29,7 @@ export function MobileSettings({
|
||||
headerActions,
|
||||
selectedSection,
|
||||
onSelectedSectionChange,
|
||||
quickLinkCandidates,
|
||||
}: MobileSettingsProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
@@ -60,7 +63,12 @@ export function MobileSettings({
|
||||
<span className="font-heading text-[30px] leading-[34px] text-stat-value">{item.label}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden px-4 pb-8 pt-4 flex flex-col gap-6">
|
||||
<SettingsSectionContent sectionId={activeSection} onDirtyChange={NOOP} showDescription />
|
||||
<SettingsSectionContent
|
||||
sectionId={activeSection}
|
||||
onDirtyChange={NOOP}
|
||||
showDescription
|
||||
quickLinkCandidates={quickLinkCandidates}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,11 @@ import type { Density } from '@/hooks/use-density';
|
||||
import { useLogChipColorMode, type LogChipColorMode } from '@/hooks/use-log-chip-color-mode';
|
||||
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
|
||||
import { useTopNavAlign, type TopNavAlign } from '@/hooks/use-top-nav-align';
|
||||
import { useTopNavMode, type TopNavMode } from '@/hooks/use-top-nav-mode';
|
||||
import { useTopNavQuickLinks, MAX_QUICK_LINKS } from '@/hooks/use-top-nav-quick-links';
|
||||
import { getAppNavItem } from '@/lib/navigation/appNavRegistry';
|
||||
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
|
||||
import type { ActiveView } from '@/lib/router/routeTypes';
|
||||
import {
|
||||
useTheme, activeVisualStyle, THEME_MODE_OPTIONS, ACCENTS, CONTRAST, BORDER_BOOST, GLOW, TYPE_SCALE,
|
||||
type VisualStyle, type HeadingStyle, type ChartStyle,
|
||||
@@ -37,6 +42,12 @@ const TOP_NAV_ALIGN_OPTIONS: { value: TopNavAlign; label: string }[] = [
|
||||
{ value: 'center', label: 'Center' },
|
||||
];
|
||||
|
||||
const TOP_NAV_MODE_OPTIONS: { value: TopNavMode; label: string }[] = [
|
||||
{ value: 'classic', label: 'Classic bar' },
|
||||
{ value: 'smart', label: 'Smart bar' },
|
||||
{ value: 'compact', label: 'Compact launcher' },
|
||||
];
|
||||
|
||||
const CHART_STYLE_OPTIONS: { value: ChartStyle; label: string }[] = [
|
||||
{ value: 'muted', label: 'Muted' },
|
||||
{ value: 'heat', label: 'Heat' },
|
||||
@@ -134,11 +145,33 @@ function VisualCard({
|
||||
);
|
||||
}
|
||||
|
||||
export function AppearanceSection() {
|
||||
export function AppearanceSection({
|
||||
quickLinkCandidates = [],
|
||||
}: {
|
||||
quickLinkCandidates?: NavDestination[];
|
||||
}) {
|
||||
const [density, setDensity] = useDensity();
|
||||
const [chipColorMode, setChipColorMode] = useLogChipColorMode();
|
||||
const [topNavLabels, setTopNavLabels] = useTopNavLabels();
|
||||
const [topNavAlign, setTopNavAlign] = useTopNavAlign();
|
||||
const [topNavMode, setTopNavMode] = useTopNavMode();
|
||||
const {
|
||||
persistedIds: quickLinkIds,
|
||||
addQuickLink,
|
||||
removeQuickLink,
|
||||
resetQuickLinks,
|
||||
} = useTopNavQuickLinks();
|
||||
const persistedSet = new Set(quickLinkIds);
|
||||
const unpinnedCandidates = quickLinkCandidates.filter((item) => !persistedSet.has(item.value));
|
||||
const atCapacity = quickLinkIds.length >= MAX_QUICK_LINKS;
|
||||
const addEnabled = !atCapacity && unpinnedCandidates.length > 0;
|
||||
const addDisabledReason = atCapacity
|
||||
? 'Remove a pin or reset to free a slot'
|
||||
: 'No more destinations available on this node';
|
||||
const addOptions = unpinnedCandidates.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
}));
|
||||
const {
|
||||
theme, accent, borderBoost, glow, contrast, uiFont, monoFont, typeScale,
|
||||
headingStyle, chartStyle, reducedEffects, reducedMotion, readability,
|
||||
@@ -413,13 +446,41 @@ export function AppearanceSection() {
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Top navigation labels"
|
||||
helper="Show text labels beside top navigation icons. Turn off for a more compact navigation bar."
|
||||
label="Log chip color"
|
||||
helper="Unified uses the accent color for all service chips. Per-service assigns each service a stable label color for faster visual scanning."
|
||||
>
|
||||
<TogglePill checked={topNavLabels} onChange={setTopNavLabels} />
|
||||
<SegmentedControl
|
||||
value={chipColorMode}
|
||||
options={CHIP_COLOR_OPTIONS}
|
||||
onChange={setChipColorMode}
|
||||
ariaLabel="Log chip color mode"
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Navigation" kicker="this browser">
|
||||
<SettingsField
|
||||
label="Navigation style"
|
||||
helper="Smart bar is the recommended default: primary destinations stay visible, and the rest live under More. Classic keeps the full horizontal strip. Compact launcher puts destinations in a menu with optional quick links."
|
||||
>
|
||||
<SegmentedControl
|
||||
value={topNavMode}
|
||||
options={TOP_NAV_MODE_OPTIONS}
|
||||
onChange={setTopNavMode}
|
||||
ariaLabel="Navigation style"
|
||||
/>
|
||||
</SettingsField>
|
||||
|
||||
{!topNavLabels && (
|
||||
{(topNavMode === 'classic' || topNavMode === 'smart') && (
|
||||
<SettingsField
|
||||
label="Top navigation labels"
|
||||
helper="Show text labels beside top navigation icons. Turn off for a more compact navigation bar."
|
||||
>
|
||||
<TogglePill checked={topNavLabels} onChange={setTopNavLabels} />
|
||||
</SettingsField>
|
||||
)}
|
||||
|
||||
{(topNavMode === 'classic' || topNavMode === 'smart') && !topNavLabels && (
|
||||
<SettingsField
|
||||
label="Top navigation alignment"
|
||||
helper="Place the icon-only navigation against the left edge or centered in the bar."
|
||||
@@ -433,17 +494,64 @@ export function AppearanceSection() {
|
||||
</SettingsField>
|
||||
)}
|
||||
|
||||
<SettingsField
|
||||
label="Log chip color"
|
||||
helper="Unified uses the accent color for all service chips. Per-service assigns each service a stable label color for faster visual scanning."
|
||||
>
|
||||
<SegmentedControl
|
||||
value={chipColorMode}
|
||||
options={CHIP_COLOR_OPTIONS}
|
||||
onChange={setChipColorMode}
|
||||
ariaLabel="Log chip color mode"
|
||||
/>
|
||||
</SettingsField>
|
||||
{topNavMode === 'compact' && (
|
||||
<SettingsField
|
||||
label="Quick links"
|
||||
helper="Up to five pinned destinations on the top bar. Defaults are a starting set; add reachable destinations here or with the trailing + on the Compact bar."
|
||||
>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{quickLinkIds.length === 0 ? (
|
||||
<p className="font-mono text-[11px] text-stat-subtitle">No quick links pinned.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{quickLinkIds.map((id) => {
|
||||
const item = getAppNavItem(id);
|
||||
const label = item?.label ?? id;
|
||||
return (
|
||||
<li
|
||||
key={id}
|
||||
className="flex items-center justify-between gap-2 rounded-md border border-glass-border px-2 py-1.5"
|
||||
>
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.14em]">
|
||||
{label}
|
||||
</span>
|
||||
<SettingsSecondaryButton
|
||||
type="button"
|
||||
aria-label={`Remove ${label}`}
|
||||
onClick={() => removeQuickLink(id)}
|
||||
>
|
||||
Remove
|
||||
</SettingsSecondaryButton>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Add quick link
|
||||
</span>
|
||||
{addEnabled ? (
|
||||
<Combobox
|
||||
options={addOptions}
|
||||
value=""
|
||||
onValueChange={(value) => {
|
||||
if (value) addQuickLink(value as ActiveView);
|
||||
}}
|
||||
placeholder="Choose a destination"
|
||||
/>
|
||||
) : (
|
||||
<p className="font-mono text-[11px] text-stat-subtitle">{addDisabledReason}</p>
|
||||
)}
|
||||
</div>
|
||||
<SettingsActions>
|
||||
<SettingsSecondaryButton type="button" onClick={resetQuickLinks}>
|
||||
Reset to defaults
|
||||
</SettingsSecondaryButton>
|
||||
</SettingsActions>
|
||||
</div>
|
||||
</SettingsField>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<p className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/70">
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
import { SettingsSidebar } from './SettingsSidebar';
|
||||
import { SettingsSectionContent } from './SettingsSectionContent';
|
||||
import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsContext';
|
||||
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
|
||||
|
||||
interface SettingsPageProps {
|
||||
currentSection: SectionId;
|
||||
@@ -35,6 +36,7 @@ interface SettingsPageProps {
|
||||
muteRulePrefill?: MuteRuleDraft | null;
|
||||
onMutePrefillConsumed?: () => void;
|
||||
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||
quickLinkCandidates?: NavDestination[];
|
||||
}
|
||||
|
||||
export function SettingsPage(props: SettingsPageProps) {
|
||||
@@ -51,6 +53,7 @@ function SettingsPageInner({
|
||||
muteRulePrefill = null,
|
||||
onMutePrefillConsumed,
|
||||
onOpenMuteRulesWithPrefill,
|
||||
quickLinkCandidates,
|
||||
}: SettingsPageProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
@@ -210,6 +213,7 @@ function SettingsPageInner({
|
||||
muteRulePrefill={muteRulePrefill}
|
||||
onMutePrefillConsumed={onMutePrefillConsumed}
|
||||
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
|
||||
quickLinkCandidates={quickLinkCandidates}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { SectionId } from './index';
|
||||
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
import LazyBoundary from '../LazyBoundary';
|
||||
import { SectionGate } from './SectionGate';
|
||||
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
|
||||
|
||||
// Paid-tier sections are loaded on demand. SectionGate returns null for
|
||||
// Community / unentitled operators before reaching the JSX that would mount
|
||||
@@ -79,10 +80,11 @@ function renderSection(
|
||||
muteRulePrefill: MuteRuleDraft | null | undefined,
|
||||
onMutePrefillConsumed: (() => void) | undefined,
|
||||
onOpenMuteRulesWithPrefill: ((draft: MuteRuleDraft) => void) | undefined,
|
||||
quickLinkCandidates: NavDestination[] | undefined,
|
||||
) {
|
||||
switch (sectionId) {
|
||||
case 'account': return <AccountSection />;
|
||||
case 'appearance': return <AppearanceSection />;
|
||||
case 'appearance': return <AppearanceSection quickLinkCandidates={quickLinkCandidates} />;
|
||||
case 'license': return <LicenseSection />;
|
||||
case 'users': return <UsersSection />;
|
||||
case 'sso': return <SSOSection />;
|
||||
@@ -125,6 +127,7 @@ interface SettingsSectionContentProps {
|
||||
muteRulePrefill?: MuteRuleDraft | null;
|
||||
onMutePrefillConsumed?: () => void;
|
||||
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||
quickLinkCandidates?: NavDestination[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,11 +143,19 @@ export function SettingsSectionContent({
|
||||
muteRulePrefill,
|
||||
onMutePrefillConsumed,
|
||||
onOpenMuteRulesWithPrefill,
|
||||
quickLinkCandidates,
|
||||
}: SettingsSectionContentProps) {
|
||||
const item = getSettingsItem(sectionId);
|
||||
const element = useMemo(
|
||||
() => renderSection(sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill),
|
||||
[sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill],
|
||||
() => renderSection(
|
||||
sectionId,
|
||||
onDirtyChange,
|
||||
muteRulePrefill,
|
||||
onMutePrefillConsumed,
|
||||
onOpenMuteRulesWithPrefill,
|
||||
quickLinkCandidates,
|
||||
),
|
||||
[sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill, quickLinkCandidates],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -161,4 +161,22 @@ describe('AppearanceSection', () => {
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Readability mode' }));
|
||||
expect((screen.getByRole('button', { name: 'Reset to default' }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('shows Navigation style and mode-conditional controls', () => {
|
||||
localStorage.clear();
|
||||
render(<AppearanceSection />);
|
||||
expect(screen.getByText('Navigation')).toBeTruthy();
|
||||
expect(screen.getByRole('radiogroup', { name: 'Navigation style' })).toBeTruthy();
|
||||
// Smart default shows label toggle, hides quick links.
|
||||
expect(screen.getByText('Top navigation labels')).toBeTruthy();
|
||||
expect(screen.queryByText('Quick links')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Compact launcher' }));
|
||||
expect(screen.getByText('Quick links')).toBeTruthy();
|
||||
expect(screen.queryByText('Top navigation labels')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Classic bar' }));
|
||||
expect(screen.getByText('Top navigation labels')).toBeTruthy();
|
||||
expect(screen.queryByText('Quick links')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,8 +61,8 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
id: 'appearance',
|
||||
group: 'personal',
|
||||
label: 'Appearance',
|
||||
description: 'Visual style, readability, theme, accent, charts, and display preferences saved to this browser.',
|
||||
keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display', 'calm', 'signature', 'readability', 'heading', 'chart', 'motion', 'effects'],
|
||||
description: 'Visual style, readability, theme, accent, charts, display, and navigation preferences saved to this browser.',
|
||||
keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display', 'calm', 'signature', 'readability', 'heading', 'chart', 'motion', 'effects', 'navigation', 'smart', 'launcher', 'quick links', 'topbar', 'top nav'],
|
||||
tier: null,
|
||||
scope: 'browser',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useTopNavMode, TOP_NAV_MODE_KEY, parseTopNavMode } from '../use-top-nav-mode';
|
||||
|
||||
describe('useTopNavMode', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it('defaults to smart when no value is stored', () => {
|
||||
const { result } = renderHook(() => useTopNavMode());
|
||||
expect(result.current[0]).toBe('smart');
|
||||
});
|
||||
|
||||
it('falls back to smart for invalid storage', () => {
|
||||
expect(parseTopNavMode('nope')).toBe('smart');
|
||||
localStorage.setItem(TOP_NAV_MODE_KEY, 'nope');
|
||||
const { result } = renderHook(() => useTopNavMode());
|
||||
expect(result.current[0]).toBe('smart');
|
||||
});
|
||||
|
||||
it('reads each valid stored mode', () => {
|
||||
for (const mode of ['classic', 'smart', 'compact'] as const) {
|
||||
localStorage.setItem(TOP_NAV_MODE_KEY, mode);
|
||||
const { result, unmount } = renderHook(() => useTopNavMode());
|
||||
expect(result.current[0]).toBe(mode);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('persists mode changes and syncs same-tab listeners', () => {
|
||||
const a = renderHook(() => useTopNavMode());
|
||||
const b = renderHook(() => useTopNavMode());
|
||||
act(() => a.result.current[1]('compact'));
|
||||
expect(a.result.current[0]).toBe('compact');
|
||||
expect(b.result.current[0]).toBe('compact');
|
||||
expect(localStorage.getItem(TOP_NAV_MODE_KEY)).toBe('compact');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { recommendedQuickLinkIds } from '@/lib/navigation/appNavRegistry';
|
||||
import {
|
||||
useTopNavQuickLinks,
|
||||
TOP_NAV_QUICK_LINKS_KEY,
|
||||
parseStoredQuickLinks,
|
||||
sanitizeQuickLinkIds,
|
||||
MAX_QUICK_LINKS,
|
||||
} from '../use-top-nav-quick-links';
|
||||
|
||||
describe('useTopNavQuickLinks', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it('uses registry recommended defaults when the key is missing', () => {
|
||||
const { result } = renderHook(() => useTopNavQuickLinks());
|
||||
expect(result.current.persistedIds).toEqual([...recommendedQuickLinkIds]);
|
||||
});
|
||||
|
||||
it('uses registry recommended defaults for malformed JSON', () => {
|
||||
localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, '{not-json');
|
||||
expect(parseStoredQuickLinks(localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY))).toEqual([
|
||||
...recommendedQuickLinkIds,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a valid empty array empty', () => {
|
||||
localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, '[]');
|
||||
const { result } = renderHook(() => useTopNavQuickLinks());
|
||||
expect(result.current.persistedIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('sanitizes unknown, ineligible, and duplicate IDs and caps at five', () => {
|
||||
expect(
|
||||
sanitizeQuickLinkIds([
|
||||
'dashboard',
|
||||
'dashboard',
|
||||
'settings',
|
||||
'not-a-view',
|
||||
'fleet',
|
||||
'security',
|
||||
'resources',
|
||||
'networking',
|
||||
'templates',
|
||||
]),
|
||||
).toEqual(['dashboard', 'fleet', 'security', 'resources', 'networking']);
|
||||
expect(
|
||||
sanitizeQuickLinkIds([
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'security',
|
||||
'resources',
|
||||
'networking',
|
||||
'templates',
|
||||
]).length,
|
||||
).toBe(MAX_QUICK_LINKS);
|
||||
});
|
||||
|
||||
it('reset writes recommendedQuickLinkIds', () => {
|
||||
localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, '[]');
|
||||
const { result } = renderHook(() => useTopNavQuickLinks());
|
||||
act(() => result.current.resetQuickLinks());
|
||||
expect(result.current.persistedIds).toEqual([...recommendedQuickLinkIds]);
|
||||
expect(JSON.parse(localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY)!)).toEqual([
|
||||
...recommendedQuickLinkIds,
|
||||
]);
|
||||
});
|
||||
|
||||
it('remove can clear all pins without repopulating', () => {
|
||||
const { result } = renderHook(() => useTopNavQuickLinks());
|
||||
act(() => {
|
||||
for (const id of [...result.current.persistedIds]) {
|
||||
result.current.removeQuickLink(id);
|
||||
}
|
||||
});
|
||||
expect(result.current.persistedIds).toEqual([]);
|
||||
expect(JSON.parse(localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY)!)).toEqual([]);
|
||||
});
|
||||
|
||||
it('add refuses beyond the persisted max of five', () => {
|
||||
const { result } = renderHook(() => useTopNavQuickLinks());
|
||||
act(() => result.current.setPersistedIds([
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'security',
|
||||
'resources',
|
||||
'networking',
|
||||
]));
|
||||
act(() => result.current.addQuickLink('templates'));
|
||||
expect(result.current.persistedIds).toEqual([
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'security',
|
||||
'resources',
|
||||
'networking',
|
||||
]);
|
||||
});
|
||||
|
||||
it('syncs a second hook in the same tab', () => {
|
||||
const a = renderHook(() => useTopNavQuickLinks());
|
||||
const b = renderHook(() => useTopNavQuickLinks());
|
||||
act(() => a.result.current.setPersistedIds(['networking']));
|
||||
expect(b.result.current.persistedIds).toEqual(['networking']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||
|
||||
export const TOP_NAV_MODE_KEY = 'sencho.appearance.topNavMode';
|
||||
|
||||
export type TopNavMode = 'classic' | 'smart' | 'compact';
|
||||
|
||||
const VALID: ReadonlySet<string> = new Set(['classic', 'smart', 'compact']);
|
||||
|
||||
/** Missing or invalid storage resolves to Smart (recommended default). */
|
||||
export function parseTopNavMode(raw: string | null): TopNavMode {
|
||||
if (raw && VALID.has(raw)) return raw as TopNavMode;
|
||||
return 'smart';
|
||||
}
|
||||
|
||||
function readStored(): TopNavMode {
|
||||
if (typeof window === 'undefined') return 'smart';
|
||||
try {
|
||||
return parseTopNavMode(window.localStorage.getItem(TOP_NAV_MODE_KEY));
|
||||
} catch {
|
||||
return 'smart';
|
||||
}
|
||||
}
|
||||
|
||||
export function useTopNavMode(): [TopNavMode, (next: TopNavMode) => void] {
|
||||
const [mode, setModeState] = useState<TopNavMode>(readStored);
|
||||
|
||||
useEffect(() => {
|
||||
function onSettingsChanged() {
|
||||
setModeState(readStored());
|
||||
}
|
||||
window.addEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
|
||||
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onStorage(event: StorageEvent) {
|
||||
if (event.key !== TOP_NAV_MODE_KEY) return;
|
||||
setModeState(parseTopNavMode(event.newValue));
|
||||
}
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, []);
|
||||
|
||||
const setMode = useCallback((next: TopNavMode) => {
|
||||
try {
|
||||
window.localStorage.setItem(TOP_NAV_MODE_KEY, next);
|
||||
} catch {
|
||||
// ignore; localStorage may be unavailable
|
||||
}
|
||||
setModeState(next);
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
|
||||
}, []);
|
||||
|
||||
return [mode, setMode];
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
isQuickLinkEligibleId,
|
||||
recommendedQuickLinkIds,
|
||||
} from '@/lib/navigation/appNavRegistry';
|
||||
import type { ActiveView } from '@/lib/router/routeTypes';
|
||||
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||
|
||||
export const TOP_NAV_QUICK_LINKS_KEY = 'sencho.appearance.topNavQuickLinks';
|
||||
export const MAX_QUICK_LINKS = 5;
|
||||
|
||||
/**
|
||||
* Sanitize a candidate ID list: keep registry-known eligible IDs, dedupe,
|
||||
* and cap at MAX_QUICK_LINKS. Does not expand empty arrays to defaults.
|
||||
*/
|
||||
export function sanitizeQuickLinkIds(ids: unknown): ActiveView[] {
|
||||
if (!Array.isArray(ids)) return [...recommendedQuickLinkIds];
|
||||
const seen = new Set<string>();
|
||||
const out: ActiveView[] = [];
|
||||
for (const raw of ids) {
|
||||
if (typeof raw !== 'string' || !isQuickLinkEligibleId(raw)) continue;
|
||||
if (seen.has(raw)) continue;
|
||||
seen.add(raw);
|
||||
out.push(raw);
|
||||
if (out.length >= MAX_QUICK_LINKS) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse stored JSON. Missing key or malformed JSON → recommended defaults.
|
||||
* Valid JSON array (including []) is sanitized and returned as-is (empty stays empty).
|
||||
*/
|
||||
export function parseStoredQuickLinks(raw: string | null): ActiveView[] {
|
||||
if (raw === null) return [...recommendedQuickLinkIds];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [...recommendedQuickLinkIds];
|
||||
return sanitizeQuickLinkIds(parsed);
|
||||
} catch {
|
||||
return [...recommendedQuickLinkIds];
|
||||
}
|
||||
}
|
||||
|
||||
function readStored(): ActiveView[] {
|
||||
if (typeof window === 'undefined') return [...recommendedQuickLinkIds];
|
||||
try {
|
||||
return parseStoredQuickLinks(window.localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY));
|
||||
} catch {
|
||||
return [...recommendedQuickLinkIds];
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(ids: ActiveView[]): void {
|
||||
try {
|
||||
window.localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, JSON.stringify(ids));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export interface TopNavQuickLinksApi {
|
||||
persistedIds: ActiveView[];
|
||||
setPersistedIds: (next: ActiveView[]) => void;
|
||||
addQuickLink: (value: ActiveView) => void;
|
||||
removeQuickLink: (value: ActiveView) => void;
|
||||
resetQuickLinks: () => void;
|
||||
}
|
||||
|
||||
export function useTopNavQuickLinks(): TopNavQuickLinksApi {
|
||||
const [persistedIds, setPersistedState] = useState<ActiveView[]>(readStored);
|
||||
|
||||
useEffect(() => {
|
||||
function onSettingsChanged() {
|
||||
setPersistedState(readStored());
|
||||
}
|
||||
window.addEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
|
||||
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onStorage(event: StorageEvent) {
|
||||
if (event.key !== TOP_NAV_QUICK_LINKS_KEY) return;
|
||||
setPersistedState(parseStoredQuickLinks(event.newValue));
|
||||
}
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, []);
|
||||
|
||||
const commit = useCallback((next: ActiveView[]) => {
|
||||
const sanitized = sanitizeQuickLinkIds(next);
|
||||
writeStored(sanitized);
|
||||
setPersistedState(sanitized);
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
|
||||
}, []);
|
||||
|
||||
const setPersistedIds = useCallback((next: ActiveView[]) => {
|
||||
commit(next);
|
||||
}, [commit]);
|
||||
|
||||
const addQuickLink = useCallback((value: ActiveView) => {
|
||||
setPersistedState((prev) => {
|
||||
if (prev.includes(value) || prev.length >= MAX_QUICK_LINKS) return prev;
|
||||
if (!isQuickLinkEligibleId(value)) return prev;
|
||||
const next = [...prev, value];
|
||||
writeStored(next);
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeQuickLink = useCallback((value: ActiveView) => {
|
||||
setPersistedState((prev) => {
|
||||
const next = prev.filter((id) => id !== value);
|
||||
writeStored(next);
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resetQuickLinks = useCallback(() => {
|
||||
commit([...recommendedQuickLinkIds]);
|
||||
}, [commit]);
|
||||
|
||||
return {
|
||||
persistedIds,
|
||||
setPersistedIds,
|
||||
addQuickLink,
|
||||
removeQuickLink,
|
||||
resetQuickLinks,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
APP_NAV_REGISTRY,
|
||||
recommendedQuickLinkIds,
|
||||
} from './appNavRegistry';
|
||||
|
||||
describe('appNavRegistry', () => {
|
||||
it('has unique ActiveView values', () => {
|
||||
const values = APP_NAV_REGISTRY.map((item) => item.value);
|
||||
expect(new Set(values).size).toBe(values.length);
|
||||
});
|
||||
|
||||
it('gives every non-Settings destination exactly one Smart placement of primary or overflow', () => {
|
||||
for (const item of APP_NAV_REGISTRY) {
|
||||
if (item.value === 'settings') {
|
||||
expect(item.smart).toBe('launcher-only');
|
||||
continue;
|
||||
}
|
||||
expect(item.smart === 'primary' || item.smart === 'overflow').toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('exports recommendedQuickLinkIds from defaultQuickLink metadata in that order', () => {
|
||||
const flagged = APP_NAV_REGISTRY.filter((item) => item.defaultQuickLink).map((item) => item.value);
|
||||
expect(new Set(recommendedQuickLinkIds)).toEqual(new Set(flagged));
|
||||
expect([...recommendedQuickLinkIds]).toEqual([
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'security',
|
||||
'resources',
|
||||
]);
|
||||
for (const id of recommendedQuickLinkIds) {
|
||||
const item = APP_NAV_REGISTRY.find((entry) => entry.value === id);
|
||||
expect(item?.quickLinkEligible).toBe(true);
|
||||
expect(item?.defaultQuickLink).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps Networking after Resources in Classic order metadata', () => {
|
||||
const resources = APP_NAV_REGISTRY.find((item) => item.value === 'resources');
|
||||
const networking = APP_NAV_REGISTRY.find((item) => item.value === 'networking');
|
||||
expect(resources).toBeDefined();
|
||||
expect(networking).toBeDefined();
|
||||
expect(networking!.classicOrder).toBeGreaterThan(resources!.classicOrder);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
Terminal, CloudDownload, Home, HardDrive, ScrollText,
|
||||
Activity, Radar, RefreshCw, Clock, ShieldCheck, Network, Settings,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { ActiveView } from '@/lib/router/routeTypes';
|
||||
|
||||
export type NavGroup =
|
||||
| 'overview'
|
||||
| 'stack-workspace'
|
||||
| 'fleet'
|
||||
| 'security-review'
|
||||
| 'operations'
|
||||
| 'tools'
|
||||
| 'settings';
|
||||
|
||||
export type SmartPlacement = 'primary' | 'overflow' | 'launcher-only';
|
||||
|
||||
/** Shared destination shape for TopBar, palette, and mobile consumers. */
|
||||
export interface NavDestination {
|
||||
value: ActiveView;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export interface AppNavItem extends NavDestination {
|
||||
group: NavGroup;
|
||||
/** Classic strip order (ascending). Settings uses a high value and is excluded from Classic. */
|
||||
classicOrder: number;
|
||||
smart: SmartPlacement;
|
||||
quickLinkEligible: boolean;
|
||||
defaultQuickLink: boolean;
|
||||
}
|
||||
|
||||
export const NAV_GROUP_META: readonly { id: NavGroup; label: string }[] = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'stack-workspace', label: 'Stack workspace' },
|
||||
{ id: 'fleet', label: 'Fleet' },
|
||||
{ id: 'security-review', label: 'Security & review' },
|
||||
{ id: 'operations', label: 'Operations' },
|
||||
{ id: 'tools', label: 'Tools' },
|
||||
{ id: 'settings', label: 'Settings' },
|
||||
] as const;
|
||||
|
||||
export const APP_NAV_REGISTRY: readonly AppNavItem[] = [
|
||||
{
|
||||
value: 'dashboard',
|
||||
label: 'Home',
|
||||
icon: Home,
|
||||
group: 'overview',
|
||||
classicOrder: 10,
|
||||
smart: 'primary',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: true,
|
||||
},
|
||||
{
|
||||
value: 'fleet',
|
||||
label: 'Fleet',
|
||||
icon: Radar,
|
||||
group: 'fleet',
|
||||
classicOrder: 20,
|
||||
smart: 'primary',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: true,
|
||||
},
|
||||
{
|
||||
value: 'resources',
|
||||
label: 'Resources',
|
||||
icon: HardDrive,
|
||||
group: 'fleet',
|
||||
classicOrder: 30,
|
||||
smart: 'primary',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: true,
|
||||
},
|
||||
{
|
||||
value: 'networking',
|
||||
label: 'Networking',
|
||||
icon: Network,
|
||||
group: 'fleet',
|
||||
classicOrder: 40,
|
||||
smart: 'primary',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'security',
|
||||
label: 'Security',
|
||||
icon: ShieldCheck,
|
||||
group: 'security-review',
|
||||
classicOrder: 50,
|
||||
smart: 'primary',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: true,
|
||||
},
|
||||
{
|
||||
value: 'templates',
|
||||
label: 'App Store',
|
||||
icon: CloudDownload,
|
||||
group: 'stack-workspace',
|
||||
classicOrder: 60,
|
||||
smart: 'primary',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'global-observability',
|
||||
label: 'Logs',
|
||||
icon: Activity,
|
||||
group: 'operations',
|
||||
classicOrder: 70,
|
||||
smart: 'overflow',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'auto-updates',
|
||||
label: 'Update',
|
||||
icon: RefreshCw,
|
||||
group: 'operations',
|
||||
classicOrder: 80,
|
||||
smart: 'overflow',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'scheduled-ops',
|
||||
label: 'Schedules',
|
||||
icon: Clock,
|
||||
group: 'operations',
|
||||
classicOrder: 90,
|
||||
smart: 'overflow',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'host-console',
|
||||
label: 'Console',
|
||||
icon: Terminal,
|
||||
group: 'tools',
|
||||
classicOrder: 100,
|
||||
smart: 'overflow',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'audit-log',
|
||||
label: 'Audit',
|
||||
icon: ScrollText,
|
||||
group: 'security-review',
|
||||
classicOrder: 110,
|
||||
smart: 'overflow',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'settings',
|
||||
label: 'Settings',
|
||||
icon: Settings,
|
||||
group: 'settings',
|
||||
classicOrder: 999,
|
||||
smart: 'launcher-only',
|
||||
quickLinkEligible: false,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Recommended quick-link pins for missing/malformed storage and Reset.
|
||||
* Order is intentional (Home, Fleet, Security, Resources), not Classic strip order.
|
||||
*/
|
||||
export const recommendedQuickLinkIds: readonly ActiveView[] = [
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'security',
|
||||
'resources',
|
||||
] as const;
|
||||
|
||||
const BY_VALUE = new Map(APP_NAV_REGISTRY.map((item) => [item.value, item]));
|
||||
|
||||
export function getAppNavItem(value: ActiveView): AppNavItem | undefined {
|
||||
return BY_VALUE.get(value);
|
||||
}
|
||||
|
||||
export function isQuickLinkEligibleId(value: string): value is ActiveView {
|
||||
const item = BY_VALUE.get(value as ActiveView);
|
||||
return Boolean(item?.quickLinkEligible);
|
||||
}
|
||||
|
||||
export function toNavDestination(item: AppNavItem): NavDestination {
|
||||
return { value: item.value, label: item.label, icon: item.icon };
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildNavigationModel } from './buildNavigationModel';
|
||||
import type { ReachabilityContext } from '@/lib/routing/reachability';
|
||||
|
||||
function makeCtx(overrides: Partial<ReachabilityContext> = {}): ReachabilityContext {
|
||||
return {
|
||||
isAdmin: true,
|
||||
isPaid: true,
|
||||
can: () => true,
|
||||
isRemote: false,
|
||||
hasFleetCapability: true,
|
||||
containerLabelsEnabled: true,
|
||||
permissionsStatus: 'ready',
|
||||
licenseStatus: 'ready',
|
||||
experimental: true,
|
||||
experimentalReady: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildNavigationModel', () => {
|
||||
it('returns exact Classic page order including Networking after Resources', () => {
|
||||
const model = buildNavigationModel(makeCtx());
|
||||
expect(model.allPageItems.map((item) => item.value)).toEqual([
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'resources',
|
||||
'networking',
|
||||
'security',
|
||||
'templates',
|
||||
'global-observability',
|
||||
'auto-updates',
|
||||
'scheduled-ops',
|
||||
'host-console',
|
||||
'audit-log',
|
||||
]);
|
||||
expect(model.allPageItems.some((item) => item.value === 'settings')).toBe(false);
|
||||
});
|
||||
|
||||
it('partitions Smart primary and overflow disjointly covering all page destinations', () => {
|
||||
const model = buildNavigationModel(makeCtx());
|
||||
const primary = model.primaryItems.map((item) => item.value);
|
||||
const overflow = model.overflowGroups.flatMap((g) => g.items.map((i) => i.value));
|
||||
expect(primary).toEqual([
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'resources',
|
||||
'networking',
|
||||
'security',
|
||||
'templates',
|
||||
]);
|
||||
expect(primary.filter((v) => overflow.includes(v))).toEqual([]);
|
||||
expect(new Set([...primary, ...overflow])).toEqual(
|
||||
new Set(model.allPageItems.map((item) => item.value)),
|
||||
);
|
||||
});
|
||||
|
||||
it('includes Settings only in launcher groups', () => {
|
||||
const model = buildNavigationModel(makeCtx());
|
||||
const launcherValues = model.launcherGroups.flatMap((g) => g.items.map((i) => i.value));
|
||||
expect(launcherValues).toContain('settings');
|
||||
expect(model.allPageItems.map((i) => i.value)).not.toContain('settings');
|
||||
expect(model.primaryItems.map((i) => i.value)).not.toContain('settings');
|
||||
});
|
||||
|
||||
it('keeps Networking reachable on a remote node while dropping hub-only pages', () => {
|
||||
const model = buildNavigationModel(makeCtx({ isRemote: true }));
|
||||
const values = model.allPageItems.map((item) => item.value);
|
||||
expect(values).toContain('networking');
|
||||
expect(values).toContain('resources');
|
||||
expect(values).toContain('security');
|
||||
expect(values).toContain('templates');
|
||||
expect(values).not.toContain('fleet');
|
||||
expect(values).not.toContain('global-observability');
|
||||
expect(values).not.toContain('auto-updates');
|
||||
expect(values).not.toContain('scheduled-ops');
|
||||
expect(values).not.toContain('audit-log');
|
||||
});
|
||||
|
||||
it('omits Console until experimental discovery is ready and enabled via reachCtx only', () => {
|
||||
expect(
|
||||
buildNavigationModel(makeCtx({ experimentalReady: false, experimental: false }))
|
||||
.allPageItems.map((i) => i.value),
|
||||
).not.toContain('host-console');
|
||||
expect(
|
||||
buildNavigationModel(makeCtx({ experimentalReady: true, experimental: false }))
|
||||
.allPageItems.map((i) => i.value),
|
||||
).not.toContain('host-console');
|
||||
expect(
|
||||
buildNavigationModel(makeCtx({ experimentalReady: true, experimental: true }))
|
||||
.allPageItems.map((i) => i.value),
|
||||
).toContain('host-console');
|
||||
});
|
||||
|
||||
it('excludes hidden views from quick-link candidates', () => {
|
||||
const model = buildNavigationModel(makeCtx({ isRemote: true, isPaid: false }));
|
||||
const values = model.quickLinkCandidates.map((i) => i.value);
|
||||
expect(values).toContain('networking');
|
||||
expect(values).not.toContain('fleet');
|
||||
expect(values).not.toContain('settings');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
APP_NAV_REGISTRY,
|
||||
NAV_GROUP_META,
|
||||
toNavDestination,
|
||||
type AppNavItem,
|
||||
type NavDestination,
|
||||
type NavGroup,
|
||||
} from '@/lib/navigation/appNavRegistry';
|
||||
import { isViewHidden, type ReachabilityContext } from '@/lib/routing/reachability';
|
||||
|
||||
export interface NavGroupBucket {
|
||||
group: NavGroup;
|
||||
label: string;
|
||||
items: NavDestination[];
|
||||
}
|
||||
|
||||
export interface ReachableNavigationModel {
|
||||
/** Classic / palette / mobile page list (excludes Settings). Exact Classic order. */
|
||||
allPageItems: NavDestination[];
|
||||
primaryItems: NavDestination[];
|
||||
overflowGroups: NavGroupBucket[];
|
||||
launcherGroups: NavGroupBucket[];
|
||||
quickLinkCandidates: NavDestination[];
|
||||
}
|
||||
|
||||
function isVisuallyDiscoverable(item: AppNavItem, reachCtx: ReachabilityContext): boolean {
|
||||
if (item.smart === 'launcher-only') {
|
||||
// Settings is always discoverable in the launcher when the operator can open Settings.
|
||||
return true;
|
||||
}
|
||||
// Console: fail-closed visual discovery until /meta settles and the flag is on.
|
||||
// URL normalization still uses isViewHidden cold-load deferral separately.
|
||||
if (item.value === 'host-console') {
|
||||
if (!reachCtx.experimentalReady || !reachCtx.experimental) return false;
|
||||
return !isViewHidden(item.value, reachCtx);
|
||||
}
|
||||
return !isViewHidden(item.value, reachCtx);
|
||||
}
|
||||
|
||||
function bucketByGroup(items: AppNavItem[]): NavGroupBucket[] {
|
||||
const byGroup = new Map<NavGroup, NavDestination[]>();
|
||||
for (const item of items) {
|
||||
const list = byGroup.get(item.group) ?? [];
|
||||
list.push(toNavDestination(item));
|
||||
byGroup.set(item.group, list);
|
||||
}
|
||||
return NAV_GROUP_META
|
||||
.map(({ id, label }) => {
|
||||
const groupItems = byGroup.get(id);
|
||||
if (!groupItems?.length) return null;
|
||||
return { group: id, label, items: groupItems };
|
||||
})
|
||||
.filter((bucket): bucket is NavGroupBucket => bucket !== null);
|
||||
}
|
||||
|
||||
/** Derive reachable navigation collections from a single ReachabilityContext. */
|
||||
export function buildNavigationModel(reachCtx: ReachabilityContext): ReachableNavigationModel {
|
||||
const reachable = APP_NAV_REGISTRY.filter((item) => isVisuallyDiscoverable(item, reachCtx));
|
||||
|
||||
const pages = reachable
|
||||
.filter((item) => item.smart !== 'launcher-only')
|
||||
.slice()
|
||||
.sort((a, b) => a.classicOrder - b.classicOrder);
|
||||
|
||||
const allPageItems = pages.map(toNavDestination);
|
||||
const primaryItems = pages
|
||||
.filter((item) => item.smart === 'primary')
|
||||
.map(toNavDestination);
|
||||
const overflowItems = pages.filter((item) => item.smart === 'overflow');
|
||||
const overflowGroups = bucketByGroup(overflowItems);
|
||||
const launcherGroups = bucketByGroup(reachable);
|
||||
const quickLinkCandidates = reachable
|
||||
.filter((item) => item.quickLinkEligible)
|
||||
.map(toNavDestination);
|
||||
|
||||
return {
|
||||
allPageItems,
|
||||
primaryItems,
|
||||
overflowGroups,
|
||||
launcherGroups,
|
||||
quickLinkCandidates,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user