Repository: PostHog/posthog
Pull request #31049: fix(devex): Fixes to tree context menus (group by product new, fix collisions)
Author: login: adamleithp
Created: 2025-04-10T10:15:37Z | Merged: 2025-04-10T12:34:26Z

## Problem
Context menus and dropdown menus in our tree were really long, so long that it cut off at the bottom of the page.

![2025-04-10 11 15 59](https://github.com/user-attachments/assets/3d014962-92a1-4d46-bc24-217496b6127d)


## Changes
* Group products in the "new" section, introducing sub menus for "data" and "insights"
* Update both context/dropdown components to handle max-heights
* Update both context/dropdown components with default collision paddings top and bottom
* Created some general css classes for the context/dropdown content (these were long tailwind classes and hard to understand)

## Does this work well for both Cloud and self-hosted?
Yes

## How did you test this code?
Clicking around on different browsers


Diff:
diff --git a/frontend/__snapshots__/scenes-other-billing--billing--light.png b/frontend/__snapshots__/scenes-other-billing--billing--light.png
index a88119bef69..7c25360f8a3 100644
Binary files a/frontend/__snapshots__/scenes-other-billing--billing--light.png and b/frontend/__snapshots__/scenes-other-billing--billing--light.png differ
diff --git a/frontend/src/layout/panel-layout/ProjectTree/ProjectTree.tsx b/frontend/src/layout/panel-layout/ProjectTree/ProjectTree.tsx
index e91d15f60c4..ee5a3bff925 100644
--- a/frontend/src/layout/panel-layout/ProjectTree/ProjectTree.tsx
+++ b/frontend/src/layout/panel-layout/ProjectTree/ProjectTree.tsx
@@ -1,10 +1,24 @@
-import { IconFolderPlus } from '@posthog/icons'
+import { IconChevronRight, IconFolderPlus } from '@posthog/icons'
 import { useActions, useValues } from 'kea'
 import { LemonTag } from 'lib/lemon-ui/LemonTag'
-import { LemonTree, LemonTreeRef } from 'lib/lemon-ui/LemonTree/LemonTree'
+import { LemonTree, LemonTreeRef, TreeDataItem } from 'lib/lemon-ui/LemonTree/LemonTree'
 import { ButtonPrimitive } from 'lib/ui/Button/ButtonPrimitives'
-import { ContextMenuGroup, ContextMenuItem, ContextMenuSeparator } from 'lib/ui/ContextMenu/ContextMenu'
-import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator } from 'lib/ui/DropdownMenu/DropdownMenu'
+import {
+    ContextMenuGroup,
+    ContextMenuItem,
+    ContextMenuSeparator,
+    ContextMenuSub,
+    ContextMenuSubContent,
+    ContextMenuSubTrigger,
+} from 'lib/ui/ContextMenu/ContextMenu'
+import {
+    DropdownMenuGroup,
+    DropdownMenuItem,
+    DropdownMenuSeparator,
+    DropdownMenuSub,
+    DropdownMenuSubContent,
+    DropdownMenuSubTrigger,
+} from 'lib/ui/DropdownMenu/DropdownMenu'
 import { RefObject, useEffect, useRef } from 'react'
 
 import { panelLayoutLogic } from '~/layout/panel-layout/panelLayoutLogic'
@@ -22,7 +36,7 @@ export function ProjectTree(): JSX.Element {
         expandedFolders,
         expandedSearchFolders,
         searchTerm,
-        treeItemsNew,
+        treeItemsNewByNestedProduct,
         checkedItems,
         checkedItemsCount,
         checkedItemCountNumeric,
@@ -62,16 +76,20 @@ export function ProjectTree(): JSX.Element {
     }, [treeRef, setPanelTreeRef])
 
     // Merge duplicate menu code for both context and dropdown menus
