/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ import { useEffect, useMemo, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import * as LucideIcons from 'lucide-react'; const { ChevronDown, Lock } = LucideIcons; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useUIStore } from '@/stores/uiStore'; import { useAccountStore } from '@/stores/accountStore'; import { useSchemaStore } from '@/stores/schemaStore'; import { visibleLayouts, findFirstVisibleLinkInLayout, findFirstAccessibleLinkInLayout, isLinkEnterprise, isLinkVisible, } from '@/lib/layout'; import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema'; function LucideIcon({ name, className }: { name: string; className?: string }) { const formatted = name .split('-') .map((s) => s[0].toUpperCase() + s.slice(1)) .join(''); const IconComp = (LucideIcons as Record)[formatted] as LucideIcons.LucideIcon | undefined; if (!IconComp) return ; return ; } function resolveViewPath(sectionName: string, viewName: string): string { return `/${sectionName}/${viewName}`; } function pathMatchesView(currentPath: string, sectionName: string, viewName: string): boolean { const base = `/${sectionName}/${viewName}`; if (currentPath === base || currentPath.startsWith(`${base}/`)) return true; if (viewName === 'CustomComponent/Dashboard') { const dashBase = `/${sectionName}/Dashboard/`; return currentPath.startsWith(dashBase); } return false; } function subtreeContainsActive(items: LayoutSubItem[], currentPath: string, sectionName: string): boolean { for (const item of items) { if (item.type === 'link') { if (pathMatchesView(currentPath, sectionName, item.viewName)) return true; } else if (item.type === 'container') { if (subtreeContainsActive(item.items, currentPath, sectionName)) return true; } } return false; } function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean { for (const item of items) { if (item.type === 'link') { if (!checkLinkVisible(item.viewName)) continue; const enterprise = checkIsEnterprise(item.viewName); if (enterprise && edition === 'oss') continue; return true; } else if (item.type === 'container') { if (subtreeHasVisibleLink(item.items, edition)) return true; } } return false; } function checkLinkVisible(viewName: string): boolean { const schema = useSchemaStore.getState().schema; if (!schema) return true; const accountStore = useAccountStore.getState(); return isLinkVisible( schema, viewName, accountStore.edition, (prefix: string) => accountStore.hasObjectPermission(prefix, 'Get'), (perm: string) => accountStore.hasPermission(perm), ); } function checkIsEnterprise(viewName: string): boolean { const schema = useSchemaStore.getState().schema; if (!schema) return false; const edition = useAccountStore.getState().edition; return isLinkEnterprise(schema, viewName, edition); } interface SidebarSubItemProps { item: LayoutSubItem; depth: number; sectionName: string; currentPath: string; navigate: ReturnType; edition: string; onUpsell: () => void; } function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, edition, onUpsell }: SidebarSubItemProps) { if (item.type === 'link') { if (!checkLinkVisible(item.viewName)) return null; const path = resolveViewPath(sectionName, item.viewName); const isActive = pathMatchesView(currentPath, sectionName, item.viewName); const enterprise = checkIsEnterprise(item.viewName); const isLocked = enterprise && edition === 'community'; const isHidden = enterprise && edition === 'oss'; if (isHidden) return null; return ( ); } if (item.type === 'container') { if (!subtreeHasVisibleLink(item.items, edition)) return null; const containsActive = subtreeContainsActive(item.items, currentPath, sectionName); return ( {item.items.map((sub) => ( ))} ); } return null; } interface SidebarTopItemProps { item: LayoutItem; sectionName: string; currentPath: string; navigate: ReturnType; edition: string; onUpsell: () => void; } function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onUpsell }: SidebarTopItemProps) { if ('link' in item) { const { name, icon, viewName } = item.link; if (!checkLinkVisible(viewName)) return null; const path = resolveViewPath(sectionName, viewName); const isActive = pathMatchesView(currentPath, sectionName, viewName); const enterprise = checkIsEnterprise(viewName); const isLocked = enterprise && edition === 'community'; const isHidden = enterprise && edition === 'oss'; if (isHidden) return null; return ( ); } if ('container' in item) { const { name, icon, items } = item.container; if (!subtreeHasVisibleLink(items, edition)) return null; const containsActive = subtreeContainsActive(items, currentPath, sectionName); return ( {items.map((sub) => ( ))} ); } return null; } export function Sidebar() { const navigate = useNavigate(); const location = useLocation(); const activeSection = useUIStore((s) => s.activeSection); const setActiveSection = useUIStore((s) => s.setActiveSection); const sidebarOpen = useUIStore((s) => s.sidebarOpen); const setSidebarOpen = useUIStore((s) => s.setSidebarOpen); const schema = useSchemaStore((s) => s.schema); const edition = useAccountStore((s) => s.edition); const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission); const hasPermission = useAccountStore((s) => s.hasPermission); const [upsellOpen, setUpsellOpen] = useState(false); const layouts = useMemo( () => schema ? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission) : [], [schema, edition, hasObjectPermission, hasPermission], ); useEffect(() => { if (!schema) return; if (layouts.length === 0) return; if (!layouts.find((l) => l.name === activeSection)) { setActiveSection(layouts[0].name); } }, [schema, layouts, activeSection, setActiveSection]); useEffect(() => { if (typeof window === 'undefined') return; if (window.matchMedia('(max-width: 767px)').matches) { setSidebarOpen(false); } }, [location.pathname, setSidebarOpen]); if (!sidebarOpen || !schema) return null; const layout: Layout | undefined = layouts.find((l) => l.name === activeSection); if (!layout) return null; const handleSectionClick = (target: Layout) => { setActiveSection(target.name); const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get'); const first = findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ?? findFirstVisibleLinkInLayout(schema, target, edition, canGet, hasPermission); if (first) navigate(`/${target.name}/${first}`); }; return ( <>