Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit f5fdc20

Browse files
committed
add drag to reorder nav items in customize dialog
1 parent 9010fc4 commit f5fdc20

9 files changed

Lines changed: 419 additions & 39 deletions

File tree

packages/shared/src/analytics-events.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,12 @@ export interface SidebarCustomizedProperties {
270270
visible: boolean;
271271
}
272272

273+
export interface SidebarReorderedProperties {
274+
item: SidebarNavItem;
275+
/** Zero-based position of the item in the nav after the drag. */
276+
to_index: number;
277+
}
278+
273279
export interface BrainrotActivatedProperties {
274280
/** Grid layout preset, e.g. "2x2". */
275281
layout: string;
@@ -1140,6 +1146,7 @@ export const ANALYTICS_EVENTS = {
11401146
POSTHOG_WEB_OPENED: "PostHog web opened",
11411147
SIDEBAR_NAV_ITEM_CLICKED: "Sidebar nav item clicked",
11421148
SIDEBAR_CUSTOMIZED: "Sidebar customized",
1149+
SIDEBAR_REORDERED: "Sidebar reordered",
11431150

11441151
// Permission events
11451152
PERMISSION_RESPONDED: "Permission responded",
@@ -1300,6 +1307,7 @@ export type EventPropertyMap = {
13001307
[ANALYTICS_EVENTS.POSTHOG_WEB_OPENED]: never;
13011308
[ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED]: SidebarNavItemClickedProperties;
13021309
[ANALYTICS_EVENTS.SIDEBAR_CUSTOMIZED]: SidebarCustomizedProperties;
1310+
[ANALYTICS_EVENTS.SIDEBAR_REORDERED]: SidebarReorderedProperties;
13031311

13041312
// Permission events
13051313
[ANALYTICS_EVENTS.PERMISSION_RESPONDED]: PermissionRespondedProperties;

packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.test.tsx

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,50 @@
11
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
22
import { Theme } from "@radix-ui/themes";
3-
import { render, screen } from "@testing-library/react";
3+
import { act, render, screen } from "@testing-library/react";
44
import userEvent from "@testing-library/user-event";
55
import { beforeEach, describe, expect, it, vi } from "vitest";
66

7-
const { track } = vi.hoisted(() => ({ track: vi.fn() }));
7+
type CapturedDragEvent = {
8+
operation: { source?: { id?: string }; target?: { id?: string } };
9+
canceled?: boolean;
10+
};
11+
12+
const { track, dndCapture } = vi.hoisted(() => ({
13+
track: vi.fn(),
14+
dndCapture: {} as {
15+
onDragStart?: (event: CapturedDragEvent) => void;
16+
onDragOver?: (event: CapturedDragEvent) => void;
17+
onDragEnd?: (event: CapturedDragEvent) => void;
18+
},
19+
}));
820

921
vi.mock("@posthog/ui/shell/analytics", () => ({ track }));
22+
vi.mock("@dnd-kit/react", () => ({
23+
DragDropProvider: ({
24+
onDragStart,
25+
onDragOver,
26+
onDragEnd,
27+
children,
28+
}: {
29+
onDragStart?: (event: CapturedDragEvent) => void;
30+
onDragOver?: (event: CapturedDragEvent) => void;
31+
onDragEnd?: (event: CapturedDragEvent) => void;
32+
children?: React.ReactNode;
33+
}) => {
34+
dndCapture.onDragStart = onDragStart;
35+
dndCapture.onDragOver = onDragOver;
36+
dndCapture.onDragEnd = onDragEnd;
37+
return <>{children}</>;
38+
},
39+
}));
40+
vi.mock("@dnd-kit/react/sortable", () => ({
41+
useSortable: () => ({
42+
ref: () => {},
43+
handleRef: () => {},
44+
isDragging: false,
45+
}),
46+
}));
47+
vi.mock("@dnd-kit/dom", () => ({ PointerSensor: class {} }));
1048

1149
import {
1250
CUSTOMIZABLE_NAV_ITEM_IDS,
@@ -38,10 +76,27 @@ function renderDialog(available = availability()) {
3876
);
3977
}
4078

79+
function drag(
80+
sourceId: string,
81+
targetId: string,
82+
{ cancel = false }: { cancel?: boolean } = {},
83+
) {
84+
act(() => {
85+
dndCapture.onDragStart?.({ operation: { source: { id: sourceId } } });
86+
dndCapture.onDragOver?.({
87+
operation: { source: { id: sourceId }, target: { id: targetId } },
88+
});
89+
dndCapture.onDragEnd?.({
90+
operation: { source: { id: sourceId }, target: { id: targetId } },
91+
canceled: cancel,
92+
});
93+
});
94+
}
95+
4196
describe("CustomizeSidebarDialog", () => {
4297
beforeEach(() => {
4398
track.mockReset();
44-
useSidebarStore.setState({ navItemOverrides: {} });
99+
useSidebarStore.setState({ navItemOverrides: {}, navItemOrder: [] });
45100
});
46101

47102
it("unchecking a visible item demotes it and tracks the change", async () => {
@@ -82,4 +137,47 @@ describe("CustomizeSidebarDialog", () => {
82137
screen.getByRole("checkbox", { name: "Configure" }),
83138
).toBeInTheDocument();
84139
});
140+
141+
it("renders rows in the stored order", () => {
142+
useSidebarStore.setState({ navItemOrder: ["loops", "search"] });
143+
renderDialog();
144+
145+
const labels = screen
146+
.getAllByRole("checkbox")
147+
.map((checkbox) => checkbox.closest("label")?.textContent);
148+
149+
expect(labels.slice(0, 2)).toEqual(["Loops", "Search"]);
150+
});
151+
152+
it("dragging a row persists the new order and tracks on drop", () => {
153+
renderDialog();
154+
155+
drag("skills", "search");
156+
157+
expect(useSidebarStore.getState().navItemOrder).toEqual([
158+
"skills",
159+
"search",
160+
"inbox",
161+
"agents",
162+
"mcp-servers",
163+
"command-center",
164+
"contexts",
165+
"activity",
166+
"configure",
167+
"loops",
168+
]);
169+
expect(track).toHaveBeenCalledWith(ANALYTICS_EVENTS.SIDEBAR_REORDERED, {
170+
item: "skills",
171+
to_index: 0,
172+
});
173+
});
174+
175+
it("a canceled drag restores the order from dragstart", () => {
176+
renderDialog();
177+
178+
drag("skills", "search", { cancel: true });
179+
180+
expect(useSidebarStore.getState().navItemOrder).toEqual([]);
181+
expect(track).not.toHaveBeenCalled();
182+
});
85183
});
Lines changed: 131 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import { PointerSensor } from "@dnd-kit/dom";
2+
import { type DragDropEvents, DragDropProvider } from "@dnd-kit/react";
3+
import { useSortable } from "@dnd-kit/react/sortable";
14
import {
25
Bell,
6+
DotsSixVertical,
37
EnvelopeSimple,
48
HashIcon,
59
Lightbulb,
@@ -12,13 +16,16 @@ import {
1216
} from "@phosphor-icons/react";
1317
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
1418
import {
15-
CUSTOMIZABLE_NAV_ITEMS,
19+
type CustomizableNavItem,
1620
type CustomizableNavItemId,
1721
isNavItemVisible,
22+
moveNavItem,
23+
orderedNavItems,
1824
} from "@posthog/ui/features/sidebar/constants";
1925
import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore";
2026
import { track } from "@posthog/ui/shell/analytics";
2127
import { Button, Checkbox, Dialog, Flex, Text } from "@radix-ui/themes";
28+
import { type RefCallback, useRef } from "react";
2229

2330
const 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+
3953
interface 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+
}

packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,11 @@ describe("SidebarNavSection", () => {
9696
beforeEach(() => {
9797
vi.clearAllMocks();
9898
useAppView.mockReturnValue({ type: "home" });
99-
useSidebarStore.setState({ navItemOverrides: {}, channelsEnabled: true });
99+
useSidebarStore.setState({
100+
navItemOverrides: {},
101+
navItemOrder: [],
102+
channelsEnabled: true,
103+
});
100104
});
101105

102106
it.each([
@@ -147,6 +151,20 @@ describe("SidebarNavSection", () => {
147151
},
148152
);
149153

154+
it("renders top-level items in the stored order", () => {
155+
useSidebarStore.setState({ navItemOrder: ["activity", "inbox"] });
156+
renderNav();
157+
158+
const labels = screen
159+
.getAllByRole("button")
160+
.map((button) => button.textContent ?? "");
161+
const position = (label: string) =>
162+
labels.findIndex((text) => text.includes(label));
163+
164+
expect(position("Activity")).toBeLessThan(position("Inbox"));
165+
expect(position("Inbox")).toBeLessThan(position("Agents"));
166+
});
167+
150168
it("never lets hidden search take over the More row", () => {
151169
useSidebarStore.setState({ navItemOverrides: { search: false } });
152170
renderNav();

0 commit comments

Comments
 (0)