feat(context-menu): added context menu to dead sidebar space and usage indicator (#2841)

This commit is contained in:
Waleed
2026-01-15 16:54:33 -08:00
committed by GitHub
parent 87e6057033
commit 81cc88b2e2
6 changed files with 541 additions and 89 deletions

View File

@@ -0,0 +1,189 @@
'use client'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
interface UsageIndicatorContextMenuProps {
/**
* Whether the context menu is open
*/
isOpen: boolean
/**
* Position of the context menu
*/
position: { x: number; y: number }
/**
* Ref for the menu element
*/
menuRef: React.RefObject<HTMLDivElement | null>
/**
* Callback when menu should close
*/
onClose: () => void
/**
* Menu items configuration based on plan and permissions
*/
menuItems: UsageMenuItems
}
interface UsageMenuItems {
/**
* Show "Set usage limit" option
*/
showSetLimit: boolean
/**
* Show "Upgrade to Pro" option (free users)
*/
showUpgradeToPro: boolean
/**
* Show "Upgrade to Team" option (free or pro users)
*/
showUpgradeToTeam: boolean
/**
* Show "Manage seats" option (team admins)
*/
showManageSeats: boolean
/**
* Show "Upgrade to Enterprise" option
*/
showUpgradeToEnterprise: boolean
/**
* Show "Contact support" option (enterprise users)
*/
showContactSupport: boolean
/**
* Callbacks
*/
onSetLimit?: () => void
onUpgradeToPro?: () => void
onUpgradeToTeam?: () => void
onManageSeats?: () => void
onUpgradeToEnterprise?: () => void
onContactSupport?: () => void
}
/**
* Context menu component for usage indicator.
* Displays plan-appropriate options in a popover at the right-click position.
*/
export function UsageIndicatorContextMenu({
isOpen,
position,
menuRef,
onClose,
menuItems,
}: UsageIndicatorContextMenuProps) {
const {
showSetLimit,
showUpgradeToPro,
showUpgradeToTeam,
showManageSeats,
showUpgradeToEnterprise,
showContactSupport,
onSetLimit,
onUpgradeToPro,
onUpgradeToTeam,
onManageSeats,
onUpgradeToEnterprise,
onContactSupport,
} = menuItems
const hasLimitSection = showSetLimit
const hasUpgradeSection =
showUpgradeToPro || showUpgradeToTeam || showUpgradeToEnterprise || showContactSupport
const hasTeamSection = showManageSeats
return (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='top' sideOffset={4}>
{/* Limit management section */}
{showSetLimit && onSetLimit && (
<PopoverItem
onClick={() => {
onSetLimit()
onClose()
}}
>
Set usage limit
</PopoverItem>
)}
{/* Team management section */}
{hasLimitSection && hasTeamSection && <PopoverDivider />}
{showManageSeats && onManageSeats && (
<PopoverItem
onClick={() => {
onManageSeats()
onClose()
}}
>
Manage seats
</PopoverItem>
)}
{/* Upgrade section */}
{(hasLimitSection || hasTeamSection) && hasUpgradeSection && <PopoverDivider />}
{showUpgradeToPro && onUpgradeToPro && (
<PopoverItem
onClick={() => {
onUpgradeToPro()
onClose()
}}
>
Upgrade to Pro
</PopoverItem>
)}
{showUpgradeToTeam && onUpgradeToTeam && (
<PopoverItem
onClick={() => {
onUpgradeToTeam()
onClose()
}}
>
Upgrade to Team
</PopoverItem>
)}
{showUpgradeToEnterprise && onUpgradeToEnterprise && (
<PopoverItem
onClick={() => {
onUpgradeToEnterprise()
onClose()
}}
>
Upgrade to Enterprise
</PopoverItem>
)}
{showContactSupport && onContactSupport && (
<PopoverItem
onClick={() => {
onContactSupport()
onClose()
}}
>
Contact support
</PopoverItem>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -1,20 +1,23 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { Badge } from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
import {
getFilledPillColor,
USAGE_PILL_COLORS,
USAGE_THRESHOLDS,
} from '@/lib/billing/client/usage-visualization'
import { getBillingStatus, getSubscriptionStatus, getUsage } from '@/lib/billing/client/utils'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useSocket } from '@/app/workspace/providers/socket-provider'
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
import { SIDEBAR_WIDTH } from '@/stores/constants'
import { useSidebarStore } from '@/stores/sidebar/store'
import { UsageIndicatorContextMenu } from './usage-indicator-context-menu'
const logger = createLogger('UsageIndicator')
@@ -188,6 +191,8 @@ interface UsageIndicatorProps {
onClick?: () => void
}
const TYPEFORM_ENTERPRISE_URL = 'https://form.typeform.com/to/jqCO12pF'
/**
* Displays a visual usage indicator with animated pill bar.
*/
@@ -196,6 +201,15 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
const { onOperationConfirmed } = useSocket()
const queryClient = useQueryClient()
const { handleUpgrade } = useSubscriptionUpgrade()
const {
isOpen: isContextMenuOpen,
position: contextMenuPosition,
menuRef: contextMenuRef,
handleContextMenu,
closeMenu: closeContextMenu,
} = useContextMenu()
useEffect(() => {
const handleOperationConfirmed = () => {
@@ -266,6 +280,96 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const filledPillsCount = Math.ceil((progressPercentage / 100) * pillCount)
const filledColor = getFilledPillColor(isCritical, isWarning)
const isFree = planType === 'free'
const isPro = planType === 'pro'
const isTeam = planType === 'team'
const isEnterprise = planType === 'enterprise'
const handleUpgradeToPro = useCallback(async () => {
try {
await handleUpgrade('pro')
} catch (error) {
logger.error('Failed to upgrade to Pro', { error })
}
}, [handleUpgrade])
const handleUpgradeToTeam = useCallback(async () => {
try {
await handleUpgrade('team')
} catch (error) {
logger.error('Failed to upgrade to Team', { error })
}
}, [handleUpgrade])
const handleSetLimit = useCallback(() => {
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'subscription' } }))
}, [])
const handleManageSeats = useCallback(() => {
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'team' } }))
}, [])
const handleUpgradeToEnterprise = useCallback(() => {
window.open(TYPEFORM_ENTERPRISE_URL, '_blank')
}, [])
const handleContactSupport = useCallback(() => {
window.dispatchEvent(new CustomEvent('open-help-modal'))
}, [])
const contextMenuItems = useMemo(
() => ({
// Set limit: Only for Pro and Team admins (not free, not enterprise)
showSetLimit: (isPro || (isTeam && userCanManageBilling)) && !isEnterprise,
// Upgrade to Pro: Only for free users
showUpgradeToPro: isFree,
// Upgrade to Team: Free users and Pro users with billing permission
showUpgradeToTeam: isFree || (isPro && userCanManageBilling),
// Manage seats: Only for Team admins
showManageSeats: isTeam && userCanManageBilling,
// Upgrade to Enterprise: Only for Team admins (not free, not pro, not enterprise)
showUpgradeToEnterprise: isTeam && userCanManageBilling,
// Contact support: Only for Enterprise admins
showContactSupport: isEnterprise && userCanManageBilling,
onSetLimit: handleSetLimit,
onUpgradeToPro: handleUpgradeToPro,
onUpgradeToTeam: handleUpgradeToTeam,
onManageSeats: handleManageSeats,
onUpgradeToEnterprise: handleUpgradeToEnterprise,
onContactSupport: handleContactSupport,
}),
[
isFree,
isPro,
isTeam,
isEnterprise,
userCanManageBilling,
handleSetLimit,
handleUpgradeToPro,
handleUpgradeToTeam,
handleManageSeats,
handleUpgradeToEnterprise,
handleContactSupport,
]
)
// Check if any context menu items will be visible
const hasContextMenuItems =
contextMenuItems.showSetLimit ||
contextMenuItems.showUpgradeToPro ||
contextMenuItems.showUpgradeToTeam ||
contextMenuItems.showManageSeats ||
contextMenuItems.showUpgradeToEnterprise ||
contextMenuItems.showContactSupport
const handleContextMenuWithCheck = useCallback(
(e: React.MouseEvent) => {
if (!hasContextMenuItems) return
handleContextMenu(e)
},
[hasContextMenuItems, handleContextMenu]
)
const [isHovered, setIsHovered] = useState(false)
const [wavePosition, setWavePosition] = useState<number | null>(null)
@@ -359,82 +463,93 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
}
return (
<div
className='group flex flex-shrink-0 cursor-pointer flex-col gap-[8px] border-t px-[13.5px] pt-[8px] pb-[10px]'
onClick={handleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Top row */}
<div className='flex h-[18px] items-center justify-between'>
<div className='flex min-w-0 flex-1 items-center gap-[6px]'>
{showPlanText && (
<>
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-primary)]'>
{PLAN_NAMES[planType]}
</span>
<div className='h-[14px] w-[1.5px] flex-shrink-0 bg-[var(--divider)]' />
</>
)}
<div className='flex min-w-0 flex-1 items-center gap-[4px]'>
{statusText.isError ? (
<span className='font-medium text-[12px] text-[var(--text-error)]'>
{statusText.text}
</span>
) : (
<>
<div
className='group flex flex-shrink-0 cursor-pointer flex-col gap-[8px] border-t px-[13.5px] pt-[8px] pb-[10px]'
onClick={handleClick}
onContextMenu={handleContextMenuWithCheck}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Top row */}
<div className='flex h-[18px] items-center justify-between'>
<div className='flex min-w-0 flex-1 items-center gap-[6px]'>
{showPlanText && (
<>
<span className='font-medium text-[12px] text-[var(--text-secondary)] tabular-nums'>
${usage.current.toFixed(2)}
</span>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>/</span>
<span className='font-medium text-[12px] text-[var(--text-secondary)] tabular-nums'>
${usage.limit.toFixed(2)}
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-primary)]'>
{PLAN_NAMES[planType]}
</span>
<div className='h-[14px] w-[1.5px] flex-shrink-0 bg-[var(--divider)]' />
</>
)}
<div className='flex min-w-0 flex-1 items-center gap-[4px]'>
{statusText.isError ? (
<span className='font-medium text-[12px] text-[var(--text-error)]'>
{statusText.text}
</span>
) : (
<>
<span className='font-medium text-[12px] text-[var(--text-secondary)] tabular-nums'>
${usage.current.toFixed(2)}
</span>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>/</span>
<span className='font-medium text-[12px] text-[var(--text-secondary)] tabular-nums'>
${usage.limit.toFixed(2)}
</span>
</>
)}
</div>
</div>
{badgeConfig.show && (
<Badge variant={badgeConfig.variant} size='sm' className='-translate-y-[1px]'>
{badgeConfig.label}
</Badge>
)}
</div>
{badgeConfig.show && (
<Badge variant={badgeConfig.variant} size='sm' className='-translate-y-[1px]'>
{badgeConfig.label}
</Badge>
)}
</div>
{/* Pills row */}
<div className='flex items-center gap-[4px]'>
{Array.from({ length: pillCount }).map((_, i) => {
const isFilled = i < filledPillsCount
const baseColor = isFilled ? filledColor : USAGE_PILL_COLORS.UNFILLED
{/* Pills row */}
<div className='flex items-center gap-[4px]'>
{Array.from({ length: pillCount }).map((_, i) => {
const isFilled = i < filledPillsCount
const baseColor = isFilled ? filledColor : USAGE_PILL_COLORS.UNFILLED
const backgroundColor = baseColor
let backgroundImage: string | undefined
const backgroundColor = baseColor
let backgroundImage: string | undefined
if (isHovered && wavePosition !== null) {
const headIndex = Math.floor(wavePosition)
const pillOffsetFromStart = i - startAnimationIndex
if (isHovered && wavePosition !== null) {
const headIndex = Math.floor(wavePosition)
const pillOffsetFromStart = i - startAnimationIndex
if (pillOffsetFromStart >= 0 && pillOffsetFromStart < headIndex) {
backgroundImage = `linear-gradient(to right, ${filledColor}, ${filledColor})`
} else if (pillOffsetFromStart === headIndex) {
const fillPercent = (wavePosition - headIndex) * 100
backgroundImage = `linear-gradient(to right, ${filledColor} ${fillPercent}%, ${baseColor} ${fillPercent}%)`
if (pillOffsetFromStart >= 0 && pillOffsetFromStart < headIndex) {
backgroundImage = `linear-gradient(to right, ${filledColor}, ${filledColor})`
} else if (pillOffsetFromStart === headIndex) {
const fillPercent = (wavePosition - headIndex) * 100
backgroundImage = `linear-gradient(to right, ${filledColor} ${fillPercent}%, ${baseColor} ${fillPercent}%)`
}
}
}
return (
<div
key={i}
className='h-[6px] flex-1 rounded-[2px]'
style={{
backgroundColor,
backgroundImage,
transition: isHovered ? 'none' : 'background-color 200ms',
}}
/>
)
})}
return (
<div
key={i}
className='h-[6px] flex-1 rounded-[2px]'
style={{
backgroundColor,
backgroundImage,
transition: isHovered ? 'none' : 'background-color 200ms',
}}
/>
)
})}
</div>
</div>
</div>
<UsageIndicatorContextMenu
isOpen={isContextMenuOpen}
position={contextMenuPosition}
menuRef={contextMenuRef}
onClose={closeContextMenu}
menuItems={contextMenuItems}
/>
</>
)
}

