feat(workflow-preview): added workflow preview for version control, with the ability to revert to old states of the workflow (#330)

* generic workflow preview and view deployed modal

* improved styling and functionality

* cleaning up throwing errors

* Remove node_modules from tracking

* coped the deployment-controls from main

* added revert to current state

* added subblock fields

* preview cleaned up

* added mrge changes

* added support for all sub-block types

* updated package-lock

* add hidden dialogheader

---------

Co-authored-by: Adam Gough <adam_gough@brown.edu>
Co-authored-by: Adam Gough <adamgough@Adams-MacBook-Pro.local>
This commit is contained in:
Waleed Latif
2025-05-06 00:30:27 -07:00
committed by GitHub
parent bac4949dfe
commit 699600feaa
14 changed files with 716 additions and 3036 deletions

View File

@@ -0,0 +1,63 @@
import { NextRequest } from 'next/server'
import { eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { workflow } from '@/db/schema'
import { validateWorkflowAccess } from '../../middleware'
import { createErrorResponse, createSuccessResponse } from '../../utils'
const logger = createLogger('WorkflowDeployedAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = crypto.randomUUID().slice(0, 8)
const { id } = await params
try {
logger.debug(`[${requestId}] Fetching deployed state for workflow: ${id}`)
const validation = await validateWorkflowAccess(request, id, false)
if (validation.error) {
logger.warn(`[${requestId}] Failed to fetch deployed state: ${validation.error.message}`)
return createErrorResponse(validation.error.message, validation.error.status)
}
// Fetch just the deployed state
const result = await db
.select({
deployedState: workflow.deployedState,
isDeployed: workflow.isDeployed
})
.from(workflow)
.where(eq(workflow.id, id))
.limit(1)
if (result.length === 0) {
logger.warn(`[${requestId}] Workflow not found: ${id}`)
return createErrorResponse('Workflow not found', 404)
}
const workflowData = result[0]
// If the workflow is not deployed, return appropriate response
if (!workflowData.isDeployed || !workflowData.deployedState) {
logger.info(`[${requestId}] No deployed state available for workflow: ${id}`)
return createSuccessResponse({
deployedState: null,
isDeployed: false
})
}
logger.info(`[${requestId}] Successfully retrieved DEPLOYED state: ${id}`)
return createSuccessResponse({
deployedState: workflowData.deployedState,
isDeployed: true
})
} catch (error: any) {
logger.error(`[${requestId}] Error fetching deployed state: ${id}`, error)
return createErrorResponse(error.message || 'Failed to fetch deployed state', 500)
}
}

View File

@@ -19,6 +19,8 @@ import { ApiEndpoint } from '@/app/w/[id]/components/control-bar/components/depl
import { ApiKey } from '@/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-key/api-key'
import { DeployStatus } from '@/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/deploy-status/deploy-status'
import { ExampleCommand } from '@/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command'
import { DeployedWorkflowModal } from '../../../deployment-controls/components/deployed-workflow-modal'
import { useNotificationStore } from '@/stores/notifications/store'
interface DeploymentInfoProps {
isLoading: boolean
@@ -34,6 +36,7 @@ interface DeploymentInfoProps {
onUndeploy: () => void
isSubmitting: boolean
isUndeploying: boolean
workflowId?: string
}
export function DeploymentInfo({
@@ -43,7 +46,51 @@ export function DeploymentInfo({
onUndeploy,
isSubmitting,
isUndeploying,
workflowId,
}: DeploymentInfoProps) {
const [isViewingDeployed, setIsViewingDeployed] = useState(false)
const [deployedWorkflowState, setDeployedWorkflowState] = useState<any>(null)
const { addNotification } = useNotificationStore()
const handleViewDeployed = async () => {
if (!workflowId) {
addNotification(
'error',
'Cannot view deployment: Workflow ID is missing',
null
)
return
}
try {
const response = await fetch(`/api/workflows/${workflowId}/deployed`)
if (!response.ok) {
throw new Error('Failed to fetch deployed workflow')
}
const data = await response.json()
if (data && data.deployedState) {
setDeployedWorkflowState(data.deployedState)
setIsViewingDeployed(true)
} else {
addNotification(
'error',
'Failed to view deployment: No deployment state found',
workflowId
)
}
} catch (error) {
console.error('Error fetching deployed workflow:', error)
addNotification(
'error',
`Failed to fetch deployed workflow: ${(error as Error).message}`,
workflowId
)
}
}
if (isLoading || !deploymentInfo) {
return (
<div className="space-y-4 px-1 overflow-y-auto">
@@ -78,51 +125,68 @@ export function DeploymentInfo({
}
return (
<div className="space-y-4 px-1 overflow-y-auto">
<div className="space-y-4">
<ApiEndpoint endpoint={deploymentInfo.endpoint} />
<ApiKey apiKey={deploymentInfo.apiKey} />
<ExampleCommand command={deploymentInfo.exampleCommand} apiKey={deploymentInfo.apiKey} />
</div>
<>
<div className="space-y-4 px-1 overflow-y-auto">
<div className="space-y-4">
<ApiEndpoint endpoint={deploymentInfo.endpoint} />
<ApiKey apiKey={deploymentInfo.apiKey} />
<ExampleCommand command={deploymentInfo.exampleCommand} apiKey={deploymentInfo.apiKey} />
</div>
<div className="flex items-center justify-between pt-2 mt-4">
<DeployStatus needsRedeployment={deploymentInfo.needsRedeployment} />
<div className="flex items-center justify-between pt-2 mt-4">
<DeployStatus needsRedeployment={deploymentInfo.needsRedeployment} />
<div className="flex gap-2">
{deploymentInfo.needsRedeployment && (
<Button variant="outline" size="sm" onClick={onRedeploy} disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" /> : null}
{isSubmitting ? 'Redeploying...' : 'Redeploy'}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleViewDeployed}
>
View Deployment
</Button>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={isUndeploying}>
{isUndeploying ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" /> : null}
{isUndeploying ? 'Undeploying...' : 'Undeploy'}
{deploymentInfo.needsRedeployment && (
<Button variant="outline" size="sm" onClick={onRedeploy} disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" /> : null}
{isSubmitting ? 'Redeploying...' : 'Redeploy'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Undeploy API</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to undeploy this workflow? This will remove the API endpoint
and make it unavailable to external users.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onUndeploy}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Undeploy
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={isUndeploying}>
{isUndeploying ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" /> : null}
{isUndeploying ? 'Undeploying...' : 'Undeploy'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Undeploy API</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to undeploy this workflow? This will remove the API endpoint
and make it unavailable to external users.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onUndeploy}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Undeploy
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
</div>
{deployedWorkflowState && (
<DeployedWorkflowModal
isOpen={isViewingDeployed}
onClose={() => setIsViewingDeployed(false)}
deployedWorkflowState={deployedWorkflowState}
/>
)}
</>
)
}

View File

@@ -589,6 +589,7 @@ export function DeployModal({
onUndeploy={handleUndeploy}
isSubmitting={isSubmitting}
isUndeploying={isUndeploying}
workflowId={workflowId || undefined}
/>
) : (
<>

View File

@@ -0,0 +1,82 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { WorkflowPreview } from '@/app/w/components/workflow-preview/generic-workflow-preview'
import { cn } from '@/lib/utils'
interface DeployedWorkflowCardProps {
// Current workflow state (if any)
currentWorkflowState?: {
blocks: Record<string, any>
edges: Array<any>
loops: Record<string, any>
}
// Deployed workflow state from Supabase
deployedWorkflowState: {
blocks: Record<string, any>
edges: Array<any>
loops: Record<string, any>
}
// Optional className for styling
className?: string
}
export function DeployedWorkflowCard({
currentWorkflowState,
deployedWorkflowState,
className,
}: DeployedWorkflowCardProps) {
// State for toggling between deployed and current workflow
const [showingDeployed, setShowingDeployed] = useState(true)
// Determine which workflow state to show
const workflowToShow = showingDeployed ? deployedWorkflowState : currentWorkflowState
return (
<Card className={cn('overflow-hidden', className)}>
<CardHeader className="space-y-4 p-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">
{showingDeployed ? 'Deployed Workflow' : 'Current Workflow'}
</h3>
{/* Controls */}
<div className="flex items-center gap-4">
{/* Version toggle - only show if there's a current version */}
{currentWorkflowState && (
<Button
variant="outline"
size="sm"
onClick={() => setShowingDeployed(!showingDeployed)}
>
{showingDeployed ? 'Show Current' : 'Show Deployed'}
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{/* Workflow preview with fixed height */}
<div className="h-[500px] w-full">
{workflowToShow ? (
<WorkflowPreview
workflowState={workflowToShow}
showSubBlocks={true}
height="100%"
width="100%"
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={1}
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
No workflow data available
</div>
)}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,107 @@
'use client'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
import { DeployedWorkflowCard } from './deployed-workflow-card'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { useState } from 'react'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface DeployedWorkflowModalProps {
isOpen: boolean
onClose: () => void
deployedWorkflowState: {
blocks: Record<string, any>
edges: Array<any>
loops: Record<string, any>
}
}
export function DeployedWorkflowModal({
isOpen,
onClose,
deployedWorkflowState,
}: DeployedWorkflowModalProps) {
const [showRevertDialog, setShowRevertDialog] = useState(false)
const { revertToDeployedState } = useWorkflowStore()
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
// Get current workflow state to compare with deployed state
const currentWorkflowState = useWorkflowStore((state) => ({
blocks: activeWorkflowId ? mergeSubblockState(state.blocks, activeWorkflowId) : state.blocks,
edges: state.edges,
loops: state.loops,
}))
const handleRevert = () => {
revertToDeployedState(deployedWorkflowState)
setShowRevertDialog(false)
onClose()
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className="sm:max-w-[1100px] max-h-[100vh] overflow-y-auto"
style={{ zIndex: 1000 }}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
hideCloseButton={true}
>
<div className="sr-only">
<DialogHeader>
<DialogTitle>Deployed Workflow</DialogTitle>
</DialogHeader>
</div>
<DeployedWorkflowCard
currentWorkflowState={currentWorkflowState}
deployedWorkflowState={deployedWorkflowState}
/>
<div className="flex justify-between mt-6">
<AlertDialog open={showRevertDialog} onOpenChange={setShowRevertDialog}>
<AlertDialogTrigger asChild>
<Button variant="destructive">
Revert to Deployed
</Button>
</AlertDialogTrigger>
<AlertDialogContent
style={{ zIndex: 1001 }}
className="sm:max-w-[425px]"
>
<AlertDialogHeader>
<AlertDialogTitle>Revert to Deployed Version?</AlertDialogTitle>
<AlertDialogDescription>
This will replace your current workflow with the deployed version.
Any unsaved changes will be lost. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRevert}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Revert
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -22,20 +22,10 @@ export function DeploymentControls({
needsRedeployment,
setNeedsRedeployment,
}: DeploymentControlsProps) {
// Store hooks
const { isDeployed } = useWorkflowStore()
// Local state
const [isDeploying, setIsDeploying] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
/**
* Open the deployment modal
*/
const handleOpenModal = () => {
setIsModalOpen(true)
}
return (
<>
<Tooltip>
@@ -44,7 +34,7 @@ export function DeploymentControls({
<Button
variant="ghost"
size="icon"
onClick={handleOpenModal}
onClick={() => setIsModalOpen(true)}
disabled={isDeploying}
className={cn('hover:text-[#802FFF]', isDeployed && 'text-[#802FFF]')}
>
@@ -56,7 +46,6 @@ export function DeploymentControls({
<span className="sr-only">Deploy API</span>
</Button>
{/* Improved redeploy indicator with animation */}
{isDeployed && needsRedeployment && (
<div className="absolute top-0.5 right-0.5 flex items-center justify-center">
<div className="relative">
@@ -88,4 +77,4 @@ export function DeploymentControls({
/>
</>
)
}
}

View File

@@ -804,37 +804,37 @@ export function ControlBar() {
/**
* Render publish button
*/
const renderPublishButton = () => {
const isPublished = isPublishedToMarketplace()
// const renderPublishButton = () => {
// const isPublished = isPublishedToMarketplace()
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handlePublishWorkflow}
disabled={isPublishing}
className={cn('hover:text-[#802FFF]', isPublished && 'text-[#802FFF]')}
>
{isPublishing ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Store className="h-5 w-5" />
)}
<span className="sr-only">Publish to Marketplace</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{isPublishing
? 'Publishing...'
: isPublished
? 'Published to Marketplace'
: 'Publish to Marketplace'}
</TooltipContent>
</Tooltip>
)
}
// return (
// <Tooltip>
// <TooltipTrigger asChild>
// <Button
// variant="ghost"
// size="icon"
// onClick={handlePublishWorkflow}
// disabled={isPublishing}
// className={cn('hover:text-[#802FFF]', isPublished && 'text-[#802FFF]')}
// >
// {isPublishing ? (
// <Loader2 className="h-5 w-5 animate-spin" />
// ) : (
// <Store className="h-5 w-5" />
// )}
// <span className="sr-only">Publish to Marketplace</span>
// </Button>
// </TooltipTrigger>
// <TooltipContent>
// {isPublishing
// ? 'Publishing...'
// : isPublished
// ? 'Published to Marketplace'
// : 'Publish to Marketplace'}
// </TooltipContent>
// </Tooltip>
// )
// }
/**
* Render workflow duplicate button

View File

@@ -639,4 +639,4 @@ export function NotificationAlert({ notification, isFading, onHide }: Notificati
/>
</>
)
}
}

View File

@@ -24,18 +24,8 @@ import { createLoopNode } from '@/app/w/[id]/components/workflow-loop/workflow-l
import { getBlock } from '@/blocks'
import { SubBlockConfig } from '@/blocks/types'
/**
* Extended SubBlockConfig interface with optional value property
* Extends the base SubBlockConfig to include the current value of the subblock
*/
interface ExtendedSubBlockConfig extends SubBlockConfig {
value?: any
}
/**
* WorkflowPreviewProps interface - defines the properties for the WorkflowPreview component
*/
interface WorkflowPreviewProps {
// The workflow state to render
workflowState: {
blocks: Record<string, any>
edges: Array<{
@@ -47,51 +37,66 @@ interface WorkflowPreviewProps {
}>
loops: Record<string, any>
}
// Whether to show subblocks
showSubBlocks?: boolean
// Optional className for container styling
className?: string
// Optional height/width overrides
height?: string | number
width?: string | number
isPannable?: boolean
defaultPosition?: { x: number, y: number }
defaultZoom?: number
}
interface ExtendedSubBlockConfig extends SubBlockConfig {
value?: any
}
// Define node types
const nodeTypes: NodeTypes = {
workflowBlock: PreviewWorkflowBlock,
loopLabel: LoopLabel,
loopInput: LoopInput,
}
// Define edge types
const edgeTypes: EdgeTypes = {
workflowEdge: WorkflowEdge,
}
/**
* Prepares subblocks by combining block state with block configuration
* @param blockSubBlocks - The subblocks from the block state
* @param blockConfig - The configuration for the block
* @returns Array of prepared subblocks with values and configuration
*/
function prepareSubBlocks(blockSubBlocks: Record<string, any> = {}, blockConfig: any) {
// Get the subBlocks from the block config
const configSubBlocks = blockConfig?.subBlocks || []
// Convert the subBlocks object to an array with proper structure
return Object.entries(blockSubBlocks)
.map(([id, subBlock]) => {
// Find the matching config for this subBlock to get the title and other properties
const matchingConfig = configSubBlocks.find((config: any) => config.id === id)
// Skip if no value or value is null/undefined/empty string
const value = subBlock.value
const hasValue = value !== undefined && value !== null && value !== ''
// Only include subblocks with values
if (!hasValue) return null
return {
...matchingConfig, // Include title and other properties from config
...subBlock, // Include value and other properties from state
id, // Ensure id is included
...matchingConfig,
...subBlock,
id,
}
})
.filter(Boolean) // Filter out any undefined or null entries
.filter(Boolean)
}
/**
* Groups subblocks into rows for layout
* @param subBlocks - Array of subblocks to group
* @returns 2D array of subblocks grouped into rows
*/
function groupSubBlocks(subBlocks: ExtendedSubBlockConfig[]) {
const rows: ExtendedSubBlockConfig[][] = []
let currentRow: ExtendedSubBlockConfig[] = []
let currentRowWidth = 0
// Filter visible blocks
const visibleSubBlocks = subBlocks.filter((block) => !block.hidden)
visibleSubBlocks.forEach((block) => {
@@ -128,8 +133,8 @@ function PreviewSubBlock({ config }: { config: ExtendedSubBlockConfig }) {
switch (config.type) {
case 'short-input':
return (
<div className="h-7 rounded-md border border-input bg-background px-3 py-1 text-xs text-muted-foreground">
{config.value || config.placeholder || 'Text input'}
<div className="h-7 rounded-md border border-input bg-background px-3 py-1.5 text-xs text-muted-foreground">
{config.password ? '**********************' : (config.value || config.placeholder || 'Text input')}
</div>
)
case 'long-input':
@@ -198,6 +203,174 @@ function PreviewSubBlock({ config }: { config: ExtendedSubBlockConfig }) {
Tool configuration
</div>
)
case 'oauth-input':
return (
<div className="h-7 rounded-md border border-input bg-background px-3 py-1 text-xs text-muted-foreground flex items-center justify-between">
<span>
{config.value ? 'Connected account' : config.placeholder || 'Select account'}
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="ml-2"
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
)
case 'file-selector':
return (
<div className="h-7 rounded-md border border-input bg-background px-3 py-1 text-xs text-muted-foreground flex items-center justify-between">
<span>
{config.value ? 'File selected' : config.placeholder || 'Select file'}
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="ml-2"
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
)
case 'folder-selector':
return (
<div className="h-7 rounded-md border border-input bg-background px-3 py-1 text-xs text-muted-foreground flex items-center justify-between">
<span>
{config.value ? 'Folder selected' : config.placeholder || 'Select folder'}
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="ml-2"
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
)
case 'project-selector':
return (
<div className="h-7 rounded-md border border-input bg-background px-3 py-1 text-xs text-muted-foreground flex items-center justify-between">
<span>
{config.value ? 'Project selected' : config.placeholder || 'Select project'}
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="ml-2"
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
)
case 'condition-input':
return (
<div className="h-16 rounded-md border border-input bg-background p-2 text-xs text-muted-foreground">
Condition configuration
</div>
)
case 'eval-input':
return (
<div className="h-12 rounded-md border border-input bg-background p-2 text-xs font-mono text-muted-foreground">
Eval expression
</div>
)
case 'date-input':
return (
<div className="h-7 rounded-md border border-input bg-background px-3 py-1 text-xs text-muted-foreground flex items-center justify-between">
<span>{config.value || config.placeholder || 'Select date'}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="ml-2"
>
<rect width="18" height="18" x="3" y="4" rx="2" ry="2" />
<line x1="16" x2="16" y1="2" y2="6" />
<line x1="8" x2="8" y1="2" y2="6" />
<line x1="3" x2="21" y1="10" y2="10" />
</svg>
</div>
)
case 'time-input':
return (
<div className="h-7 rounded-md border border-input bg-background px-3 py-1 text-xs text-muted-foreground flex items-center justify-between">
<span>{config.value || config.placeholder || 'Select time'}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="ml-2"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</div>
)
case 'file-upload':
return (
<div className="h-7 rounded-md border border-dashed border-input bg-background px-3 py-1 text-xs text-muted-foreground flex items-center justify-center">
{config.value ? 'File uploaded' : 'Upload file'}
</div>
)
case 'webhook-config':
return (
<div className="h-7 rounded-md border border-input bg-background px-3 py-1 text-xs text-muted-foreground">
Webhook configuration
</div>
)
case 'schedule-config':
return (
<div className="h-7 rounded-md border border-input bg-background px-3 py-1 text-xs text-muted-foreground">
Schedule configuration
</div>
)
case 'input-format':
return (
<div className="h-7 rounded-md border border-input bg-background px-3 py-1 text-xs text-muted-foreground">
Input format configuration
</div>
)
case 'slider':
return (
<div className="h-7 px-1 py-2">
@@ -230,37 +403,32 @@ function PreviewSubBlock({ config }: { config: ExtendedSubBlockConfig }) {
)
}
/**
* PreviewWorkflowBlock component - Simplified version of WorkflowBlock for preview
* Renders a block with its subblocks in a card layout
* @param id - The ID of the block
* @param data - The data for the block
*/
function PreviewWorkflowBlock({ id, data }: NodeProps<any>) {
const { type, config, name, blockState } = data
const { type, config, name, blockState, showSubBlocks = true } = data
// Prepare subblocks from block state and config
const preparedSubBlocks = prepareSubBlocks(blockState?.subBlocks, config)
// Only prepare subblocks if they should be shown
const preparedSubBlocks = useMemo(() => {
if (!showSubBlocks) return []
return prepareSubBlocks(blockState?.subBlocks, config)
}, [blockState?.subBlocks, config, showSubBlocks])
// Group subblocks for layout
const subBlockRows = groupSubBlocks(preparedSubBlocks)
const subBlockRows = useMemo(() => {
return groupSubBlocks(preparedSubBlocks)
}, [preparedSubBlocks])
return (
<div className="relative">
<Card
className={cn(
'shadow-md select-none relative',
'transition-ring transition-block-bg',
blockState?.isWide ? 'w-[400px]' : 'w-[260px]'
)}
>
<Card className={cn(
'shadow-md select-none relative',
'transition-ring transition-block-bg',
blockState?.isWide ? 'w-[400px]' : 'w-[260px]'
)}>
{/* Block Header */}
<div className="flex items-center justify-between p-2 border-b">
<div className="flex items-center gap-2">
<div
className="flex items-center justify-center w-6 h-6 rounded"
style={{ backgroundColor: config.bgColor }}
>
<div className="flex items-center justify-center w-6 h-6 rounded"
style={{ backgroundColor: config.bgColor }}>
<config.icon className="w-4 h-4 text-white" />
</div>
<span className="font-medium text-sm truncate max-w-[180px]" title={name}>
@@ -269,27 +437,27 @@ function PreviewWorkflowBlock({ id, data }: NodeProps<any>) {
</div>
</div>
{/* Block Content with SubBlocks */}
<div className="px-3 py-2 space-y-2">
{subBlockRows.length > 0 ? (
subBlockRows.map((row, rowIndex) => (
<div key={`row-${rowIndex}`} className="flex gap-2">
{row.map((subBlock, blockIndex) => (
<div
key={`${id}-${rowIndex}-${blockIndex}`}
className={cn('space-y-1', subBlock.layout === 'half' ? 'flex-1' : 'w-full')}
>
<PreviewSubBlock config={subBlock} />
</div>
))}
</div>
))
) : (
<div className="text-xs text-muted-foreground py-2">No configured items</div>
)}
</div>
{/* Block Content */}
{showSubBlocks && (
<div className="px-3 py-2 space-y-2">
{subBlockRows.length > 0 ? (
subBlockRows.map((row, rowIndex) => (
<div key={`row-${rowIndex}`} className="flex gap-2">
{row.map((subBlock, blockIndex) => (
<div key={`${id}-${rowIndex}-${blockIndex}`}
className={cn('space-y-1', subBlock.layout === 'half' ? 'flex-1' : 'w-full')}>
<PreviewSubBlock config={subBlock} />
</div>
))}
</div>
))
) : (
<div className="text-xs text-muted-foreground py-2">No configured items</div>
)}
</div>
)}
{/* Input Handle - Don't show for starter blocks */}
{/* Handles */}
{type !== 'starter' && (
<Handle
type="target"
@@ -304,7 +472,6 @@ function PreviewWorkflowBlock({ id, data }: NodeProps<any>) {
/>
)}
{/* Output Handle */}
{type !== 'condition' && (
<Handle
type="source"
@@ -323,46 +490,20 @@ function PreviewWorkflowBlock({ id, data }: NodeProps<any>) {
)
}
// Define node types and edge types for ReactFlow
const nodeTypes: NodeTypes = {
workflowBlock: PreviewWorkflowBlock,
loopLabel: LoopLabel,
loopInput: LoopInput,
}
const edgeTypes: EdgeTypes = { workflowEdge: WorkflowEdge }
/**
* WorkflowPreviewContent component - Inner content of the workflow preview
* Handles the transformation of workflow state into ReactFlow nodes and edges
* @param workflowState - The state of the workflow to preview
*/
function WorkflowPreviewContent({ workflowState }: WorkflowPreviewProps) {
// Transform blocks into ReactFlow nodes
function WorkflowPreviewContent({
workflowState,
showSubBlocks = true,
className,
height = '100%',
width = '100%',
isPannable = false,
defaultPosition,
defaultZoom,
}: WorkflowPreviewProps) {
// Transform blocks and loops into ReactFlow nodes
const nodes: Node[] = useMemo(() => {
const nodeArray: Node[] = []
// Add block nodes
Object.entries(workflowState.blocks).forEach(([blockId, block]) => {
// Get block configuration from registry
const blockConfig = getBlock(block.type)
if (!blockConfig) return
// Create node
nodeArray.push({
id: blockId,
type: 'workflowBlock',
position: block.position,
data: {
type: block.type,
config: blockConfig,
name: block.name,
blockState: block, // Pass the entire block state
},
draggable: false,
})
})
// Add loop nodes
Object.entries(workflowState.loops || {}).forEach(([loopId, loop]) => {
const loopNodes = createLoopNode({
@@ -380,10 +521,30 @@ function WorkflowPreviewContent({ workflowState }: WorkflowPreviewProps) {
}
})
return nodeArray
}, [workflowState.blocks, workflowState.loops])
// Add block nodes
Object.entries(workflowState.blocks).forEach(([blockId, block]) => {
const blockConfig = getBlock(block.type)
if (!blockConfig) return
// Transform edges into ReactFlow edges
nodeArray.push({
id: blockId,
type: 'workflowBlock',
position: block.position,
data: {
type: block.type,
config: blockConfig,
name: block.name,
blockState: block,
showSubBlocks,
},
draggable: false,
})
})
return nodeArray
}, [workflowState.blocks, workflowState.loops, showSubBlocks])
// Transform edges
const edges: Edge[] = useMemo(() => {
return workflowState.edges.map((edge) => ({
id: edge.id,
@@ -392,66 +553,41 @@ function WorkflowPreviewContent({ workflowState }: WorkflowPreviewProps) {
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
type: 'workflowEdge',
data: {
// No edge deletion in preview mode
onDelete: undefined,
selectedEdgeId: null,
},
}))
}, [workflowState.edges])
return (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
fitViewOptions={{
padding: 0,
minZoom: 0.2,
maxZoom: 3,
}}
minZoom={0.2}
maxZoom={3}
defaultEdgeOptions={{ type: 'workflowEdge' }}
proOptions={{ hideAttribution: true }}
connectionLineType={ConnectionLineType.SmoothStep}
connectionLineStyle={{
stroke: '#94a3b8',
strokeWidth: 1,
strokeDasharray: '3,3',
}}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
panOnScroll={false}
zoomOnScroll={false}
zoomOnPinch={false}
zoomOnDoubleClick={false}
panOnDrag={false}
preventScrolling={false}
disableKeyboardA11y={true}
attributionPosition="bottom-right"
className="w-full h-full pointer-events-none"
style={{ background: 'transparent', pointerEvents: 'none' }}
>
<Background gap={12} size={1} className="opacity-30 pointer-events-none" />
</ReactFlow>
<div style={{ height, width }} className={className}>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionLineType={ConnectionLineType.SmoothStep}
fitView
panOnScroll={false}
panOnDrag={isPannable}
zoomOnScroll={false}
draggable={false}
defaultViewport={{
x: defaultPosition?.x ?? 0,
y: defaultPosition?.y ?? 0,
zoom: defaultZoom ?? 1,
}}
minZoom={0.1}
maxZoom={2}
proOptions={{ hideAttribution: true }}
>
<Background />
</ReactFlow>
</div>
)
}
/**
* WorkflowPreview component - Main exported component for workflow preview
* Wraps the preview content in a ReactFlowProvider
* @param workflowState - The state of the workflow to preview
*/
export function WorkflowPreview({ workflowState }: WorkflowPreviewProps) {
export function WorkflowPreview(props: WorkflowPreviewProps) {
return (
<ReactFlowProvider>
<div className="h-full w-full -m-1">
<WorkflowPreviewContent workflowState={workflowState} />
</div>
<WorkflowPreviewContent {...props} />
</ReactFlowProvider>
)
}
}

View File

@@ -6,7 +6,7 @@ import { Eye } from 'lucide-react'
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { Workflow } from '../marketplace'
import { WorkflowPreview } from './workflow-preview'
import { WorkflowPreview } from '@/app/w/components/workflow-preview/generic-workflow-preview'
/**
* WorkflowCardProps interface - defines the properties for the WorkflowCard component
@@ -30,6 +30,7 @@ export function WorkflowCard({ workflow, onHover }: WorkflowCardProps) {
const router = useRouter()
const { createWorkflow } = useWorkflowRegistry()
// When workflow state becomes available, update preview ready state
useEffect(() => {
if (workflow.workflowState && !isPreviewReady) {
@@ -93,7 +94,7 @@ export function WorkflowCard({ workflow, onHover }: WorkflowCardProps) {
{/* Workflow preview/thumbnail area */}
<div className="h-40 relative overflow-hidden bg-gradient-to-br from-slate-100 to-slate-200 dark:from-slate-800 dark:to-slate-900">
{isPreviewReady && workflow.workflowState ? (
// Show interactive workflow preview if state is available
// Interactive Preview
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-full h-full transform-gpu scale-[0.9]">
<WorkflowPreview workflowState={workflow.workflowState} />
@@ -108,7 +109,8 @@ export function WorkflowCard({ workflow, onHover }: WorkflowCardProps) {
backgroundSize: 'cover',
backgroundPosition: 'center top',
}}
/>
>
</div>
) : (
// Fallback to text if no preview or thumbnail is available
<div className="h-full w-full flex items-center justify-center">

2786
sim/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,11 +17,17 @@ export interface NotificationSection {
content: string
}
export interface NotificationAction {
label: string
onClick: () => void
}
export interface NotificationOptions {
copyableContent?: string
isPersistent?: boolean
sections?: NotificationSection[]
needsRedeployment?: boolean
actions?: NotificationAction[]
}
export interface NotificationStore {

View File

@@ -32,6 +32,7 @@ const MAX_HISTORY_LENGTH = 20
// Types for workflow store with history management capabilities
export interface WorkflowStoreWithHistory extends WorkflowStore, HistoryActions {
history: WorkflowHistory
revertToDeployedState: (deployedState: WorkflowState) => void
}
// Higher-order store middleware that adds undo/redo functionality

View File

@@ -9,7 +9,7 @@ import { useWorkflowRegistry } from '../registry/store'
import { useSubBlockStore } from '../subblock/store'
import { workflowSync } from '../sync'
import { mergeSubblockState } from '../utils'
import { Loop, Position, SubBlockState } from './types'
import { Loop, Position, SubBlockState, WorkflowState } from './types'
import { detectCycle } from './utils'
const initialState = {
@@ -669,6 +669,21 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
get().updateLastSaved()
}
},
revertToDeployedState: (deployedState: WorkflowState) => {
const newState = {
blocks: deployedState.blocks,
edges: deployedState.edges,
loops: deployedState.loops,
isDeployed: true,
needsRedeployment: false,
}
set(newState)
pushHistory(set, get, newState, 'Reverted to deployed state')
get().updateLastSaved()
workflowSync.sync()
},
})),
{ name: 'workflow-store' }
)