Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 342 lines 10 kB view raw
1import { Tooltip, TooltipContent, TooltipTrigger } from '@/coop-ui/Tooltip'; 2import { GQLUserPermission } from '@/graphql/generated'; 3import { CogFilled, ExitFilled, UserAlt3Filled } from '@/icons'; 4import AngleDoubleLeft from '@/icons/lni/Direction/angle-double-left.svg?react'; 5import AngleDoubleRight from '@/icons/lni/Direction/angle-double-right.svg?react'; 6import { cn } from '@/lib/utils'; 7import { makeEnumLike } from '@roostorg/types'; 8import React, { 9 ReactElement, 10 useEffect, 11 useMemo, 12 useState, 13 type SVGProps, 14} from 'react'; 15import { Link, useLocation } from 'react-router-dom'; 16 17import DashboardMenuButton from '@/webpages/dashboard/components/DashboardMenuButton'; 18 19import LogoAndWordmarkPurple from '../images/LogoAndWordmarkPurple.png'; 20 21// eslint-disable-next-line @typescript-eslint/no-unused-vars -- value consumed only via `typeof` for MenuItemName 22const MenuItemNames = makeEnumLike([ 23 'Overview', 24 'Automated Enforcement', 25 'Proactive Rules', 26 'Report Rules', 27 'Review Console', 28 'Queues', 29 'Routing', 30 'Analytics', 31 'Investigation', 32 'Bulk Actioning', 33 'Recent Decisions', 34 'NCMEC Reports', 35 'Policies', 36 'Matching Banks', 37 'Log Out', 38 'Account', 39 'Settings', 40 'Item Types', 41 'Actions', 42 'API Keys', 43 'Integrations', 44 'Appeal Settings', 45 'Users', 46 'Wellness', 47 'NCMEC Settings', 48 'SSO', 49 'Organization', 50]); 51 52type MenuItemName = keyof typeof MenuItemNames; 53 54export type MenuItem = { 55 title: MenuItemName; 56 urlPath: string; 57 icon?: React.JSXElementConstructor<SVGProps<SVGSVGElement>>; 58 requiredPermissions: GQLUserPermission[]; 59 subItems?: Omit<MenuItem, 'subItems'>[]; 60}; 61 62interface SidebarProps { 63 menuItems: MenuItem[]; 64 settingsMenuItems: MenuItem[]; 65 selectedMenuItem: string | null; 66 setSelectedMenuItem: React.Dispatch<React.SetStateAction<string | null>>; 67 permissions: readonly GQLUserPermission[] | undefined; 68 logout: () => void; 69 isDemoOrg?: boolean; 70} 71 72export default function Sidebar(props: SidebarProps) { 73 const { 74 menuItems, 75 settingsMenuItems, 76 selectedMenuItem, 77 setSelectedMenuItem, 78 permissions, 79 logout, 80 isDemoOrg, 81 } = props; 82 83 const [collapsed, setCollapsed] = useState(false); 84 const { pathname } = useLocation(); 85 86 useEffect(() => { 87 const pathParts = pathname.split('/'); 88 let items: MenuItem[] = [...menuItems, ...settingsMenuItems]; 89 90 if (pathParts.length < 2) { 91 return; 92 } 93 94 for (let i = 2; i < pathParts.length; i++) { 95 const part = pathParts[i]; 96 const item = items.find((item) => item.urlPath === part); 97 if (item == null) { 98 return; 99 } 100 if (item.subItems) { 101 items = item.subItems; 102 } else { 103 setSelectedMenuItem(item.title); 104 } 105 } 106 }, [menuItems, pathname, settingsMenuItems, setSelectedMenuItem]); 107 108 const isSettingsSelected = useMemo( 109 () => 110 settingsMenuItems[0]?.subItems?.some( 111 (item) => item.title === selectedMenuItem, 112 ) ?? false, 113 [selectedMenuItem, settingsMenuItems], 114 ); 115 116 const isDescendant = ( 117 parent: MenuItem, 118 descendantTitle: string | null, 119 ): boolean => { 120 if (descendantTitle == null) { 121 return false; 122 } 123 return ( 124 parent.subItems != null && 125 parent.subItems.some( 126 (subItem) => 127 subItem.title === descendantTitle || 128 isDescendant(subItem, descendantTitle), 129 ) 130 ); 131 }; 132 133 const recursiveMenuItems = ( 134 item: MenuItem, 135 level: number, 136 prevUrlPath: string, 137 ): ReactElement | null => { 138 if ( 139 item.requiredPermissions.filter( 140 (perm) => permissions?.includes(perm) ?? false, 141 ).length < item.requiredPermissions.length 142 ) { 143 return null; 144 } 145 const subItems = item.subItems?.map((subItem, i) => ( 146 <React.Fragment key={i}> 147 {recursiveMenuItems(subItem, level + 1, item.urlPath)} 148 </React.Fragment> 149 )); 150 const isInSelectedPath = 151 selectedMenuItem === item.title || isDescendant(item, selectedMenuItem); 152 return ( 153 <div className="flex flex-col justify-start"> 154 <DashboardMenuButton 155 title={item.title} 156 url={ 157 prevUrlPath.length > 0 158 ? `${prevUrlPath}/${item.urlPath}` 159 : `${item.urlPath}` 160 } 161 selected={selectedMenuItem === item.title} 162 onClick={() => { 163 if (collapsed) { 164 setCollapsed(false); 165 } 166 setSelectedMenuItem(item.title); 167 }} 168 level={level} 169 icon={item.icon} 170 collapsed={collapsed} 171 highlighted={isInSelectedPath && level === 0} 172 /> 173 {!collapsed && isInSelectedPath ? subItems : null} 174 </div> 175 ); 176 }; 177 178 const footerButton = ( 179 props: { 180 icon: React.JSXElementConstructor<SVGProps<SVGSVGElement>>; 181 menuItemName: MenuItemName; 182 } & ( 183 | { onClick: () => void; url?: undefined } 184 | { onClick?: undefined; url: string } 185 ), 186 ) => { 187 const { icon: Icon, menuItemName, onClick, url } = props; 188 const isFooterButtonSelected = 189 selectedMenuItem === menuItemName || 190 (menuItemName === 'Settings' && isSettingsSelected); 191 return ( 192 <Tooltip> 193 <TooltipTrigger asChild> 194 {onClick ? ( 195 <div 196 className={`flex cursor-pointer w-min h-min p-[8px] rounded border-none ${ 197 isFooterButtonSelected 198 ? 'text-primary hover:text-primary bg-indigo-50 hover:bg-indigo-50' 199 : 'text-black hover:text-black/70 bg-transparent hover:bg-gray-100' 200 }`} 201 onClick={() => { 202 onClick(); 203 setSelectedMenuItem(menuItemName); 204 }} 205 > 206 <Icon 207 style={{ width: '16px', height: '16px' }} 208 className="fill-black" 209 /> 210 </div> 211 ) : ( 212 <Link 213 to={url} 214 className={`flex cursor-pointer w-min h-min p-[8px] rounded border-none ${ 215 isFooterButtonSelected 216 ? 'text-primary hover:text-primary bg-indigo-50 hover:bg-indigo-50' 217 : 'text-black hover:text-black/70 bg-transparent hover:bg-gray-100' 218 }`} 219 onClick={() => setSelectedMenuItem(menuItemName)} 220 > 221 <Icon 222 style={{ width: '16px', height: '16px' }} 223 className="fill-black" 224 /> 225 </Link> 226 )} 227 </TooltipTrigger> 228 <TooltipContent side="top">{menuItemName}</TooltipContent> 229 </Tooltip> 230 ); 231 }; 232 233 const isSettingsMenuVisible = isSettingsSelected && !collapsed; 234 235 const settingsMenu = ( 236 <div 237 className={cn( 238 'bg-slate-50 overflow-hidden', 239 'border border-t-0 border-gray-200 border-solid border-x-0', 240 { 241 'max-h-[1000px]': isSettingsMenuVisible, 242 'max-h-0': !isSettingsMenuVisible, 243 }, 244 )} 245 style={{ 246 transition: 'max-height 0.5s ease-in-out', 247 }} 248 > 249 <div className="flex flex-col gap-[4px] m-[16px]"> 250 {settingsMenuItems[0]?.subItems?.map((item) => ( 251 <Link 252 key={item.title} 253 to={`settings/${item.urlPath}`} 254 className={`flex text-start items-center rounded-lg my-[4px] cursor-pointer hover:text-primary ${ 255 selectedMenuItem === item.title 256 ? 'text-primary font-bold' 257 : 'text-black font-medium' 258 } ${collapsed ? 'w-fit' : 'py-[6px] px-[8px]'}`} 259 onClick={() => setSelectedMenuItem(item.title)} 260 > 261 <div className="pl-[12px] whitespace-nowrap text-[14px]"> 262 {item.title} 263 </div> 264 </Link> 265 ))} 266 </div> 267 </div> 268 ); 269 270 return ( 271 <div 272 className={`relative flex flex-col justify-between bg-white ${ 273 collapsed ? '' : 'min-w-[250px]' 274 } text-[14px] leading-normal`} 275 > 276 <div className="flex flex-col p-[14px]"> 277 <div className="flex items-center justify-between mb-[24px]"> 278 {!collapsed && ( 279 <Link to="/" className="mt-[4px] ml-[4px] text-start"> 280 <img 281 src={LogoAndWordmarkPurple} 282 alt="Logo" 283 width="110" 284 height="29" 285 /> 286 </Link> 287 )} 288 <div 289 className="flex p-[8px] rounded cursor-pointer hover:bg-primary/10 h-min" 290 onClick={() => setCollapsed((prev) => !prev)} 291 > 292 {collapsed ? ( 293 <AngleDoubleRight 294 style={{ width: '16px', height: '16px' }} 295 className="fill-black" 296 /> 297 ) : ( 298 <AngleDoubleLeft 299 style={{ width: '16px', height: '16px' }} 300 className="fill-black" 301 /> 302 )} 303 </div> 304 </div> 305 {menuItems.map((item, i) => ( 306 <React.Fragment key={i}> 307 {recursiveMenuItems(item, 0, '')} 308 </React.Fragment> 309 ))} 310 </div> 311 312 <div className="absolute bottom-0 flex flex-col justify-end w-full gap-0"> 313 {isDemoOrg ? ( 314 <div className="flex justify-center py-1 m-4 mx-8 text-center text-yellow-800 bg-yellow-100 rounded-lg grow"> 315 Demo Account 316 </div> 317 ) : null} 318 {settingsMenu} 319 <div className="flex justify-center gap-[20px] p-[16px] bg-slate-50"> 320 {!collapsed && 321 footerButton({ 322 icon: ExitFilled, 323 menuItemName: 'Log Out' as const, 324 onClick: async () => logout(), 325 })} 326 {!(collapsed && selectedMenuItem === 'Account') && 327 footerButton({ 328 icon: CogFilled, 329 menuItemName: 'Settings' as const, 330 url: '/dashboard/settings', 331 })} 332 {!(collapsed && selectedMenuItem !== 'Account') && 333 footerButton({ 334 icon: UserAlt3Filled, 335 menuItemName: 'Account' as const, 336 url: '/dashboard/account', 337 })} 338 </div> 339 </div> 340 </div> 341 ); 342}