View File

@@ -0,0 +1,93 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
interface EmptyAreaContextMenuProps {
/**
* Whether the context menu is open
*/
isOpen: boolean
/**
* Position of the context menu
*/
position: { x: number; y: number }
/**
* Ref for the menu element
*/
menuRef: React.RefObject<HTMLDivElement | null>
/**
* Callback when menu should close
*/
onClose: () => void
/**
* Callback when create workflow is clicked
*/
onCreateWorkflow: () => void
/**
* Callback when create folder is clicked
*/
onCreateFolder: () => void
/**
* Whether create workflow is disabled
*/
disableCreateWorkflow?: boolean
/**
* Whether create folder is disabled
*/
disableCreateFolder?: boolean
}
/**
* Context menu component for sidebar empty area.
* Displays options to create a workflow or folder when right-clicking on empty space.
*/
export function EmptyAreaContextMenu({
isOpen,
position,
menuRef,
onClose,
onCreateWorkflow,
onCreateFolder,
disableCreateWorkflow = false,
disableCreateFolder = false,
}: EmptyAreaContextMenuProps) {
return (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
<PopoverItem
disabled={disableCreateWorkflow}
onClick={() => {
onCreateWorkflow()
onClose()
}}
>
Create workflow
</PopoverItem>
<PopoverItem
disabled={disableCreateFolder}
onClick={() => {
onCreateFolder()
onClose()
}}
>
Create folder
</PopoverItem>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1 @@
export { EmptyAreaContextMenu } from './empty-area-context-menu'

View File

@@ -3,9 +3,11 @@
import { memo, useCallback, useEffect, useMemo } from 'react'
import clsx from 'clsx'
import { useParams, usePathname } from 'next/navigation'
import { EmptyAreaContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/empty-area-context-menu'
import { FolderItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item'
import { WorkflowItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item'
import {
useContextMenu,
useDragDrop,
useWorkflowSelection,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
@@ -36,6 +38,9 @@ interface WorkflowListProps {
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void
fileInputRef: React.RefObject<HTMLInputElement | null>
scrollContainerRef: React.RefObject<HTMLDivElement | null>
onCreateWorkflow?: () => void
onCreateFolder?: () => void
disableCreate?: boolean
}
const DropIndicatorLine = memo(function DropIndicatorLine({
@@ -63,6 +68,9 @@ export function WorkflowList({
handleFileChange,
fileInputRef,
scrollContainerRef,
onCreateWorkflow,
onCreateFolder,
disableCreate = false,
}: WorkflowListProps) {
const pathname = usePathname()
const params = useParams()
@@ -72,6 +80,14 @@ export function WorkflowList({
const { isLoading: foldersLoading } = useFolders(workspaceId)
const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore()
const {
isOpen: isEmptyAreaMenuOpen,
position: emptyAreaMenuPosition,
menuRef: emptyAreaMenuRef,
handleContextMenu: handleEmptyAreaContextMenu,
closeMenu: closeEmptyAreaMenu,
} = useContextMenu()
const {
dropIndicator,
isDragging,
@@ -351,36 +367,71 @@ export function WorkflowList({
[workflowId]
)
const handleContainerContextMenu = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement
const isOnEmptyArea =
target === e.currentTarget ||
target.classList.contains('space-y-[2px]') ||
target.closest('[data-empty-area]')
if (!isOnEmptyArea) return
if (!onCreateWorkflow && !onCreateFolder) return
handleEmptyAreaContextMenu(e)
},
[handleEmptyAreaContextMenu, onCreateWorkflow, onCreateFolder]
)
return (
<div className='flex min-h-full flex-col pb-[8px]' onClick={handleContainerClick}>
<>
<div
className={clsx('relative flex-1 rounded-[4px]', !hasRootItems && 'min-h-[26px]')}
{...rootDropZoneHandlers}
className='flex min-h-full flex-col pb-[8px]'
onClick={handleContainerClick}
onContextMenu={handleContainerContextMenu}
data-empty-area
>
{/* Root drop target highlight overlay */}
<div
className={clsx(
'pointer-events-none absolute inset-0 z-10 rounded-[4px] transition-opacity duration-75',
showRootInside && isDragging ? 'bg-[#33b4ff1a] opacity-100' : 'opacity-0'
)}
/>
<div className='space-y-[2px]'>
{rootItems.map((item) =>
item.type === 'folder'
? renderFolderSection(item.data as FolderTreeNode, 0, null)
: renderWorkflowItem(item.data as WorkflowMetadata, 0, null)
)}
className={clsx('relative flex-1 rounded-[4px]', !hasRootItems && 'min-h-[26px]')}
{...rootDropZoneHandlers}
data-empty-area
>
{/* Root drop target highlight overlay */}
<div
className={clsx(
'pointer-events-none absolute inset-0 z-10 rounded-[4px] transition-opacity duration-75',
showRootInside && isDragging ? 'bg-[#33b4ff1a] opacity-100' : 'opacity-0'
)}
/>
<div className='space-y-[2px]' data-empty-area>
{rootItems.map((item) =>
item.type === 'folder'
? renderFolderSection(item.data as FolderTreeNode, 0, null)
: renderWorkflowItem(item.data as WorkflowMetadata, 0, null)
)}
</div>
</div>
<input
ref={fileInputRef}
type='file'
accept='.json,.zip'
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</div>
<input
ref={fileInputRef}
type='file'
accept='.json,.zip'
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</div>
{onCreateWorkflow && onCreateFolder && (
<EmptyAreaContextMenu
isOpen={isEmptyAreaMenuOpen}
position={emptyAreaMenuPosition}
menuRef={emptyAreaMenuRef}
onClose={closeEmptyAreaMenu}
onCreateWorkflow={onCreateWorkflow}
onCreateFolder={onCreateFolder}
disableCreateWorkflow={disableCreate}
disableCreateFolder={disableCreate}
/>
)}
</>
)
}

View File

@@ -639,6 +639,9 @@ export function Sidebar() {
handleFileChange={handleImportFileChange}
fileInputRef={fileInputRef}
scrollContainerRef={scrollContainerRef}
onCreateWorkflow={handleCreateWorkflow}
onCreateFolder={handleCreateFolder}
disableCreate={!canEdit || isCreatingWorkflow || isCreatingFolder}
/>
</div>
</div>