-    const renderMenuItems = (item: any, MenuItem: typeof ContextMenuItem | typeof DropdownMenuItem): JSX.Element => {
+    const renderMenuItems = (item: TreeDataItem, type: 'context' | 'dropdown'): JSX.Element => {
         // Determine the separator component based on MenuItem type
-        const MenuSeparator = MenuItem === ContextMenuItem ? ContextMenuSeparator : DropdownMenuSeparator
+        const MenuItem = type === 'context' ? ContextMenuItem : DropdownMenuItem
+        const MenuSeparator = type === 'context' ? ContextMenuSeparator : DropdownMenuSeparator
+        const MenuSub = type === 'context' ? ContextMenuSub : DropdownMenuSub
+        const MenuSubTrigger = type === 'context' ? ContextMenuSubTrigger : DropdownMenuSubTrigger
+        const MenuSubContent = type === 'context' ? ContextMenuSubContent : DropdownMenuSubContent
 
         return (
             <>
                 {item.record?.path ? (
                     <MenuItem
                         asChild
-                        onClick={(e: any) => {
+                        onClick={(e) => {
                             e.stopPropagation()
                             onItemChecked(item.id, !checkedItems[item.id])
                         }}
@@ -82,9 +100,9 @@ export function ProjectTree(): JSX.Element {
                 {checkedItemCountNumeric > 0 && item.record?.type === 'folder' ? (
                     <MenuItem
                         asChild
-                        onClick={(e: any) => {
+                        onClick={(e) => {
                             e.stopPropagation()
-                            linkCheckedItems(item.record.path)
+                            linkCheckedItems(item?.record?.path)
                         }}
                     >
                         <ButtonPrimitive menuItem>
@@ -95,9 +113,9 @@ export function ProjectTree(): JSX.Element {
                 {checkedItemCountNumeric > 0 && item.record?.type === 'folder' ? (
                     <MenuItem
                         asChild
-                        onClick={(e: any) => {
+                        onClick={(e) => {
                             e.stopPropagation()
-                            moveCheckedItems(item.record.path)
+                            moveCheckedItems(item?.record?.path)
                         }}
                     >
                         <ButtonPrimitive menuItem>
@@ -111,7 +129,7 @@ export function ProjectTree(): JSX.Element {
                 {item.record?.path ? (
                     <MenuItem
                         asChild
-                        onClick={(e: any) => {
+                        onClick={(e) => {
                             e.stopPropagation()
                             handleCopyPath(item.record?.path)
                         }}
@@ -122,7 +140,7 @@ export function ProjectTree(): JSX.Element {
                 {item.record?.path && item.record?.shortcut ? (
                     <MenuItem
                         asChild
-                        onClick={(e: any) => {
+                        onClick={(e) => {
                             e.stopPropagation()
                             assureVisibility({ type: item.record?.type, ref: item.record?.ref })
                         }}
@@ -133,7 +151,7 @@ export function ProjectTree(): JSX.Element {
                 {item.record?.path && item.record?.type === 'folder' ? (
                     <MenuItem
                         asChild
-                        onClick={(e: any) => {
+                        onClick={(e) => {
                             e.stopPropagation()
                             rename(item.record as unknown as FileSystemEntry)
                         }}
@@ -164,7 +182,7 @@ export function ProjectTree(): JSX.Element {
                 ) : item.record?.shortcut ? (
                     <MenuItem
                         asChild
-                        onClick={(e: any) => {
+                        onClick={(e) => {
                             e.stopPropagation()
                             deleteItem(item.record as unknown as FileSystemEntry)
                         }}
@@ -199,7 +217,7 @@ export function ProjectTree(): JSX.Element {
                         {!item.id?.startsWith('project-folder-empty/') ? <MenuSeparator /> : null}
                         <MenuItem
                             asChild
-                            onClick={(e: any) => {
+                            onClick={(e) => {
                                 e.stopPropagation()
                                 createFolder(item.record?.path)
                             }}
@@ -207,23 +225,61 @@ export function ProjectTree(): JSX.Element {
                             <ButtonPrimitive menuItem>New folder</ButtonPrimitive>
                         </MenuItem>
                         <MenuSeparator />
-                        {treeItemsNew.map((treeItem) => (
-                            <MenuItem
-                                key={treeItem.id}
-                                asChild
-                                onClick={(e: any) => {
-                                    e.stopPropagation()
-                                    const objectType: string | undefined = treeItem.record?.type
-                                    const folder = item.record?.path
-                                    if (objectType && folder) {
-                                        setLastNewOperation(objectType, folder)
-                                    }
-                                    treeItem.onClick?.()
-                                }}
-                            >
-                                <ButtonPrimitive menuItem>New {treeItem.name}</ButtonPrimitive>
-                            </MenuItem>
-                        ))}
+                        {treeItemsNewByNestedProduct.map((treeItem): JSX.Element => {
+                            if (treeItem.children) {
+                                return (
+                                    <MenuSub key={treeItem.id}>
+                                        <MenuSubTrigger asChild>
+                                            <ButtonPrimitive menuItem>
+                                                New{' '}
+                                                {treeItem.name ||
+                                                    treeItem.id.charAt(0).toUpperCase() + treeItem.id.slice(1)}
+                                                ...
+                                                <IconChevronRight className="ml-auto h-4 w-4" />
+                                            </ButtonPrimitive>
+                                        </MenuSubTrigger>
+                                        <MenuSubContent>
+                                            {treeItem.children.map((child) => (
+                                                <MenuItem
+                                                    key={child.id}
+                                                    asChild
+                                                    onClick={(e) => {
+                                                        e.stopPropagation()
+                                                        const objectType: string | undefined = child.record?.type
+                                                        const folder = item.record?.path
+                                                        if (objectType && folder) {
+                                                            setLastNewOperation(objectType, folder)
+                                                        }
+                                                        child.onClick?.()
+                                                    }}
+                                                >
+                                                    <ButtonPrimitive menuItem className="capitalize">
+                                                        {child.name}
+                                                    </ButtonPrimitive>
+                                                </MenuItem>
+                                            ))}
+                                        </MenuSubContent>
+                                    </MenuSub>
+                                )
+                            }
+                            return (
+                                <MenuItem
+                                    key={treeItem.id}
+                                    asChild
+                                    onClick={(e) => {
+                                        e.stopPropagation()
+                                        const objectType: string | undefined = treeItem.record?.type
+                                        const folder = item.record?.path
+                                        if (objectType && folder) {
+                                            setLastNewOperation(objectType, folder)
+                                        }
+                                        treeItem.onClick?.()
+                                    }}
+                                >
+                                    <ButtonPrimitive menuItem>New {treeItem.name}</ButtonPrimitive>
+                                </MenuItem>
+                            )
+                        })}
                     </>
                 ) : null}
             </>
@@ -347,20 +403,20 @@ export function ProjectTree(): JSX.Element {
                     if (item.id.startsWith('project-folder-empty/')) {
                         return undefined
                     }
-                    return <ContextMenuGroup>{renderMenuItems(item, ContextMenuItem)}</ContextMenuGroup>
+                    return <ContextMenuGroup>{renderMenuItems(item, 'context')}</ContextMenuGroup>
                 }}
                 itemSideAction={(item) => {
                     if (item.id.startsWith('project-folder-empty/')) {
                         return undefined
                     }
-                    return <DropdownMenuGroup>{renderMenuItems(item, DropdownMenuItem)}</DropdownMenuGroup>
+                    return <DropdownMenuGroup>{renderMenuItems(item, 'dropdown')}</DropdownMenuGroup>
                 }}
                 emptySpaceContextMenu={() => {
                     return (
                         <ContextMenuGroup>
                             <ContextMenuItem
                                 asChild
-                                onClick={(e: any) => {
+                                onClick={(e) => {
                                     e.stopPropagation()
                                     createFolder('')
                                 }}
diff --git a/frontend/src/layout/panel-layout/ProjectTree/defaultTree.tsx b/frontend/src/layout/panel-layout/ProjectTree/defaultTree.tsx
index 70b9e1d7b22..2f578aaf47a 100644
--- a/frontend/src/layout/panel-layout/ProjectTree/defaultTree.tsx
+++ b/frontend/src/layout/panel-layout/ProjectTree/defaultTree.tsx
@@ -62,17 +62,17 @@ export const getDefaultTreeNew = (): FileSystemImport[] =>
             href: () => urls.survey('new'),
         },
         {
-            path: `Data source`,
+            path: `Source`,
             type: 'hog_function/source',
             href: () => urls.pipelineNodeNew(PipelineStage.Source),
         },
         {
-            path: `Data destination`,
+            path: `Destination`,
             type: 'hog_function/destination',
             href: () => urls.pipelineNodeNew(PipelineStage.Destination),
         },
         {
-            path: `Data transformation`,
+            path: `Transformation`,
             type: 'hog_function/transformation',
             href: () => urls.pipelineNodeNew(PipelineStage.Transformation),
         },
diff --git a/frontend/src/layout/panel-layout/ProjectTree/projectTreeLogic.tsx b/frontend/src/layout/panel-layout/ProjectTree/projectTreeLogic.tsx
index bd0c9ee854a..27d3fa3126d 100644
--- a/frontend/src/layout/panel-layout/ProjectTree/projectTreeLogic.tsx
+++ b/frontend/src/layout/panel-layout/ProjectTree/projectTreeLogic.tsx
@@ -608,6 +608,42 @@ export const projectTreeLogic = kea<projectTreeLogicType>([
                     root: 'new',
                 }),
         ],
+        treeItemsNewByNestedProduct: [
+            (s) => [s.treeItemsNew],
+            (treeItemsNew): TreeDataItem[] => {
+                // Create arrays for each category
+                const dataItems = treeItemsNew
+                    .filter((item: TreeDataItem) => item.record?.type.includes('hog_function/'))
+                    .sort((a: TreeDataItem, b: TreeDataItem) => a.name.localeCompare(b.name))
+
+                const insightItems = treeItemsNew
+                    .filter((item: TreeDataItem) => item.record?.type === 'insight')
+                    .sort((a: TreeDataItem, b: TreeDataItem) => a.name.localeCompare(b.name))
+
+                // Get other items (not data or insight)
+                const otherItems = treeItemsNew
+                    .filter(
+                        (item: TreeDataItem) =>
+                            !item.record?.type.includes('hog_function/') && !item.record?.type.includes('insight')
+                    )
+                    .sort((a: TreeDataItem, b: TreeDataItem) => a.name.localeCompare(b.name))
+
+                // Create the final hierarchical structure with explicit names for grouped items
+                const result = [
+                    ...otherItems,
+                    { id: 'data', name: 'Data', children: dataItems },
+                    { id: 'insight', name: 'Insight', children: insightItems },
+                ]
+
+                // Sort the top level alphabetically (keeping the structure)
+                return result.sort((a: TreeDataItem, b: TreeDataItem) => {
+                    // Always use name for sorting (with fallback to id)
+                    const nameA = a.name || a.id.charAt(0).toUpperCase() + a.id.slice(1)
+                    const nameB = b.name || b.id.charAt(0).toUpperCase() + b.id.slice(1)
+                    return nameA.localeCompare(nameB)
+                })
+            },
+        ],
         treeItemsExplore: [
             (s) => [s.featureFlags, s.groupNodes, s.folderStates],
             (featureFlags, groupNodes: FileSystemImport[], folderStates): TreeDataItem[] =>
diff --git a/frontend/src/lib/lemon-ui/LemonTree/LemonTree.tsx b/frontend/src/lib/lemon-ui/LemonTree/LemonTree.tsx
index 39a9eab5136..87208b77252 100644
--- a/frontend/src/lib/lemon-ui/LemonTree/LemonTree.tsx
+++ b/frontend/src/lib/lemon-ui/LemonTree/LemonTree.tsx
@@ -375,7 +375,7 @@ const LemonTreeNode = forwardRef<HTMLDivElement, LemonTreeNodeProps>(
                                         </ContextMenuTrigger>
 
                                         {isContextMenuOpenForItem === item.id && itemContextMenu?.(item) ? (
-                                            <ContextMenuContent loop className="max-w-[250px]" collisionPadding={100}>
+                                            <ContextMenuContent loop className="max-w-[250px]">
                                                 {itemContextMenu(item)}
                                             </ContextMenuContent>
                                         ) : null}
@@ -1105,7 +1105,7 @@ const LemonTree = forwardRef<LemonTreeRef, LemonTreeProps>(
                             <ContextMenuTrigger className="flex-1 w-full">
                                 <div className="h-full w-full" />
                             </ContextMenuTrigger>
-                            <ContextMenuContent collisionPadding={100}>{emptySpaceContextMenu?.()}</ContextMenuContent>
+                            <ContextMenuContent>{emptySpaceContextMenu?.()}</ContextMenuContent>
                         </ContextMenu>
                     </TreeNodeDroppable>
                 </ScrollableShadows>
diff --git a/frontend/src/lib/ui/ContextMenu/ContextMenu.tsx b/frontend/src/lib/ui/ContextMenu/ContextMenu.tsx
index 91c51447919..21bfbc2abc9 100644
--- a/frontend/src/lib/ui/ContextMenu/ContextMenu.tsx
+++ b/frontend/src/lib/ui/ContextMenu/ContextMenu.tsx
@@ -1,6 +1,6 @@
 'use client'
 
-import { IconCheckCircle, IconChevronRight } from '@posthog/icons'
+import { IconCheckCircle } from '@posthog/icons'
 import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
 import { ScrollableShadows } from 'lib/components/ScrollableShadows/ScrollableShadows'
 import { cn } from 'lib/utils/css-classes'
@@ -24,19 +24,8 @@ const ContextMenuSubTrigger = React.forwardRef<
         inset?: boolean
     }
 >(
-    ({ className, inset, children, ...props }, ref): JSX.Element => (
-        <ContextMenuPrimitive.SubTrigger
-            ref={ref}
-            className={cn(
-                'flex cursor-default select-none items-center rounded-xs px-2 py-1.5 text-sm outline-hidden focus:bg-accent-highlight focus:text-accent-foreground data-[state=open]:bg-accent-highlight data-[state=open]:text-accent-foreground',
-                inset && 'pl-8',
-                className
-            )}
-            {...props}
-        >
-            {children}
-            <IconChevronRight className="ml-auto h-4 w-4" />
-        </ContextMenuPrimitive.SubTrigger>
+    ({ className, inset, ...props }, ref): JSX.Element => (
+        <ContextMenuPrimitive.SubTrigger ref={ref} className={cn(inset && 'pl-8', className)} {...props} />
     )
 )
 ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
@@ -45,15 +34,17 @@ const ContextMenuSubContent = React.forwardRef<
     React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
     React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
 >(
-    ({ className, ...props }, ref): JSX.Element => (
+    ({ className, collisionPadding = { top: 50, bottom: 50 }, children, ...props }, ref): JSX.Element => (
         <ContextMenuPrimitive.SubContent
             ref={ref}
-            className={cn(
-                'z-top relative min-w-[8rem] overflow-hidden rounded-md border border-secondary bg-surface-primary p-1 text-primary shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
-                className
-            )}
+            collisionPadding={collisionPadding}
+            className={cn('primitive-menu-content', className)}
             {...props}
-        />
+        >
+            <ScrollableShadows direction="vertical" styledScrollbars innerClassName="primitive-menu-content-inner">
+                {children}
+            </ScrollableShadows>
+        </ContextMenuPrimitive.SubContent>
     )
 )
 ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
@@ -62,22 +53,15 @@ const ContextMenuContent = React.forwardRef<
     React.ElementRef<typeof ContextMenuPrimitive.Content>,
     React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
 >(
-    ({ className, children, ...props }, ref): JSX.Element => (
+    ({ className, children, collisionPadding = { top: 50, bottom: 50 }, ...props }, ref): JSX.Element => (
         <ContextMenuPrimitive.Portal>
             <ContextMenuPrimitive.Content
                 ref={ref}
-                className={cn(
-                    `z-top relative overflow-hidden min-w-[8rem] max-w-[200px] rounded-md border border-secondary bg-surface-primary text-primary shadow`,
-                    `max-h-[calc(var(--radix-popper-available-height)-100px)]`,
-                    className
-                )}
+                collisionPadding={collisionPadding}
+                className={cn(`primitive-menu-content`, className)}
                 {...props}
             >
-                <ScrollableShadows
-                    direction="vertical"
-                    styledScrollbars
-                    innerClassName="p-1 max-h-[calc(var(--radix-popper-available-height)-50px)]"
-                >
+                <ScrollableShadows direction="vertical" styledScrollbars innerClassName="primitive-menu-content-inner">
                     {children}
                 </ScrollableShadows>
             </ContextMenuPrimitive.Content>
@@ -92,10 +76,8 @@ const ContextMenuItem = React.forwardRef<
         inset?: boolean
     }
 >(
-    ({ className, inset, children, ...props }, ref): JSX.Element => (
-        <ContextMenuPrimitive.Item ref={ref} className={cn(inset && 'pl-8', className)} {...props}>
-            {children}
-        </ContextMenuPrimitive.Item>
+    ({ className, inset, ...props }, ref): JSX.Element => (
+        <ContextMenuPrimitive.Item ref={ref} className={cn(inset && 'pl-8', className)} {...props} />
     )
 )
 ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
diff --git a/frontend/src/lib/ui/DropdownMenu/DropdownMenu.tsx b/frontend/src/lib/ui/DropdownMenu/DropdownMenu.tsx
index 75251f011e4..64d9a04a142 100644
--- a/frontend/src/lib/ui/DropdownMenu/DropdownMenu.tsx
+++ b/frontend/src/lib/ui/DropdownMenu/DropdownMenu.tsx
@@ -57,15 +57,20 @@ const DropdownMenuSubContent = React.forwardRef<
     React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
     React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
 >(
-    ({ className, ...props }, ref): JSX.Element => (
+    ({ className, children, collisionPadding = { top: 50, bottom: 50 }, ...props }, ref): JSX.Element => (
         <DropdownMenuPrimitive.SubContent
             ref={ref}
+            collisionPadding={collisionPadding}
             className={cn(
-                'z-[var(--z-popover)] min-w-[8rem] max-w-[200px] overflow-hidden rounded-md border border-secondary bg-surface-primary p-1 text-primary shadow',
+                'primitive-menu-content max-h-[var(--radix-dropdown-menu-content-available-height)]',
                 className
             )}
             {...props}
-        />
+        >
+            <ScrollableShadows direction="vertical" styledScrollbars innerClassName="primitive-menu-content-inner">
+                {children}
+            </ScrollableShadows>
+        </DropdownMenuPrimitive.SubContent>
     )
 )
 DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
@@ -75,28 +80,43 @@ const DropdownMenuContent = React.forwardRef<
     React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
         matchTriggerWidth?: boolean
     }
->(({ className, children, sideOffset = 4, matchTriggerWidth, ...props }, ref): JSX.Element => {
-    return (
-        <DropdownMenuPrimitive.Portal>
-            <DropdownMenuPrimitive.Content
-                ref={ref}
-                sideOffset={sideOffset}
-                className={cn(
-                    'z-[var(--z-popover)] min-w-[8rem] overflow-hidden rounded-md border border-secondary bg-surface-primary text-primary shadow flex flex-col gap-px',
-                    'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
-                    'max-h-[calc(var(--radix-dropdown-menu-content-available-height)-100px)]',
-                    matchTriggerWidth && 'min-w-[var(--radix-dropdown-menu-trigger-width)]',
-                    className
-                )}
-                {...props}
-            >
-                <ScrollableShadows direction="vertical" styledScrollbars innerClassName="p-1">
-                    {children}
-                </ScrollableShadows>
-            </DropdownMenuPrimitive.Content>
-        </DropdownMenuPrimitive.Portal>
-    )
-})
+>(
+    (
+        {
+            className,
+            children,
+            sideOffset = 4,
+            collisionPadding = { top: 50, bottom: 50 },
+            matchTriggerWidth,
+            ...props
+        },
+        ref
+    ): JSX.Element => {
+        return (
+            <DropdownMenuPrimitive.Portal>
+                <DropdownMenuPrimitive.Content
+                    ref={ref}
+                    sideOffset={sideOffset}
+                    collisionPadding={collisionPadding}
+                    className={cn(
+                        'primitive-menu-content max-h-[var(--radix-dropdown-menu-content-available-height)]',
+                        matchTriggerWidth && 'min-w-[var(--radix-dropdown-menu-trigger-width)]',
+                        className
+                    )}
+                    {...props}
+                >
+                    <ScrollableShadows
+                        direction="vertical"
+                        styledScrollbars
+                        innerClassName="primitive-menu-content-inner"
+                    >
+                        {children}
+                    </ScrollableShadows>
+                </DropdownMenuPrimitive.Content>
+            </DropdownMenuPrimitive.Portal>
+        )
+    }
+)
 DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
 
 const DropdownMenuItem = React.forwardRef<
diff --git a/frontend/src/products.tsx b/frontend/src/products.tsx
index 52aeff041d8..a2c17458782 100644
--- a/frontend/src/products.tsx
+++ b/frontend/src/products.tsx
@@ -285,13 +285,13 @@ export const treeItemsNew = [
     { path: `Dashboard`, type: 'dashboard', href: () => urls.dashboards() + '#newDashboard=modal' },
     { path: `Experiment`, type: 'experiment', href: () => urls.experiment('new') },
     { path: `Feature flag`, type: 'feature_flag', href: () => urls.featureFlag('new') },
-    { path: `Insight - Funnels`, type: 'insight', href: () => urls.insightNew({ type: InsightType.FUNNELS }) },
-    { path: `Insight - Lifecycle`, type: 'insight', href: () => urls.insightNew({ type: InsightType.LIFECYCLE }) },
-    { path: `Insight - Retention`, type: 'insight', href: () => urls.insightNew({ type: InsightType.RETENTION }) },
-    { path: `Insight - Stickiness`, type: 'insight', href: () => urls.insightNew({ type: InsightType.STICKINESS }) },
-    { path: `Insight - Trends`, type: 'insight', href: () => urls.insightNew({ type: InsightType.TRENDS }) },
-    { path: `Insight - User paths`, type: 'insight', href: () => urls.insightNew({ type: InsightType.PATHS }) },
+    { path: `Funnels`, type: 'insight', href: () => urls.insightNew({ type: InsightType.FUNNELS }) },
+    { path: `Lifecycle`, type: 'insight', href: () => urls.insightNew({ type: InsightType.LIFECYCLE }) },
     { path: `Notebook`, type: 'notebook', href: () => urls.notebook('new') },
+    { path: `Retention`, type: 'insight', href: () => urls.insightNew({ type: InsightType.RETENTION }) },
+    { path: `Stickiness`, type: 'insight', href: () => urls.insightNew({ type: InsightType.STICKINESS }) },
+    { path: `Trends`, type: 'insight', href: () => urls.insightNew({ type: InsightType.TRENDS }) },
+    { path: `User paths`, type: 'insight', href: () => urls.insightNew({ type: InsightType.PATHS }) },
 ]
 
 /** This const is auto-generated, as is the whole file */
diff --git a/frontend/src/styles/base.scss b/frontend/src/styles/base.scss
index 0f0fe809793..ec2687e6aaf 100644
--- a/frontend/src/styles/base.scss
+++ b/frontend/src/styles/base.scss
@@ -1530,6 +1530,30 @@
         animation: float 3s infinite ease-in-out;
     }
 
+    // Primitive classes
+
+    // Used for ui/dropdown-menu and ui/context-menu
+    .primitive-menu-content {
+        z-index: var(--z-popover);
+        display: flex;
+        flex-direction: column;
+        gap: 1px;
+        min-width: 8rem;
+        max-width: 200px;
+        max-height: calc(90vh - 100px);
+        overflow: hidden;
+        color: var(--text-primary);
+        background-color: var(--bg-surface-primary);
+        border: 1px solid var(--border-secondary);
+        border-radius: var(--radius-md);
+        box-shadow: var(--shadow);
+    }
+
+    .primitive-menu-content-inner {
+        max-height: calc(90vh - 100px);
+        padding: var(--spacing);
+    }
+
     // Highlight background blink
 
     /* stylelint-disable-next-line keyframes-name-pattern */
diff --git a/products/product_analytics/manifest.tsx b/products/product_analytics/manifest.tsx
index 491b8712842..466c5a2f50a 100644
--- a/products/product_analytics/manifest.tsx
+++ b/products/product_analytics/manifest.tsx
@@ -73,32 +73,32 @@ export const manifest: ProductManifest = {
     },
     treeItemsNew: [
         {
-            path: `Insight - Trends`,
+            path: `Trends`,
             type: 'insight',
             href: () => urls.insightNew({ type: InsightType.TRENDS }),
         },
         {
-            path: `Insight - Funnels`,
+            path: `Funnels`,
             type: 'insight',
             href: () => urls.insightNew({ type: InsightType.FUNNELS }),
         },
         {
-            path: `Insight - Retention`,
+            path: `Retention`,
             type: 'insight',
             href: () => urls.insightNew({ type: InsightType.RETENTION }),
         },
         {
-            path: `Insight - User paths`,
+            path: `User paths`,
             type: 'insight',
             href: () => urls.insightNew({ type: InsightType.PATHS }),
         },
         {
-            path: `Insight - Stickiness`,
+            path: `Stickiness`,
             type: 'insight',
             href: () => urls.insightNew({ type: InsightType.STICKINESS }),
         },
         {
-            path: `Insight - Lifecycle`,
+            path: `Lifecycle`,
             type: 'insight',
             href: () => urls.insightNew({ type: InsightType.LIFECYCLE }),
         },


Reviews:
user: login: greptile-apps[bot]
body: 
## PR Summary

This PR improves context and dropdown menus in the project tree by addressing overflow issues and enhancing organization of menu items.

- Added product categorization in `ProjectTree.tsx` with the `createByProductTreeViewItems` function that groups items into "Data" and "Insight" categories with submenus
- Implemented consistent scrollable shadows in both `ContextMenu.tsx` and `DropdownMenu.tsx` to handle overflow for long menus
- Added default collision padding (`top: 50, bottom: 50`) to prevent menus from extending beyond viewport boundaries
- Created reusable CSS classes (`primitive-menu-content` and `primitive-menu-content-inner`) to replace verbose Tailwind classes
- Unified menu rendering logic in `ProjectTree.tsx` with a single function that handles both context and dropdown menus

<sub>5 file(s) reviewed, 2 comment(s)</sub>
<sub>[Edit PR Review Bot Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile.com?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=posthog_31049)</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T10:16:55Z
user: login: mariusandra
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T11:01:13Z
user: login: adamleithp
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T11:04:35Z
user: login: adamleithp
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T11:35:12Z
user: login: adamleithp
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T11:39:26Z
user: login: mariusandra
body: Cool!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-10T11:45:07Z

Review comments:
diff_hunk: @@ -166,23 +225,61 @@ export function ProjectTree(): JSX.Element {
                             <ButtonPrimitive menuItem>New folder</ButtonPrimitive>
                         </MenuItem>
                         <MenuSeparator />
-                        {treeItemsNew.map((treeItem: any) => (
-                            <MenuItem
-                                key={treeItem.id}
-                                asChild
-                                onClick={(e: any) => {
-                                    e.stopPropagation()
-                                    const objectType: string | undefined = treeItem.record?.type
-                                    const folder = item.record?.path
-                                    if (objectType && folder) {
-                                        setLastNewOperation(objectType, folder)
-                                    }
-                                    treeItem.onClick?.()
-                                }}
-                            >
-                                <ButtonPrimitive menuItem>New {treeItem.name}</ButtonPrimitive>
-                            </MenuItem>
-                        ))}
+                        {newTreeItemsByProduct.map((treeItem: any): JSX.Element => {
path: frontend/src/layout/panel-layout/ProjectTree/ProjectTree.tsx
user: login: greptile-apps[bot]
body: style: Consider adding a type annotation for treeItem instead of using 'any' to maintain type safety throughout the component.
created_at: 2025-04-10T10:16:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -166,23 +225,61 @@ export function ProjectTree(): JSX.Element {
                             <ButtonPrimitive menuItem>New folder</ButtonPrimitive>
                         </MenuItem>
                         <MenuSeparator />
-                        {treeItemsNew.map((treeItem: any) => (
-                            <MenuItem
-                                key={treeItem.id}
-                                asChild
-                                onClick={(e: any) => {
-                                    e.stopPropagation()
-                                    const objectType: string | undefined = treeItem.record?.type
-                                    const folder = item.record?.path
-                                    if (objectType && folder) {
-                                        setLastNewOperation(objectType, folder)
-                                    }
-                                    treeItem.onClick?.()
-                                }}
-                            >
-                                <ButtonPrimitive menuItem>New {treeItem.name}</ButtonPrimitive>
-                            </MenuItem>
-                        ))}
+                        {newTreeItemsByProduct.map((treeItem: any): JSX.Element => {
+                            if (treeItem.children) {
+                                return (
+                                    <MenuSub key={treeItem.id}>
+                                        <MenuSubTrigger asChild>
+                                            <ButtonPrimitive menuItem>
+                                                New{' '}
+                                                {treeItem.name ||
+                                                    treeItem.id.charAt(0).toUpperCase() + treeItem.id.slice(1)}
+                                                ...
+                                                <IconChevronRight className="ml-auto h-4 w-4" />
+                                            </ButtonPrimitive>
+                                        </MenuSubTrigger>
+                                        <MenuSubContent>
+                                            {treeItem.children.map((child: any) => (
path: frontend/src/layout/panel-layout/ProjectTree/ProjectTree.tsx
user: login: greptile-apps[bot]
body: style: Same here - child is typed as 'any' which could lead to type safety issues.
created_at: 2025-04-10T10:16:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -60,17 +74,62 @@ export function ProjectTree(): JSX.Element {
         setPanelTreeRef(treeRef)
     }, [treeRef, setPanelTreeRef])
 
+    const createByProductTreeViewItems = (items: TreeDataItem[]): TreeDataItem[] => {
+        // Create arrays for each category
+        const dataItems = items
+            .filter((item) => item.name?.startsWith('Data '))
+            .map((item) => ({
+                ...item,
+                name: item.name.replace('Data ', ''), // Remove 'Data ' prefix from name
+            }))
+            .sort((a, b) => a.name.localeCompare(b.name))
+
+        const insightItems = items
+            .filter((item) => item.name?.startsWith('Insight - '))
+            .map((item) => ({
+                ...item,
+                name: item.name.replace('Insight - ', ''), // Remove 'Insight - ' prefix from name
+            }))
+            .sort((a, b) => a.name.localeCompare(b.name))
+
+        // Get other items (not data or insight)
+        const otherItems = items
+            .filter((item) => !item.name?.startsWith('Data ') && !item.name?.startsWith('Insight - '))
+            .sort((a, b) => a.name.localeCompare(b.name))
+
+        // Create the final hierarchical structure with explicit names for grouped items
+        const result = [
+            ...otherItems,
+            { id: 'data', name: 'Data', children: dataItems },
+            { id: 'insight', name: 'Insight', children: insightItems },
+        ]
+
+        // Sort the top level alphabetically (keeping the structure)
+        return result.sort((a, b) => {
+            // Always use name for sorting (with fallback to id)
+            const nameA = a.name || a.id.charAt(0).toUpperCase() + a.id.slice(1)
+            const nameB = b.name || b.id.charAt(0).toUpperCase() + b.id.slice(1)
+            return nameA.localeCompare(nameB)
+        })
+    }
path: frontend/src/layout/panel-layout/ProjectTree/ProjectTree.tsx
user: login: mariusandra
body: Why not fix the names already at the source? Also, please move this into a logic if you still need it... we should never do any data wrangling or calculations in React components. (except for library components of course)
created_at: 2025-04-10T11:01:08Z
author_association: COLLABORATOR
diff_hunk: @@ -60,17 +74,62 @@ export function ProjectTree(): JSX.Element {
         setPanelTreeRef(treeRef)
     }, [treeRef, setPanelTreeRef])
 
+    const createByProductTreeViewItems = (items: TreeDataItem[]): TreeDataItem[] => {
+        // Create arrays for each category
+        const dataItems = items
+            .filter((item) => item.name?.startsWith('Data '))
+            .map((item) => ({
+                ...item,
+                name: item.name.replace('Data ', ''), // Remove 'Data ' prefix from name
+            }))
+            .sort((a, b) => a.name.localeCompare(b.name))
+
+        const insightItems = items
+            .filter((item) => item.name?.startsWith('Insight - '))
+            .map((item) => ({
+                ...item,
+                name: item.name.replace('Insight - ', ''), // Remove 'Insight - ' prefix from name
+            }))
+            .sort((a, b) => a.name.localeCompare(b.name))
+
+        // Get other items (not data or insight)
+        const otherItems = items
+            .filter((item) => !item.name?.startsWith('Data ') && !item.name?.startsWith('Insight - '))
+            .sort((a, b) => a.name.localeCompare(b.name))
+
+        // Create the final hierarchical structure with explicit names for grouped items
+        const result = [
+            ...otherItems,
+            { id: 'data', name: 'Data', children: dataItems },
+            { id: 'insight', name: 'Insight', children: insightItems },
+        ]
+
+        // Sort the top level alphabetically (keeping the structure)
+        return result.sort((a, b) => {
+            // Always use name for sorting (with fallback to id)
+            const nameA = a.name || a.id.charAt(0).toUpperCase() + a.id.slice(1)
+            const nameB = b.name || b.id.charAt(0).toUpperCase() + b.id.slice(1)
+            return nameA.localeCompare(nameB)
+        })
+    }
path: frontend/src/layout/panel-layout/ProjectTree/ProjectTree.tsx
user: login: adamleithp
body: ok! thanks i'll do that now

created_at: 2025-04-10T11:04:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -60,17 +74,62 @@ export function ProjectTree(): JSX.Element {
         setPanelTreeRef(treeRef)
     }, [treeRef, setPanelTreeRef])
 
+    const createByProductTreeViewItems = (items: TreeDataItem[]): TreeDataItem[] => {
+        // Create arrays for each category
+        const dataItems = items
+            .filter((item) => item.name?.startsWith('Data '))
+            .map((item) => ({
+                ...item,
+                name: item.name.replace('Data ', ''), // Remove 'Data ' prefix from name
+            }))
+            .sort((a, b) => a.name.localeCompare(b.name))
+
+        const insightItems = items
+            .filter((item) => item.name?.startsWith('Insight - '))
+            .map((item) => ({
+                ...item,
+                name: item.name.replace('Insight - ', ''), // Remove 'Insight - ' prefix from name
+            }))
+            .sort((a, b) => a.name.localeCompare(b.name))
+
+        // Get other items (not data or insight)
+        const otherItems = items
+            .filter((item) => !item.name?.startsWith('Data ') && !item.name?.startsWith('Insight - '))
+            .sort((a, b) => a.name.localeCompare(b.name))
+
+        // Create the final hierarchical structure with explicit names for grouped items
+        const result = [
+            ...otherItems,
+            { id: 'data', name: 'Data', children: dataItems },
+            { id: 'insight', name: 'Insight', children: insightItems },
+        ]
+
+        // Sort the top level alphabetically (keeping the structure)
+        return result.sort((a, b) => {
+            // Always use name for sorting (with fallback to id)
+            const nameA = a.name || a.id.charAt(0).toUpperCase() + a.id.slice(1)
+            const nameB = b.name || b.id.charAt(0).toUpperCase() + b.id.slice(1)
+            return nameA.localeCompare(nameB)
+        })
+    }
path: frontend/src/layout/panel-layout/ProjectTree/ProjectTree.tsx
user: login: adamleithp
body: done!
created_at: 2025-04-10T11:35:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -285,13 +285,13 @@ export const treeItemsNew = [
     { path: `Dashboard`, type: 'dashboard', href: () => urls.dashboards() + '#newDashboard=modal' },
     { path: `Experiment`, type: 'experiment', href: () => urls.experiment('new') },
     { path: `Feature flag`, type: 'feature_flag', href: () => urls.featureFlag('new') },
-    { path: `Insight - Funnels`, type: 'insight', href: () => urls.insightNew({ type: InsightType.FUNNELS }) },
-    { path: `Insight - Lifecycle`, type: 'insight', href: () => urls.insightNew({ type: InsightType.LIFECYCLE }) },
-    { path: `Insight - Retention`, type: 'insight', href: () => urls.insightNew({ type: InsightType.RETENTION }) },
-    { path: `Insight - Stickiness`, type: 'insight', href: () => urls.insightNew({ type: InsightType.STICKINESS }) },
-    { path: `Insight - Trends`, type: 'insight', href: () => urls.insightNew({ type: InsightType.TRENDS }) },
-    { path: `Insight - User paths`, type: 'insight', href: () => urls.insightNew({ type: InsightType.PATHS }) },
+    { path: `Funnels`, type: 'insight', href: () => urls.insightNew({ type: InsightType.FUNNELS }) },
+    { path: `Lifecycle`, type: 'insight', href: () => urls.insightNew({ type: InsightType.LIFECYCLE }) },
     { path: `Notebook`, type: 'notebook', href: () => urls.notebook('new') },
+    { path: `Retention`, type: 'insight', href: () => urls.insightNew({ type: InsightType.RETENTION }) },
+    { path: `Stickiness`, type: 'insight', href: () => urls.insightNew({ type: InsightType.STICKINESS }) },
+    { path: `Trends`, type: 'insight', href: () => urls.insightNew({ type: InsightType.TRENDS }) },
+    { path: `User paths`, type: 'insight', href: () => urls.insightNew({ type: InsightType.PATHS }) },
 ]
path: frontend/src/products.tsx
user: login: adamleithp
body: autogenerated file
created_at: 2025-04-10T11:39:26Z
author_association: CONTRIBUTOR

PR comments:
user: login: posthog-bot
created_at: 2025-04-10T11:46:23Z
body: ## 📸 UI snapshots have been updated

**1** snapshot changes in total. **0** added, **1** modified, **0** deleted:

- **`chromium`**: **0** added, **1** modified, **0** deleted ([diff for shard 5](https://github.com/PostHog/posthog/pull/31049/commits/9b7dbc83df53f24b8cfec531958db928ba31b28c))
- `webkit`: **0** added, **0** modified, **0** deleted

Triggered by [this commit](https://github.com/PostHog/posthog/pull/31049/commits/eda6ed1bd49068a6c340ee9054da9e53a3554c2b).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/31049/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-10T11:56:43Z
body: **Size Change:** +582 B (0%) 

**Total Size:** 13.2 MB



<details><summary>ℹ️ <strong>View Unchanged</strong></summary>

| Filename | Size | Change |
| :--- | :---: | :---: |
| `frontend/dist/toolbar.js` | 13.2 MB | +582 B (0%) |

</details>



<a href="https://github.com/preactjs/compressed-size-action"><sub>compressed-size-action</sub></a>
author_association: CONTRIBUTOR