diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 967cb75048..8811b48d0e 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -270,6 +270,12 @@ export interface SidebarCustomizedProperties { visible: boolean; } +export interface SidebarReorderedProperties { + item: SidebarNavItem; + /** Zero-based position of the item in the nav after the drag. */ + to_index: number; +} + export interface BrainrotActivatedProperties { /** Grid layout preset, e.g. "2x2". */ layout: string; @@ -1140,6 +1146,7 @@ export const ANALYTICS_EVENTS = { POSTHOG_WEB_OPENED: "PostHog web opened", SIDEBAR_NAV_ITEM_CLICKED: "Sidebar nav item clicked", SIDEBAR_CUSTOMIZED: "Sidebar customized", + SIDEBAR_REORDERED: "Sidebar reordered", // Permission events PERMISSION_RESPONDED: "Permission responded", @@ -1300,6 +1307,7 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.POSTHOG_WEB_OPENED]: never; [ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED]: SidebarNavItemClickedProperties; [ANALYTICS_EVENTS.SIDEBAR_CUSTOMIZED]: SidebarCustomizedProperties; + [ANALYTICS_EVENTS.SIDEBAR_REORDERED]: SidebarReorderedProperties; // Permission events [ANALYTICS_EVENTS.PERMISSION_RESPONDED]: PermissionRespondedProperties; diff --git a/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.test.tsx b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.test.tsx index c2a56ed80d..2267056bd8 100644 --- a/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.test.tsx +++ b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.test.tsx @@ -1,12 +1,49 @@ import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { Theme } from "@radix-ui/themes"; -import { render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { track } = vi.hoisted(() => ({ track: vi.fn() })); +type CapturedDragEvent = { + operation: { source?: { id?: string }; target?: { id?: string } }; + canceled?: boolean; +}; + +const { track, dndCapture } = vi.hoisted(() => ({ + track: vi.fn(), + dndCapture: {} as { + onDragStart?: (event: CapturedDragEvent) => void; + onDragOver?: (event: CapturedDragEvent) => void; + onDragEnd?: (event: CapturedDragEvent) => void; + }, +})); vi.mock("@posthog/ui/shell/analytics", () => ({ track })); +vi.mock("@dnd-kit/react", () => ({ + DragDropProvider: ({ + onDragStart, + onDragOver, + onDragEnd, + children, + }: { + onDragStart?: (event: CapturedDragEvent) => void; + onDragOver?: (event: CapturedDragEvent) => void; + onDragEnd?: (event: CapturedDragEvent) => void; + children?: React.ReactNode; + }) => { + dndCapture.onDragStart = onDragStart; + dndCapture.onDragOver = onDragOver; + dndCapture.onDragEnd = onDragEnd; + return <>{children}; + }, +})); +vi.mock("@dnd-kit/react/sortable", () => ({ + useSortable: () => ({ + ref: () => {}, + handleRef: () => {}, + isDragging: false, + }), +})); import { CUSTOMIZABLE_NAV_ITEM_IDS, @@ -38,10 +75,42 @@ function renderDialog(available = availability()) { ); } +function dragStart(sourceId: string) { + act(() => { + dndCapture.onDragStart?.({ operation: { source: { id: sourceId } } }); + }); +} + +function dragOver(sourceId: string, targetId: string) { + act(() => { + dndCapture.onDragOver?.({ + operation: { source: { id: sourceId }, target: { id: targetId } }, + }); + }); +} + +function dragEnd( + sourceId: string, + { cancel = false }: { cancel?: boolean } = {}, +) { + act(() => { + dndCapture.onDragEnd?.({ + operation: { source: { id: sourceId } }, + canceled: cancel, + }); + }); +} + +function rowLabels() { + return screen + .getAllByRole("checkbox") + .map((checkbox) => checkbox.closest("label")?.textContent); +} + describe("CustomizeSidebarDialog", () => { beforeEach(() => { track.mockReset(); - useSidebarStore.setState({ navItemOverrides: {} }); + useSidebarStore.setState({ navItemOverrides: {}, navItemOrder: [] }); }); it("unchecking a visible item demotes it and tracks the change", async () => { @@ -82,4 +151,88 @@ describe("CustomizeSidebarDialog", () => { screen.getByRole("checkbox", { name: "Configure" }), ).toBeInTheDocument(); }); + + it("renders rows in the stored order", () => { + useSidebarStore.setState({ navItemOrder: ["loops", "search"] }); + renderDialog(); + + expect(rowLabels().slice(0, 2)).toEqual(["Loops", "Search"]); + }); + + it("previews on dragover and persists only on drop", () => { + renderDialog(); + + dragStart("skills"); + dragOver("skills", "search"); + + expect(rowLabels()[0]).toBe("Skills"); + expect(useSidebarStore.getState().navItemOrder).toEqual([]); + expect(track).not.toHaveBeenCalled(); + + dragEnd("skills"); + + expect(useSidebarStore.getState().navItemOrder).toEqual([ + "skills", + "search", + "inbox", + "agents", + "mcp-servers", + "command-center", + "contexts", + "activity", + "configure", + "loops", + ]); + expect(track).toHaveBeenCalledWith(ANALYTICS_EVENTS.SIDEBAR_REORDERED, { + item: "skills", + to_index: 0, + }); + }); + + it("ignores a repeated dragover for the same source and target", () => { + renderDialog(); + + dragStart("skills"); + dragOver("skills", "search"); + dragOver("skills", "search"); + + expect(rowLabels()[0]).toBe("Skills"); + + dragEnd("skills"); + + expect(useSidebarStore.getState().navItemOrder[0]).toBe("skills"); + }); + + it("a canceled drag drops the preview and leaves the store untouched", () => { + renderDialog(); + + dragStart("skills"); + dragOver("skills", "search"); + dragEnd("skills", { cancel: true }); + + expect(rowLabels()[0]).toBe("Search"); + expect(useSidebarStore.getState().navItemOrder).toEqual([]); + expect(track).not.toHaveBeenCalled(); + }); + + it("a drop without movement neither persists nor tracks", () => { + renderDialog(); + + dragStart("skills"); + dragEnd("skills"); + + expect(useSidebarStore.getState().navItemOrder).toEqual([]); + expect(track).not.toHaveBeenCalled(); + }); + + it("reset clears the stored order back to the default", async () => { + const user = userEvent.setup(); + useSidebarStore.setState({ navItemOrder: ["loops", "search"] }); + renderDialog(); + + await user.click(screen.getByRole("button", { name: "Reset" })); + + expect(useSidebarStore.getState().navItemOrder).toEqual([]); + expect(rowLabels()[0]).toBe("Search"); + }); }); diff --git a/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx index 11188f1f39..924448c69d 100644 --- a/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx +++ b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx @@ -1,5 +1,8 @@ +import { type DragDropEvents, DragDropProvider } from "@dnd-kit/react"; +import { useSortable } from "@dnd-kit/react/sortable"; import { Bell, + DotsSixVertical, EnvelopeSimple, HashIcon, Lightbulb, @@ -13,12 +16,16 @@ import { import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { CUSTOMIZABLE_NAV_ITEMS, + type CustomizableNavItem, type CustomizableNavItemId, isNavItemVisible, + moveNavItem, + orderedNavItems, } from "@posthog/ui/features/sidebar/constants"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { track } from "@posthog/ui/shell/analytics"; import { Button, Checkbox, Dialog, Flex, Text } from "@radix-ui/themes"; +import { type RefCallback, useRef, useState } from "react"; const ITEM_ICONS: Record< CustomizableNavItemId, @@ -36,6 +43,13 @@ const ITEM_ICONS: Record< loops: RepeatIcon, }; +function sameOrder( + a: readonly CustomizableNavItemId[], + b: readonly CustomizableNavItemId[], +): boolean { + return a.length === b.length && a.every((id, i) => id === b[i]); +} + interface CustomizeSidebarDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -50,46 +64,116 @@ export function CustomizeSidebarDialog({ available, }: CustomizeSidebarDialogProps) { const navItemOverrides = useSidebarStore((s) => s.navItemOverrides); + const navItemOrder = useSidebarStore((s) => s.navItemOrder); const setNavItemVisible = useSidebarStore((s) => s.setNavItemVisible); + const setNavItemOrder = useSidebarStore((s) => s.setNavItemOrder); + + // Dragover only moves this local preview. Committing to the store per + // dragover would serialize it to localStorage and re-render the live + // sidebar on every pointer move, which made dragging visibly lag; the + // store commits once on drop and a canceled drag just drops the preview. + const previewRef = useRef(null); + const [previewOrder, setPreviewOrder] = useState< + readonly CustomizableNavItemId[] | null + >(null); + const updatePreview = (order: readonly CustomizableNavItemId[] | null) => { + previewRef.current = order; + setPreviewOrder(order); + }; + // dragover can re-fire for the same source/target pair while the pointer + // sits on a row boundary; replaying the move would swap the rows back. + const lastMove = useRef(null); + + const items = orderedNavItems(previewOrder ?? navItemOrder).filter( + ({ id }) => available?.[id] !== false, + ); + + const handleDragStart: DragDropEvents["dragstart"] = () => { + lastMove.current = null; + updatePreview( + orderedNavItems(useSidebarStore.getState().navItemOrder).map( + (item) => item.id, + ), + ); + }; + + const handleDragOver: DragDropEvents["dragover"] = (event) => { + const sourceId = event.operation.source?.id; + const targetId = event.operation.target?.id; + const current = previewRef.current; + if (!current || !sourceId || !targetId || sourceId === targetId) return; + const moveKey = `${String(sourceId)}->${String(targetId)}`; + if (lastMove.current === moveKey) return; + const next = moveNavItem(current, String(sourceId), String(targetId)); + if (next !== current) { + lastMove.current = moveKey; + updatePreview(next); + } + }; + + const handleDragEnd: DragDropEvents["dragend"] = (event) => { + const preview = previewRef.current; + updatePreview(null); + if (event.canceled || !preview) return; + const stored = orderedNavItems(useSidebarStore.getState().navItemOrder).map( + (item) => item.id, + ); + if (sameOrder(stored, preview)) return; + setNavItemOrder(preview); + const moved = CUSTOMIZABLE_NAV_ITEMS.find( + ({ id }) => id === event.operation.source?.id, + ); + if (!moved) return; + track(ANALYTICS_EVENTS.SIDEBAR_REORDERED, { + item: moved.analyticsId, + to_index: preview.indexOf(moved.id), + }); + }; return ( Customize sidebar - Choose which items appear in your sidebar. Unchecked items live under - More. + Choose which items appear in your sidebar and drag to reorder. + Unchecked items live under More. - - {CUSTOMIZABLE_NAV_ITEMS.filter( - ({ id }) => available?.[id] !== false, - ).map(({ id, label, analyticsId }) => { - const ItemIcon = ITEM_ICONS[id]; - const visible = isNavItemVisible(navItemOverrides, id); - return ( - - - { - const nextVisible = checked === true; - setNavItemVisible(id, nextVisible); - track(ANALYTICS_EVENTS.SIDEBAR_CUSTOMIZED, { - item: analyticsId, - visible: nextVisible, - }); - }} - /> - - {label} - - - ); - })} - + {/* Default pointer activation starts a mouse drag from the handle + immediately; a distance constraint here would delay pickup. */} + + + {items.map((item, index) => ( + { + setNavItemVisible(item.id, nextVisible); + track(ANALYTICS_EVENTS.SIDEBAR_CUSTOMIZED, { + item: item.analyticsId, + visible: nextVisible, + }); + }} + /> + ))} + + - + + + + + onVisibleChange(checked === true)} + /> + + {item.label} + + + + + ); +} diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx index edfda181c1..e0ca40bd39 100644 --- a/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx @@ -96,7 +96,11 @@ describe("SidebarNavSection", () => { beforeEach(() => { vi.clearAllMocks(); useAppView.mockReturnValue({ type: "home" }); - useSidebarStore.setState({ navItemOverrides: {}, channelsEnabled: true }); + useSidebarStore.setState({ + navItemOverrides: {}, + navItemOrder: [], + channelsEnabled: true, + }); }); it.each([ @@ -147,6 +151,20 @@ describe("SidebarNavSection", () => { }, ); + it("renders top-level items in the stored order", () => { + useSidebarStore.setState({ navItemOrder: ["activity", "inbox"] }); + renderNav(); + + const labels = screen + .getAllByRole("button") + .map((button) => button.textContent ?? ""); + const position = (label: string) => + labels.findIndex((text) => text.includes(label)); + + expect(position("Activity")).toBeLessThan(position("Inbox")); + expect(position("Inbox")).toBeLessThan(position("Agents")); + }); + it("never lets hidden search take over the More row", () => { useSidebarStore.setState({ navItemOverrides: { search: false } }); renderNav(); diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx index 0775edd3b2..9d167a9c77 100644 --- a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx @@ -10,9 +10,9 @@ import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAll import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; import { CUSTOMIZABLE_NAV_ITEM_IDS, - CUSTOMIZABLE_NAV_ITEMS, type CustomizableNavItemId, isNavItemVisible, + orderedNavItems, } from "@posthog/ui/features/sidebar/constants"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; @@ -167,6 +167,8 @@ export function SidebarNavSection({ }; const navItemOverrides = useSidebarStore((s) => s.navItemOverrides); + const navItemOrder = useSidebarStore((s) => s.navItemOrder); + const orderedItems = orderedNavItems(navItemOrder); const hidden = new Set( CUSTOMIZABLE_NAV_ITEM_IDS.filter( (id) => !isNavItemVisible(navItemOverrides, id), @@ -206,7 +208,7 @@ export function SidebarNavSection({ loops: loopsEnabled, }; - const activeHiddenItem = CUSTOMIZABLE_NAV_ITEMS.find( + const activeHiddenItem = orderedItems.find( ({ id }) => navItemAvailable[id] && hidden.has(id) && moreItemActive[id], ); const takeoverLabel = @@ -308,10 +310,10 @@ export function SidebarNavSection({ ), }; - const topLevelItems = CUSTOMIZABLE_NAV_ITEMS.filter( + const topLevelItems = orderedItems.filter( ({ id }) => navItemAvailable[id] && !hidden.has(id), ); - const moreItems = CUSTOMIZABLE_NAV_ITEMS.filter( + const moreItems = orderedItems.filter( ({ id }) => navItemAvailable[id] && hidden.has(id), ); diff --git a/packages/ui/src/features/sidebar/constants.test.ts b/packages/ui/src/features/sidebar/constants.test.ts new file mode 100644 index 0000000000..666d62e832 --- /dev/null +++ b/packages/ui/src/features/sidebar/constants.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + CUSTOMIZABLE_NAV_ITEM_IDS, + moveNavItem, + orderedNavItems, + sanitizeNavItemOrder, +} from "./constants"; + +describe("orderedNavItems", () => { + it("returns the default order for an empty stored order", () => { + expect(orderedNavItems([]).map((item) => item.id)).toEqual( + CUSTOMIZABLE_NAV_ITEM_IDS, + ); + }); + + it("inserts an id missing from a full stored order after its default predecessor", () => { + const withoutSkills = CUSTOMIZABLE_NAV_ITEM_IDS.filter( + (id) => id !== "skills", + ).reverse(); + + const ids = orderedNavItems(withoutSkills).map((item) => item.id); + + expect(ids.indexOf("skills")).toBe(ids.indexOf("agents") + 1); + }); + + it("inserts a missing id with no present predecessor at the start", () => { + const ids = orderedNavItems(["loops", "inbox"]).map((item) => item.id); + + expect(ids[0]).toBe("search"); + }); + + it("puts stored ids first and appends the rest in default order", () => { + const ids = orderedNavItems(["loops", "search"]).map((item) => item.id); + + expect(ids.slice(0, 2)).toEqual(["loops", "search"]); + expect(ids.slice(2)).toEqual( + CUSTOMIZABLE_NAV_ITEM_IDS.filter( + (id) => id !== "loops" && id !== "search", + ), + ); + }); +}); + +describe("moveNavItem", () => { + it("moves an item backward to the target position", () => { + const next = moveNavItem([], "skills", "search"); + + expect(next[0]).toBe("skills"); + expect(next).toHaveLength(CUSTOMIZABLE_NAV_ITEM_IDS.length); + }); + + it("moves an item forward to the target position", () => { + const next = moveNavItem([], "search", "agents"); + + expect(next.indexOf("search")).toBe( + CUSTOMIZABLE_NAV_ITEM_IDS.indexOf("agents"), + ); + }); + + it.each([ + ["an unknown source", "retired-item", "search"], + ["an unknown target", "search", "retired-item"], + ["the same source and target", "search", "search"], + ])("returns the order unchanged for %s", (_label, source, target) => { + const order: readonly ("loops" | "search")[] = ["loops", "search"]; + + expect(moveNavItem(order, source, target)).toBe(order); + }); +}); + +describe("sanitizeNavItemOrder", () => { + it.each([ + ["a string", "corrupt"], + ["an object", { search: 0 }], + ["null", null], + ["a number", 7], + ])("returns an empty order when the value is %s", (_label, value) => { + expect(sanitizeNavItemOrder(value)).toEqual([]); + }); + + it("drops unknown ids, non-strings and duplicates", () => { + expect( + sanitizeNavItemOrder(["loops", "retired-item", 7, "search", "loops"]), + ).toEqual(["loops", "search"]); + }); +}); diff --git a/packages/ui/src/features/sidebar/constants.ts b/packages/ui/src/features/sidebar/constants.ts index 92a2e7eba5..dbb63adf30 100644 --- a/packages/ui/src/features/sidebar/constants.ts +++ b/packages/ui/src/features/sidebar/constants.ts @@ -86,6 +86,60 @@ export function isNavItemVisible( return overrides[id] ?? DEFAULT_VISIBILITY[id]; } +export type CustomizableNavItem = (typeof CUSTOMIZABLE_NAV_ITEMS)[number]; + +/** Applies a stored drag order. Ids missing from it (newly shipped items) + * slot in after their nearest default predecessor instead of at the end, so + * users with a saved order still get new items near their intended spot. */ +export function orderedNavItems( + order: readonly CustomizableNavItemId[], +): readonly CustomizableNavItem[] { + if (order.length === 0) return CUSTOMIZABLE_NAV_ITEMS; + const byId = new Map(CUSTOMIZABLE_NAV_ITEMS.map((item) => [item.id, item])); + const result = [...order]; + for (const [defaultIndex, item] of CUSTOMIZABLE_NAV_ITEMS.entries()) { + if (result.includes(item.id)) continue; + let insertAt = 0; + for (let i = defaultIndex - 1; i >= 0; i--) { + const neighbor = result.indexOf(CUSTOMIZABLE_NAV_ITEMS[i].id); + if (neighbor !== -1) { + insertAt = neighbor + 1; + break; + } + } + result.splice(insertAt, 0, item.id); + } + return result.flatMap((id) => { + const item = byId.get(id); + return item ? [item] : []; + }); +} + +export function moveNavItem( + order: readonly CustomizableNavItemId[], + sourceId: string, + targetId: string, +): readonly CustomizableNavItemId[] { + const full = orderedNavItems(order).map((item) => item.id); + const ids: readonly string[] = full; + const from = ids.indexOf(sourceId); + const to = ids.indexOf(targetId); + if (from === -1 || to === -1 || from === to) return order; + const [moved] = full.splice(from, 1); + full.splice(to, 0, moved); + return full; +} + +export function sanitizeNavItemOrder(value: unknown): CustomizableNavItemId[] { + if (!Array.isArray(value)) return []; + const order = new Set(); + for (const entry of value) { + const id = CUSTOMIZABLE_NAV_ITEM_IDS.find((known) => known === entry); + if (id) order.add(id); + } + return [...order]; +} + /** Keeps only known item ids with boolean values, so corrupt or stale * persisted state degrades to per-item defaults instead of crashing. */ export function sanitizeNavItemOverrides(value: unknown): NavItemOverrides { diff --git a/packages/ui/src/features/sidebar/sidebarStore.test.ts b/packages/ui/src/features/sidebar/sidebarStore.test.ts index c4e769c973..87b9bc4450 100644 --- a/packages/ui/src/features/sidebar/sidebarStore.test.ts +++ b/packages/ui/src/features/sidebar/sidebarStore.test.ts @@ -4,7 +4,7 @@ import { useSidebarStore } from "./sidebarStore"; describe("sidebarStore navItemOverrides", () => { beforeEach(() => { - useSidebarStore.setState({ navItemOverrides: {} }); + useSidebarStore.setState({ navItemOverrides: {}, navItemOrder: [] }); }); it.each( @@ -73,6 +73,38 @@ describe("sidebarStore navItemOverrides", () => { localStorage.removeItem("sidebar-storage"); }); + it("rehydration sanitizes navItemOrder to known unique ids", async () => { + localStorage.setItem( + "sidebar-storage", + JSON.stringify({ + state: { + navItemOrder: ["loops", "retired-item", 7, "search", "loops"], + }, + version: 0, + }), + ); + + await useSidebarStore.persist.rehydrate(); + + expect(useSidebarStore.getState().navItemOrder).toEqual([ + "loops", + "search", + ]); + localStorage.removeItem("sidebar-storage"); + }); + + it("rehydration falls back to default order when the persisted order is not an array", async () => { + localStorage.setItem( + "sidebar-storage", + JSON.stringify({ state: { navItemOrder: "corrupt" }, version: 0 }), + ); + + await useSidebarStore.persist.rehydrate(); + + expect(useSidebarStore.getState().navItemOrder).toEqual([]); + localStorage.removeItem("sidebar-storage"); + }); + it("rehydration drops unknown ids and non-boolean values", async () => { localStorage.setItem( "sidebar-storage", diff --git a/packages/ui/src/features/sidebar/sidebarStore.ts b/packages/ui/src/features/sidebar/sidebarStore.ts index 52e5f0f57e..2067513dd4 100644 --- a/packages/ui/src/features/sidebar/sidebarStore.ts +++ b/packages/ui/src/features/sidebar/sidebarStore.ts @@ -6,6 +6,7 @@ import { type CustomizableNavItemId, type NavItemOverrides, SIDEBAR_MIN_WIDTH, + sanitizeNavItemOrder, sanitizeNavItemOverrides, } from "./constants"; @@ -30,6 +31,9 @@ interface SidebarStoreState { // absent from the map follow their CUSTOMIZABLE_NAV_ITEMS defaultVisible, so newly // shipped moreable items keep their intended default for existing users. navItemOverrides: NavItemOverrides; + // Drag order from the Customize sidebar dialog. Empty means default order; + // ids absent from it (newly shipped items) render after the ordered ones. + navItemOrder: readonly CustomizableNavItemId[]; } interface SidebarStoreActions { @@ -51,6 +55,7 @@ interface SidebarStoreActions { toggleTaskType: (mode: WorkspaceMode) => void; setChannelsEnabled: (channelsEnabled: boolean) => void; setNavItemVisible: (item: CustomizableNavItemId, visible: boolean) => void; + setNavItemOrder: (order: readonly CustomizableNavItemId[]) => void; } type SidebarStore = SidebarStoreState & SidebarStoreActions; @@ -72,6 +77,7 @@ export const useSidebarStore = create()( taskTypeFilter: [...ALL_WORKSPACE_MODES], channelsEnabled: false, navItemOverrides: {}, + navItemOrder: [], setOpen: (open) => set({ open, hasUserSetOpen: true }), setOpenAuto: (open) => set((state) => (state.hasUserSetOpen ? state : { open })), @@ -133,6 +139,7 @@ export const useSidebarStore = create()( set((state) => ({ navItemOverrides: { ...state.navItemOverrides, [item]: visible }, })), + setNavItemOrder: (navItemOrder) => set({ navItemOrder }), }), { name: "sidebar-storage", @@ -150,6 +157,7 @@ export const useSidebarStore = create()( taskTypeFilter: state.taskTypeFilter, channelsEnabled: state.channelsEnabled, navItemOverrides: state.navItemOverrides, + navItemOrder: state.navItemOrder, }), merge: (persisted, current) => { const persistedState = persisted as { @@ -166,6 +174,7 @@ export const useSidebarStore = create()( taskTypeFilter?: WorkspaceMode[]; channelsEnabled?: boolean; navItemOverrides?: unknown; + navItemOrder?: unknown; }; return { ...current, @@ -191,6 +200,7 @@ export const useSidebarStore = create()( navItemOverrides: sanitizeNavItemOverrides( persistedState.navItemOverrides, ), + navItemOrder: sanitizeNavItemOrder(persistedState.navItemOrder), }; }, },