Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
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}