fix(workflows): fixed workflow loading in without start block, added templates RQ hook, cleaned up unused templates code (#2035)

This commit is contained in:
Waleed
2025-11-17 17:31:01 -08:00
committed by GitHub
parent b5b2855b40
commit 00d9b45a22
14 changed files with 524 additions and 1256 deletions
@@ -20,7 +20,7 @@ interface NavProps {
}
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
const [githubStars, setGithubStars] = useState('18k')
const [githubStars, setGithubStars] = useState('18.5k')
const [isHovered, setIsHovered] = useState(false)
const [isLoginHovered, setIsLoginHovered] = useState(false)
const router = useRouter()
+13 -75
View File
@@ -34,9 +34,9 @@ import { createLogger } from '@/lib/logs/console/logger'
import { getBaseUrl } from '@/lib/urls/utils'
import { cn } from '@/lib/utils'
import type { CredentialRequirement } from '@/lib/workflows/credential-extractor'
import type { Template } from '@/app/templates/templates'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import { getBlock } from '@/blocks/registry'
import { useStarTemplate, useTemplate } from '@/hooks/queries/templates'
const logger = createLogger('TemplateDetails')
@@ -52,16 +52,14 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
const workspaceId = isWorkspaceContext ? (params?.workspaceId as string) : null
const { data: session } = useSession()
const [template, setTemplate] = useState<Template | null>(null)
const { data: template, isLoading: loading } = useTemplate(templateId)
const starTemplate = useStarTemplate()
const [currentUserOrgs, setCurrentUserOrgs] = useState<string[]>([])
const [currentUserOrgRoles, setCurrentUserOrgRoles] = useState<
Array<{ organizationId: string; role: string }>
>([])
const [isSuperUser, setIsSuperUser] = useState(false)
const [loading, setLoading] = useState(true)
const [isStarred, setIsStarred] = useState(false)
const [starCount, setStarCount] = useState(0)
const [isStarring, setIsStarring] = useState(false)
const [isUsing, setIsUsing] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [isApproving, setIsApproving] = useState(false)
@@ -76,29 +74,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
const currentUserId = session?.user?.id || null
// Fetch template data on client side
useEffect(() => {
if (!templateId) {
setLoading(false)
return
}
const fetchTemplate = async () => {
try {
const response = await fetch(`/api/templates/${templateId}`)
if (response.ok) {
const data = await response.json()
setTemplate(data.data)
setIsStarred(data.data.isStarred || false)
setStarCount(data.data.stars || 0)
}
} catch (error) {
logger.error('Error fetching template:', error)
} finally {
setLoading(false)
}
}
const fetchUserOrganizations = async () => {
if (!currentUserId) return
@@ -134,12 +110,10 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
}
}
fetchTemplate()
fetchSuperUserStatus()
fetchUserOrganizations()
}, [templateId, currentUserId])
}, [currentUserId])
// Fetch workspaces when user is logged in
useEffect(() => {
if (!currentUserId) return
@@ -149,7 +123,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
const response = await fetch('/api/workspaces')
if (response.ok) {
const data = await response.json()
// Filter workspaces where user has write/admin permissions
const availableWorkspaces = data.workspaces
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
.map((ws: any) => ({
@@ -169,7 +142,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
fetchWorkspaces()
}, [currentUserId])
// Clean up URL when returning from login
useEffect(() => {
if (template && searchParams?.get('use') === 'true' && currentUserId) {
if (isWorkspaceContext && workspaceId) {
@@ -181,26 +153,20 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
}
}, [searchParams, currentUserId, template, isWorkspaceContext, workspaceId, router])
// Check if user can edit template
const canEditTemplate = (() => {
if (!currentUserId || !template?.creator) return false
// For user creator profiles: must be the user themselves
if (template.creator.referenceType === 'user') {
return template.creator.referenceId === currentUserId
}
// For organization creator profiles:
if (template.creator.referenceType === 'organization' && template.creator.referenceId) {
const isOrgMember = currentUserOrgs.includes(template.creator.referenceId)
// If template has a connected workflow, any org member with workspace access can edit
if (template.workflowId) {
return isOrgMember
}
// If template is orphaned, only admin/owner can edit
// We need to check the user's role in the organization
const orgMembership = currentUserOrgRoles.find(
(org) => org.organizationId === template.creator?.referenceId
)
@@ -212,7 +178,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
return false
})()
// Check workspace access for connected workflow
useEffect(() => {
const checkWorkspaceAccess = async () => {
if (!template?.workflowId || !currentUserId || !canEditTemplate) {
@@ -227,7 +192,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
} else if (checkResponse.ok) {
setHasWorkspaceAccess(true)
} else {
// Workflow doesn't exist
setHasWorkspaceAccess(null)
}
} catch (error) {
@@ -319,32 +283,20 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
* @param event - The wheel event fired when the user scrolls over the preview area.
*/
const handleCanvasWheelCapture = (event: React.WheelEvent<HTMLDivElement>) => {
// Allow pinch/zoom gestures (e.g., ctrl/cmd + wheel) to continue to the canvas.
if (event.ctrlKey || event.metaKey) {
return
}
// Prevent React Flow from handling the wheel; let the page scroll naturally.
event.stopPropagation()
}
const handleStarToggle = async () => {
if (isStarring || !currentUserId) return
if (!currentUserId || !template) return
setIsStarring(true)
try {
const method = isStarred ? 'DELETE' : 'POST'
const response = await fetch(`/api/templates/${template.id}/star`, { method })
if (response.ok) {
setIsStarred(!isStarred)
setStarCount((prev) => (isStarred ? prev - 1 : prev + 1))
}
} catch (error) {
logger.error('Error toggling star:', error)
} finally {
setIsStarring(false)
}
starTemplate.mutate({
templateId: template.id,
action: template.isStarred ? 'remove' : 'add',
})
}
const handleUseTemplate = () => {
@@ -357,7 +309,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
return
}
// In workspace context, use current workspace directly
if (isWorkspaceContext && workspaceId) {
handleWorkspaceSelectForUse(workspaceId)
}
@@ -366,7 +317,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
const handleEditTemplate = async () => {
if (!currentUserId || !template) return
// In workspace context with existing workflow, navigate directly
if (isWorkspaceContext && workspaceId && template.workflowId) {
setIsEditing(true)
try {
@@ -381,10 +331,8 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
} finally {
setIsEditing(false)
}
// If workflow doesn't exist, fall through to workspace selector
}
// Check if workflow exists and user has access (global context)
if (template.workflowId && !isWorkspaceContext) {
setIsEditing(true)
try {
@@ -410,7 +358,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
}
}
// Workflow doesn't exist - show workspace selector or use current workspace
if (isWorkspaceContext && workspaceId) {
handleWorkspaceSelectForEdit(workspaceId)
} else {
@@ -435,7 +382,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
const { workflowId } = await response.json()
// Navigate to the new workflow with full page load
window.location.href = `/workspace/${workspaceId}/w/${workflowId}`
} catch (error) {
logger.error('Error using template:', error)
@@ -450,7 +396,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
setIsUsing(true)
setShowWorkspaceSelectorForEdit(false)
try {
// Import template as a new workflow and connect it to the template
const response = await fetch(`/api/templates/${template.id}/use`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -463,7 +408,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
const { workflowId } = await response.json()
// Navigate to the new workflow with full page load
window.location.href = `/workspace/${workspaceId}/w/${workflowId}`
} catch (error) {
logger.error('Error importing template for editing:', error)
@@ -482,9 +426,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
})
if (response.ok) {
// Update template status optimistically
setTemplate({ ...template, status: 'approved' })
// Redirect back to templates page after approval
if (isWorkspaceContext && workspaceId) {
router.push(`/workspace/${workspaceId}/templates`)
} else {
@@ -508,9 +449,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
})
if (response.ok) {
// Update template status optimistically
setTemplate({ ...template, status: 'rejected' })
// Redirect back to templates page after rejection
if (isWorkspaceContext && workspaceId) {
router.push(`/workspace/${workspaceId}/templates`)
} else {
@@ -752,11 +690,11 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
onClick={handleStarToggle}
className={cn(
'h-[14px] w-[14px] cursor-pointer transition-colors',
isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
isStarring && 'opacity-50'
template.isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
starTemplate.isPending && 'opacity-50'
)}
/>
<span className='font-medium text-[#888888] text-[14px]'>{starCount}</span>
<span className='font-medium text-[#888888] text-[14px]'>{template.stars || 0}</span>
{/* Users icon and count */}
<ChartNoAxesColumn className='h-[16px] w-[16px] text-[#888888]' />
@@ -5,6 +5,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import { getBlock } from '@/blocks/registry'
import { useStarTemplate } from '@/hooks/queries/templates'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplateCard')
@@ -12,37 +13,20 @@ const logger = createLogger('TemplateCard')
interface TemplateCardProps {
id: string
title: string
description: string
author: string
authorImageUrl?: string | null
usageCount: string
stars?: number
icon?: React.ReactNode | string
iconColor?: string
blocks?: string[]
onClick?: () => void
className?: string
// Workflow state for rendering preview
state?: WorkflowState
isStarred?: boolean
// Optional callback when template is successfully used (for closing modals, etc.)
onTemplateUsed?: () => void
// Callback when star state changes (for parent state updates)
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
// User authentication status
isAuthenticated?: boolean
}
/**
* Skeleton component for loading states
*/
export function TemplateCardSkeleton({ className }: { className?: string }) {
return (
<div className={cn('h-[268px] w-full rounded-[8px] bg-[#202020] p-[8px]', className)}>
{/* Workflow preview skeleton */}
<div className='h-[180px] w-full animate-pulse rounded-[6px] bg-gray-700' />
{/* Title and blocks row skeleton */}
<div className='mt-[14px] flex items-center justify-between'>
<div className='h-4 w-32 animate-pulse rounded bg-gray-700' />
<div className='flex items-center gap-[-4px]'>
@@ -55,7 +39,6 @@ export function TemplateCardSkeleton({ className }: { className?: string }) {
</div>
</div>
{/* Creator and stats row skeleton */}
<div className='mt-[14px] flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
<div className='h-[14px] w-[14px] animate-pulse rounded-full bg-gray-700' />
@@ -72,31 +55,23 @@ export function TemplateCardSkeleton({ className }: { className?: string }) {
)
}
// Utility function to extract block types from workflow state
const extractBlockTypesFromState = (state?: {
blocks?: Record<string, { type: string; name?: string }>
}): string[] => {
if (!state?.blocks) return []
// Get unique block types from the state, excluding starter blocks
// Sort the keys to ensure consistent ordering between server and client
const blockTypes = Object.keys(state.blocks)
.sort() // Sort keys to ensure consistent order
.sort()
.map((key) => state.blocks![key].type)
.filter((type) => type !== 'starter')
return [...new Set(blockTypes)]
}
// Utility function to get the full block config for colored icon display
const getBlockConfig = (blockType: string) => {
const block = getBlock(blockType)
return block
}
/**
* Normalize an arbitrary workflow-like object into a valid WorkflowState for preview rendering.
* Ensures required fields exist: blocks with required properties, edges array, loops and parallels maps.
*/
function normalizeWorkflowState(input?: any): WorkflowState | null {
if (!input || !input.blocks) return null
@@ -142,34 +117,22 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
function TemplateCardInner({
id,
title,
description,
author,
authorImageUrl,
usageCount,
stars = 0,
icon,
iconColor = 'bg-blue-500',
blocks = [],
onClick,
className,
state,
isStarred = false,
onTemplateUsed,
onStarChange,
isAuthenticated = true,
}: TemplateCardProps) {
const router = useRouter()
const params = useParams()
// Local state for optimistic updates
const [localIsStarred, setLocalIsStarred] = useState(isStarred)
const [localStarCount, setLocalStarCount] = useState(stars)
const [isStarLoading, setIsStarLoading] = useState(false)
const { mutate: toggleStar, isPending: isStarLoading } = useStarTemplate()
// Memoize normalized workflow state to avoid recalculation on every render
const normalizedState = useMemo(() => normalizeWorkflowState(state), [state])
// Use IntersectionObserver to defer rendering the heavy WorkflowPreview until in viewport
const previewRef = useRef<HTMLDivElement | null>(null)
const [isInView, setIsInView] = useState(false)
@@ -188,9 +151,6 @@ function TemplateCardInner({
return () => observer.disconnect()
}, [])
// Extract block types from state if provided, otherwise use the blocks prop
// Filter out starter blocks in both cases and sort for consistent rendering
// Memoized to prevent recalculation on every render
const blockTypes = useMemo(
() =>
state
@@ -199,65 +159,16 @@ function TemplateCardInner({
[state, blocks]
)
// Handle star toggle with optimistic updates
const handleStarClick = async (e: React.MouseEvent) => {
const handleStarClick = (e: React.MouseEvent) => {
e.stopPropagation()
// Prevent multiple clicks while loading
if (isStarLoading) return
setIsStarLoading(true)
// Optimistic update - update UI immediately
const newIsStarred = !localIsStarred
const newStarCount = newIsStarred ? localStarCount + 1 : localStarCount - 1
setLocalIsStarred(newIsStarred)
setLocalStarCount(newStarCount)
// Notify parent component immediately for optimistic update
if (onStarChange) {
onStarChange(id, newIsStarred, newStarCount)
}
try {
const method = localIsStarred ? 'DELETE' : 'POST'
const response = await fetch(`/api/templates/${id}/star`, { method })
if (!response.ok) {
// Rollback on error
setLocalIsStarred(localIsStarred)
setLocalStarCount(localStarCount)
// Rollback parent state too
if (onStarChange) {
onStarChange(id, localIsStarred, localStarCount)
}
logger.error('Failed to toggle star:', response.statusText)
}
} catch (error) {
// Rollback on error
setLocalIsStarred(localIsStarred)
setLocalStarCount(localStarCount)
// Rollback parent state too
if (onStarChange) {
onStarChange(id, localIsStarred, localStarCount)
}
logger.error('Error toggling star:', error)
} finally {
setIsStarLoading(false)
}
toggleStar({
templateId: id,
action: isStarred ? 'remove' : 'add',
})
}
/**
* Get the appropriate template detail page URL based on context.
* If we're in a workspace context, navigate to the workspace template page.
* Otherwise, navigate to the global template page.
* Memoized to avoid recalculation on every render.
*/
const templateUrl = useMemo(() => {
const workspaceId = params?.workspaceId as string | undefined
if (workspaceId) {
@@ -266,23 +177,8 @@ function TemplateCardInner({
return `/templates/${id}`
}, [params?.workspaceId, id])
/**
* Handle use button click - navigate to template detail page
*/
const handleUseClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
router.push(templateUrl)
},
[router, templateUrl]
)
/**
* Handle card click - navigate to template detail page
*/
const handleCardClick = useCallback(
(e: React.MouseEvent) => {
// Don't navigate if clicking on action buttons
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[data-action]')) {
return
@@ -298,7 +194,6 @@ function TemplateCardInner({
onClick={handleCardClick}
className={cn('w-full cursor-pointer rounded-[8px] bg-[#202020] p-[8px]', className)}
>
{/* Workflow Preview */}
<div
ref={previewRef}
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
@@ -318,16 +213,12 @@ function TemplateCardInner({
)}
</div>
{/* Title and Blocks Row */}
<div className='mt-[10px] flex items-center justify-between'>
{/* Template Name */}
<h3 className='truncate pr-[8px] pl-[2px] font-medium text-[16px] text-white'>{title}</h3>
{/* Block Icons */}
<div className='flex flex-shrink-0'>
{blockTypes.length > 4 ? (
<>
{/* Show first 3 blocks when there are more than 4 */}
{blockTypes.slice(0, 3).map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
@@ -345,7 +236,6 @@ function TemplateCardInner({
</div>
)
})}
{/* Show +n for remaining blocks */}
<div
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#4A4A4A]'
style={{ marginLeft: '-4px' }}
@@ -354,7 +244,6 @@ function TemplateCardInner({
</div>
</>
) : (
/* Show all blocks when 4 or fewer */
blockTypes.map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
@@ -376,9 +265,7 @@ function TemplateCardInner({
</div>
</div>
{/* Creator and Stats Row */}
<div className='mt-[10px] flex items-center justify-between'>
{/* Creator Info */}
<div className='flex items-center gap-[8px]'>
{authorImageUrl ? (
<div className='h-[26px] w-[26px] flex-shrink-0 overflow-hidden rounded-full'>
@@ -392,7 +279,6 @@ function TemplateCardInner({
<span className='truncate font-medium text-[#888888] text-[12px]'>{author}</span>
</div>
{/* Stats */}
<div className='flex flex-shrink-0 items-center gap-[6px] font-medium text-[#888888] text-[12px]'>
<User className='h-[12px] w-[12px]' />
<span>{usageCount}</span>
@@ -400,11 +286,11 @@ function TemplateCardInner({
onClick={handleStarClick}
className={cn(
'h-[12px] w-[12px] cursor-pointer transition-colors',
localIsStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
isStarLoading && 'opacity-50'
)}
/>
<span>{localStarCount}</span>
<span>{stars}</span>
</div>
</div>
</div>
+6 -21
View File
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input'
import { createLogger } from '@/lib/logs/console/logger'
import type { CredentialRequirement } from '@/lib/workflows/credential-extractor'
import { TemplateCard, TemplateCardSkeleton } from '@/app/templates/components/template-card'
import { useDebounce } from '@/hooks/use-debounce'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import type { CreatorProfileDetails } from '@/types/creator-profile'
@@ -55,11 +56,11 @@ export default function Templates({
}: TemplatesProps) {
const router = useRouter()
const [searchQuery, setSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(searchQuery, 300)
const [activeTab, setActiveTab] = useState('gallery')
const [templates, setTemplates] = useState<Template[]>(initialTemplates)
const [loading, setLoading] = useState(false)
// Redirect authenticated users to workspace templates
useEffect(() => {
if (currentUserId) {
const redirectToWorkspace = async () => {
@@ -80,32 +81,19 @@ export default function Templates({
}
}, [currentUserId, router])
/**
* Update star status for a template
*/
const handleStarChange = (templateId: string, isStarred: boolean, newStarCount: number) => {
setTemplates((prevTemplates) =>
prevTemplates.map((template) =>
template.id === templateId ? { ...template, isStarred, stars: newStarCount } : template
)
)
}
/**
* Filter templates based on active tab and search query
* Memoized to prevent unnecessary recalculations on render
*/
const filteredTemplates = useMemo(() => {
const query = searchQuery.toLowerCase()
const query = debouncedSearchQuery.toLowerCase()
return templates.filter((template) => {
// Filter by tab - only gallery and pending for public page
const tabMatch =
activeTab === 'gallery' ? template.status === 'approved' : template.status === 'pending'
if (!tabMatch) return false
// Filter by search query
if (!query) return true
const searchableText = [template.name, template.details?.tagline, template.creator?.name]
@@ -115,14 +103,14 @@ export default function Templates({
return searchableText.includes(query)
})
}, [templates, activeTab, searchQuery])
}, [templates, activeTab, debouncedSearchQuery])
/**
* Get empty state message based on current filters
* Memoized to prevent unnecessary recalculations on render
*/
const emptyState = useMemo(() => {
if (searchQuery) {
if (debouncedSearchQuery) {
return {
title: 'No templates found',
description: 'Try a different search term',
@@ -141,7 +129,7 @@ export default function Templates({
}
return messages[activeTab as keyof typeof messages] || messages.gallery
}, [searchQuery, activeTab])
}, [debouncedSearchQuery, activeTab])
return (
<div className='flex h-[100vh] flex-col'>
@@ -209,15 +197,12 @@ export default function Templates({
key={template.id}
id={template.id}
title={template.name}
description={template.details?.tagline || ''}
author={template.creator?.name || 'Unknown'}
authorImageUrl={template.creator?.profileImageUrl || null}
usageCount={template.views.toString()}
stars={template.stars}
state={template.state}
isStarred={template.isStarred}
onStarChange={handleStarChange}
isAuthenticated={!!currentUserId}
/>
))
)}
@@ -5,6 +5,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import { getBlock } from '@/blocks/registry'
import { useStarTemplate } from '@/hooks/queries/templates'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplateCard')
@@ -12,37 +13,21 @@ const logger = createLogger('TemplateCard')
interface TemplateCardProps {
id: string
title: string
description: string
author: string
authorImageUrl?: string | null
usageCount: string
stars?: number
icon?: React.ReactNode | string
iconColor?: string
blocks?: string[]
onClick?: () => void
className?: string
// Workflow state for rendering preview
state?: WorkflowState
isStarred?: boolean
// Optional callback when template is successfully used (for closing modals, etc.)
onTemplateUsed?: () => void
// Callback when star state changes (for parent state updates)
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
// User authentication status
isAuthenticated?: boolean
}
/**
* Skeleton component for loading states
*/
export function TemplateCardSkeleton({ className }: { className?: string }) {
return (
<div className={cn('h-[268px] w-full rounded-[8px] bg-[#202020] p-[8px]', className)}>
{/* Workflow preview skeleton */}
<div className='h-[180px] w-full animate-pulse rounded-[6px] bg-gray-700' />
{/* Title and blocks row skeleton */}
<div className='mt-[14px] flex items-center justify-between'>
<div className='h-4 w-32 animate-pulse rounded bg-gray-700' />
<div className='flex items-center gap-[-4px]'>
@@ -55,7 +40,6 @@ export function TemplateCardSkeleton({ className }: { className?: string }) {
</div>
</div>
{/* Creator and stats row skeleton */}
<div className='mt-[14px] flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
<div className='h-[14px] w-[14px] animate-pulse rounded-full bg-gray-700' />
@@ -72,31 +56,23 @@ export function TemplateCardSkeleton({ className }: { className?: string }) {
)
}
// Utility function to extract block types from workflow state
const extractBlockTypesFromState = (state?: {
blocks?: Record<string, { type: string; name?: string }>
}): string[] => {
if (!state?.blocks) return []
// Get unique block types from the state, excluding starter blocks
// Sort the keys to ensure consistent ordering between server and client
const blockTypes = Object.keys(state.blocks)
.sort() // Sort keys to ensure consistent order
.sort()
.map((key) => state.blocks![key].type)
.filter((type) => type !== 'starter')
return [...new Set(blockTypes)]
}
// Utility function to get the full block config for colored icon display
const getBlockConfig = (blockType: string) => {
const block = getBlock(blockType)
return block
}
/**
* Normalize an arbitrary workflow-like object into a valid WorkflowState for preview rendering.
* Ensures required fields exist: blocks with required properties, edges array, loops and parallels maps.
*/
function normalizeWorkflowState(input?: any): WorkflowState | null {
if (!input || !input.blocks) return null
@@ -142,34 +118,22 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
function TemplateCardInner({
id,
title,
description,
author,
authorImageUrl,
usageCount,
stars = 0,
icon,
iconColor = 'bg-blue-500',
blocks = [],
onClick,
className,
state,
isStarred = false,
onTemplateUsed,
onStarChange,
isAuthenticated = true,
}: TemplateCardProps) {
const router = useRouter()
const params = useParams()
// Local state for optimistic updates
const [localIsStarred, setLocalIsStarred] = useState(isStarred)
const [localStarCount, setLocalStarCount] = useState(stars)
const [isStarLoading, setIsStarLoading] = useState(false)
const { mutate: toggleStar, isPending: isStarLoading } = useStarTemplate()
// Memoize normalized workflow state to avoid recalculation on every render
const normalizedState = useMemo(() => normalizeWorkflowState(state), [state])
// Use IntersectionObserver to defer rendering the heavy WorkflowPreview until in viewport
const previewRef = useRef<HTMLDivElement | null>(null)
const [isInView, setIsInView] = useState(false)
@@ -188,9 +152,6 @@ function TemplateCardInner({
return () => observer.disconnect()
}, [])
// Extract block types from state if provided, otherwise use the blocks prop
// Filter out starter blocks in both cases and sort for consistent rendering
// Memoized to prevent recalculation on every render
const blockTypes = useMemo(
() =>
state
@@ -199,65 +160,16 @@ function TemplateCardInner({
[state, blocks]
)
// Handle star toggle with optimistic updates
const handleStarClick = async (e: React.MouseEvent) => {
const handleStarClick = (e: React.MouseEvent) => {
e.stopPropagation()
// Prevent multiple clicks while loading
if (isStarLoading) return
setIsStarLoading(true)
// Optimistic update - update UI immediately
const newIsStarred = !localIsStarred
const newStarCount = newIsStarred ? localStarCount + 1 : localStarCount - 1
setLocalIsStarred(newIsStarred)
setLocalStarCount(newStarCount)
// Notify parent component immediately for optimistic update
if (onStarChange) {
onStarChange(id, newIsStarred, newStarCount)
}
try {
const method = localIsStarred ? 'DELETE' : 'POST'
const response = await fetch(`/api/templates/${id}/star`, { method })
if (!response.ok) {
// Rollback on error
setLocalIsStarred(localIsStarred)
setLocalStarCount(localStarCount)
// Rollback parent state too
if (onStarChange) {
onStarChange(id, localIsStarred, localStarCount)
}
logger.error('Failed to toggle star:', response.statusText)
}
} catch (error) {
// Rollback on error
setLocalIsStarred(localIsStarred)
setLocalStarCount(localStarCount)
// Rollback parent state too
if (onStarChange) {
onStarChange(id, localIsStarred, localStarCount)
}
logger.error('Error toggling star:', error)
} finally {
setIsStarLoading(false)
}
toggleStar({
templateId: id,
action: isStarred ? 'remove' : 'add',
})
}
/**
* Get the appropriate template detail page URL based on context.
* If we're in a workspace context, navigate to the workspace template page.
* Otherwise, navigate to the global template page.
* Memoized to avoid recalculation on every render.
*/
const templateUrl = useMemo(() => {
const workspaceId = params?.workspaceId as string | undefined
if (workspaceId) {
@@ -266,23 +178,8 @@ function TemplateCardInner({
return `/templates/${id}`
}, [params?.workspaceId, id])
/**
* Handle use button click - navigate to template detail page
*/
const handleUseClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
router.push(templateUrl)
},
[router, templateUrl]
)
/**
* Handle card click - navigate to template detail page
*/
const handleCardClick = useCallback(
(e: React.MouseEvent) => {
// Don't navigate if clicking on action buttons
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[data-action]')) {
return
@@ -298,7 +195,6 @@ function TemplateCardInner({
onClick={handleCardClick}
className={cn('w-full cursor-pointer rounded-[8px] bg-[#202020] p-[8px]', className)}
>
{/* Workflow Preview */}
<div
ref={previewRef}
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
@@ -318,16 +214,12 @@ function TemplateCardInner({
)}
</div>
{/* Title and Blocks Row */}
<div className='mt-[10px] flex items-center justify-between'>
{/* Template Name */}
<h3 className='truncate pr-[8px] pl-[2px] font-medium text-[16px] text-white'>{title}</h3>
{/* Block Icons */}
<div className='flex flex-shrink-0'>
{blockTypes.length > 4 ? (
<>
{/* Show first 3 blocks when there are more than 4 */}
{blockTypes.slice(0, 3).map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
@@ -345,7 +237,6 @@ function TemplateCardInner({
</div>
)
})}
{/* Show +n for remaining blocks */}
<div
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#4A4A4A]'
style={{ marginLeft: '-4px' }}
@@ -354,7 +245,6 @@ function TemplateCardInner({
</div>
</>
) : (
/* Show all blocks when 4 or fewer */
blockTypes.map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
@@ -376,9 +266,7 @@ function TemplateCardInner({
</div>
</div>
{/* Creator and Stats Row */}
<div className='mt-[10px] flex items-center justify-between'>
{/* Creator Info */}
<div className='flex items-center gap-[8px]'>
{authorImageUrl ? (
<div className='h-[26px] w-[26px] flex-shrink-0 overflow-hidden rounded-full'>
@@ -392,7 +280,6 @@ function TemplateCardInner({
<span className='truncate font-medium text-[#888888] text-[12px]'>{author}</span>
</div>
{/* Stats */}
<div className='flex flex-shrink-0 items-center gap-[6px] font-medium text-[#888888] text-[12px]'>
<User className='h-[12px] w-[12px]' />
<span>{usageCount}</span>
@@ -400,11 +287,11 @@ function TemplateCardInner({
onClick={handleStarClick}
className={cn(
'h-[12px] w-[12px] cursor-pointer transition-colors',
localIsStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
isStarLoading && 'opacity-50'
)}
/>
<span>{localStarCount}</span>
<span>{stars}</span>
</div>
</div>
</div>
@@ -9,6 +9,7 @@ import {
TemplateCard,
TemplateCardSkeleton,
} from '@/app/workspace/[workspaceId]/templates/components/template-card'
import { useDebounce } from '@/hooks/use-debounce'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import type { CreatorProfileDetails } from '@/types/creator-profile'
@@ -70,30 +71,19 @@ export default function Templates({
isSuperUser,
}: TemplatesProps) {
const [searchQuery, setSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(searchQuery, 300)
const [activeTab, setActiveTab] = useState('gallery')
const [templates, setTemplates] = useState<Template[]>(initialTemplates)
const [loading, setLoading] = useState(false)
/**
* Update star status for a template
*/
const handleStarChange = (templateId: string, isStarred: boolean, newStarCount: number) => {
setTemplates((prevTemplates) =>
prevTemplates.map((template) =>
template.id === templateId ? { ...template, isStarred, stars: newStarCount } : template
)
)
}
/**
* Filter templates based on active tab and search query
* Memoized to prevent unnecessary recalculations on render
*/
const filteredTemplates = useMemo(() => {
const query = searchQuery.toLowerCase()
const query = debouncedSearchQuery.toLowerCase()
return templates.filter((template) => {
// Filter by tab
const tabMatch =
activeTab === 'your'
? template.userId === currentUserId || template.isStarred
@@ -103,7 +93,6 @@ export default function Templates({
if (!tabMatch) return false
// Filter by search query
if (!query) return true
const searchableText = [
@@ -119,14 +108,14 @@ export default function Templates({
return searchableText.includes(query)
})
}, [templates, activeTab, searchQuery, currentUserId])
}, [templates, activeTab, debouncedSearchQuery, currentUserId])
/**
* Get empty state message based on current filters
* Memoized to prevent unnecessary recalculations on render
*/
const emptyState = useMemo(() => {
if (searchQuery) {
if (debouncedSearchQuery) {
return {
title: 'No templates found',
description: 'Try a different search term',
@@ -149,7 +138,7 @@ export default function Templates({
}
return messages[activeTab as keyof typeof messages] || messages.gallery
}, [searchQuery, activeTab])
}, [debouncedSearchQuery, activeTab])
return (
<div className='flex h-[100vh] flex-col pl-64'>
@@ -228,17 +217,12 @@ export default function Templates({
key={template.id}
id={template.id}
title={template.name}
description={template.description || template.details?.tagline || ''}
author={author}
authorImageUrl={authorImageUrl}
usageCount={template.views.toString()}
stars={template.stars}
icon={template.icon}
iconColor={template.color}
state={template.state}
isStarred={template.isStarred}
onStarChange={handleStarChange}
isAuthenticated={true}
/>
)
})
@@ -27,6 +27,12 @@ import { TagInput } from '@/components/ui/tag-input'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import {
useCreateTemplate,
useDeleteTemplate,
useTemplateByWorkflow,
useUpdateTemplate,
} from '@/hooks/queries/templates'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplateDeploy')
@@ -55,15 +61,16 @@ interface TemplateDeployProps {
export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDeployProps) {
const { data: session } = useSession()
const [isSubmitting, setIsSubmitting] = useState(false)
const [existingTemplate, setExistingTemplate] = useState<any>(null)
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [creatorOptions, setCreatorOptions] = useState<CreatorOption[]>([])
const [loadingCreators, setLoadingCreators] = useState(false)
const [showPreviewDialog, setShowPreviewDialog] = useState(false)
const { data: existingTemplate, isLoading: isLoadingTemplate } = useTemplateByWorkflow(workflowId)
const createMutation = useCreateTemplate()
const updateMutation = useUpdateTemplate()
const deleteMutation = useDeleteTemplate()
const form = useForm<TemplateFormData>({
resolver: zodResolver(templateSchema),
defaultValues: {
@@ -75,7 +82,6 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
},
})
// Fetch creator profiles
const fetchCreatorOptions = async () => {
if (!session?.user?.id) return
@@ -105,7 +111,6 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
fetchCreatorOptions()
}, [session?.user?.id])
// Auto-select creator profile when there's only one option and no selection yet
useEffect(() => {
const currentCreatorId = form.getValues('creatorId')
if (creatorOptions.length === 1 && !currentCreatorId) {
@@ -114,15 +119,12 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
}
}, [creatorOptions, form])
// Listen for creator profile saved event
useEffect(() => {
const handleCreatorProfileSaved = async () => {
logger.info('Creator profile saved, refreshing profiles...')
// Refetch creator profiles (autoselection will happen via the effect above)
await fetchCreatorOptions()
// Close settings modal and reopen deploy modal to template tab
window.dispatchEvent(new CustomEvent('close-settings'))
setTimeout(() => {
window.dispatchEvent(new CustomEvent('open-deploy-modal', { detail: { tab: 'template' } }))
@@ -136,41 +138,20 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
}
}, [])
// Check for existing template
useEffect(() => {
const checkExistingTemplate = async () => {
setIsLoadingTemplate(true)
try {
const response = await fetch(`/api/templates?workflowId=${workflowId}&limit=1`)
if (response.ok) {
const result = await response.json()
const template = result.data?.[0] || null
setExistingTemplate(template)
if (existingTemplate) {
const tagline = existingTemplate.details?.tagline || ''
const about = existingTemplate.details?.about || ''
if (template) {
// Map old template format to new format if needed
const tagline = (template.details as any)?.tagline || template.description || ''
const about = (template.details as any)?.about || ''
form.reset({
name: template.name,
tagline: tagline,
about: about,
creatorId: template.creatorId || undefined,
tags: template.tags || [],
})
}
}
} catch (error) {
logger.error('Error checking existing template:', error)
setExistingTemplate(null)
} finally {
setIsLoadingTemplate(false)
}
form.reset({
name: existingTemplate.name,
tagline: tagline,
about: about,
creatorId: existingTemplate.creatorId || undefined,
tags: existingTemplate.tags || [],
})
}
checkExistingTemplate()
}, [workflowId, session?.user?.id])
}, [existingTemplate, form])
const onSubmit = async (data: TemplateFormData) => {
if (!session?.user) {
@@ -178,85 +159,51 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
return
}
setIsSubmitting(true)
try {
// Build template data with new schema
const templateData: any = {
const templateData = {
name: data.name,
details: {
tagline: data.tagline || '',
about: data.about || '',
},
creatorId: data.creatorId || null,
creatorId: data.creatorId || undefined,
tags: data.tags || [],
}
let response
if (existingTemplate) {
// Update template metadata AND state from current workflow
response = await fetch(`/api/templates/${existingTemplate.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
await updateMutation.mutateAsync({
id: existingTemplate.id,
data: {
...templateData,
updateState: true, // Update state from current workflow
}),
updateState: true,
},
})
} else {
// Create new template with workflowId
response = await fetch('/api/templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...templateData, workflowId }),
})
await createMutation.mutateAsync({ ...templateData, workflowId })
}
if (!response.ok) {
const errorData = await response.json()
throw new Error(
errorData.error || `Failed to ${existingTemplate ? 'update' : 'create'} template`
)
}
const result = await response.json()
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully:`, result)
// Update existing template state
setExistingTemplate(result.data || result)
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully`)
onDeploymentComplete?.()
} catch (error) {
logger.error('Failed to save template:', error)
} finally {
setIsSubmitting(false)
}
}
const handleDelete = async () => {
if (!existingTemplate) return
setIsDeleting(true)
try {
const response = await fetch(`/api/templates/${existingTemplate.id}`, {
method: 'DELETE',
await deleteMutation.mutateAsync(existingTemplate.id)
setShowDeleteDialog(false)
form.reset({
name: '',
tagline: '',
about: '',
creatorId: undefined,
tags: [],
})
if (response.ok) {
setExistingTemplate(null)
setShowDeleteDialog(false)
form.reset({
name: '',
tagline: '',
about: '',
creatorId: undefined,
tags: [],
})
}
} catch (error) {
logger.error('Error deleting template:', error)
} finally {
setIsDeleting(false)
}
}
@@ -422,7 +369,7 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
onChange={field.onChange}
placeholder='Type and press Enter to add tags'
maxTags={10}
disabled={isSubmitting}
disabled={createMutation.isPending || updateMutation.isPending}
/>
</FormControl>
<p className='text-muted-foreground text-xs'>
@@ -447,9 +394,11 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
<Button
type='submit'
variant='primary'
disabled={isSubmitting || !form.formState.isValid}
disabled={
createMutation.isPending || updateMutation.isPending || !form.formState.isValid
}
>
{isSubmitting ? (
{createMutation.isPending || updateMutation.isPending ? (
<>
<Loader2 className='mr-[8px] h-[14px] w-[14px] animate-spin' />
{existingTemplate ? 'Updating...' : 'Publishing...'}
@@ -479,10 +428,10 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
</Button>
<Button
onClick={handleDelete}
disabled={isDeleting}
disabled={deleteMutation.isPending}
className='bg-red-600 text-white hover:bg-red-700'
>
{isDeleting ? 'Deleting...' : 'Delete'}
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
@@ -511,7 +460,6 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
)
}
// Ensure the state has the right structure
const workflowState: WorkflowState = {
blocks: existingTemplate.state.blocks || {},
edges: existingTemplate.state.edges || [],
@@ -1,5 +1,4 @@
export { DeployModal } from './deploy-modal/deploy-modal'
export { DeploymentControls } from './deployment-controls/deployment-controls'
export { ExportControls } from './export-controls/export-controls'
export { TemplateModal } from './template-modal/template-modal'
export { WebhookSettings } from './webhook-settings/webhook-settings'
@@ -1,756 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import {
Award,
BarChart3,
Bell,
BookOpen,
Bot,
Brain,
Briefcase,
Calculator,
Cloud,
Code,
Cpu,
CreditCard,
Database,
DollarSign,
Edit,
Eye,
FileText,
Folder,
Globe,
HeadphonesIcon,
Layers,
Lightbulb,
LineChart,
Loader2,
Mail,
Megaphone,
MessageSquare,
NotebookPen,
Phone,
Play,
Search,
Server,
Settings,
ShoppingCart,
Star,
Target,
TrendingUp,
User,
Users,
Workflow,
Wrench,
X,
Zap,
} from 'lucide-react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { ColorPicker } from '@/components/ui/color-picker'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { buildWorkflowStateForTemplate } from '@/lib/workflows/state-builder'
const logger = createLogger('TemplateModal')
const templateSchema = z.object({
name: z.string().min(1, 'Name is required').max(100, 'Name must be less than 100 characters'),
description: z
.string()
.min(1, 'Description is required')
.max(500, 'Description must be less than 500 characters'),
author: z
.string()
.min(1, 'Author is required')
.max(100, 'Author must be less than 100 characters'),
authorType: z.enum(['user', 'organization']).default('user'),
organizationId: z.string().optional(),
icon: z.string().min(1, 'Icon is required'),
color: z.string().regex(/^#[0-9A-F]{6}$/i, 'Color must be a valid hex color (e.g., #3972F6)'),
})
type TemplateFormData = z.infer<typeof templateSchema>
interface Organization {
id: string
name: string
}
interface TemplateModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workflowId: string
}
const icons = [
// Content & Documentation
{ value: 'FileText', label: 'File Text', component: FileText },
{ value: 'NotebookPen', label: 'Notebook', component: NotebookPen },
{ value: 'BookOpen', label: 'Book', component: BookOpen },
{ value: 'Edit', label: 'Edit', component: Edit },
// Analytics & Charts
{ value: 'BarChart3', label: 'Bar Chart', component: BarChart3 },
{ value: 'LineChart', label: 'Line Chart', component: LineChart },
{ value: 'TrendingUp', label: 'Trending Up', component: TrendingUp },
{ value: 'Target', label: 'Target', component: Target },
// Database & Storage
{ value: 'Database', label: 'Database', component: Database },
{ value: 'Server', label: 'Server', component: Server },
{ value: 'Cloud', label: 'Cloud', component: Cloud },
{ value: 'Folder', label: 'Folder', component: Folder },
// Marketing & Communication
{ value: 'Megaphone', label: 'Megaphone', component: Megaphone },
{ value: 'Mail', label: 'Mail', component: Mail },
{ value: 'MessageSquare', label: 'Message', component: MessageSquare },
{ value: 'Phone', label: 'Phone', component: Phone },
{ value: 'Bell', label: 'Bell', component: Bell },
// Sales & Finance
{ value: 'DollarSign', label: 'Dollar Sign', component: DollarSign },
{ value: 'CreditCard', label: 'Credit Card', component: CreditCard },
{ value: 'Calculator', label: 'Calculator', component: Calculator },
{ value: 'ShoppingCart', label: 'Shopping Cart', component: ShoppingCart },
{ value: 'Briefcase', label: 'Briefcase', component: Briefcase },
// Support & Service
{ value: 'HeadphonesIcon', label: 'Headphones', component: HeadphonesIcon },
{ value: 'User', label: 'User', component: User },
{ value: 'Users', label: 'Users', component: Users },
{ value: 'Settings', label: 'Settings', component: Settings },
{ value: 'Wrench', label: 'Wrench', component: Wrench },
// AI & Technology
{ value: 'Bot', label: 'Bot', component: Bot },
{ value: 'Brain', label: 'Brain', component: Brain },
{ value: 'Cpu', label: 'CPU', component: Cpu },
{ value: 'Code', label: 'Code', component: Code },
{ value: 'Zap', label: 'Zap', component: Zap },
// Workflow & Process
{ value: 'Workflow', label: 'Workflow', component: Workflow },
{ value: 'Search', label: 'Search', component: Search },
{ value: 'Play', label: 'Play', component: Play },
{ value: 'Layers', label: 'Layers', component: Layers },
// General
{ value: 'Lightbulb', label: 'Lightbulb', component: Lightbulb },
{ value: 'Star', label: 'Star', component: Star },
{ value: 'Globe', label: 'Globe', component: Globe },
{ value: 'Award', label: 'Award', component: Award },
]
export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalProps) {
const { data: session } = useSession()
const [isSubmitting, setIsSubmitting] = useState(false)
const [iconPopoverOpen, setIconPopoverOpen] = useState(false)
const [existingTemplate, setExistingTemplate] = useState<any>(null)
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [organizations, setOrganizations] = useState<Organization[]>([])
const [loadingOrgs, setLoadingOrgs] = useState(false)
const form = useForm<TemplateFormData>({
resolver: zodResolver(templateSchema),
defaultValues: {
name: '',
description: '',
author: session?.user?.name || session?.user?.email || '',
authorType: 'user',
organizationId: undefined,
icon: 'FileText',
color: '#3972F6',
},
})
// Watch form state to determine if all required fields are valid
const formValues = form.watch()
const authorType = form.watch('authorType')
const isFormValid =
form.formState.isValid &&
formValues.name?.trim() &&
formValues.description?.trim() &&
formValues.author?.trim()
// Fetch user's organizations when modal opens
useEffect(() => {
const fetchOrganizations = async () => {
if (!open || !session?.user?.id) return
setLoadingOrgs(true)
try {
const response = await fetch('/api/organizations')
if (response.ok) {
const data = await response.json()
setOrganizations(data.organizations || [])
}
} catch (error) {
logger.error('Error fetching organizations:', error)
setOrganizations([])
} finally {
setLoadingOrgs(false)
}
}
if (open) {
fetchOrganizations()
}
}, [open, session?.user?.id])
// Check for existing template when modal opens
useEffect(() => {
if (open && workflowId) {
checkExistingTemplate()
}
}, [open, workflowId])
const checkExistingTemplate = async () => {
setIsLoadingTemplate(true)
try {
const response = await fetch(`/api/templates?workflowId=${workflowId}&limit=1`)
if (response.ok) {
const result = await response.json()
const template = result.data?.[0] || null
setExistingTemplate(template)
// Pre-fill form with existing template data
if (template) {
form.reset({
name: template.name,
description: template.description,
author: template.author,
authorType: template.authorType || 'user',
organizationId: template.organizationId || undefined,
icon: template.icon,
color: template.color,
})
} else {
// No existing template found
setExistingTemplate(null)
// Reset form to defaults
form.reset({
name: '',
description: '',
author: session?.user?.name || session?.user?.email || '',
authorType: 'user',
organizationId: undefined,
icon: 'FileText',
color: '#3972F6',
})
}
}
} catch (error) {
logger.error('Error checking existing template:', error)
setExistingTemplate(null)
} finally {
setIsLoadingTemplate(false)
}
}
const onSubmit = async (data: TemplateFormData) => {
if (!session?.user) {
logger.error('User not authenticated')
return
}
setIsSubmitting(true)
try {
// Create the template state from current workflow using the same format as deployment
const templateState = buildWorkflowStateForTemplate(workflowId)
const templateData = {
workflowId,
name: data.name,
description: data.description || '',
author: data.author,
authorType: data.authorType,
organizationId: data.organizationId,
icon: data.icon,
color: data.color,
state: templateState,
}
let response
if (existingTemplate) {
// Update existing template
response = await fetch(`/api/templates/${existingTemplate.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(templateData),
})
} else {
// Create new template
response = await fetch('/api/templates', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(templateData),
})
}
if (!response.ok) {
const errorData = await response.json()
throw new Error(
errorData.error || `Failed to ${existingTemplate ? 'update' : 'create'} template`
)
}
const result = await response.json()
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully:`, result)
// Reset form and close modal
form.reset()
onOpenChange(false)
// TODO: Show success toast/notification
} catch (error) {
logger.error('Failed to create template:', error)
// TODO: Show error toast/notification
} finally {
setIsSubmitting(false)
}
}
const SelectedIconComponent =
icons.find((icon) => icon.value === form.watch('icon'))?.component || FileText
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='flex h-[70vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'
hideCloseButton
>
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<DialogTitle className='font-medium text-lg'>
{isLoadingTemplate
? 'Loading...'
: existingTemplate
? 'Update Template'
: 'Publish Template'}
</DialogTitle>
{existingTemplate && (
<div className='flex items-center gap-2'>
{existingTemplate.stars > 0 && (
<div className='flex items-center gap-1 rounded-full bg-yellow-50 px-2 py-1 dark:bg-yellow-900/20'>
<Star className='h-3 w-3 fill-yellow-400 text-yellow-400' />
<span className='font-medium text-xs text-yellow-700 dark:text-yellow-300'>
{existingTemplate.stars}
</span>
</div>
)}
{existingTemplate.views > 0 && (
<div className='flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 dark:bg-blue-900/20'>
<Eye className='h-3 w-3 text-blue-500' />
<span className='font-medium text-blue-700 text-xs dark:text-blue-300'>
{existingTemplate.views}
</span>
</div>
)}
</div>
)}
</div>
<Button
variant='ghost'
size='icon'
className={cn(
'h-8 w-8 rounded-md p-0 text-muted-foreground/70 transition-all duration-200',
'hover:scale-105 hover:bg-muted/50 hover:text-foreground',
'active:scale-95',
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
onClick={() => onOpenChange(false)}
>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
</div>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className='flex flex-1 flex-col overflow-hidden'
>
<div className='flex-1 overflow-y-auto px-6 py-4'>
{isLoadingTemplate ? (
<div className='space-y-6'>
{/* Icon and Color row */}
<div className='flex gap-3'>
<div className='w-20'>
<Skeleton className='mb-2 h-4 w-8' /> {/* Label */}
<Skeleton className='h-10 w-20' /> {/* Icon picker */}
</div>
<div className='w-20'>
<Skeleton className='mb-2 h-4 w-10' /> {/* Label */}
<Skeleton className='h-10 w-20' /> {/* Color picker */}
</div>
</div>
{/* Name field */}
<div>
<Skeleton className='mb-2 h-4 w-12' /> {/* Label */}
<Skeleton className='h-10 w-full' /> {/* Input */}
</div>
{/* Author and Author Type row */}
<div className='grid grid-cols-2 gap-4'>
<div>
<Skeleton className='mb-2 h-4 w-14' /> {/* Label */}
<Skeleton className='h-10 w-full' /> {/* Input */}
</div>
<div>
<Skeleton className='mb-2 h-4 w-24' /> {/* Label */}
<Skeleton className='h-10 w-full' /> {/* Select */}
</div>
</div>
{/* Description field */}
<div>
<Skeleton className='mb-2 h-4 w-20' /> {/* Label */}
<Skeleton className='h-20 w-full' /> {/* Textarea */}
</div>
</div>
) : (
<div className='space-y-5'>
<div className='flex gap-3'>
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem className='w-20'>
<FormLabel className='!text-foreground font-medium text-sm'>
Icon
</FormLabel>
<Popover open={iconPopoverOpen} onOpenChange={setIconPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
className='h-10 w-20 rounded-[8px] border-border/50 p-0 transition-all duration-200 hover:border-border hover:bg-muted/50'
>
<SelectedIconComponent className='h-4 w-4' />
</Button>
</PopoverTrigger>
<PopoverContent className='z-50 w-84 rounded-[8px] p-0' align='start'>
<div className='p-3'>
<div className='grid max-h-80 grid-cols-8 gap-2 overflow-y-auto'>
{icons.map((icon) => {
const IconComponent = icon.component
return (
<button
key={icon.value}
type='button'
onClick={() => {
field.onChange(icon.value)
setIconPopoverOpen(false)
}}
className={cn(
'flex h-8 w-8 items-center justify-center rounded-md border border-border/40 transition-all duration-200',
'hover:scale-105 hover:border-border hover:bg-muted/50 active:scale-95',
field.value === icon.value &&
'border-primary/30 bg-primary/10 text-primary'
)}
>
<IconComponent className='h-4 w-4' />
</button>
)
})}
</div>
</div>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='color'
render={({ field }) => (
<FormItem className='w-20'>
<FormLabel className='!text-foreground font-medium text-sm'>
Color
</FormLabel>
<FormControl>
<ColorPicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
className='h-10 w-20 rounded-[8px]'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel className='!text-foreground font-medium text-sm'>Name</FormLabel>
<FormControl>
<Input
placeholder='Enter template name'
className='h-10 rounded-[8px]'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='author'
render={({ field }) => (
<FormItem>
<FormLabel className='!text-foreground font-medium text-sm'>
Author
</FormLabel>
<FormControl>
<Input
placeholder='Enter author name'
className='h-10 rounded-[8px]'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='authorType'
render={({ field }) => (
<FormItem>
<FormLabel className='!text-foreground font-medium text-sm'>
Author Type
</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value)
// Reset org selection when switching to user
if (value === 'user') {
form.setValue('organizationId', undefined)
}
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className='h-10 rounded-[8px]'>
<SelectValue placeholder='Select author type' />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='user'>User</SelectItem>
<SelectItem value='organization'>Organization</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Organization selector - only show when authorType is 'organization' */}
{authorType === 'organization' && (
<FormField
control={form.control}
name='organizationId'
render={({ field }) => (
<FormItem>
<FormLabel className='!text-foreground font-medium text-sm'>
Organization
</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className='h-10 rounded-[8px]'>
<SelectValue placeholder='Select an organization' />
</SelectTrigger>
</FormControl>
<SelectContent>
{loadingOrgs ? (
<SelectItem value='loading' disabled>
Loading organizations...
</SelectItem>
) : organizations.length === 0 ? (
<SelectItem value='none' disabled>
No organizations available
</SelectItem>
) : (
organizations.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel className='!text-foreground font-medium text-sm'>
Description
</FormLabel>
<FormControl>
<Textarea
placeholder='Describe what this template does...'
className='min-h-[80px] resize-none rounded-[8px]'
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
{/* Fixed Footer */}
<div className='mt-auto border-t px-6 py-4'>
<div className='flex items-center'>
{existingTemplate && (
<Button
type='button'
variant='destructive'
onClick={() => setShowDeleteDialog(true)}
disabled={isSubmitting || isLoadingTemplate}
className='h-9 rounded-[8px] px-4'
>
Delete
</Button>
)}
<Button
type='submit'
disabled={isSubmitting || !isFormValid || isLoadingTemplate}
className={cn(
'ml-auto h-9 rounded-[8px] px-4 font-[480]',
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'text-white transition-all duration-200',
'disabled:opacity-50 disabled:hover:bg-[var(--brand-primary-hex)] disabled:hover:shadow-none'
)}
>
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{existingTemplate ? 'Updating...' : 'Publishing...'}
</>
) : existingTemplate ? (
'Update Template'
) : (
'Publish Template'
)}
</Button>
</div>
</div>
</form>
</Form>
{existingTemplate && (
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Template?</AlertDialogTitle>
<AlertDialogDescription>
Deleting this template will remove it from the gallery. This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className='flex'>
<AlertDialogCancel className='h-9 w-full rounded-[8px]' disabled={isDeleting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
disabled={isDeleting}
onClick={async () => {
if (!existingTemplate) return
setIsDeleting(true)
try {
const resp = await fetch(`/api/templates/${existingTemplate.id}`, {
method: 'DELETE',
})
if (!resp.ok) {
const err = await resp.json().catch(() => ({}))
throw new Error(err.error || 'Failed to delete template')
}
setShowDeleteDialog(false)
onOpenChange(false)
} catch (err) {
logger.error('Failed to delete template', err)
} finally {
setIsDeleting(false)
}
}}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</DialogContent>
</Dialog>
)
}
@@ -1214,16 +1214,29 @@ const WorkflowContent = React.memo(() => {
// Initialize workflow when it exists in registry and isn't active
useEffect(() => {
let cancelled = false
const currentId = params.workflowId as string
if (!currentId || !workflows[currentId]) return
// Wait for registry to be ready to prevent race conditions
// Don't proceed if: no workflowId, registry is loading, or workflow not in registry
if (!currentId || isLoading || !workflows[currentId]) return
if (activeWorkflowId !== currentId) {
// Clear diff and set as active
const { clearDiff } = useWorkflowDiffStore.getState()
clearDiff()
setActiveWorkflow(currentId)
setActiveWorkflow(currentId).catch((error) => {
if (!cancelled) {
logger.error(`Failed to set active workflow ${currentId}:`, error)
}
})
}
}, [params.workflowId, workflows, activeWorkflowId, setActiveWorkflow])
return () => {
cancelled = true
}
}, [params.workflowId, workflows, activeWorkflowId, setActiveWorkflow, isLoading])
// Track when workflow is ready for rendering
useEffect(() => {
@@ -1233,11 +1246,15 @@ const WorkflowContent = React.memo(() => {
// 1. We have an active workflow that matches the URL
// 2. The workflow exists in the registry
// 3. Workflows are not currently loading
// 4. The workflow store has been initialized (lastSaved exists means state was loaded)
const shouldBeReady =
activeWorkflowId === currentId && Boolean(workflows[currentId]) && !isLoading
activeWorkflowId === currentId &&
Boolean(workflows[currentId]) &&
!isLoading &&
lastSaved !== undefined
setIsWorkflowReady(shouldBeReady)
}, [activeWorkflowId, params.workflowId, workflows, isLoading])
}, [activeWorkflowId, params.workflowId, workflows, isLoading, lastSaved])
// Preload workspace environment - React Query handles caching automatically
useWorkspaceEnvironment(workspaceId)
@@ -38,12 +38,8 @@ export default function WorkflowsPage() {
// If we have valid workspace workflows, redirect to the first one
if (workspaceWorkflows.length > 0) {
// Ensure the workflow is set as active before redirecting
// This prevents the empty canvas issue on first login
const firstWorkflowId = workspaceWorkflows[0]
setActiveWorkflow(firstWorkflowId).then(() => {
router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`)
})
router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`)
}
}, [isLoading, workflows, workspaceId, router, setActiveWorkflow, isError])
+396
View File
@@ -0,0 +1,396 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('TemplateQueries')
export const templateKeys = {
all: ['templates'] as const,
lists: () => [...templateKeys.all, 'list'] as const,
list: (filters?: TemplateListFilters) => [...templateKeys.lists(), filters ?? {}] as const,
details: () => [...templateKeys.all, 'detail'] as const,
detail: (templateId?: string) => [...templateKeys.details(), templateId ?? ''] as const,
byWorkflow: (workflowId?: string) =>
[...templateKeys.all, 'byWorkflow', workflowId ?? ''] as const,
}
export interface TemplateListFilters {
search?: string
status?: 'pending' | 'approved' | 'rejected'
workflowId?: string
limit?: number
offset?: number
includeAllStatuses?: boolean
}
export interface TemplateCreator {
id: string
name: string
referenceType: 'user' | 'organization'
referenceId: string
email?: string
website?: string
profileImageUrl?: string | null
details?: {
about?: string
xUrl?: string
linkedinUrl?: string
websiteUrl?: string
contactEmail?: string
} | null
createdAt: string
updatedAt: string
}
export interface Template {
id: string
workflowId: string
name: string
details?: {
tagline?: string
about?: string
}
creatorId?: string
creator?: TemplateCreator
views: number
stars: number
status: 'pending' | 'approved' | 'rejected'
tags: string[]
requiredCredentials: Record<string, any>
state: any
createdAt: string
updatedAt: string
isStarred?: boolean
isSuperUser?: boolean
}
export interface TemplatesResponse {
data: Template[]
pagination: {
total: number
limit: number
offset: number
page: number
totalPages: number
}
}
export interface TemplateDetailResponse {
data: Template
}
export interface CreateTemplateInput {
workflowId: string
name: string
details?: {
tagline?: string
about?: string
}
creatorId?: string
tags?: string[]
}
export interface UpdateTemplateInput {
name?: string
details?: {
tagline?: string
about?: string
}
creatorId?: string
tags?: string[]
updateState?: boolean
}
async function fetchTemplates(filters?: TemplateListFilters): Promise<TemplatesResponse> {
const params = new URLSearchParams()
if (filters?.search) params.set('search', filters.search)
if (filters?.status) params.set('status', filters.status)
if (filters?.workflowId) params.set('workflowId', filters.workflowId)
if (filters?.includeAllStatuses) params.set('includeAllStatuses', 'true')
params.set('limit', (filters?.limit ?? 50).toString())
params.set('offset', (filters?.offset ?? 0).toString())
const response = await fetch(`/api/templates?${params.toString()}`)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Failed to fetch templates')
}
return response.json()
}
async function fetchTemplate(templateId: string): Promise<TemplateDetailResponse> {
const response = await fetch(`/api/templates/${templateId}`)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Failed to fetch template')
}
return response.json()
}
async function fetchTemplateByWorkflow(workflowId: string): Promise<Template | null> {
const response = await fetch(`/api/templates?workflowId=${workflowId}&limit=1`)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Failed to fetch template')
}
const result: TemplatesResponse = await response.json()
return result.data?.[0] || null
}
export function useTemplates(
filters?: TemplateListFilters,
options?: {
enabled?: boolean
}
) {
return useQuery({
queryKey: templateKeys.list(filters),
queryFn: () => fetchTemplates(filters),
enabled: options?.enabled ?? true,
staleTime: 5 * 60 * 1000, // 5 minutes - templates don't change frequently
placeholderData: keepPreviousData,
})
}
export function useTemplate(
templateId?: string,
options?: {
enabled?: boolean
}
) {
return useQuery({
queryKey: templateKeys.detail(templateId),
queryFn: () => fetchTemplate(templateId as string),
enabled: (options?.enabled ?? true) && Boolean(templateId),
staleTime: 10 * 60 * 1000, // 10 minutes - individual templates are fairly static
select: (data) => data.data,
})
}
export function useTemplateByWorkflow(
workflowId?: string,
options?: {
enabled?: boolean
}
) {
return useQuery({
queryKey: templateKeys.byWorkflow(workflowId),
queryFn: () => fetchTemplateByWorkflow(workflowId as string),
enabled: (options?.enabled ?? true) && Boolean(workflowId),
staleTime: 5 * 60 * 1000, // 5 minutes
})
}
export function useCreateTemplate() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: CreateTemplateInput) => {
const response = await fetch('/api/templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Failed to create template')
}
return response.json()
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: templateKeys.lists() })
queryClient.invalidateQueries({ queryKey: templateKeys.byWorkflow(variables.workflowId) })
logger.info('Template created successfully')
},
onError: (error) => {
logger.error('Failed to create template', error)
},
})
}
export function useUpdateTemplate() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: UpdateTemplateInput }) => {
const response = await fetch(`/api/templates/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Failed to update template')
}
return response.json()
},
onMutate: async ({ id, data }) => {
await queryClient.cancelQueries({ queryKey: templateKeys.detail(id) })
const previousTemplate = queryClient.getQueryData<TemplateDetailResponse>(
templateKeys.detail(id)
)
if (previousTemplate) {
queryClient.setQueryData<TemplateDetailResponse>(templateKeys.detail(id), {
...previousTemplate,
data: {
...previousTemplate.data,
...data,
updatedAt: new Date().toISOString(),
},
})
}
return { previousTemplate }
},
onError: (error, { id }, context) => {
if (context?.previousTemplate) {
queryClient.setQueryData(templateKeys.detail(id), context.previousTemplate)
}
logger.error('Failed to update template', error)
},
onSuccess: (result, { id }) => {
queryClient.setQueryData<TemplateDetailResponse>(templateKeys.detail(id), result)
queryClient.invalidateQueries({ queryKey: templateKeys.lists() })
if (result.data?.workflowId) {
queryClient.invalidateQueries({
queryKey: templateKeys.byWorkflow(result.data.workflowId),
})
}
logger.info('Template updated successfully')
},
})
}
export function useDeleteTemplate() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (templateId: string) => {
const response = await fetch(`/api/templates/${templateId}`, {
method: 'DELETE',
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Failed to delete template')
}
return response.json()
},
onSuccess: (_, templateId) => {
queryClient.removeQueries({ queryKey: templateKeys.detail(templateId) })
queryClient.invalidateQueries({ queryKey: templateKeys.lists() })
queryClient.invalidateQueries({
queryKey: [...templateKeys.all, 'byWorkflow'],
exact: false,
})
logger.info('Template deleted successfully')
},
onError: (error) => {
logger.error('Failed to delete template', error)
},
})
}
export function useStarTemplate() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
templateId,
action,
}: {
templateId: string
action: 'add' | 'remove'
}) => {
const method = action === 'add' ? 'POST' : 'DELETE'
const response = await fetch(`/api/templates/${templateId}/star`, { method })
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Failed to toggle star')
}
return response.json()
},
onMutate: async ({ templateId, action }) => {
await queryClient.cancelQueries({ queryKey: templateKeys.detail(templateId) })
const previousTemplate = queryClient.getQueryData<TemplateDetailResponse>(
templateKeys.detail(templateId)
)
if (previousTemplate) {
const newStarCount =
action === 'add'
? previousTemplate.data.stars + 1
: Math.max(0, previousTemplate.data.stars - 1)
queryClient.setQueryData<TemplateDetailResponse>(templateKeys.detail(templateId), {
...previousTemplate,
data: {
...previousTemplate.data,
stars: newStarCount,
isStarred: action === 'add',
},
})
}
const listQueries = queryClient.getQueriesData<TemplatesResponse>({
queryKey: templateKeys.lists(),
})
listQueries.forEach(([key, data]) => {
if (!data) return
queryClient.setQueryData<TemplatesResponse>(key, {
...data,
data: data.data.map((template) => {
if (template.id === templateId) {
const newStarCount =
action === 'add' ? template.stars + 1 : Math.max(0, template.stars - 1)
return {
...template,
stars: newStarCount,
isStarred: action === 'add',
}
}
return template
}),
})
})
return { previousTemplate }
},
onError: (error, { templateId }, context) => {
if (context?.previousTemplate) {
queryClient.setQueryData(templateKeys.detail(templateId), context.previousTemplate)
}
queryClient.invalidateQueries({ queryKey: templateKeys.lists() })
logger.error('Failed to toggle star', error)
},
onSettled: (_, __, { templateId }) => {
queryClient.invalidateQueries({ queryKey: templateKeys.detail(templateId) })
queryClient.invalidateQueries({ queryKey: templateKeys.lists() })
},
})
}
+8 -13
View File
@@ -105,23 +105,18 @@ export function useCreateWorkflow() {
const { workflowState } = buildDefaultWorkflowArtifacts()
fetch(`/api/workflows/${workflowId}/state`, {
const stateResponse = await fetch(`/api/workflows/${workflowId}/state`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(workflowState),
})
.then((response) => {
if (!response.ok) {
response.text().then((text) => {
logger.error('Failed to persist default Start block:', text)
})
} else {
logger.info('Successfully persisted default Start block')
}
})
.catch((error) => {
logger.error('Error persisting default Start block:', error)
})
if (!stateResponse.ok) {
const text = await stateResponse.text()
logger.error('Failed to persist default Start block:', text)
} else {
logger.info('Successfully persisted default Start block')
}
return {
id: workflowId,
+5 -12
View File
@@ -31,7 +31,6 @@ export function useCollaborativeWorkflow() {
const moveHandler = (e: any) => {
const { blockId, before, after } = e.detail || {}
if (!blockId || !before || !after) return
// Don't record moves during undo/redo operations
if (isUndoRedoInProgress.current) return
undoRedo.recordMove(blockId, before, after)
}
@@ -40,7 +39,6 @@ export function useCollaborativeWorkflow() {
const { blockId, oldParentId, newParentId, oldPosition, newPosition, affectedEdges } =
e.detail || {}
if (!blockId) return
// Don't record during undo/redo operations
if (isUndoRedoInProgress.current) return
undoRedo.recordUpdateParent(
blockId,
@@ -1097,6 +1095,9 @@ export function useCollaborativeWorkflow() {
// Generate operation ID for queue tracking
const operationId = crypto.randomUUID()
// Get fresh activeWorkflowId from store to avoid stale closure
const currentActiveWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
// Add to queue for retry mechanism
addToQueue({
id: operationId,
@@ -1105,7 +1106,7 @@ export function useCollaborativeWorkflow() {
target: 'subblock',
payload: { blockId, subblockId, value },
},
workflowId: activeWorkflowId || '',
workflowId: currentActiveWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
@@ -1134,15 +1135,7 @@ export function useCollaborativeWorkflow() {
// Best-effort; do not block on clearing
}
},
[
subBlockStore,
currentWorkflowId,
activeWorkflowId,
addToQueue,
session?.user?.id,
isShowingDiff,
isInActiveRoom,
]
[subBlockStore, currentWorkflowId, addToQueue, session?.user?.id, isShowingDiff, isInActiveRoom]
)
// Immediate tag selection (uses queue but processes immediately, no debouncing)