Sidebar.tsx447 lines · main
| 1 | import { LOCAL_STORAGE_KEYS, useFlag, useIsMFAEnabled, useParams } from 'common' |
| 2 | import { AnimatePresence, motion, MotionProps } from 'framer-motion' |
| 3 | import { Home } from 'icons' |
| 4 | import { isUndefined } from 'lodash' |
| 5 | import { Blocks, Boxes, ChartArea, PanelLeftDashed, Receipt, Settings, Users } from 'lucide-react' |
| 6 | import Link from 'next/link' |
| 7 | import { useRouter } from 'next/router' |
| 8 | import { ComponentProps, ComponentPropsWithoutRef, FC, ReactNode, useEffect } from 'react' |
| 9 | import { |
| 10 | Button, |
| 11 | cn, |
| 12 | DropdownMenu, |
| 13 | DropdownMenuContent, |
| 14 | DropdownMenuLabel, |
| 15 | DropdownMenuRadioGroup, |
| 16 | DropdownMenuRadioItem, |
| 17 | DropdownMenuSeparator, |
| 18 | DropdownMenuTrigger, |
| 19 | Separator, |
| 20 | SidebarContent as SidebarContentPrimitive, |
| 21 | SidebarFooter, |
| 22 | SidebarGroup, |
| 23 | SidebarMenu, |
| 24 | SidebarMenuButton, |
| 25 | SidebarMenuItem, |
| 26 | Sidebar as SidebarPrimitive, |
| 27 | useSidebar, |
| 28 | } from 'ui' |
| 29 | |
| 30 | import { Shortcut } from '../ui/Shortcut' |
| 31 | import { Route } from '../ui/ui.types' |
| 32 | import { useUnifiedLogsPreview } from './App/FeaturePreview/FeaturePreviewContext' |
| 33 | import { |
| 34 | generateOtherRoutes, |
| 35 | generateProductRoutes, |
| 36 | generateSettingsRoutes, |
| 37 | generateToolRoutes, |
| 38 | } from '@/components/layouts/Navigation/NavigationBar/NavigationBar.utils' |
| 39 | import { ProjectIndexPageLink } from '@/data/prefetchers/project.$ref' |
| 40 | import { useHideSidebar } from '@/hooks/misc/useHideSidebar' |
| 41 | import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' |
| 42 | import { useLints } from '@/hooks/misc/useLints' |
| 43 | import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' |
| 44 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 45 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 46 | import { SHORTCUT_IDS } from '@/state/shortcuts/registry' |
| 47 | |
| 48 | export const ICON_SIZE = 32 |
| 49 | export const ICON_STROKE_WIDTH = 1.5 |
| 50 | export type SidebarBehaviourType = 'expandable' | 'open' | 'closed' |
| 51 | export const DEFAULT_SIDEBAR_BEHAVIOR = 'expandable' |
| 52 | |
| 53 | const SidebarMotion = motion.create(SidebarPrimitive) as FC< |
| 54 | ComponentProps<typeof SidebarPrimitive> & { |
| 55 | transition?: MotionProps['transition'] |
| 56 | } |
| 57 | > |
| 58 | |
| 59 | export interface SidebarProps extends ComponentPropsWithoutRef<typeof SidebarPrimitive> {} |
| 60 | |
| 61 | export const Sidebar = ({ className, ...props }: SidebarProps) => { |
| 62 | const { setOpen } = useSidebar() |
| 63 | const hideSideBar = useHideSidebar() |
| 64 | |
| 65 | const [sidebarBehaviour, setSidebarBehaviour] = useLocalStorageQuery( |
| 66 | LOCAL_STORAGE_KEYS.SIDEBAR_BEHAVIOR, |
| 67 | DEFAULT_SIDEBAR_BEHAVIOR |
| 68 | ) |
| 69 | |
| 70 | useEffect(() => { |
| 71 | // logic to toggle sidebar open based on sidebarBehaviour state |
| 72 | if (sidebarBehaviour === 'open') setOpen(true) |
| 73 | if (sidebarBehaviour === 'closed') setOpen(false) |
| 74 | }, [sidebarBehaviour, setOpen]) |
| 75 | |
| 76 | return ( |
| 77 | <AnimatePresence> |
| 78 | {!hideSideBar && ( |
| 79 | <SidebarMotion |
| 80 | {...props} |
| 81 | className={cn('z-50', className)} |
| 82 | transition={{ delay: 0.4, duration: 0.4 }} |
| 83 | overflowing={sidebarBehaviour === 'expandable'} |
| 84 | collapsible="icon" |
| 85 | variant="sidebar" |
| 86 | onMouseEnter={() => { |
| 87 | if (sidebarBehaviour === 'expandable') setOpen(true) |
| 88 | }} |
| 89 | onMouseLeave={() => { |
| 90 | if (sidebarBehaviour === 'expandable') setOpen(false) |
| 91 | }} |
| 92 | > |
| 93 | <SidebarContent |
| 94 | footer={ |
| 95 | <DropdownMenu> |
| 96 | <DropdownMenuTrigger asChild> |
| 97 | <Button |
| 98 | type="text" |
| 99 | className={`w-min px-1.5 mx-0.5 ${sidebarBehaviour === 'open' ? 'px-2!' : ''}`} |
| 100 | icon={<PanelLeftDashed size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />} |
| 101 | aria-label="Sidebar control" |
| 102 | /> |
| 103 | </DropdownMenuTrigger> |
| 104 | <DropdownMenuContent side="top" align="start" className="w-40"> |
| 105 | <DropdownMenuRadioGroup |
| 106 | value={sidebarBehaviour} |
| 107 | onValueChange={(value) => setSidebarBehaviour(value as SidebarBehaviourType)} |
| 108 | > |
| 109 | <DropdownMenuLabel>Sidebar control</DropdownMenuLabel> |
| 110 | <DropdownMenuSeparator /> |
| 111 | <DropdownMenuRadioItem value="open">Expanded</DropdownMenuRadioItem> |
| 112 | <DropdownMenuRadioItem value="closed">Collapsed</DropdownMenuRadioItem> |
| 113 | <DropdownMenuRadioItem value="expandable"> |
| 114 | Expand on hover |
| 115 | </DropdownMenuRadioItem> |
| 116 | </DropdownMenuRadioGroup> |
| 117 | </DropdownMenuContent> |
| 118 | </DropdownMenu> |
| 119 | } |
| 120 | /> |
| 121 | </SidebarMotion> |
| 122 | )} |
| 123 | </AnimatePresence> |
| 124 | ) |
| 125 | } |
| 126 | |
| 127 | export const SidebarContent = ({ footer }: { footer?: ReactNode }) => { |
| 128 | const { ref: projectRef } = useParams() |
| 129 | |
| 130 | return ( |
| 131 | <> |
| 132 | <AnimatePresence mode="wait"> |
| 133 | <SidebarContentPrimitive> |
| 134 | {projectRef ? ( |
| 135 | <motion.div key="project-links"> |
| 136 | <ProjectLinks /> |
| 137 | </motion.div> |
| 138 | ) : ( |
| 139 | <motion.div |
| 140 | key="org-links" |
| 141 | initial={{ opacity: 0, y: -20 }} |
| 142 | animate={{ opacity: 1, y: 0 }} |
| 143 | exit={{ opacity: 0, y: -20 }} |
| 144 | transition={{ duration: 0.2, ease: 'easeOut' }} |
| 145 | > |
| 146 | <OrganizationLinks /> |
| 147 | </motion.div> |
| 148 | )} |
| 149 | </SidebarContentPrimitive> |
| 150 | </AnimatePresence> |
| 151 | <SidebarFooter> |
| 152 | <SidebarGroup className="p-0">{footer}</SidebarGroup> |
| 153 | </SidebarFooter> |
| 154 | </> |
| 155 | ) |
| 156 | } |
| 157 | |
| 158 | export function SideBarNavLink({ |
| 159 | route, |
| 160 | active, |
| 161 | onClick, |
| 162 | ...props |
| 163 | }: { |
| 164 | route: Route |
| 165 | active?: boolean |
| 166 | onClick?: () => void |
| 167 | } & ComponentPropsWithoutRef<typeof SidebarMenuButton>) { |
| 168 | const router = useRouter() |
| 169 | const { state: sidebarState } = useSidebar() |
| 170 | const [sidebarBehaviour] = useLocalStorageQuery( |
| 171 | LOCAL_STORAGE_KEYS.SIDEBAR_BEHAVIOR, |
| 172 | DEFAULT_SIDEBAR_BEHAVIOR |
| 173 | ) |
| 174 | |
| 175 | const isActiveLink = !!(route.link && !route.disabled) |
| 176 | const hasShortcut = !!(route.shortcutId && isActiveLink) |
| 177 | |
| 178 | // Collapsed: show immediately (replaces the old label-only tooltip |
| 179 | // that used to surface the name of an icon-only item). Expanded: |
| 180 | // slight delay so the tooltip doesn't flash while skimming the nav. |
| 181 | const shortcutPopoverDelay = sidebarState === 'collapsed' ? 0 : 1000 |
| 182 | |
| 183 | const buttonProps = { |
| 184 | disabled: route.disabled, |
| 185 | isActive: active, |
| 186 | className: cn('text-sm', sidebarBehaviour === 'open' ? 'px-2!' : ''), |
| 187 | size: 'default' as const, |
| 188 | onClick: onClick, |
| 189 | } |
| 190 | |
| 191 | const content = props.children ? ( |
| 192 | props.children |
| 193 | ) : ( |
| 194 | <> |
| 195 | {route.icon} |
| 196 | <span>{route.label}</span> |
| 197 | </> |
| 198 | ) |
| 199 | |
| 200 | const button = isActiveLink ? ( |
| 201 | <SidebarMenuButton {...buttonProps} asChild> |
| 202 | <Link href={route.link!}>{content}</Link> |
| 203 | </SidebarMenuButton> |
| 204 | ) : ( |
| 205 | <SidebarMenuButton {...buttonProps}>{content}</SidebarMenuButton> |
| 206 | ) |
| 207 | |
| 208 | return ( |
| 209 | <SidebarMenuItem> |
| 210 | {hasShortcut ? ( |
| 211 | <Shortcut |
| 212 | id={route.shortcutId!} |
| 213 | onTrigger={() => router.push(route.link!)} |
| 214 | side="right" |
| 215 | delayDuration={shortcutPopoverDelay} |
| 216 | > |
| 217 | {button} |
| 218 | </Shortcut> |
| 219 | ) : ( |
| 220 | button |
| 221 | )} |
| 222 | </SidebarMenuItem> |
| 223 | ) |
| 224 | } |
| 225 | |
| 226 | const ActiveDot = ({ hasErrors, hasWarnings }: { hasErrors: boolean; hasWarnings: boolean }) => { |
| 227 | return ( |
| 228 | <div |
| 229 | className={cn( |
| 230 | 'absolute pointer-events-none flex h-2 w-2 left-[18px] group-data-[state=expanded]:left-[20px] top-2 z-10 rounded-full', |
| 231 | hasErrors ? 'bg-destructive-600' : hasWarnings ? 'bg-warning-600' : 'bg-transparent' |
| 232 | )} |
| 233 | /> |
| 234 | ) |
| 235 | } |
| 236 | |
| 237 | const ProjectLinks = () => { |
| 238 | const router = useRouter() |
| 239 | const { ref } = useParams() |
| 240 | const { data: project } = useSelectedProjectQuery() |
| 241 | const { securityLints, errorLints } = useLints() |
| 242 | const showReports = useIsFeatureEnabled('reports:all') |
| 243 | const showLogs = useIsFeatureEnabled('logs:all') |
| 244 | |
| 245 | const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview() |
| 246 | |
| 247 | const activeRoute = router.pathname.split('/')[3] |
| 248 | |
| 249 | const { |
| 250 | projectAuthAll: authEnabled, |
| 251 | projectEdgeFunctionAll: edgeFunctionsEnabled, |
| 252 | projectStorageAll: storageEnabled, |
| 253 | realtimeAll: realtimeEnabled, |
| 254 | } = useIsFeatureEnabled([ |
| 255 | 'project_auth:all', |
| 256 | 'project_edge_function:all', |
| 257 | 'project_storage:all', |
| 258 | 'realtime:all', |
| 259 | ]) |
| 260 | |
| 261 | const authOverviewPageEnabled = useFlag('authOverviewPage') |
| 262 | |
| 263 | const toolRoutes = generateToolRoutes(ref, project) |
| 264 | const productRoutes = generateProductRoutes(ref, project, { |
| 265 | auth: authEnabled, |
| 266 | edgeFunctions: edgeFunctionsEnabled, |
| 267 | storage: storageEnabled, |
| 268 | realtime: realtimeEnabled, |
| 269 | authOverviewPage: authOverviewPageEnabled, |
| 270 | }) |
| 271 | const otherRoutes = generateOtherRoutes(ref, project, { |
| 272 | unifiedLogs: isUnifiedLogsEnabled, |
| 273 | showReports, |
| 274 | showLogs, |
| 275 | }) |
| 276 | const settingsRoutes = generateSettingsRoutes(ref) |
| 277 | |
| 278 | return ( |
| 279 | <SidebarMenu> |
| 280 | <SidebarGroup className="gap-0.5"> |
| 281 | <SideBarNavLink |
| 282 | key="home" |
| 283 | active={isUndefined(activeRoute) && !isUndefined(router.query.ref)} |
| 284 | route={{ |
| 285 | key: 'HOME', |
| 286 | label: 'Project Overview', |
| 287 | icon: <Home size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />, |
| 288 | link: `/project/${ref}`, |
| 289 | linkElement: <ProjectIndexPageLink projectRef={ref} />, |
| 290 | shortcutId: SHORTCUT_IDS.NAV_HOME, |
| 291 | }} |
| 292 | /> |
| 293 | {toolRoutes.map((route, i) => ( |
| 294 | <SideBarNavLink |
| 295 | key={`tools-routes-${i}`} |
| 296 | route={route} |
| 297 | active={activeRoute === route.key} |
| 298 | /> |
| 299 | ))} |
| 300 | </SidebarGroup> |
| 301 | <Separator className="w-[calc(100%-1rem)] mx-auto" /> |
| 302 | <SidebarGroup className="gap-0.5"> |
| 303 | {productRoutes.map((route, i) => ( |
| 304 | <SideBarNavLink |
| 305 | key={`product-routes-${i}`} |
| 306 | route={route} |
| 307 | active={activeRoute === route.key} |
| 308 | /> |
| 309 | ))} |
| 310 | </SidebarGroup> |
| 311 | <Separator className="w-[calc(100%-1rem)] mx-auto" /> |
| 312 | <SidebarGroup className="gap-0.5"> |
| 313 | {otherRoutes.map((route) => { |
| 314 | if (route.key === 'advisors') { |
| 315 | return ( |
| 316 | <div className="relative" key={route.key}> |
| 317 | {!route.disabled && ( |
| 318 | <ActiveDot |
| 319 | hasErrors={errorLints.length > 0} |
| 320 | hasWarnings={securityLints.length > 0} |
| 321 | /> |
| 322 | )} |
| 323 | <SideBarNavLink key={route.key} route={route} active={activeRoute === route.key} /> |
| 324 | </div> |
| 325 | ) |
| 326 | } else { |
| 327 | return ( |
| 328 | <SideBarNavLink key={route.key} route={route} active={activeRoute === route.key} /> |
| 329 | ) |
| 330 | } |
| 331 | })} |
| 332 | </SidebarGroup> |
| 333 | <Separator className="w-[calc(100%-1rem)] mx-auto" /> |
| 334 | {/* Settings routes to be added in with project/org nav */} |
| 335 | <SidebarGroup className="gap-0.5"> |
| 336 | {settingsRoutes.map((route, i) => ( |
| 337 | <SideBarNavLink |
| 338 | key={`settings-routes-${i}`} |
| 339 | route={route} |
| 340 | active={activeRoute === route.key} |
| 341 | /> |
| 342 | ))} |
| 343 | </SidebarGroup> |
| 344 | </SidebarMenu> |
| 345 | ) |
| 346 | } |
| 347 | |
| 348 | const OrganizationLinks = () => { |
| 349 | const router = useRouter() |
| 350 | const { slug } = useParams() |
| 351 | |
| 352 | const organizationSlug: string = slug ?? (router.query.orgSlug as string) ?? '' |
| 353 | |
| 354 | const { data: org } = useSelectedOrganizationQuery() |
| 355 | const isUserMFAEnabled = useIsMFAEnabled() |
| 356 | const disableAccessMfa = org?.organization_requires_mfa && !isUserMFAEnabled |
| 357 | |
| 358 | const showBilling = useIsFeatureEnabled('billing:all') |
| 359 | |
| 360 | const activeRoute = router.pathname.split('/')[3] |
| 361 | const organizationSettingsRoutes = new Set([ |
| 362 | 'general', |
| 363 | 'security', |
| 364 | 'sso', |
| 365 | 'apps', |
| 366 | 'audit', |
| 367 | 'documents', |
| 368 | ]) |
| 369 | |
| 370 | const navMenuItems = [ |
| 371 | { |
| 372 | label: 'Projects', |
| 373 | href: `/org/${organizationSlug}`, |
| 374 | key: 'projects', |
| 375 | icon: <Boxes size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />, |
| 376 | shortcutId: SHORTCUT_IDS.NAV_ORG_PROJECTS, |
| 377 | }, |
| 378 | { |
| 379 | label: 'Team', |
| 380 | href: `/org/${organizationSlug}/team`, |
| 381 | key: 'team', |
| 382 | icon: <Users size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />, |
| 383 | shortcutId: SHORTCUT_IDS.NAV_ORG_TEAM, |
| 384 | }, |
| 385 | { |
| 386 | label: 'Integrations', |
| 387 | href: `/org/${organizationSlug}/integrations`, |
| 388 | key: 'integrations', |
| 389 | icon: <Blocks size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />, |
| 390 | shortcutId: SHORTCUT_IDS.NAV_ORG_INTEGRATIONS, |
| 391 | }, |
| 392 | { |
| 393 | label: 'Usage', |
| 394 | href: `/org/${organizationSlug}/usage`, |
| 395 | key: 'usage', |
| 396 | icon: <ChartArea size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />, |
| 397 | shortcutId: SHORTCUT_IDS.NAV_ORG_USAGE, |
| 398 | }, |
| 399 | ...(showBilling |
| 400 | ? [ |
| 401 | { |
| 402 | label: 'Billing', |
| 403 | href: `/org/${organizationSlug}/billing`, |
| 404 | key: 'billing', |
| 405 | icon: <Receipt size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />, |
| 406 | shortcutId: SHORTCUT_IDS.NAV_ORG_BILLING, |
| 407 | }, |
| 408 | ] |
| 409 | : []), |
| 410 | { |
| 411 | label: 'Organization Settings', |
| 412 | href: `/org/${organizationSlug}/general`, |
| 413 | key: 'settings', |
| 414 | icon: <Settings size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />, |
| 415 | shortcutId: SHORTCUT_IDS.NAV_ORG_SETTINGS, |
| 416 | }, |
| 417 | ] |
| 418 | |
| 419 | if (!organizationSlug) return null |
| 420 | |
| 421 | return ( |
| 422 | <SidebarMenu className="flex flex-col gap-1 items-start"> |
| 423 | <SidebarGroup className="gap-0.5"> |
| 424 | {navMenuItems.map((item, i) => ( |
| 425 | <SideBarNavLink |
| 426 | key={item.key} |
| 427 | active={ |
| 428 | i === 0 |
| 429 | ? activeRoute === undefined |
| 430 | : item.key === 'settings' |
| 431 | ? organizationSettingsRoutes.has(activeRoute ?? '') |
| 432 | : activeRoute === item.key |
| 433 | } |
| 434 | route={{ |
| 435 | label: item.label, |
| 436 | link: item.href, |
| 437 | key: item.label, |
| 438 | icon: item.icon, |
| 439 | disabled: disableAccessMfa, |
| 440 | shortcutId: item.shortcutId, |
| 441 | }} |
| 442 | /> |
| 443 | ))} |
| 444 | </SidebarGroup> |
| 445 | </SidebarMenu> |
| 446 | ) |
| 447 | } |