@@ -25,8 +25,9 @@ import {
2525import { DialogClose } from "@radix-ui/react-dialog" ;
2626import { Form , Link , useFetcher , useNavigation } from "@remix-run/react" ;
2727import { LayoutGroup , motion } from "framer-motion" ;
28- import { LineChartIcon } from "lucide-react" ;
29- import { type ReactNode , useCallback , useEffect , useRef , useState } from "react" ;
28+ import { GripVerticalIcon , LineChartIcon } from "lucide-react" ;
29+ import { type Ref , type ReactNode , useCallback , useEffect , useMemo , useRef , useState } from "react" ;
30+ import ReactGridLayout , { useContainerWidth , type Layout } from "react-grid-layout" ;
3031import simplur from "simplur" ;
3132import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon" ;
3233import { DropdownIcon } from "~/assets/icons/DropdownIcon" ;
@@ -182,6 +183,75 @@ export function SideMenu({
182183 const { isManagedCloud } = useFeatures ( ) ;
183184 const featureFlags = useFeatureFlags ( ) ;
184185 const customDashboards = useCustomDashboards ( ) ;
186+ const dashboardOrderFetcher = useFetcher ( ) ;
187+
188+ // Dashboard reorder state
189+ const [ dashboardOrder , setDashboardOrder ] = useState < string [ ] > (
190+ ( ) =>
191+ user . dashboardPreferences . sideMenu ?. customDashboardOrder ?. [ organization . id ] ??
192+ customDashboards . map ( ( d ) => d . friendlyId )
193+ ) ;
194+
195+ // Sync order when organization changes (component may not remount)
196+ useEffect ( ( ) => {
197+ setDashboardOrder (
198+ user . dashboardPreferences . sideMenu ?. customDashboardOrder ?. [ organization . id ] ??
199+ customDashboards . map ( ( d ) => d . friendlyId )
200+ ) ;
201+ } , [ organization . id ] ) ;
202+
203+ // Sort dashboards by stored order, new dashboards go to end
204+ const orderedDashboards = useMemo ( ( ) => {
205+ const orderMap = new Map ( dashboardOrder . map ( ( id , i ) => [ id , i ] ) ) ;
206+ return [ ...customDashboards ] . sort ( ( a , b ) => {
207+ const aIdx = orderMap . get ( a . friendlyId ) ?? Infinity ;
208+ const bIdx = orderMap . get ( b . friendlyId ) ?? Infinity ;
209+ return aIdx - bIdx ;
210+ } ) ;
211+ } , [ customDashboards , dashboardOrder ] ) ;
212+
213+ // Layout for ReactGridLayout (1-column vertical list, each item h=1 row)
214+ const dashboardLayout = useMemo (
215+ ( ) =>
216+ orderedDashboards . map ( ( d , i ) => ( {
217+ i : d . friendlyId ,
218+ x : 0 ,
219+ y : i ,
220+ w : 1 ,
221+ h : 1 ,
222+ } ) ) ,
223+ [ orderedDashboards ]
224+ ) ;
225+
226+ // Width measurement for ReactGridLayout
227+ const {
228+ width : gridWidth ,
229+ containerRef : gridContainerRef ,
230+ mounted : gridMounted ,
231+ } = useContainerWidth ( { initialWidth : 216 } ) ;
232+
233+ const canReorder = orderedDashboards . length >= 2 && ! isCollapsed ;
234+
235+ // Handle drag stop - extract new order from layout y-positions
236+ const handleDashboardDragStop = useCallback (
237+ ( layout : Layout ) => {
238+ const sorted = [ ...layout ] . sort ( ( a , b ) => a . y - b . y ) ;
239+ const newOrder = sorted . map ( ( item ) => item . i ) ;
240+ if ( JSON . stringify ( newOrder ) === JSON . stringify ( dashboardOrder ) ) return ;
241+ setDashboardOrder ( newOrder ) ;
242+ // Persist immediately
243+ if ( ! user . isImpersonating ) {
244+ const formData = new FormData ( ) ;
245+ formData . append ( "organizationId" , organization . id ) ;
246+ formData . append ( "customDashboardOrder" , JSON . stringify ( newOrder ) ) ;
247+ dashboardOrderFetcher . submit ( formData , {
248+ method : "POST" ,
249+ action : "/resources/preferences/sidemenu" ,
250+ } ) ;
251+ }
252+ } ,
253+ [ dashboardOrder , organization . id , user . isImpersonating , dashboardOrderFetcher ]
254+ ) ;
185255
186256 const persistSideMenuPreferences = useCallback (
187257 ( data : {
@@ -511,26 +581,82 @@ export function SideMenu({
511581 />
512582 }
513583 />
514- { customDashboards . map ( ( dashboard , index ) => {
515- const isLast = index === customDashboards . length - 1 ;
516- return (
517- < SideMenuItem
518- key = { dashboard . friendlyId }
519- name = { dashboard . title }
520- icon = {
521- isCollapsed
522- ? LineChartIcon
523- : isLast
524- ? TreeConnectorEnd
525- : TreeConnectorBranch
526- }
527- activeIconColor = { isCollapsed ? "text-text-dimmed" : "text-customDashboards" }
528- inactiveIconColor = { isCollapsed ? "text-text-dimmed" : "text-customDashboards" }
529- to = { v3CustomDashboardPath ( organization , project , environment , dashboard ) }
530- isCollapsed = { isCollapsed }
531- />
532- ) ;
533- } ) }
584+ < div ref = { gridContainerRef as Ref < HTMLDivElement > } >
585+ { canReorder && gridMounted ? (
586+ < ReactGridLayout
587+ layout = { dashboardLayout }
588+ width = { gridWidth }
589+ gridConfig = { {
590+ cols : 1 ,
591+ rowHeight : 32 ,
592+ margin : [ 0 , 0 ] as const ,
593+ containerPadding : [ 0 , 0 ] as const ,
594+ } }
595+ resizeConfig = { { enabled : false } }
596+ dragConfig = { { enabled : true , handle : ".sidebar-drag-handle" } }
597+ onDragStop = { handleDashboardDragStop }
598+ className = "sidebar-reorder-grid"
599+ autoSize
600+ >
601+ { orderedDashboards . map ( ( dashboard , index ) => {
602+ const isLast = index === orderedDashboards . length - 1 ;
603+ return (
604+ < div key = { dashboard . friendlyId } >
605+ < SideMenuItem
606+ name = { dashboard . title }
607+ icon = {
608+ isCollapsed
609+ ? LineChartIcon
610+ : isLast
611+ ? TreeConnectorEnd
612+ : TreeConnectorBranch
613+ }
614+ activeIconColor = { isCollapsed ? "text-customDashboards" : undefined }
615+ inactiveIconColor = { isCollapsed ? "text-customDashboards" : undefined }
616+ to = { v3CustomDashboardPath (
617+ organization ,
618+ project ,
619+ environment ,
620+ dashboard
621+ ) }
622+ isCollapsed = { isCollapsed }
623+ action = {
624+ < div className = "sidebar-drag-handle flex h-full w-full cursor-grab items-center justify-center rounded text-text-dimmed opacity-0 transition group-hover/menuitem:opacity-100 hover:text-text-bright active:cursor-grabbing" >
625+ < GripVerticalIcon className = "size-3.5" />
626+ </ div >
627+ }
628+ />
629+ </ div >
630+ ) ;
631+ } ) }
632+ </ ReactGridLayout >
633+ ) : (
634+ orderedDashboards . map ( ( dashboard , index ) => {
635+ const isLast = index === orderedDashboards . length - 1 ;
636+ return (
637+ < SideMenuItem
638+ key = { dashboard . friendlyId }
639+ name = { dashboard . title }
640+ icon = {
641+ isCollapsed
642+ ? LineChartIcon
643+ : isLast
644+ ? TreeConnectorEnd
645+ : TreeConnectorBranch
646+ }
647+ activeIconColor = {
648+ isCollapsed ? "text-customDashboards" : "text-charcoal-700"
649+ }
650+ inactiveIconColor = {
651+ isCollapsed ? "text-customDashboards" : "text-charcoal-700"
652+ }
653+ to = { v3CustomDashboardPath ( organization , project , environment , dashboard ) }
654+ isCollapsed = { isCollapsed }
655+ />
656+ ) ;
657+ } )
658+ ) }
659+ </ div >
534660 </ SideMenuSection >
535661 ) }
536662
@@ -1236,7 +1362,11 @@ function AnimatedChevron({
12361362// Lines extend to y=-6 and y=26 to fill the full 32px row height (6px gap above/below the 20px icon).
12371363function TreeConnectorBranch ( { className } : { className ?: string } ) {
12381364 return (
1239- < svg className = { cn ( "overflow-visible" , className ) } viewBox = "0 0 20 20" fill = "none" >
1365+ < svg
1366+ className = { cn ( "overflow-visible" , className , "text-charcoal-600" ) }
1367+ viewBox = "0 0 20 20"
1368+ fill = "none"
1369+ >
12401370 < line x1 = "10" y1 = "-6" x2 = "10" y2 = "26" stroke = "currentColor" strokeWidth = "1" />
12411371 < line x1 = "10" y1 = "10" x2 = "20" y2 = "10" stroke = "currentColor" strokeWidth = "1" />
12421372 </ svg >
@@ -1245,7 +1375,11 @@ function TreeConnectorBranch({ className }: { className?: string }) {
12451375
12461376function TreeConnectorEnd ( { className } : { className ?: string } ) {
12471377 return (
1248- < svg className = { cn ( "overflow-visible" , className ) } viewBox = "0 0 20 20" fill = "none" >
1378+ < svg
1379+ className = { cn ( "overflow-visible" , className , "text-charcoal-600" ) }
1380+ viewBox = "0 0 20 20"
1381+ fill = "none"
1382+ >
12491383 < line x1 = "10" y1 = "-6" x2 = "10" y2 = "10" stroke = "currentColor" strokeWidth = "1" />
12501384 < line x1 = "10" y1 = "10" x2 = "20" y2 = "10" stroke = "currentColor" strokeWidth = "1" />
12511385 </ svg >
0 commit comments