OrgMenuContent.tsx171 lines · main
1'use client'
2
3import { useIsMFAEnabled, useParams } from 'common'
4import { Blocks, Boxes, ChartArea, ChevronLeft, Receipt, Settings, Users } from 'lucide-react'
5import { useRouter } from 'next/router'
6import React, { useMemo } from 'react'
7import { Button, cn, SidebarGroup, SidebarMenu } from 'ui'
8import { GenericSkeletonLoader } from 'ui-patterns'
9
10import { getOrgMenuComponent } from './mobileOrgMenuRegistry'
11import type { OrgNavItem } from './OrgMenuContent.utils'
12import {
13 getOrgActiveRoute,
14 getOrgSectionKeyFromPathname,
15 isOrgMenuActive,
16} from './OrgMenuContent.utils'
17import { OrgMenuItem } from './OrgMenuItem'
18import { orgItemHasSubmenu, useOrgMenuNavigation } from './useOrgMenuNavigation'
19import { ICON_SIZE, ICON_STROKE_WIDTH } from '@/components/interfaces/Sidebar'
20import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
21import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
22import { getPathnameWithoutQuery } from '@/lib/pathname.utils'
23import { useTrack } from '@/lib/telemetry/track'
24
25export interface OrgMenuContentProps {
26 onCloseSheet?: () => void
27}
28
29export function OrgMenuContent({ onCloseSheet }: OrgMenuContentProps) {
30 const router = useRouter()
31 const { slug } = useParams()
32 const organizationSlug: string = slug ?? (router.query.orgSlug as string) ?? ''
33 const { data: org } = useSelectedOrganizationQuery()
34 const isUserMFAEnabled = useIsMFAEnabled()
35 const disableAccessMfa = org?.organization_requires_mfa && !isUserMFAEnabled
36
37 const showBilling = useIsFeatureEnabled('billing:all')
38
39 const pathname = getPathnameWithoutQuery(router.asPath, router.pathname)
40 const activeRoute = getOrgActiveRoute(pathname)
41 const initialSectionKey = getOrgSectionKeyFromPathname(activeRoute)
42
43 const track = useTrack()
44 const {
45 viewLevel,
46 selectedSectionKey,
47 handleSubmenuClick: navigateToSubmenu,
48 handleBackToTop: navigateBackToTop,
49 } = useOrgMenuNavigation({ initialSectionKey })
50
51 const handleSubmenuClick = (item: OrgNavItem) => {
52 track('org_submenu_opened', { itemKey: item.key, itemLabel: item.label })
53 navigateToSubmenu(item)
54 }
55
56 const handleBackToTop = () => {
57 track('org_menu_back_clicked')
58 navigateBackToTop()
59 }
60
61 const navMenuItems: OrgNavItem[] = useMemo(
62 () => [
63 {
64 label: 'Projects',
65 href: `/org/${organizationSlug}`,
66 key: 'projects',
67 icon: <Boxes size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
68 },
69 {
70 label: 'Team',
71 href: `/org/${organizationSlug}/team`,
72 key: 'team',
73 icon: <Users size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
74 },
75 {
76 label: 'Integrations',
77 href: `/org/${organizationSlug}/integrations`,
78 key: 'integrations',
79 icon: <Blocks size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
80 },
81 {
82 label: 'Usage',
83 href: `/org/${organizationSlug}/usage`,
84 key: 'usage',
85 icon: <ChartArea size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
86 },
87 ...(showBilling
88 ? [
89 {
90 label: 'Billing',
91 href: `/org/${organizationSlug}/billing`,
92 key: 'billing',
93 icon: <Receipt size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
94 },
95 ]
96 : []),
97 {
98 label: 'Organization settings',
99 href: `/org/${organizationSlug}/general`,
100 key: 'settings',
101 icon: <Settings size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
102 },
103 ],
104 [organizationSlug, showBilling]
105 )
106
107 const sectionKeyToShow = viewLevel === 'section' ? selectedSectionKey : null
108 const sectionLabel =
109 sectionKeyToShow && navMenuItems.find((item) => item.key === sectionKeyToShow)?.label
110
111 const SectionMenuContent = sectionKeyToShow ? getOrgMenuComponent(sectionKeyToShow) : null
112
113 if (!organizationSlug) return null
114
115 if (viewLevel === 'section' && sectionKeyToShow && SectionMenuContent) {
116 return (
117 <div className="flex flex-col h-full">
118 <div
119 className={cn(
120 'shrink-0 flex items-center gap-2 border-b border-default px-3 min-h-(--header-height)'
121 )}
122 >
123 <Button
124 type="text"
125 className="p-1! justify-start"
126 icon={<ChevronLeft size={20} />}
127 onClick={handleBackToTop}
128 aria-label="Back to menu"
129 block
130 >
131 <span className="font-medium truncate text-sm">{sectionLabel ?? sectionKeyToShow}</span>
132 </Button>
133 </div>
134 <div className="flex-1 overflow-y-auto text-sidebar-foreground px-2">
135 <React.Suspense fallback={<GenericSkeletonLoader className="p-4" />}>
136 <SectionMenuContent onCloseSheet={onCloseSheet} />
137 </React.Suspense>
138 </div>
139 </div>
140 )
141 }
142
143 return (
144 <div className="flex flex-col h-full">
145 <div className="flex-1 overflow-y-auto text-sidebar-foreground">
146 <nav className="flex flex-col gap-2 p-1" aria-label="Organization menu">
147 <SidebarMenu>
148 <SidebarGroup className="gap-0.5">
149 {navMenuItems.map((item, i) => (
150 <OrgMenuItem
151 key={item.key}
152 item={item}
153 isActive={isOrgMenuActive(item, i, pathname, activeRoute)}
154 disabled={disableAccessMfa}
155 onCloseSheet={onCloseSheet}
156 onSubmenuClick={orgItemHasSubmenu(item) ? handleSubmenuClick : undefined}
157 onSelect={() =>
158 track('org_menu_item_clicked', {
159 itemKey: item.key,
160 itemHref: item.href,
161 })
162 }
163 />
164 ))}
165 </SidebarGroup>
166 </SidebarMenu>
167 </nav>
168 </div>
169 </div>
170 )
171}