mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(workflows): fixed workflow loading in without start block, added templates RQ hook, cleaned up unused templates code (#2035)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
+46
-98
@@ -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
@@ -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'
|
||||
|
||||
-756
@@ -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])
|
||||
|
||||
|
||||
@@ -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() })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user