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:
Emir Karabeg
2025-12-05 13:01:12 -08:00
committed by GitHub
parent 58251e28e6
commit 7101dc58d4
14 changed files with 516 additions and 243 deletions

View File

@@ -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 />

View File

@@ -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>
)
}

View File

@@ -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>
</>
)}

View File

@@ -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()
}
}}
>
<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()
}
}}
>
<Copy className='h-3 w-3' />
@@ -164,10 +167,8 @@ export function ContextMenu({
<PopoverItem
disabled={disableExport}
onClick={() => {
if (!disableExport) {
onExport()
onClose()
}
}}
>
<ArrowUp className='h-3 w-3' />
@@ -177,10 +178,8 @@ export function ContextMenu({
<PopoverItem
disabled={disableDelete}
onClick={() => {
if (!disableDelete) {
onDelete()
onClose()
}
}}
>
<Trash className='h-3 w-3' />

View File

@@ -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,17 +67,30 @@ 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 () => {
try {
// Generate name and color upfront for optimistic updates
const name = generateCreativeWorkflowName()
const color = getNextWorkflowColor()
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])
// Folder expand hook
@@ -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}
/>

View File

@@ -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,

View File

@@ -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,

View File

@@ -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({

View File

@@ -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,19 +45,17 @@ 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)
try {
// Get fresh workflow IDs at duplication time
const workflowIdsOrId = getWorkflowIds()
if (!workflowIdsOrId) {
@@ -68,12 +69,25 @@ export function useDuplicateWorkflow({
const duplicatedIds: string[] = []
try {
// 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,
}
}

View File

@@ -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) })
},

View File

@@ -0,0 +1,6 @@
export {
createOptimisticMutationHandlers,
generateTempId,
type OptimisticMutationConfig,
type OptimisticMutationContext,
} from './optimistic-mutation'

View 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()}`
}

View File

@@ -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 },
},
}))
}
},
})
}

View File

@@ -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
}