mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
improvement: loading, optimistic actions (#2193)
* improvement: loading, optimistic operations * improvement: folders update * fix usage indicator rounding + new tsconfig * remove redundant checks * fix hmr case for missing workflow loads * add abstraction for zustand/react hybrid optimism * remove comments --------- Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
This commit is contained in:
@@ -11,6 +11,7 @@ import ReactFlow, {
|
||||
useReactFlow,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { OAuthProvider } from '@/lib/oauth'
|
||||
@@ -1276,7 +1277,7 @@ const WorkflowContent = React.memo(() => {
|
||||
[screenToFlowPosition, isPointInLoopNode, getNodes]
|
||||
)
|
||||
|
||||
// Initialize workflow when it exists in registry and isn't active
|
||||
// Initialize workflow when it exists in registry and isn't active or needs hydration
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const currentId = params.workflowId as string
|
||||
@@ -1294,8 +1295,16 @@ const WorkflowContent = React.memo(() => {
|
||||
return
|
||||
}
|
||||
|
||||
if (activeWorkflowId !== currentId) {
|
||||
// Clear diff and set as active
|
||||
// Check if we need to load the workflow state:
|
||||
// 1. Different workflow than currently active
|
||||
// 2. Same workflow but hydration phase is not 'ready' (e.g., after a quick refresh)
|
||||
const needsWorkflowLoad =
|
||||
activeWorkflowId !== currentId ||
|
||||
(activeWorkflowId === currentId &&
|
||||
hydration.phase !== 'ready' &&
|
||||
hydration.phase !== 'state-loading')
|
||||
|
||||
if (needsWorkflowLoad) {
|
||||
const { clearDiff } = useWorkflowDiffStore.getState()
|
||||
clearDiff()
|
||||
|
||||
@@ -2216,7 +2225,11 @@ const WorkflowContent = React.memo(() => {
|
||||
return (
|
||||
<div className='flex h-screen w-full flex-col overflow-hidden'>
|
||||
<div className='relative h-full w-full flex-1 transition-all duration-200'>
|
||||
<div className='workflow-container h-full' />
|
||||
<div className='workflow-container flex h-full items-center justify-center'>
|
||||
<div className='flex flex-col items-center gap-3'>
|
||||
<Loader2 className='h-[24px] w-[24px] animate-spin text-muted-foreground' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Panel />
|
||||
<Terminal />
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
export interface RotatingDigitProps {
|
||||
value: number | string
|
||||
height?: number
|
||||
width?: number
|
||||
className?: string
|
||||
textClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* RotatingDigit component for displaying numbers with a rolling animation effect.
|
||||
* Useful for live-updating metrics like usage, pricing, or counters.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <RotatingDigit value={123.45} height={14} width={8} />
|
||||
* ```
|
||||
*/
|
||||
export function RotatingDigit({
|
||||
value,
|
||||
height = 14, // Default to match text size
|
||||
width = 8,
|
||||
className,
|
||||
textClassName,
|
||||
}: RotatingDigitProps) {
|
||||
const parts =
|
||||
typeof value === 'number' ? value.toFixed(2).split('') : (value as string).toString().split('')
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center overflow-hidden', className)} style={{ height }}>
|
||||
{parts.map((part: string, index: number) => {
|
||||
if (/[0-9]/.test(part)) {
|
||||
return (
|
||||
<SingleDigit
|
||||
key={`${index}-${parts.length}`} // Key by index and length to reset if length changes
|
||||
digit={Number.parseInt(part, 10)}
|
||||
height={height}
|
||||
width={width}
|
||||
className={textClassName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`${index}-${part}`}
|
||||
className={cn('flex items-center justify-center', textClassName)}
|
||||
style={{ height, width: width / 2 }}
|
||||
>
|
||||
{part}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SingleDigit({
|
||||
digit,
|
||||
height,
|
||||
width,
|
||||
className,
|
||||
}: {
|
||||
digit: number
|
||||
height: number
|
||||
width: number
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className='relative overflow-hidden' style={{ height, width }}>
|
||||
<div
|
||||
className='absolute top-0 left-0 flex flex-col will-change-transform'
|
||||
style={{
|
||||
transform: `translateY(-${digit * height}px)`,
|
||||
transition: 'transform 500ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}}
|
||||
>
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((num) => (
|
||||
<div
|
||||
key={num}
|
||||
className={cn('flex items-center justify-center', className)}
|
||||
style={{ height, width }}
|
||||
>
|
||||
{num}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
getUsage,
|
||||
} from '@/lib/billing/client/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { RotatingDigit } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/rotating-digit'
|
||||
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store'
|
||||
@@ -272,18 +271,12 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className='flex items-center font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
<span className='mr-[1px]'>$</span>
|
||||
<RotatingDigit
|
||||
value={usage.current}
|
||||
height={14}
|
||||
width={7}
|
||||
textClassName='font-medium text-[12px] text-[var(--text-tertiary)] tabular-nums'
|
||||
/>
|
||||
</div>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)] tabular-nums'>
|
||||
${usage.current.toFixed(2)}
|
||||
</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>/</span>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)] tabular-nums'>
|
||||
${usage.limit}
|
||||
${usage.limit.toFixed(2)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -81,6 +81,11 @@ interface ContextMenuProps {
|
||||
* Set to true when user lacks permissions
|
||||
*/
|
||||
disableDelete?: boolean
|
||||
/**
|
||||
* Whether the create option is disabled (default: false)
|
||||
* Set to true when creation is in progress or user lacks permissions
|
||||
*/
|
||||
disableCreate?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,6 +113,7 @@ export function ContextMenu({
|
||||
disableRename = false,
|
||||
disableDuplicate = false,
|
||||
disableDelete = false,
|
||||
disableCreate = false,
|
||||
}: ContextMenuProps) {
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose}>
|
||||
@@ -125,10 +131,8 @@ export function ContextMenu({
|
||||
<PopoverItem
|
||||
disabled={disableRename}
|
||||
onClick={() => {
|
||||
if (!disableRename) {
|
||||
onRename()
|
||||
onClose()
|
||||
}
|
||||
onRename()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-3 w-3' />
|
||||
@@ -137,6 +141,7 @@ export function ContextMenu({
|
||||
)}
|
||||
{showCreate && onCreate && (
|
||||
<PopoverItem
|
||||
disabled={disableCreate}
|
||||
onClick={() => {
|
||||
onCreate()
|
||||
onClose()
|
||||
@@ -150,10 +155,8 @@ export function ContextMenu({
|
||||
<PopoverItem
|
||||
disabled={disableDuplicate}
|
||||
onClick={() => {
|
||||
if (!disableDuplicate) {
|
||||
onDuplicate()
|
||||
onClose()
|
||||
}
|
||||
onDuplicate()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
@@ -164,10 +167,8 @@ export function ContextMenu({
|
||||
<PopoverItem
|
||||
disabled={disableExport}
|
||||
onClick={() => {
|
||||
if (!disableExport) {
|
||||
onExport()
|
||||
onClose()
|
||||
}
|
||||
onExport()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<ArrowUp className='h-3 w-3' />
|
||||
@@ -177,10 +178,8 @@ export function ContextMenu({
|
||||
<PopoverItem
|
||||
disabled={disableDelete}
|
||||
onClick={() => {
|
||||
if (!disableDelete) {
|
||||
onDelete()
|
||||
onClose()
|
||||
}
|
||||
onDelete()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<Trash className='h-3 w-3' />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronRight, Folder, FolderOpen } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu'
|
||||
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/delete-modal/delete-modal'
|
||||
@@ -17,6 +18,12 @@ import { useDeleteFolder, useDuplicateFolder } from '@/app/workspace/[workspaceI
|
||||
import { useUpdateFolder } from '@/hooks/queries/folders'
|
||||
import { useCreateWorkflow } from '@/hooks/queries/workflows'
|
||||
import type { FolderTreeNode } from '@/stores/folders/store'
|
||||
import {
|
||||
generateCreativeWorkflowName,
|
||||
getNextWorkflowColor,
|
||||
} from '@/stores/workflows/registry/utils'
|
||||
|
||||
const logger = createLogger('FolderItem')
|
||||
|
||||
interface FolderItemProps {
|
||||
folder: FolderTreeNode
|
||||
@@ -60,16 +67,29 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
})
|
||||
|
||||
/**
|
||||
* Handle create workflow in folder using React Query mutation
|
||||
* Handle create workflow in folder using React Query mutation.
|
||||
* Generates name and color upfront for optimistic UI updates.
|
||||
* The UI disables the trigger when isPending, so no guard needed here.
|
||||
*/
|
||||
const handleCreateWorkflowInFolder = useCallback(async () => {
|
||||
const result = await createWorkflowMutation.mutateAsync({
|
||||
workspaceId,
|
||||
folderId: folder.id,
|
||||
})
|
||||
try {
|
||||
// Generate name and color upfront for optimistic updates
|
||||
const name = generateCreativeWorkflowName()
|
||||
const color = getNextWorkflowColor()
|
||||
|
||||
if (result.id) {
|
||||
router.push(`/workspace/${workspaceId}/w/${result.id}`)
|
||||
const result = await createWorkflowMutation.mutateAsync({
|
||||
workspaceId,
|
||||
folderId: folder.id,
|
||||
name,
|
||||
color,
|
||||
})
|
||||
|
||||
if (result.id) {
|
||||
router.push(`/workspace/${workspaceId}/w/${result.id}`)
|
||||
}
|
||||
} catch (error) {
|
||||
// Error already handled by mutation's onError callback
|
||||
logger.error('Failed to create workflow in folder:', error)
|
||||
}
|
||||
}, [createWorkflowMutation, workspaceId, folder.id, router])
|
||||
|
||||
@@ -263,6 +283,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
onDelete={() => setIsDeleteModalOpen(true)}
|
||||
showCreate={true}
|
||||
disableRename={!userPermissions.canEdit}
|
||||
disableCreate={!userPermissions.canEdit || createWorkflowMutation.isPending}
|
||||
disableDuplicate={!userPermissions.canEdit}
|
||||
disableDelete={!userPermissions.canEdit}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateFolderName } from '@/lib/workspaces/naming'
|
||||
import { useCreateFolder } from '@/hooks/queries/folders'
|
||||
@@ -12,25 +12,20 @@ interface UseFolderOperationsProps {
|
||||
/**
|
||||
* Custom hook to manage folder operations including creating folders.
|
||||
* Handles folder name generation and state management.
|
||||
* Uses React Query mutation's isPending state for immediate loading feedback.
|
||||
*
|
||||
* @param props - Configuration object containing workspaceId
|
||||
* @returns Folder operations state and handlers
|
||||
*/
|
||||
export function useFolderOperations({ workspaceId }: UseFolderOperationsProps) {
|
||||
const createFolderMutation = useCreateFolder()
|
||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false)
|
||||
|
||||
/**
|
||||
* Create folder handler - creates folder with auto-generated name
|
||||
*/
|
||||
const handleCreateFolder = useCallback(async (): Promise<string | null> => {
|
||||
if (isCreatingFolder || !workspaceId) {
|
||||
logger.info('Folder creation already in progress or no workspaceId available')
|
||||
if (!workspaceId) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
setIsCreatingFolder(true)
|
||||
const folderName = await generateFolderName(workspaceId)
|
||||
const folder = await createFolderMutation.mutateAsync({ name: folderName, workspaceId })
|
||||
logger.info(`Created folder: ${folderName}`)
|
||||
@@ -38,14 +33,12 @@ export function useFolderOperations({ workspaceId }: UseFolderOperationsProps) {
|
||||
} catch (error) {
|
||||
logger.error('Failed to create folder:', { error })
|
||||
return null
|
||||
} finally {
|
||||
setIsCreatingFolder(false)
|
||||
}
|
||||
}, [createFolderMutation, workspaceId, isCreatingFolder])
|
||||
}, [createFolderMutation, workspaceId])
|
||||
|
||||
return {
|
||||
// State
|
||||
isCreatingFolder,
|
||||
isCreatingFolder: createFolderMutation.isPending,
|
||||
|
||||
// Operations
|
||||
handleCreateFolder,
|
||||
|
||||
@@ -1,35 +1,25 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useCreateWorkflow, useWorkflows } from '@/hooks/queries/workflows'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import {
|
||||
generateCreativeWorkflowName,
|
||||
getNextWorkflowColor,
|
||||
} from '@/stores/workflows/registry/utils'
|
||||
|
||||
const logger = createLogger('useWorkflowOperations')
|
||||
|
||||
interface UseWorkflowOperationsProps {
|
||||
workspaceId: string
|
||||
isWorkspaceValid: (workspaceId: string) => Promise<boolean>
|
||||
onWorkspaceInvalid: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to manage workflow operations including creating and loading workflows.
|
||||
* Handles workflow state management and navigation.
|
||||
*
|
||||
* @param props - Configuration object containing workspaceId and validation handlers
|
||||
* @returns Workflow operations state and handlers
|
||||
*/
|
||||
export function useWorkflowOperations({
|
||||
workspaceId,
|
||||
isWorkspaceValid,
|
||||
onWorkspaceInvalid,
|
||||
}: UseWorkflowOperationsProps) {
|
||||
export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProps) {
|
||||
const router = useRouter()
|
||||
const { workflows } = useWorkflowRegistry()
|
||||
const workflowsQuery = useWorkflows(workspaceId)
|
||||
const createWorkflowMutation = useCreateWorkflow()
|
||||
const [isCreatingWorkflow, setIsCreatingWorkflow] = useState(false)
|
||||
|
||||
/**
|
||||
* Filter and sort workflows for the current workspace
|
||||
@@ -41,29 +31,20 @@ export function useWorkflowOperations({
|
||||
return b.createdAt.getTime() - a.createdAt.getTime()
|
||||
})
|
||||
|
||||
/**
|
||||
* Create workflow handler - creates workflow and navigates to it
|
||||
* Now uses React Query mutation for better performance and caching
|
||||
*/
|
||||
const handleCreateWorkflow = useCallback(async (): Promise<string | null> => {
|
||||
if (isCreatingWorkflow) {
|
||||
logger.info('Workflow creation already in progress, ignoring request')
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
setIsCreatingWorkflow(true)
|
||||
|
||||
// Clear workflow diff store when creating a new workflow
|
||||
const { clearDiff } = useWorkflowDiffStore.getState()
|
||||
clearDiff()
|
||||
|
||||
// Use React Query mutation for creation
|
||||
const name = generateCreativeWorkflowName()
|
||||
const color = getNextWorkflowColor()
|
||||
|
||||
const result = await createWorkflowMutation.mutateAsync({
|
||||
workspaceId: workspaceId,
|
||||
workspaceId,
|
||||
name,
|
||||
color,
|
||||
})
|
||||
|
||||
// Navigate to the newly created workflow
|
||||
if (result.id) {
|
||||
router.push(`/workspace/${workspaceId}/w/${result.id}`)
|
||||
return result.id
|
||||
@@ -72,17 +53,15 @@ export function useWorkflowOperations({
|
||||
} catch (error) {
|
||||
logger.error('Error creating workflow:', error)
|
||||
return null
|
||||
} finally {
|
||||
setIsCreatingWorkflow(false)
|
||||
}
|
||||
}, [isCreatingWorkflow, createWorkflowMutation, workspaceId, router])
|
||||
}, [createWorkflowMutation, workspaceId, router])
|
||||
|
||||
return {
|
||||
// State
|
||||
workflows,
|
||||
regularWorkflows,
|
||||
workflowsLoading: workflowsQuery.isLoading,
|
||||
isCreatingWorkflow,
|
||||
isCreatingWorkflow: createWorkflowMutation.isPending,
|
||||
|
||||
// Operations
|
||||
handleCreateWorkflow,
|
||||
|
||||
@@ -118,11 +118,7 @@ export function SidebarNew() {
|
||||
workflowsLoading,
|
||||
isCreatingWorkflow,
|
||||
handleCreateWorkflow: createWorkflow,
|
||||
} = useWorkflowOperations({
|
||||
workspaceId,
|
||||
isWorkspaceValid,
|
||||
onWorkspaceInvalid: fetchWorkspaces,
|
||||
})
|
||||
} = useWorkflowOperations({ workspaceId })
|
||||
|
||||
// Folder operations hook
|
||||
const { isCreatingFolder, handleCreateFolder: createFolder } = useFolderOperations({
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useDuplicateWorkflowMutation } from '@/hooks/queries/workflows'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { getNextWorkflowColor } from '@/stores/workflows/registry/utils'
|
||||
|
||||
const logger = createLogger('useDuplicateWorkflow')
|
||||
|
||||
@@ -23,11 +25,12 @@ interface UseDuplicateWorkflowProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing workflow duplication.
|
||||
* Hook for managing workflow duplication with optimistic updates.
|
||||
*
|
||||
* Handles:
|
||||
* - Single or bulk workflow duplication
|
||||
* - Calling duplicate API for each workflow
|
||||
* - Optimistic UI updates (shows new workflow immediately)
|
||||
* - Automatic rollback on failure
|
||||
* - Loading state management
|
||||
* - Error handling and logging
|
||||
* - Clearing selection after duplication
|
||||
@@ -42,38 +45,49 @@ export function useDuplicateWorkflow({
|
||||
onSuccess,
|
||||
}: UseDuplicateWorkflowProps) {
|
||||
const router = useRouter()
|
||||
const { duplicateWorkflow } = useWorkflowRegistry()
|
||||
const [isDuplicating, setIsDuplicating] = useState(false)
|
||||
const { workflows } = useWorkflowRegistry()
|
||||
const duplicateMutation = useDuplicateWorkflowMutation()
|
||||
|
||||
/**
|
||||
* Duplicate the workflow(s)
|
||||
*/
|
||||
const handleDuplicateWorkflow = useCallback(async () => {
|
||||
if (isDuplicating) {
|
||||
if (duplicateMutation.isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDuplicating(true)
|
||||
// Get fresh workflow IDs at duplication time
|
||||
const workflowIdsOrId = getWorkflowIds()
|
||||
if (!workflowIdsOrId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize to array for consistent handling
|
||||
const workflowIdsToDuplicate = Array.isArray(workflowIdsOrId)
|
||||
? workflowIdsOrId
|
||||
: [workflowIdsOrId]
|
||||
|
||||
const duplicatedIds: string[] = []
|
||||
|
||||
try {
|
||||
// Get fresh workflow IDs at duplication time
|
||||
const workflowIdsOrId = getWorkflowIds()
|
||||
if (!workflowIdsOrId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize to array for consistent handling
|
||||
const workflowIdsToDuplicate = Array.isArray(workflowIdsOrId)
|
||||
? workflowIdsOrId
|
||||
: [workflowIdsOrId]
|
||||
|
||||
const duplicatedIds: string[] = []
|
||||
|
||||
// Duplicate each workflow sequentially
|
||||
for (const workflowId of workflowIdsToDuplicate) {
|
||||
const newWorkflowId = await duplicateWorkflow(workflowId)
|
||||
if (newWorkflowId) {
|
||||
duplicatedIds.push(newWorkflowId)
|
||||
for (const sourceId of workflowIdsToDuplicate) {
|
||||
const sourceWorkflow = workflows[sourceId]
|
||||
if (!sourceWorkflow) {
|
||||
logger.warn(`Workflow ${sourceId} not found, skipping`)
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await duplicateMutation.mutateAsync({
|
||||
workspaceId,
|
||||
sourceId,
|
||||
name: `${sourceWorkflow.name} (Copy)`,
|
||||
description: sourceWorkflow.description,
|
||||
color: getNextWorkflowColor(),
|
||||
folderId: sourceWorkflow.folderId,
|
||||
})
|
||||
|
||||
duplicatedIds.push(result.id)
|
||||
}
|
||||
|
||||
// Clear selection after successful duplication
|
||||
@@ -94,13 +108,11 @@ export function useDuplicateWorkflow({
|
||||
} catch (error) {
|
||||
logger.error('Error duplicating workflow(s):', { error })
|
||||
throw error
|
||||
} finally {
|
||||
setIsDuplicating(false)
|
||||
}
|
||||
}, [getWorkflowIds, isDuplicating, duplicateWorkflow, workspaceId, router, onSuccess])
|
||||
}, [getWorkflowIds, duplicateMutation, workflows, workspaceId, router, onSuccess])
|
||||
|
||||
return {
|
||||
isDuplicating,
|
||||
isDuplicating: duplicateMutation.isPending,
|
||||
handleDuplicateWorkflow,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useEffect } from 'react'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
createOptimisticMutationHandlers,
|
||||
generateTempId,
|
||||
} from '@/hooks/queries/utils/optimistic-mutation'
|
||||
import { workflowKeys } from '@/hooks/queries/workflows'
|
||||
import { useFolderStore, type WorkflowFolder } from '@/stores/folders/store'
|
||||
|
||||
@@ -84,9 +88,83 @@ interface DuplicateFolderVariables {
|
||||
color?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates optimistic mutation handlers for folder operations
|
||||
*/
|
||||
function createFolderMutationHandlers<TVariables extends { workspaceId: string }>(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
name: string,
|
||||
createOptimisticFolder: (
|
||||
variables: TVariables,
|
||||
tempId: string,
|
||||
previousFolders: Record<string, WorkflowFolder>
|
||||
) => WorkflowFolder
|
||||
) {
|
||||
return createOptimisticMutationHandlers<WorkflowFolder, TVariables, WorkflowFolder>(queryClient, {
|
||||
name,
|
||||
getQueryKey: (variables) => folderKeys.list(variables.workspaceId),
|
||||
getSnapshot: () => ({ ...useFolderStore.getState().folders }),
|
||||
generateTempId: () => generateTempId('temp-folder'),
|
||||
createOptimisticItem: (variables, tempId) => {
|
||||
const previousFolders = useFolderStore.getState().folders
|
||||
return createOptimisticFolder(variables, tempId, previousFolders)
|
||||
},
|
||||
applyOptimisticUpdate: (tempId, item) => {
|
||||
useFolderStore.setState((state) => ({
|
||||
folders: { ...state.folders, [tempId]: item },
|
||||
}))
|
||||
},
|
||||
replaceOptimisticEntry: (tempId, data) => {
|
||||
useFolderStore.setState((state) => {
|
||||
const { [tempId]: _, ...remainingFolders } = state.folders
|
||||
return {
|
||||
folders: {
|
||||
...remainingFolders,
|
||||
[data.id]: data,
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
rollback: (snapshot) => {
|
||||
useFolderStore.setState({ folders: snapshot })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the next sort order for a folder in a given parent
|
||||
*/
|
||||
function getNextSortOrder(
|
||||
folders: Record<string, WorkflowFolder>,
|
||||
workspaceId: string,
|
||||
parentId: string | null | undefined
|
||||
): number {
|
||||
const siblingFolders = Object.values(folders).filter(
|
||||
(f) => f.workspaceId === workspaceId && f.parentId === (parentId || null)
|
||||
)
|
||||
return siblingFolders.reduce((max, f) => Math.max(max, f.sortOrder), -1) + 1
|
||||
}
|
||||
|
||||
export function useCreateFolder() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const handlers = createFolderMutationHandlers<CreateFolderVariables>(
|
||||
queryClient,
|
||||
'CreateFolder',
|
||||
(variables, tempId, previousFolders) => ({
|
||||
id: tempId,
|
||||
name: variables.name,
|
||||
userId: '',
|
||||
workspaceId: variables.workspaceId,
|
||||
parentId: variables.parentId || null,
|
||||
color: variables.color || '#808080',
|
||||
isExpanded: false,
|
||||
sortOrder: getNextSortOrder(previousFolders, variables.workspaceId, variables.parentId),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, ...payload }: CreateFolderVariables) => {
|
||||
const response = await fetch('/api/folders', {
|
||||
@@ -103,9 +181,7 @@ export function useCreateFolder() {
|
||||
const { folder } = await response.json()
|
||||
return mapFolder(folder)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) })
|
||||
},
|
||||
...handlers,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -158,8 +234,35 @@ export function useDeleteFolderMutation() {
|
||||
export function useDuplicateFolderMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const handlers = createFolderMutationHandlers<DuplicateFolderVariables>(
|
||||
queryClient,
|
||||
'DuplicateFolder',
|
||||
(variables, tempId, previousFolders) => {
|
||||
// Get source folder info if available
|
||||
const sourceFolder = previousFolders[variables.id]
|
||||
return {
|
||||
id: tempId,
|
||||
name: variables.name,
|
||||
userId: sourceFolder?.userId || '',
|
||||
workspaceId: variables.workspaceId,
|
||||
parentId: variables.parentId ?? sourceFolder?.parentId ?? null,
|
||||
color: variables.color || sourceFolder?.color || '#808080',
|
||||
isExpanded: false,
|
||||
sortOrder: getNextSortOrder(previousFolders, variables.workspaceId, variables.parentId),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, workspaceId, name, parentId, color }: DuplicateFolderVariables) => {
|
||||
mutationFn: async ({
|
||||
id,
|
||||
workspaceId,
|
||||
name,
|
||||
parentId,
|
||||
color,
|
||||
}: DuplicateFolderVariables): Promise<WorkflowFolder> => {
|
||||
const response = await fetch(`/api/folders/${id}/duplicate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -176,9 +279,12 @@ export function useDuplicateFolderMutation() {
|
||||
throw new Error(error.error || 'Failed to duplicate folder')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
const data = await response.json()
|
||||
return mapFolder(data.folder || data)
|
||||
},
|
||||
onSuccess: async (_data, variables) => {
|
||||
...handlers,
|
||||
onSettled: (_data, _error, variables) => {
|
||||
// Invalidate both folders and workflows (duplicated folder may contain workflows)
|
||||
queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: workflowKeys.list(variables.workspaceId) })
|
||||
},
|
||||
|
||||
6
apps/sim/hooks/queries/utils/index.ts
Normal file
6
apps/sim/hooks/queries/utils/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createOptimisticMutationHandlers,
|
||||
generateTempId,
|
||||
type OptimisticMutationConfig,
|
||||
type OptimisticMutationContext,
|
||||
} from './optimistic-mutation'
|
||||
77
apps/sim/hooks/queries/utils/optimistic-mutation.ts
Normal file
77
apps/sim/hooks/queries/utils/optimistic-mutation.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('OptimisticMutation')
|
||||
|
||||
export interface OptimisticMutationConfig<TData, TVariables, TItem, TContext> {
|
||||
name: string
|
||||
getQueryKey: (variables: TVariables) => readonly unknown[]
|
||||
getSnapshot: () => Record<string, TItem>
|
||||
generateTempId: () => string
|
||||
createOptimisticItem: (variables: TVariables, tempId: string) => TItem
|
||||
applyOptimisticUpdate: (tempId: string, item: TItem) => void
|
||||
replaceOptimisticEntry: (tempId: string, data: TData) => void
|
||||
rollback: (snapshot: Record<string, TItem>) => void
|
||||
onSuccessExtra?: (data: TData, variables: TVariables) => void
|
||||
}
|
||||
|
||||
export interface OptimisticMutationContext<TItem> {
|
||||
tempId: string
|
||||
previousState: Record<string, TItem>
|
||||
}
|
||||
|
||||
export function createOptimisticMutationHandlers<TData, TVariables, TItem>(
|
||||
queryClient: QueryClient,
|
||||
config: OptimisticMutationConfig<TData, TVariables, TItem, OptimisticMutationContext<TItem>>
|
||||
) {
|
||||
const {
|
||||
name,
|
||||
getQueryKey,
|
||||
getSnapshot,
|
||||
generateTempId,
|
||||
createOptimisticItem,
|
||||
applyOptimisticUpdate,
|
||||
replaceOptimisticEntry,
|
||||
rollback,
|
||||
onSuccessExtra,
|
||||
} = config
|
||||
|
||||
return {
|
||||
onMutate: async (variables: TVariables): Promise<OptimisticMutationContext<TItem>> => {
|
||||
const queryKey = getQueryKey(variables)
|
||||
await queryClient.cancelQueries({ queryKey })
|
||||
const previousState = getSnapshot()
|
||||
const tempId = generateTempId()
|
||||
const optimisticItem = createOptimisticItem(variables, tempId)
|
||||
applyOptimisticUpdate(tempId, optimisticItem)
|
||||
logger.info(`[${name}] Added optimistic entry: ${tempId}`)
|
||||
return { tempId, previousState }
|
||||
},
|
||||
|
||||
onSuccess: (data: TData, variables: TVariables, context: OptimisticMutationContext<TItem>) => {
|
||||
logger.info(`[${name}] Success, replacing temp entry ${context.tempId}`)
|
||||
replaceOptimisticEntry(context.tempId, data)
|
||||
onSuccessExtra?.(data, variables)
|
||||
},
|
||||
|
||||
onError: (
|
||||
error: Error,
|
||||
_variables: TVariables,
|
||||
context: OptimisticMutationContext<TItem> | undefined
|
||||
) => {
|
||||
logger.error(`[${name}] Failed:`, error)
|
||||
if (context?.previousState) {
|
||||
rollback(context.previousState)
|
||||
logger.info(`[${name}] Rolled back to previous state`)
|
||||
}
|
||||
},
|
||||
|
||||
onSettled: (_data: TData | undefined, _error: Error | null, variables: TVariables) => {
|
||||
queryClient.invalidateQueries({ queryKey: getQueryKey(variables) })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function generateTempId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}`
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { useEffect } from 'react'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
|
||||
import {
|
||||
createOptimisticMutationHandlers,
|
||||
generateTempId,
|
||||
} from '@/hooks/queries/utils/optimistic-mutation'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
import {
|
||||
@@ -87,11 +91,106 @@ interface CreateWorkflowVariables {
|
||||
folderId?: string | null
|
||||
}
|
||||
|
||||
interface CreateWorkflowResult {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
color: string
|
||||
workspaceId: string
|
||||
folderId?: string | null
|
||||
}
|
||||
|
||||
interface DuplicateWorkflowVariables {
|
||||
workspaceId: string
|
||||
sourceId: string
|
||||
name: string
|
||||
description?: string
|
||||
color: string
|
||||
folderId?: string | null
|
||||
}
|
||||
|
||||
interface DuplicateWorkflowResult {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
color: string
|
||||
workspaceId: string
|
||||
folderId?: string | null
|
||||
blocksCount: number
|
||||
edgesCount: number
|
||||
subflowsCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates optimistic mutation handlers for workflow operations
|
||||
*/
|
||||
function createWorkflowMutationHandlers<TVariables extends { workspaceId: string }>(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
name: string,
|
||||
createOptimisticWorkflow: (variables: TVariables, tempId: string) => WorkflowMetadata
|
||||
) {
|
||||
return createOptimisticMutationHandlers<
|
||||
CreateWorkflowResult | DuplicateWorkflowResult,
|
||||
TVariables,
|
||||
WorkflowMetadata
|
||||
>(queryClient, {
|
||||
name,
|
||||
getQueryKey: (variables) => workflowKeys.list(variables.workspaceId),
|
||||
getSnapshot: () => ({ ...useWorkflowRegistry.getState().workflows }),
|
||||
generateTempId: () => generateTempId('temp-workflow'),
|
||||
createOptimisticItem: createOptimisticWorkflow,
|
||||
applyOptimisticUpdate: (tempId, item) => {
|
||||
useWorkflowRegistry.setState((state) => ({
|
||||
workflows: { ...state.workflows, [tempId]: item },
|
||||
}))
|
||||
},
|
||||
replaceOptimisticEntry: (tempId, data) => {
|
||||
useWorkflowRegistry.setState((state) => {
|
||||
const { [tempId]: _, ...remainingWorkflows } = state.workflows
|
||||
return {
|
||||
workflows: {
|
||||
...remainingWorkflows,
|
||||
[data.id]: {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
description: data.description,
|
||||
color: data.color,
|
||||
workspaceId: data.workspaceId,
|
||||
folderId: data.folderId,
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
})
|
||||
},
|
||||
rollback: (snapshot) => {
|
||||
useWorkflowRegistry.setState({ workflows: snapshot })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateWorkflow() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const handlers = createWorkflowMutationHandlers<CreateWorkflowVariables>(
|
||||
queryClient,
|
||||
'CreateWorkflow',
|
||||
(variables, tempId) => ({
|
||||
id: tempId,
|
||||
name: variables.name || generateCreativeWorkflowName(),
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
description: variables.description || 'New workflow',
|
||||
color: variables.color || getNextWorkflowColor(),
|
||||
workspaceId: variables.workspaceId,
|
||||
folderId: variables.folderId || null,
|
||||
})
|
||||
)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (variables: CreateWorkflowVariables) => {
|
||||
mutationFn: async (variables: CreateWorkflowVariables): Promise<CreateWorkflowResult> => {
|
||||
const { workspaceId, name, description, color, folderId } = variables
|
||||
|
||||
logger.info(`Creating new workflow in workspace: ${workspaceId}`)
|
||||
@@ -144,9 +243,11 @@ export function useCreateWorkflow() {
|
||||
folderId: createdWorkflow.folderId,
|
||||
}
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
logger.info(`Workflow ${data.id} created successfully`)
|
||||
...handlers,
|
||||
onSuccess: (data, variables, context) => {
|
||||
handlers.onSuccess(data, variables, context)
|
||||
|
||||
// Initialize subblock values for new workflow
|
||||
const { subBlockValues } = buildDefaultWorkflowArtifacts()
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
@@ -154,28 +255,87 @@ export function useCreateWorkflow() {
|
||||
[data.id]: subBlockValues,
|
||||
},
|
||||
}))
|
||||
|
||||
useWorkflowRegistry.setState((state) => ({
|
||||
workflows: {
|
||||
...state.workflows,
|
||||
[data.id]: {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
description: data.description,
|
||||
color: data.color,
|
||||
workspaceId: data.workspaceId,
|
||||
folderId: data.folderId,
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
}))
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: workflowKeys.list(variables.workspaceId) })
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
logger.error('Failed to create workflow:', error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDuplicateWorkflowMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const handlers = createWorkflowMutationHandlers<DuplicateWorkflowVariables>(
|
||||
queryClient,
|
||||
'DuplicateWorkflow',
|
||||
(variables, tempId) => ({
|
||||
id: tempId,
|
||||
name: variables.name,
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
description: variables.description,
|
||||
color: variables.color,
|
||||
workspaceId: variables.workspaceId,
|
||||
folderId: variables.folderId || null,
|
||||
})
|
||||
)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (variables: DuplicateWorkflowVariables): Promise<DuplicateWorkflowResult> => {
|
||||
const { workspaceId, sourceId, name, description, color, folderId } = variables
|
||||
|
||||
logger.info(`Duplicating workflow ${sourceId} in workspace: ${workspaceId}`)
|
||||
|
||||
const response = await fetch(`/api/workflows/${sourceId}/duplicate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description,
|
||||
color,
|
||||
workspaceId,
|
||||
folderId: folderId ?? null,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(`Failed to duplicate workflow: ${errorData.error || response.statusText}`)
|
||||
}
|
||||
|
||||
const duplicatedWorkflow = await response.json()
|
||||
|
||||
logger.info(`Successfully duplicated workflow ${sourceId} to ${duplicatedWorkflow.id}`, {
|
||||
blocksCount: duplicatedWorkflow.blocksCount,
|
||||
edgesCount: duplicatedWorkflow.edgesCount,
|
||||
subflowsCount: duplicatedWorkflow.subflowsCount,
|
||||
})
|
||||
|
||||
return {
|
||||
id: duplicatedWorkflow.id,
|
||||
name: duplicatedWorkflow.name || name,
|
||||
description: duplicatedWorkflow.description || description,
|
||||
color: duplicatedWorkflow.color || color,
|
||||
workspaceId,
|
||||
folderId: duplicatedWorkflow.folderId ?? folderId,
|
||||
blocksCount: duplicatedWorkflow.blocksCount || 0,
|
||||
edgesCount: duplicatedWorkflow.edgesCount || 0,
|
||||
subflowsCount: duplicatedWorkflow.subflowsCount || 0,
|
||||
}
|
||||
},
|
||||
...handlers,
|
||||
onSuccess: (data, variables, context) => {
|
||||
handlers.onSuccess(data, variables, context)
|
||||
|
||||
// Copy subblock values from source if it's the active workflow
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (variables.sourceId === activeWorkflowId) {
|
||||
const sourceSubblockValues =
|
||||
useSubBlockStore.getState().workflowValues[variables.sourceId] || {}
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[data.id]: { ...sourceSubblockValues },
|
||||
},
|
||||
}))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -426,12 +426,22 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
|
||||
// Modified setActiveWorkflow to work with clean DB-only architecture
|
||||
setActiveWorkflow: async (id: string) => {
|
||||
const { activeWorkflowId } = get()
|
||||
const { activeWorkflowId, hydration } = get()
|
||||
|
||||
const workflowStoreState = useWorkflowStore.getState()
|
||||
const hasWorkflowData = Object.keys(workflowStoreState.blocks).length > 0
|
||||
|
||||
if (activeWorkflowId === id && hasWorkflowData) {
|
||||
// Skip loading only if:
|
||||
// - Same workflow is already active
|
||||
// - Workflow data exists
|
||||
// - Hydration is complete (phase is 'ready')
|
||||
const isFullyHydrated =
|
||||
activeWorkflowId === id &&
|
||||
hasWorkflowData &&
|
||||
hydration.phase === 'ready' &&
|
||||
hydration.workflowId === id
|
||||
|
||||
if (isFullyHydrated) {
|
||||
logger.info(`Already active workflow ${id} with data loaded, skipping switch`)
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user