1+ import { PointerSensor } from "@dnd-kit/dom" ;
2+ import { type DragDropEvents , DragDropProvider } from "@dnd-kit/react" ;
3+ import { useSortable } from "@dnd-kit/react/sortable" ;
14import {
25 Bell ,
6+ DotsSixVertical ,
37 EnvelopeSimple ,
48 HashIcon ,
59 Lightbulb ,
@@ -12,13 +16,16 @@ import {
1216} from "@phosphor-icons/react" ;
1317import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events" ;
1418import {
15- CUSTOMIZABLE_NAV_ITEMS ,
19+ type CustomizableNavItem ,
1620 type CustomizableNavItemId ,
1721 isNavItemVisible ,
22+ moveNavItem ,
23+ orderedNavItems ,
1824} from "@posthog/ui/features/sidebar/constants" ;
1925import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore" ;
2026import { track } from "@posthog/ui/shell/analytics" ;
2127import { Button , Checkbox , Dialog , Flex , Text } from "@radix-ui/themes" ;
28+ import { type RefCallback , useRef } from "react" ;
2229
2330const ITEM_ICONS : Record <
2431 CustomizableNavItemId ,
@@ -36,6 +43,13 @@ const ITEM_ICONS: Record<
3643 loops : RepeatIcon ,
3744} ;
3845
46+ function sameOrder (
47+ a : readonly CustomizableNavItemId [ ] ,
48+ b : readonly CustomizableNavItemId [ ] ,
49+ ) : boolean {
50+ return a . length === b . length && a . every ( ( id , i ) => id === b [ i ] ) ;
51+ }
52+
3953interface CustomizeSidebarDialogProps {
4054 open : boolean ;
4155 onOpenChange : ( open : boolean ) => void ;
@@ -50,44 +64,87 @@ export function CustomizeSidebarDialog({
5064 available,
5165} : CustomizeSidebarDialogProps ) {
5266 const navItemOverrides = useSidebarStore ( ( s ) => s . navItemOverrides ) ;
67+ const navItemOrder = useSidebarStore ( ( s ) => s . navItemOrder ) ;
5368 const setNavItemVisible = useSidebarStore ( ( s ) => s . setNavItemVisible ) ;
69+ const setNavItemOrder = useSidebarStore ( ( s ) => s . setNavItemOrder ) ;
70+
71+ const items = orderedNavItems ( navItemOrder ) . filter (
72+ ( { id } ) => available ?. [ id ] !== false ,
73+ ) ;
74+
75+ // Dragover persists the reorder live so the list previews it, which means a
76+ // canceled drag must restore the order captured at dragstart.
77+ const initialOrder = useRef < readonly CustomizableNavItemId [ ] | null > ( null ) ;
78+
79+ const handleDragStart : DragDropEvents [ "dragstart" ] = ( ) => {
80+ initialOrder . current = useSidebarStore . getState ( ) . navItemOrder ;
81+ } ;
82+
83+ const handleDragOver : DragDropEvents [ "dragover" ] = ( event ) => {
84+ const sourceId = event . operation . source ?. id ;
85+ const targetId = event . operation . target ?. id ;
86+ if ( ! sourceId || ! targetId || sourceId === targetId ) return ;
87+ const current = useSidebarStore . getState ( ) . navItemOrder ;
88+ const next = moveNavItem ( current , String ( sourceId ) , String ( targetId ) ) ;
89+ if ( next !== current ) setNavItemOrder ( next ) ;
90+ } ;
91+
92+ const handleDragEnd : DragDropEvents [ "dragend" ] = ( event ) => {
93+ const before = initialOrder . current ;
94+ initialOrder . current = null ;
95+ if ( event . canceled ) {
96+ if ( before ) setNavItemOrder ( before ) ;
97+ return ;
98+ }
99+ const after = useSidebarStore . getState ( ) . navItemOrder ;
100+ if ( before && sameOrder ( before , after ) ) return ;
101+ const sourceId = event . operation . source ?. id ;
102+ const moved = orderedNavItems ( after ) . find ( ( { id } ) => id === sourceId ) ;
103+ if ( ! moved ) return ;
104+ track ( ANALYTICS_EVENTS . SIDEBAR_REORDERED , {
105+ item : moved . analyticsId ,
106+ to_index : orderedNavItems ( after ) . findIndex ( ( { id } ) => id === moved . id ) ,
107+ } ) ;
108+ } ;
54109
55110 return (
56111 < Dialog . Root open = { open } onOpenChange = { onOpenChange } >
57112 < Dialog . Content maxWidth = "360px" >
58113 < Dialog . Title > Customize sidebar</ Dialog . Title >
59114 < Dialog . Description className = "text-gray-10 text-sm" >
60- Choose which items appear in your sidebar. Unchecked items live under
61- More.
115+ Choose which items appear in your sidebar and drag to reorder.
116+ Unchecked items live under More.
62117 </ Dialog . Description >
63118
64- < Flex direction = "column" gap = "3" mt = "4" >
65- { CUSTOMIZABLE_NAV_ITEMS . filter (
66- ( { id } ) => available ?. [ id ] !== false ,
67- ) . map ( ( { id, label, analyticsId } ) => {
68- const ItemIcon = ITEM_ICONS [ id ] ;
69- const visible = isNavItemVisible ( navItemOverrides , id ) ;
70- return (
71- < Text key = { id } as = "label" size = "2" >
72- < Flex gap = "2" align = "center" >
73- < Checkbox
74- checked = { visible }
75- onCheckedChange = { ( checked ) => {
76- const nextVisible = checked === true ;
77- setNavItemVisible ( id , nextVisible ) ;
78- track ( ANALYTICS_EVENTS . SIDEBAR_CUSTOMIZED , {
79- item : analyticsId ,
80- visible : nextVisible ,
81- } ) ;
82- } }
83- />
84- < ItemIcon size = { 16 } />
85- { label }
86- </ Flex >
87- </ Text >
88- ) ;
89- } ) }
90- </ Flex >
119+ < DragDropProvider
120+ onDragStart = { handleDragStart }
121+ onDragOver = { handleDragOver }
122+ onDragEnd = { handleDragEnd }
123+ sensors = { [
124+ {
125+ plugin : PointerSensor ,
126+ options : { activationConstraints : { distance : { value : 5 } } } ,
127+ } ,
128+ ] }
129+ >
130+ < Flex direction = "column" gap = "3" mt = "4" >
131+ { items . map ( ( item , index ) => (
132+ < SortableNavItemRow
133+ key = { item . id }
134+ item = { item }
135+ index = { index }
136+ visible = { isNavItemVisible ( navItemOverrides , item . id ) }
137+ onVisibleChange = { ( nextVisible ) => {
138+ setNavItemVisible ( item . id , nextVisible ) ;
139+ track ( ANALYTICS_EVENTS . SIDEBAR_CUSTOMIZED , {
140+ item : item . analyticsId ,
141+ visible : nextVisible ,
142+ } ) ;
143+ } }
144+ />
145+ ) ) }
146+ </ Flex >
147+ </ DragDropProvider >
91148
92149 < Flex mt = "4" justify = "end" >
93150 < Dialog . Close >
@@ -100,3 +157,47 @@ export function CustomizeSidebarDialog({
100157 </ Dialog . Root >
101158 ) ;
102159}
160+
161+ function SortableNavItemRow ( {
162+ item,
163+ index,
164+ visible,
165+ onVisibleChange,
166+ } : {
167+ item : CustomizableNavItem ;
168+ index : number ;
169+ visible : boolean ;
170+ onVisibleChange : ( visible : boolean ) => void ;
171+ } ) {
172+ const { ref, handleRef, isDragging } = useSortable ( {
173+ id : item . id ,
174+ index,
175+ group : "customize-sidebar-nav" ,
176+ transition : { duration : 200 , easing : "ease" } ,
177+ } ) ;
178+ const ItemIcon = ITEM_ICONS [ item . id ] ;
179+ return (
180+ < div ref = { ref } style = { { opacity : isDragging ? 0.5 : 1 } } >
181+ < Flex gap = "2" align = "center" >
182+ < button
183+ ref = { handleRef as RefCallback < HTMLButtonElement > }
184+ type = "button"
185+ title = "Drag to reorder"
186+ className = "shrink-0 cursor-grab text-gray-9 hover:text-gray-11"
187+ >
188+ < DotsSixVertical size = { 14 } />
189+ </ button >
190+ < Text as = "label" size = "2" className = "flex-1" >
191+ < Flex gap = "2" align = "center" >
192+ < Checkbox
193+ checked = { visible }
194+ onCheckedChange = { ( checked ) => onVisibleChange ( checked === true ) }
195+ />
196+ < ItemIcon size = { 16 } />
197+ { item . label }
198+ </ Flex >
199+ </ Text >
200+ </ Flex >
201+ </ div >
202+ ) ;
203+ }
0 commit comments