mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement(sidebar): auto-scroll (#2312)
* fix(sidebar): re-render auto-scroll * improvement: sidebar-scrolling listener
This commit is contained in:
@@ -10,6 +10,7 @@ import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { searchItems } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils'
|
||||
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
|
||||
interface SearchModalProps {
|
||||
@@ -430,6 +431,12 @@ export function SearchModal({
|
||||
window.open(item.href, '_blank', 'noopener,noreferrer')
|
||||
} else {
|
||||
router.push(item.href)
|
||||
// Scroll to the workflow in the sidebar after navigation
|
||||
if (item.type === 'workflow') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: item.id } })
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useItemDrag,
|
||||
useItemRename,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
||||
import { useDeleteFolder, useDuplicateFolder } from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
|
||||
import { useCreateWorkflow } from '@/hooks/queries/workflows'
|
||||
@@ -87,6 +88,10 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
|
||||
if (result.id) {
|
||||
router.push(`/workspace/${workspaceId}/w/${result.id}`)
|
||||
// Scroll to the newly created workflow
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: result.id } })
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
// Error already handled by mutation's onError callback
|
||||
@@ -100,11 +105,17 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
*/
|
||||
const handleCreateFolderInFolder = useCallback(async () => {
|
||||
try {
|
||||
await createFolderMutation.mutateAsync({
|
||||
const result = await createFolderMutation.mutateAsync({
|
||||
workspaceId,
|
||||
name: 'New Folder',
|
||||
parentId: folder.id,
|
||||
})
|
||||
if (result.id) {
|
||||
// Scroll to the newly created folder
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: result.id } })
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create folder:', error)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { useParams, usePathname } from 'next/navigation'
|
||||
import { FolderItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item'
|
||||
@@ -144,11 +144,8 @@ export function WorkflowList({
|
||||
[pathname, workspaceId]
|
||||
)
|
||||
|
||||
// Track last scrolled workflow to avoid redundant scroll checks
|
||||
const lastScrolledWorkflowRef = useRef<string | null>(null)
|
||||
|
||||
/**
|
||||
* Auto-expand folders, select active workflow, and scroll into view if needed.
|
||||
* Auto-expand folders and select active workflow.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!workflowId || isLoading || foldersLoading) return
|
||||
@@ -164,25 +161,6 @@ export function WorkflowList({
|
||||
if (!selectedWorkflows.has(workflowId)) {
|
||||
selectOnly(workflowId)
|
||||
}
|
||||
|
||||
// Skip scroll check if already handled for this workflow
|
||||
if (lastScrolledWorkflowRef.current === workflowId) return
|
||||
lastScrolledWorkflowRef.current = workflowId
|
||||
|
||||
// Scroll after render only if element is completely off-screen
|
||||
requestAnimationFrame(() => {
|
||||
const element = document.querySelector(`[data-item-id="${workflowId}"]`)
|
||||
const container = scrollContainerRef.current
|
||||
if (!element || !container) return
|
||||
|
||||
const { top: elTop, bottom: elBottom } = element.getBoundingClientRect()
|
||||
const { top: ctTop, bottom: ctBottom } = container.getBoundingClientRect()
|
||||
|
||||
// Only scroll if completely above or below the visible area
|
||||
if (elBottom <= ctTop || elTop >= ctBottom) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
})
|
||||
}, [workflowId, activeWorkflowFolderId, isLoading, foldersLoading, getFolderPath, setExpanded])
|
||||
|
||||
const renderWorkflowItem = useCallback(
|
||||
|
||||
@@ -34,9 +34,13 @@ import { useSearchModalStore } from '@/stores/search-modal/store'
|
||||
import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store'
|
||||
|
||||
const logger = createLogger('Sidebar')
|
||||
// Feature flag: Billing usage indicator visibility (matches legacy sidebar behavior)
|
||||
|
||||
/** Feature flag for billing usage indicator visibility */
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
|
||||
/** Event name for sidebar scroll operations - centralized for consistency */
|
||||
export const SIDEBAR_SCROLL_EVENT = 'sidebar-scroll-to-item'
|
||||
|
||||
/**
|
||||
* Sidebar component with resizable width that persists across page refreshes.
|
||||
*
|
||||
@@ -60,63 +64,79 @@ export function Sidebar() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Session data
|
||||
const { data: sessionData, isPending: sessionLoading } = useSession()
|
||||
|
||||
// Sidebar state - use store's hydration tracking to prevent SSR mismatch
|
||||
/**
|
||||
* Sidebar state from store with hydration tracking to prevent SSR mismatch.
|
||||
* Uses default (expanded) state until hydrated.
|
||||
*/
|
||||
const hasHydrated = useSidebarStore((state) => state._hasHydrated)
|
||||
const isCollapsedStore = useSidebarStore((state) => state.isCollapsed)
|
||||
const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed)
|
||||
const setSidebarWidth = useSidebarStore((state) => state.setSidebarWidth)
|
||||
|
||||
// Use default (expanded) state until hydrated to prevent hydration mismatch
|
||||
const isCollapsed = hasHydrated ? isCollapsedStore : false
|
||||
|
||||
// Determine if we're on a workflow page (only workflow pages allow collapse and resize)
|
||||
const isOnWorkflowPage = !!workflowId
|
||||
|
||||
// Import state
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
|
||||
// Workspace import input ref
|
||||
const workspaceFileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Workspace import hook
|
||||
const { isImporting: isImportingWorkspace, handleImportWorkspace: importWorkspace } =
|
||||
useImportWorkspace()
|
||||
const { handleExportWorkspace: exportWorkspace } = useExportWorkspace()
|
||||
|
||||
// Workspace export hook
|
||||
const { isExporting: isExportingWorkspace, handleExportWorkspace: exportWorkspace } =
|
||||
useExportWorkspace()
|
||||
|
||||
// Workspace popover state
|
||||
const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false)
|
||||
|
||||
// Footer navigation modal state
|
||||
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false)
|
||||
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false)
|
||||
|
||||
// Listen for external events to open help modal
|
||||
/** Listens for external events to open help modal */
|
||||
useEffect(() => {
|
||||
const handleOpenHelpModal = () => setIsHelpModalOpen(true)
|
||||
window.addEventListener('open-help-modal', handleOpenHelpModal)
|
||||
return () => window.removeEventListener('open-help-modal', handleOpenHelpModal)
|
||||
}, [])
|
||||
|
||||
// Global search modal state
|
||||
/** Listens for scroll events and scrolls items into view if off-screen */
|
||||
useEffect(() => {
|
||||
const handleScrollToItem = (e: CustomEvent<{ itemId: string }>) => {
|
||||
const { itemId } = e.detail
|
||||
if (!itemId) return
|
||||
|
||||
const tryScroll = (retriesLeft: number) => {
|
||||
requestAnimationFrame(() => {
|
||||
const element = document.querySelector(`[data-item-id="${itemId}"]`)
|
||||
const container = scrollContainerRef.current
|
||||
|
||||
if (!element || !container) {
|
||||
if (retriesLeft > 0) tryScroll(retriesLeft - 1)
|
||||
return
|
||||
}
|
||||
|
||||
const { top: elTop, bottom: elBottom } = element.getBoundingClientRect()
|
||||
const { top: ctTop, bottom: ctBottom } = container.getBoundingClientRect()
|
||||
|
||||
if (elBottom <= ctTop || elTop >= ctBottom) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
tryScroll(10)
|
||||
}
|
||||
window.addEventListener(SIDEBAR_SCROLL_EVENT, handleScrollToItem as EventListener)
|
||||
return () =>
|
||||
window.removeEventListener(SIDEBAR_SCROLL_EVENT, handleScrollToItem as EventListener)
|
||||
}, [])
|
||||
|
||||
const {
|
||||
isOpen: isSearchModalOpen,
|
||||
setOpen: setIsSearchModalOpen,
|
||||
open: openSearchModal,
|
||||
} = useSearchModalStore()
|
||||
|
||||
// Workspace management hook
|
||||
const {
|
||||
workspaces,
|
||||
activeWorkspace,
|
||||
isWorkspacesLoading,
|
||||
fetchWorkspaces,
|
||||
isWorkspaceValid,
|
||||
switchWorkspace,
|
||||
handleCreateWorkspace,
|
||||
isCreatingWorkspace,
|
||||
@@ -127,10 +147,8 @@ export function Sidebar() {
|
||||
sessionUserId: sessionData?.user?.id,
|
||||
})
|
||||
|
||||
// Sidebar resize hook
|
||||
const { handleMouseDown } = useSidebarResize()
|
||||
|
||||
// Workflow operations hook
|
||||
const {
|
||||
regularWorkflows,
|
||||
workflowsLoading,
|
||||
@@ -138,17 +156,14 @@ export function Sidebar() {
|
||||
handleCreateWorkflow: createWorkflow,
|
||||
} = useWorkflowOperations({ workspaceId })
|
||||
|
||||
// Folder operations hook
|
||||
const { isCreatingFolder, handleCreateFolder: createFolder } = useFolderOperations({
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
// Duplicate workspace hook
|
||||
const { handleDuplicateWorkspace: duplicateWorkspace } = useDuplicateWorkspace({
|
||||
getWorkspaceId: () => workspaceId,
|
||||
})
|
||||
|
||||
// Prepare data for search modal
|
||||
const searchModalWorkflows = useMemo(
|
||||
() =>
|
||||
regularWorkflows.map((workflow) => ({
|
||||
@@ -172,7 +187,6 @@ export function Sidebar() {
|
||||
[workspaces, workspaceId]
|
||||
)
|
||||
|
||||
// Footer navigation items
|
||||
const footerNavigationItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -209,116 +223,85 @@ export function Sidebar() {
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
// Combined loading state
|
||||
const isLoading = workflowsLoading || sessionLoading
|
||||
const initialScrollDoneRef = useRef<string | null>(null)
|
||||
|
||||
/**
|
||||
* Scrolls a newly created element into view if completely off-screen.
|
||||
* Uses requestAnimationFrame to sync with render, then scrolls.
|
||||
*/
|
||||
const scrollToElement = useCallback((elementId: string) => {
|
||||
/** Scrolls to active workflow on initial load or workspace switch */
|
||||
useEffect(() => {
|
||||
if (!workflowId || workflowsLoading || initialScrollDoneRef.current === workflowId) return
|
||||
initialScrollDoneRef.current = workflowId
|
||||
requestAnimationFrame(() => {
|
||||
const element = document.querySelector(`[data-item-id="${elementId}"]`)
|
||||
const container = scrollContainerRef.current
|
||||
if (!element || !container) return
|
||||
|
||||
const { top: elTop, bottom: elBottom } = element.getBoundingClientRect()
|
||||
const { top: ctTop, bottom: ctBottom } = container.getBoundingClientRect()
|
||||
|
||||
// Only scroll if element is completely off-screen
|
||||
if (elBottom <= ctTop || elTop >= ctBottom) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: workflowId } })
|
||||
)
|
||||
})
|
||||
}, [])
|
||||
}, [workflowId, workflowsLoading])
|
||||
|
||||
/**
|
||||
* Force sidebar to minimum width and ensure it's expanded when not on a workflow page
|
||||
*/
|
||||
/** Forces sidebar to minimum width and ensures it's expanded when not on a workflow page */
|
||||
useEffect(() => {
|
||||
if (!isOnWorkflowPage) {
|
||||
// Ensure sidebar is always expanded on non-workflow pages
|
||||
if (isCollapsed) {
|
||||
setIsCollapsed(false)
|
||||
}
|
||||
// Force sidebar to minimum width
|
||||
setSidebarWidth(MIN_SIDEBAR_WIDTH)
|
||||
}
|
||||
}, [isOnWorkflowPage, isCollapsed, setIsCollapsed, setSidebarWidth])
|
||||
|
||||
/**
|
||||
* Handle create workflow - creates workflow and scrolls to it
|
||||
*/
|
||||
/** Creates a workflow and scrolls to it */
|
||||
const handleCreateWorkflow = useCallback(async () => {
|
||||
const workflowId = await createWorkflow()
|
||||
if (workflowId) {
|
||||
scrollToElement(workflowId)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: workflowId } })
|
||||
)
|
||||
}
|
||||
}, [createWorkflow, scrollToElement])
|
||||
}, [createWorkflow])
|
||||
|
||||
/**
|
||||
* Handle create folder - creates folder and scrolls to it
|
||||
*/
|
||||
/** Creates a folder and scrolls to it */
|
||||
const handleCreateFolder = useCallback(async () => {
|
||||
const folderId = await createFolder()
|
||||
if (folderId) {
|
||||
scrollToElement(folderId)
|
||||
window.dispatchEvent(new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: folderId } }))
|
||||
}
|
||||
}, [createFolder, scrollToElement])
|
||||
}, [createFolder])
|
||||
|
||||
/**
|
||||
* Handle import workflow button click - triggers file input
|
||||
*/
|
||||
/** Triggers file input for workflow import */
|
||||
const handleImportWorkflow = useCallback(() => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click()
|
||||
}
|
||||
fileInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle workspace switch from popover menu
|
||||
*/
|
||||
/** Handles workspace switch from popover menu */
|
||||
const handleWorkspaceSwitch = useCallback(
|
||||
async (workspace: { id: string; name: string; ownerId: string; role?: string }) => {
|
||||
if (workspace.id === workspaceId) {
|
||||
setIsWorkspaceMenuOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
await switchWorkspace(workspace)
|
||||
setIsWorkspaceMenuOpen(false)
|
||||
},
|
||||
[workspaceId, switchWorkspace]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle sidebar collapse toggle
|
||||
*/
|
||||
/** Toggles sidebar collapse state */
|
||||
const handleToggleCollapse = useCallback(() => {
|
||||
setIsCollapsed(!isCollapsed)
|
||||
}, [isCollapsed, setIsCollapsed])
|
||||
|
||||
/**
|
||||
* Handle click on sidebar elements to revert to active workflow selection
|
||||
*/
|
||||
/** Reverts to active workflow selection when clicking sidebar background */
|
||||
const handleSidebarClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
const target = e.target as HTMLElement
|
||||
// Revert to active workflow selection if clicking on sidebar background, header, or search area
|
||||
// But not on interactive elements like buttons or links
|
||||
if (target.tagName === 'BUTTON' || target.closest('button, [role="button"], a')) {
|
||||
return
|
||||
}
|
||||
|
||||
const { selectOnly, clearSelection } = useFolderStore.getState()
|
||||
workflowId ? selectOnly(workflowId) : clearSelection()
|
||||
},
|
||||
[workflowId]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle workspace rename
|
||||
*/
|
||||
/** Renames a workspace */
|
||||
const handleRenameWorkspace = useCallback(
|
||||
async (workspaceIdToRename: string, newName: string) => {
|
||||
await updateWorkspaceName(workspaceIdToRename, newName)
|
||||
@@ -326,9 +309,7 @@ export function Sidebar() {
|
||||
[updateWorkspaceName]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle workspace delete
|
||||
*/
|
||||
/** Deletes a workspace */
|
||||
const handleDeleteWorkspace = useCallback(
|
||||
async (workspaceIdToDelete: string) => {
|
||||
const workspaceToDelete = workspaces.find((w) => w.id === workspaceIdToDelete)
|
||||
@@ -339,9 +320,7 @@ export function Sidebar() {
|
||||
[workspaces, confirmDeleteWorkspace]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle workspace duplicate
|
||||
*/
|
||||
/** Duplicates a workspace */
|
||||
const handleDuplicateWorkspace = useCallback(
|
||||
async (_workspaceIdToDuplicate: string, workspaceName: string) => {
|
||||
await duplicateWorkspace(workspaceName)
|
||||
@@ -349,9 +328,7 @@ export function Sidebar() {
|
||||
[duplicateWorkspace]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle workspace export
|
||||
*/
|
||||
/** Exports a workspace */
|
||||
const handleExportWorkspace = useCallback(
|
||||
async (workspaceIdToExport: string, workspaceName: string) => {
|
||||
await exportWorkspace(workspaceIdToExport, workspaceName)
|
||||
@@ -359,18 +336,12 @@ export function Sidebar() {
|
||||
[exportWorkspace]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle workspace import button click
|
||||
*/
|
||||
/** Triggers file input for workspace import */
|
||||
const handleImportWorkspace = useCallback(() => {
|
||||
if (workspaceFileInputRef.current) {
|
||||
workspaceFileInputRef.current.click()
|
||||
}
|
||||
workspaceFileInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle workspace import file change
|
||||
*/
|
||||
/** Handles workspace import file selection */
|
||||
const handleWorkspaceFileChange = useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files
|
||||
@@ -379,7 +350,6 @@ export function Sidebar() {
|
||||
const zipFile = files[0]
|
||||
await importWorkspace(zipFile)
|
||||
|
||||
// Reset file input
|
||||
if (event.target) {
|
||||
event.target.value = ''
|
||||
}
|
||||
@@ -387,12 +357,7 @@ export function Sidebar() {
|
||||
[importWorkspace]
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolve a workspace id from either params or the current URL path.
|
||||
*
|
||||
* This mirrors existing behavior but is wrapped in a helper to keep command
|
||||
* handlers small and focused.
|
||||
*/
|
||||
/** Resolves workspace ID from params or URL path */
|
||||
const resolveWorkspaceIdFromPath = useCallback((): string | undefined => {
|
||||
if (workspaceId) return workspaceId
|
||||
if (typeof window === 'undefined') return undefined
|
||||
@@ -404,12 +369,7 @@ export function Sidebar() {
|
||||
return parts[idx + 1]
|
||||
}, [workspaceId])
|
||||
|
||||
/**
|
||||
* Register global sidebar commands using the central commands registry.
|
||||
*
|
||||
* Only commands declared in the registry can be registered here. The
|
||||
* registry owns ids and shortcut strings; this component supplies handlers.
|
||||
*/
|
||||
/** Registers global sidebar commands with the central commands registry */
|
||||
useRegisterGlobalCommands(() =>
|
||||
createCommands([
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user