mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(starterinput): added option to specify input format for starter block for api triggered executions (#204)
* added docs to landing page * added basic input format for starter block * improvements for input format for api calls, tested with and without input format * restore old db configs for idle timeout and connect timeout * added tests for execution with formatted input * added syncing of subblock values to make input format persist * fixed empty variable name bug * hide api key in deployment notif, fix db issues
This commit is contained in:
@@ -49,6 +49,13 @@ export default function NavClient({ children }: { children: React.ReactNode }) {
|
||||
|
||||
{/* Social media icons */}
|
||||
<div className={`flex items-center ${isMobile ? 'gap-2' : 'gap-3'}`}>
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_DOCS_URL}/docs`}
|
||||
className="text-white/80 hover:text-white/100 text-xl p-2 rounded-md hover:scale-[1.04] transition-colors transition-transform duration-200"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
docs
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/simstudioai"
|
||||
className="text-white/80 hover:text-white/100 p-2 rounded-md group hover:scale-[1.04] transition-colors transition-transform duration-200"
|
||||
|
||||
@@ -3,10 +3,24 @@
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockRequest } from '@/app/api/__test-utils__/utils'
|
||||
|
||||
describe('Workflow Execution API Route', () => {
|
||||
let executeMock = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
output: {
|
||||
response: 'Test response',
|
||||
},
|
||||
logs: [],
|
||||
metadata: {
|
||||
duration: 123,
|
||||
startTime: new Date().toISOString(),
|
||||
endTime: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
|
||||
@@ -48,21 +62,24 @@ describe('Workflow Execution API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
// Reset execute mock to track calls
|
||||
executeMock = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
output: {
|
||||
response: 'Test response',
|
||||
},
|
||||
logs: [],
|
||||
metadata: {
|
||||
duration: 123,
|
||||
startTime: new Date().toISOString(),
|
||||
endTime: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
// Mock executor
|
||||
vi.doMock('@/executor', () => ({
|
||||
Executor: vi.fn().mockImplementation(() => ({
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
output: {
|
||||
response: 'Test response',
|
||||
},
|
||||
logs: [],
|
||||
metadata: {
|
||||
duration: 123,
|
||||
startTime: new Date().toISOString(),
|
||||
endTime: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
execute: executeMock,
|
||||
})),
|
||||
}))
|
||||
|
||||
@@ -110,6 +127,11 @@ describe('Workflow Execution API Route', () => {
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
update: vi.fn().mockImplementation(() => ({
|
||||
set: vi.fn().mockImplementation(() => ({
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
|
||||
return { db: mockDb }
|
||||
@@ -178,6 +200,9 @@ describe('Workflow Execution API Route', () => {
|
||||
// Verify executor was initialized
|
||||
const Executor = (await import('@/executor')).Executor
|
||||
expect(Executor).toHaveBeenCalled()
|
||||
|
||||
// Verify execute was called with undefined input (GET requests don't have body)
|
||||
expect(executeMock).toHaveBeenCalledWith('workflow-id')
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -232,6 +257,124 @@ describe('Workflow Execution API Route', () => {
|
||||
// Verify executor was constructed
|
||||
const Executor = (await import('@/executor')).Executor
|
||||
expect(Executor).toHaveBeenCalled()
|
||||
|
||||
// Verify execute was called with the input body
|
||||
expect(executeMock).toHaveBeenCalledWith('workflow-id')
|
||||
// Verify the body was passed to the executor constructor
|
||||
expect(Executor).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
requestBody
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test POST execution with structured input matching the input format
|
||||
*/
|
||||
it('should execute workflow with structured input matching the input format', async () => {
|
||||
// Create structured input matching the expected input format
|
||||
const structuredInput = {
|
||||
firstName: 'John',
|
||||
age: 30,
|
||||
isActive: true,
|
||||
preferences: { theme: 'dark' },
|
||||
tags: ['test', 'api'],
|
||||
}
|
||||
|
||||
// Create a mock request with the structured input
|
||||
const req = createMockRequest('POST', structuredInput)
|
||||
|
||||
// Create params similar to what Next.js would provide
|
||||
const params = Promise.resolve({ id: 'workflow-id' })
|
||||
|
||||
// Import the handler after mocks are set up
|
||||
const { POST } = await import('./route')
|
||||
|
||||
// Call the handler
|
||||
const response = await POST(req, { params })
|
||||
|
||||
// Ensure response exists and is successful
|
||||
expect(response).toBeDefined()
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
// Parse the response body
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('success', true)
|
||||
|
||||
// Verify the executor was constructed with the structured input
|
||||
const Executor = (await import('@/executor')).Executor
|
||||
expect(Executor).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
structuredInput
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test POST execution with empty request body
|
||||
*/
|
||||
it('should execute workflow with empty request body', async () => {
|
||||
// Create a mock request with empty body
|
||||
const req = createMockRequest('POST')
|
||||
|
||||
// Create params similar to what Next.js would provide
|
||||
const params = Promise.resolve({ id: 'workflow-id' })
|
||||
|
||||
// Import the handler after mocks are set up
|
||||
const { POST } = await import('./route')
|
||||
|
||||
// Call the handler
|
||||
const response = await POST(req, { params })
|
||||
|
||||
// Ensure response exists and is successful
|
||||
expect(response).toBeDefined()
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
// Parse the response body
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('success', true)
|
||||
|
||||
// Verify the executor was constructed with an empty object
|
||||
const Executor = (await import('@/executor')).Executor
|
||||
expect(Executor).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
{}
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test POST execution with invalid JSON body
|
||||
*/
|
||||
it('should handle invalid JSON in request body', async () => {
|
||||
// Create a mock request with invalid JSON text
|
||||
const req = new NextRequest('https://example.com/api/workflows/workflow-id/execute', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: 'this is not valid JSON',
|
||||
})
|
||||
|
||||
// Create params similar to what Next.js would provide
|
||||
const params = Promise.resolve({ id: 'workflow-id' })
|
||||
|
||||
// Import the handler after mocks are set up
|
||||
const { POST } = await import('./route')
|
||||
|
||||
// Call the handler - should throw an error when trying to parse the body
|
||||
const response = await POST(req, { params })
|
||||
|
||||
// Expect error response due to JSON parsing failure
|
||||
expect(response.status).toBe(500)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error')
|
||||
// Check for JSON parse error message rather than "Failed to execute workflow"
|
||||
expect(data.error).toContain('JSON')
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -234,7 +234,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return createErrorResponse(validation.error.message, validation.error.status)
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const bodyText = await request.text()
|
||||
const body = bodyText ? JSON.parse(bodyText) : {}
|
||||
|
||||
const result = await executeWorkflow(validation.workflow, requestId, body)
|
||||
return createSuccessResponse(result)
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -43,6 +43,7 @@ import { useExecutionStore } from '@/stores/execution/store'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import { useWorkflowExecution } from '../../hooks/use-workflow-execution'
|
||||
import { HistoryDropdownItem } from './components/history-dropdown-item/history-dropdown-item'
|
||||
@@ -205,12 +206,64 @@ export function ControlBar() {
|
||||
removeWorkflow(activeWorkflowId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an example of the input format for the workflow
|
||||
*/
|
||||
const getInputFormatExample = () => {
|
||||
let inputFormatExample = ''
|
||||
try {
|
||||
// Find the starter block in the workflow
|
||||
const blocks = Object.values(useWorkflowStore.getState().blocks)
|
||||
const starterBlock = blocks.find((block) => block.type === 'starter')
|
||||
|
||||
if (starterBlock) {
|
||||
const inputFormat = useSubBlockStore.getState().getValue(starterBlock.id, 'inputFormat')
|
||||
|
||||
// If input format is defined, create an example
|
||||
if (inputFormat && Array.isArray(inputFormat) && inputFormat.length > 0) {
|
||||
const exampleData: Record<string, any> = {}
|
||||
|
||||
// Create example values for each field
|
||||
inputFormat.forEach((field) => {
|
||||
if (field.name) {
|
||||
switch (field.type) {
|
||||
case 'string':
|
||||
exampleData[field.name] = 'example'
|
||||
break
|
||||
case 'number':
|
||||
exampleData[field.name] = 42
|
||||
break
|
||||
case 'boolean':
|
||||
exampleData[field.name] = true
|
||||
break
|
||||
case 'object':
|
||||
exampleData[field.name] = { key: 'value' }
|
||||
break
|
||||
case 'array':
|
||||
exampleData[field.name] = [1, 2, 3]
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
inputFormatExample = ` -d '${JSON.stringify(exampleData)}'`
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating input format example:', error)
|
||||
}
|
||||
|
||||
return inputFormatExample
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow deployment handler
|
||||
*/
|
||||
const handleDeploy = async () => {
|
||||
if (!activeWorkflowId) return
|
||||
|
||||
const inputFormatExample = getInputFormatExample()
|
||||
|
||||
// If already deployed, show the API info
|
||||
if (isDeployed) {
|
||||
// Try to find an existing API notification
|
||||
@@ -249,7 +302,7 @@ export function ControlBar() {
|
||||
{
|
||||
label: 'Example curl command',
|
||||
content: apiKey
|
||||
? `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json" ${endpoint}`
|
||||
? `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`
|
||||
: `You need an API key to call this endpoint. Visit your account settings to create one.`,
|
||||
},
|
||||
],
|
||||
@@ -292,7 +345,7 @@ export function ControlBar() {
|
||||
{
|
||||
label: 'Example curl command',
|
||||
content: apiKey
|
||||
? `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json" ${endpoint}`
|
||||
? `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`
|
||||
: `You need an API key to call this endpoint. Visit your account settings to create one.`,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Info, Rocket, Store, Terminal, X } from 'lucide-react'
|
||||
import { Copy, Eye, EyeOff, Info, Rocket, Store, Terminal, X } from 'lucide-react'
|
||||
import { ErrorIcon } from '@/components/icons'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import {
|
||||
@@ -258,6 +258,7 @@ interface NotificationAlertProps {
|
||||
function NotificationAlert({ notification, isFading, onHide }: NotificationAlertProps) {
|
||||
const { id, type, message, options, workflowId } = notification
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [showApiKey, setShowApiKey] = useState(false)
|
||||
const { setDeploymentStatus } = useWorkflowStore()
|
||||
const { isDeployed } = useWorkflowStore((state) => ({
|
||||
isDeployed: state.isDeployed,
|
||||
@@ -288,6 +289,27 @@ function NotificationAlert({ notification, isFading, onHide }: NotificationAlert
|
||||
}
|
||||
}
|
||||
|
||||
// Function to mask API key with asterisks but keep first and last 4 chars visible
|
||||
const maskApiKey = (key: string) => {
|
||||
if (!key || key.includes('No API key found')) return key
|
||||
if (key.length <= 8) return key
|
||||
return `${key.substring(0, 4)}${'*'.repeat(key.length - 8)}${key.substring(key.length - 4)}`
|
||||
}
|
||||
|
||||
// Modify the curl command to use a placeholder for the API key
|
||||
const formatCurlCommand = (command: string, apiKey: string) => {
|
||||
if (!command.includes('curl')) return command
|
||||
|
||||
// Replace the actual API key with a placeholder in the command
|
||||
const sanitizedCommand = command.replace(apiKey, 'SIM_API_KEY')
|
||||
|
||||
// Format the command with line breaks for better readability
|
||||
return sanitizedCommand
|
||||
.replace(' -H ', '\n -H ')
|
||||
.replace(' -d ', '\n -d ')
|
||||
.replace(' http', '\n http')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
@@ -317,19 +339,52 @@ function NotificationAlert({ notification, isFading, onHide }: NotificationAlert
|
||||
<p>{!isDeployed ? 'Workflow currently not deployed' : message}</p>
|
||||
|
||||
{/* Optional sections with copyable content */}
|
||||
{options?.sections?.map((section, index) => (
|
||||
<div key={index} className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground">{section.label}</div>
|
||||
{options?.sections?.map((section, index) => {
|
||||
// Get the API key from the sections to use in curl command formatting
|
||||
const apiKey = options.sections?.find((s) => s.label === 'API Key')?.content || ''
|
||||
|
||||
{/* Copyable code block */}
|
||||
<div className="relative group rounded-md border bg-muted/50 hover:bg-muted/80 transition-colors">
|
||||
<pre className="p-3 text-xs font-mono whitespace-pre-wrap overflow-x-auto">
|
||||
{section.content}
|
||||
</pre>
|
||||
<CopyButton text={section.content} />
|
||||
return (
|
||||
<div key={index} className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{section.label}
|
||||
</div>
|
||||
|
||||
{/* Copyable code block */}
|
||||
<div className="relative group rounded-md border bg-muted/50 hover:bg-muted/80 transition-colors">
|
||||
{section.label === 'API Key' ? (
|
||||
<>
|
||||
<pre
|
||||
className="p-3 text-xs font-mono whitespace-pre-wrap overflow-x-auto cursor-pointer"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
title={
|
||||
showApiKey ? 'Click to hide API Key' : 'Click to reveal API Key'
|
||||
}
|
||||
>
|
||||
{showApiKey ? section.content : maskApiKey(section.content)}
|
||||
</pre>
|
||||
<div className="text-xs font-mono whitespace-pre-wrap overflow-x-auto">
|
||||
<CopyButton text={section.content} showLabel={false} />
|
||||
</div>
|
||||
</>
|
||||
) : section.label === 'Example curl command' ? (
|
||||
<>
|
||||
<pre className="p-3 text-xs font-mono whitespace-pre-wrap overflow-x-auto">
|
||||
{formatCurlCommand(section.content, apiKey)}
|
||||
</pre>
|
||||
<CopyButton text={section.content} showLabel={false} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<pre className="p-3 text-xs font-mono whitespace-pre-wrap overflow-x-auto">
|
||||
{section.content}
|
||||
</pre>
|
||||
<CopyButton text={section.content} showLabel={false} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Status and Delete button row - with pulsing green indicator */}
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
+51
@@ -1,4 +1,5 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { ConnectedBlock, useBlockConnections } from '@/app/w/[id]/hooks/use-block-connections'
|
||||
|
||||
interface ConnectionBlocksProps {
|
||||
@@ -69,6 +70,42 @@ export function ConnectionBlocks({ blockId, setIsConnecting }: ConnectionBlocksP
|
||||
}))
|
||||
}
|
||||
|
||||
// Extract fields from starter block input format
|
||||
const extractFieldsFromStarterInput = (connection: ConnectedBlock): ResponseField[] => {
|
||||
// Only process for starter blocks
|
||||
if (connection.type !== 'starter') return []
|
||||
|
||||
try {
|
||||
// Get input format from subblock store
|
||||
const inputFormat = useSubBlockStore.getState().getValue(connection.id, 'inputFormat')
|
||||
|
||||
// Make sure we have a valid input format
|
||||
if (!inputFormat || !Array.isArray(inputFormat) || inputFormat.length === 0) {
|
||||
return [{ name: 'input', type: 'any' }]
|
||||
}
|
||||
|
||||
// Check if any fields have been configured with names
|
||||
const hasConfiguredFields = inputFormat.some(
|
||||
(field: any) => field.name && field.name.trim() !== ''
|
||||
)
|
||||
|
||||
// If no fields have been configured, return the default input field
|
||||
if (!hasConfiguredFields) {
|
||||
return [{ name: 'input', type: 'any' }]
|
||||
}
|
||||
|
||||
// Map input fields to response fields
|
||||
return inputFormat.map((field: any) => ({
|
||||
name: `input.${field.name}`,
|
||||
type: field.type || 'string',
|
||||
description: field.description,
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error('Error extracting fields from starter input format:', e)
|
||||
return [{ name: 'input', type: 'any' }]
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate connections by ID
|
||||
const connectionMap = incomingConnections.reduce(
|
||||
(acc, connection) => {
|
||||
@@ -112,6 +149,20 @@ export function ConnectionBlocks({ blockId, setIsConnecting }: ConnectionBlocksP
|
||||
return (
|
||||
<div className="absolute right-full pr-5 top-0 space-y-2 flex flex-col items-end max-h-[400px] overflow-y-auto">
|
||||
{sortedConnections.map((connection, index) => {
|
||||
// Special handling for starter blocks with input format
|
||||
if (connection.type === 'starter') {
|
||||
const starterFields = extractFieldsFromStarterInput(connection)
|
||||
|
||||
if (starterFields.length > 0) {
|
||||
return (
|
||||
<div key={connection.id} className="space-y-2">
|
||||
{starterFields.map((field) => renderConnectionCard(connection, field))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Regular connection handling
|
||||
return (
|
||||
<div key={`${connection.id}-${index}`} className="space-y-2">
|
||||
{Array.isArray(connection.outputType)
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
import { ChevronDown, Plus, Trash } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
|
||||
|
||||
interface InputField {
|
||||
id: string
|
||||
name: string
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array'
|
||||
collapsed?: boolean
|
||||
}
|
||||
|
||||
interface InputFormatProps {
|
||||
blockId: string
|
||||
subBlockId: string
|
||||
}
|
||||
|
||||
// Default values
|
||||
const DEFAULT_FIELD: InputField = {
|
||||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
type: 'string',
|
||||
collapsed: true,
|
||||
}
|
||||
|
||||
export function InputFormat({ blockId, subBlockId }: InputFormatProps) {
|
||||
// State hooks
|
||||
const [value, setValue] = useSubBlockValue<InputField[]>(blockId, subBlockId)
|
||||
const fields = value || [DEFAULT_FIELD]
|
||||
|
||||
// Field operations
|
||||
const addField = () => {
|
||||
const newField: InputField = {
|
||||
...DEFAULT_FIELD,
|
||||
id: crypto.randomUUID(),
|
||||
}
|
||||
setValue([...fields, newField])
|
||||
}
|
||||
|
||||
const removeField = (id: string) => {
|
||||
if (fields.length === 1) return
|
||||
setValue(fields.filter((field) => field.id !== id))
|
||||
}
|
||||
|
||||
// Update handlers
|
||||
const updateField = (id: string, field: keyof InputField, value: any) => {
|
||||
setValue(fields.map((f) => (f.id === id ? { ...f, [field]: value } : f)))
|
||||
}
|
||||
|
||||
const toggleCollapse = (id: string) => {
|
||||
setValue(fields.map((f) => (f.id === id ? { ...f, collapsed: !f.collapsed } : f)))
|
||||
}
|
||||
|
||||
// Field header
|
||||
const renderFieldHeader = (field: InputField, index: number) => {
|
||||
const isUnconfigured = !field.name || field.name.trim() === ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-9 items-center justify-between px-3 py-1 cursor-pointer"
|
||||
onClick={() => toggleCollapse(field.id)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm',
|
||||
isUnconfigured ? 'text-muted-foreground/50' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{field.name ? field.name : `Field ${index + 1}`}
|
||||
</span>
|
||||
{field.name && (
|
||||
<Badge variant="outline" className="ml-2 py-0 h-5 text-xs font-normal bg-muted">
|
||||
{field.type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" onClick={addField} className="h-6 w-6 rounded-full">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Add Field</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeField(field.id)}
|
||||
disabled={fields.length === 1}
|
||||
className="h-6 w-6 rounded-full text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Delete Field</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Check if any fields have been configured
|
||||
const hasConfiguredFields = fields.some((field) => field.name && field.name.trim() !== '')
|
||||
|
||||
// Main render
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{fields.map((field, index) => {
|
||||
const isUnconfigured = !field.name || field.name.trim() === ''
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
data-field-id={field.id}
|
||||
className={cn(
|
||||
'rounded-md border shadow-sm',
|
||||
isUnconfigured ? 'border-input/50' : 'border-input',
|
||||
field.collapsed ? 'overflow-hidden' : 'overflow-visible'
|
||||
)}
|
||||
>
|
||||
{renderFieldHeader(field, index)}
|
||||
|
||||
{!field.collapsed && (
|
||||
<div className="px-3 pt-1.5 pb-2 space-y-2 border-t">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Name</Label>
|
||||
<Input
|
||||
name="name"
|
||||
value={field.name}
|
||||
onChange={(e) => updateField(field.id, 'name', e.target.value)}
|
||||
placeholder="firstName"
|
||||
className="h-9 placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Type</Label>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-between h-9 font-normal">
|
||||
<div className="flex items-center">
|
||||
<span>{field.type}</span>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateField(field.id, 'type', 'string')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span className="font-mono mr-2">Aa</span>
|
||||
<span>String</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateField(field.id, 'type', 'number')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span className="font-mono mr-2">123</span>
|
||||
<span>Number</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateField(field.id, 'type', 'boolean')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span className="font-mono mr-2">0/1</span>
|
||||
<span>Boolean</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateField(field.id, 'type', 'object')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span className="font-mono mr-2">{'{}'}</span>
|
||||
<span>Object</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => updateField(field.id, 'type', 'array')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span className="font-mono mr-2">[]</span>
|
||||
<span>Array</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{!hasConfiguredFields && (
|
||||
<div className="text-xs text-muted-foreground/70 italic mt-1 px-1">
|
||||
Define fields above to enable structured API input
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-1
@@ -28,7 +28,6 @@ export function SlackConfig({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slack-signing-secret">Signing Secret</Label>
|
||||
<CopyableField
|
||||
id="slack-signing-secret"
|
||||
label="Signing Secret"
|
||||
|
||||
@@ -17,6 +17,7 @@ import { LongInput } from './components/long-input'
|
||||
import { ScheduleConfig } from './components/schedule/schedule-config'
|
||||
import { ShortInput } from './components/short-input'
|
||||
import { SliderInput } from './components/slider-input'
|
||||
import { InputFormat } from './components/starter/input-format'
|
||||
import { Switch } from './components/switch'
|
||||
import { Table } from './components/table'
|
||||
import { TimeInput } from './components/time-input'
|
||||
@@ -161,8 +162,10 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
|
||||
return <FileSelectorInput blockId={blockId} subBlock={config} disabled={isConnecting} />
|
||||
case 'folder-selector':
|
||||
return <FolderSelectorInput blockId={blockId} subBlock={config} disabled={isConnecting} />
|
||||
case 'input-format':
|
||||
return <InputFormat blockId={blockId} subBlockId={config.id} />
|
||||
default:
|
||||
return null
|
||||
return <div>Unknown input type: {config.type}</div>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,14 @@ export const StarterBlock: BlockConfig<StarterBlockOutput> = {
|
||||
],
|
||||
value: () => 'manual',
|
||||
},
|
||||
// Structured Input format - visible if manual run is selected
|
||||
{
|
||||
id: 'inputFormat',
|
||||
title: 'Input Format (for API calls)',
|
||||
type: 'input-format',
|
||||
layout: 'full',
|
||||
condition: { field: 'startWorkflow', value: 'manual' },
|
||||
},
|
||||
// Webhook configuration
|
||||
{
|
||||
id: 'webhookProvider',
|
||||
|
||||
+5
-1
@@ -30,6 +30,7 @@ export type SubBlockType =
|
||||
| 'schedule-config' // Schedule status and information
|
||||
| 'file-selector' // File selector for Google Drive, etc.
|
||||
| 'folder-selector' // Folder selector for Gmail, etc.
|
||||
| 'input-format' // Input structure format
|
||||
|
||||
// Component width setting
|
||||
export type SubBlockLayout = 'full' | 'half'
|
||||
@@ -84,7 +85,10 @@ export interface SubBlockConfig {
|
||||
title?: string
|
||||
type: SubBlockType
|
||||
layout?: SubBlockLayout
|
||||
options?: string[] | { label: string; id: string }[] | (() => string[] | { label: string; id: string }[])
|
||||
options?:
|
||||
| string[]
|
||||
| { label: string; id: string }[]
|
||||
| (() => string[] | { label: string; id: string }[])
|
||||
min?: number
|
||||
max?: number
|
||||
columns?: string[]
|
||||
|
||||
@@ -115,9 +115,31 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
return prefix ? [prefix] : []
|
||||
}
|
||||
|
||||
// Special handling for starter block.
|
||||
// TODO: In the future, we will support response formats and required input types. For now, we just take the input altogether.
|
||||
// Special handling for starter block with input format
|
||||
if (isStarterBlock && prefix === 'response') {
|
||||
try {
|
||||
// Check if there's an input format defined
|
||||
const inputFormatValue = useSubBlockStore
|
||||
.getState()
|
||||
.getValue(activeSourceBlockId || blockId, 'inputFormat')
|
||||
if (inputFormatValue && Array.isArray(inputFormatValue) && inputFormatValue.length > 0) {
|
||||
// Check if any fields have been configured with names
|
||||
const hasConfiguredFields = inputFormatValue.some(
|
||||
(field: any) => field.name && field.name.trim() !== ''
|
||||
)
|
||||
|
||||
// If no fields have been configured, return the default input path
|
||||
if (!hasConfiguredFields) {
|
||||
return ['response.input']
|
||||
}
|
||||
|
||||
// Return fields from input format
|
||||
return inputFormatValue.map((field: any) => `response.input.${field.name}`)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error parsing input format:', { e })
|
||||
}
|
||||
|
||||
return ['response.input']
|
||||
}
|
||||
|
||||
|
||||
@@ -8,5 +8,9 @@ const connectionString = process.env.POSTGRES_URL || process.env.DATABASE_URL!
|
||||
// Disable prefetch as it is not supported for "Transaction" pool mode
|
||||
const client = postgres(connectionString, {
|
||||
prepare: false,
|
||||
idle_timeout: 30, // Keep connections alive for 30 seconds when idle
|
||||
connect_timeout: 30, // Timeout after 30 seconds when connecting
|
||||
})
|
||||
|
||||
// Export the database client (never null)
|
||||
export const db = drizzle(client)
|
||||
|
||||
+108
-12
@@ -39,8 +39,8 @@ export class Executor {
|
||||
private workflow: SerializedWorkflow,
|
||||
private initialBlockStates: Record<string, BlockOutput> = {},
|
||||
private environmentVariables: Record<string, string> = {},
|
||||
private workflowVariables: Record<string, any> = {},
|
||||
workflowInput?: any
|
||||
workflowInput?: any,
|
||||
private workflowVariables: Record<string, any> = {}
|
||||
) {
|
||||
this.validateWorkflow()
|
||||
this.workflowInput = workflowInput || {}
|
||||
@@ -334,18 +334,114 @@ export class Executor {
|
||||
const starterBlock = this.workflow.blocks.find((block) => block.metadata?.id === 'starter')
|
||||
if (starterBlock) {
|
||||
// Initialize the starter block with the workflow input
|
||||
const starterOutput = {
|
||||
response: {
|
||||
input: this.workflowInput,
|
||||
},
|
||||
try {
|
||||
const blockParams = starterBlock.config.params
|
||||
const inputFormat = blockParams?.inputFormat
|
||||
|
||||
// If input format is defined, structure the input according to the schema
|
||||
if (inputFormat && Array.isArray(inputFormat) && inputFormat.length > 0) {
|
||||
// Create structured input based on input format
|
||||
const structuredInput: Record<string, any> = {}
|
||||
|
||||
// Process each field in the input format
|
||||
for (const field of inputFormat) {
|
||||
if (field.name && field.type) {
|
||||
// Get the field value from workflow input if available
|
||||
const inputValue = this.workflowInput?.[field.name]
|
||||
|
||||
// Convert the value to the appropriate type
|
||||
let typedValue = inputValue
|
||||
if (inputValue !== undefined) {
|
||||
if (field.type === 'number' && typeof inputValue !== 'number') {
|
||||
typedValue = Number(inputValue)
|
||||
} else if (field.type === 'boolean' && typeof inputValue !== 'boolean') {
|
||||
typedValue = inputValue === 'true' || inputValue === true
|
||||
} else if (
|
||||
(field.type === 'object' || field.type === 'array') &&
|
||||
typeof inputValue === 'string'
|
||||
) {
|
||||
try {
|
||||
typedValue = JSON.parse(inputValue)
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to parse ${field.type} input for field ${field.name}:`, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the field to structured input
|
||||
structuredInput[field.name] = typedValue
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the starter block with structured input
|
||||
const starterOutput = {
|
||||
response: {
|
||||
input: structuredInput,
|
||||
...structuredInput, // Add input fields directly at response level too
|
||||
},
|
||||
}
|
||||
|
||||
context.blockStates.set(starterBlock.id, {
|
||||
output: starterOutput,
|
||||
executed: true,
|
||||
executionTime: 0,
|
||||
})
|
||||
} else {
|
||||
// No input format defined or not an array,
|
||||
// check if we're receiving input from API call
|
||||
if (this.workflowInput && typeof this.workflowInput === 'object') {
|
||||
// For API calls, use the raw input but make it accessible at both paths
|
||||
const starterOutput = {
|
||||
response: {
|
||||
input: this.workflowInput,
|
||||
...this.workflowInput, // Make fields directly accessible at response level
|
||||
},
|
||||
}
|
||||
|
||||
logger.info(`Using API input type: ${typeof this.workflowInput}`, {
|
||||
isArray: Array.isArray(this.workflowInput),
|
||||
keys: Object.keys(this.workflowInput),
|
||||
rawInput: JSON.stringify(this.workflowInput),
|
||||
inputEmpty: Object.keys(this.workflowInput).length === 0,
|
||||
})
|
||||
|
||||
context.blockStates.set(starterBlock.id, {
|
||||
output: starterOutput,
|
||||
executed: true,
|
||||
executionTime: 0,
|
||||
})
|
||||
} else {
|
||||
// Fallback for other cases
|
||||
const starterOutput = {
|
||||
response: {
|
||||
input: this.workflowInput,
|
||||
},
|
||||
}
|
||||
|
||||
context.blockStates.set(starterBlock.id, {
|
||||
output: starterOutput,
|
||||
executed: true,
|
||||
executionTime: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Error processing starter block input format:', e)
|
||||
// Fallback to raw input with both paths accessible
|
||||
const starterOutput = {
|
||||
response: {
|
||||
input: this.workflowInput,
|
||||
...this.workflowInput, // Add input fields directly at response level too
|
||||
},
|
||||
}
|
||||
|
||||
context.blockStates.set(starterBlock.id, {
|
||||
output: starterOutput,
|
||||
executed: true,
|
||||
executionTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
context.blockStates.set(starterBlock.id, {
|
||||
output: starterOutput,
|
||||
executed: true,
|
||||
executionTime: 0,
|
||||
})
|
||||
|
||||
// Mark the starter block as executed and add its connections to the active path
|
||||
context.executedBlocks.add(starterBlock.id)
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
if (!state.variables[id]) return state
|
||||
|
||||
// If name is being updated, ensure it's unique
|
||||
if (update.name) {
|
||||
if (update.name !== undefined) {
|
||||
const oldVariable = state.variables[id]
|
||||
const oldVariableName = oldVariable.name
|
||||
const workflowId = oldVariable.workflowId
|
||||
@@ -124,7 +124,8 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
nameIndex++
|
||||
}
|
||||
|
||||
// If name has changed, update references in subblocks
|
||||
// Always update references in subblocks when name changes, even if empty
|
||||
// This ensures references are updated even when name is completely cleared
|
||||
if (uniqueName !== oldVariableName) {
|
||||
// Update references in subblock store
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
|
||||
@@ -4,6 +4,17 @@ import { SubBlockConfig } from '@/blocks/types'
|
||||
import { loadSubblockValues, saveSubblockValues } from '../persistence'
|
||||
import { useWorkflowRegistry } from '../registry/store'
|
||||
|
||||
/**
|
||||
* SubBlockState stores values for all subblocks in workflows
|
||||
*
|
||||
* Important implementation notes:
|
||||
* 1. Values are stored per workflow, per block, per subblock
|
||||
* 2. When workflows are synced to the database, the mergeSubblockState function
|
||||
* in utils.ts combines the block structure with these values
|
||||
* 3. If a subblock value exists here but not in the block structure
|
||||
* (e.g., inputFormat in starter block), the merge function will include it
|
||||
* in the synchronized state to ensure persistence
|
||||
*/
|
||||
interface SubBlockState {
|
||||
workflowValues: Record<string, Record<string, Record<string, any>>> // Store values per workflow ID
|
||||
}
|
||||
|
||||
@@ -130,6 +130,24 @@ export async function fetchWorkflowsFromDB(): Promise<void> {
|
||||
})
|
||||
})
|
||||
|
||||
// Get any additional subblock values that might not be in the state but are in the store
|
||||
const storedValues = useSubBlockStore.getState().workflowValues[id] || {}
|
||||
Object.entries(storedValues).forEach(([blockId, blockValues]) => {
|
||||
if (!subblockValues[blockId]) {
|
||||
subblockValues[blockId] = {}
|
||||
}
|
||||
|
||||
Object.entries(blockValues).forEach(([subblockId, value]) => {
|
||||
// Only update if not already set or if value is null
|
||||
if (
|
||||
subblockValues[blockId][subblockId] === null ||
|
||||
subblockValues[blockId][subblockId] === undefined
|
||||
) {
|
||||
subblockValues[blockId][subblockId] = value
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 4. Store the workflow state and subblock values in localStorage
|
||||
// This ensures compatibility with existing code that loads from localStorage
|
||||
localStorage.setItem(`workflow-${id}`, JSON.stringify(workflowState))
|
||||
|
||||
@@ -16,15 +16,24 @@ export function mergeSubblockState(
|
||||
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
|
||||
// Get all the values stored in the subblock store for this workflow
|
||||
const workflowSubblockValues = workflowId ? subBlockStore.workflowValues[workflowId] || {} : {}
|
||||
|
||||
return Object.entries(blocksToProcess).reduce(
|
||||
(acc, [id, block]) => {
|
||||
// Skip if block is undefined or doesn't have subBlocks
|
||||
if (!block || !block.subBlocks) {
|
||||
// Skip if block is undefined
|
||||
if (!block) {
|
||||
return acc
|
||||
}
|
||||
|
||||
// Initialize subBlocks if not present
|
||||
const blockSubBlocks = block.subBlocks || {}
|
||||
|
||||
// Get stored values for this block
|
||||
const blockValues = workflowSubblockValues[id] || {}
|
||||
|
||||
// Create a deep copy of the block's subBlocks to maintain structure
|
||||
const mergedSubBlocks = Object.entries(block.subBlocks).reduce(
|
||||
const mergedSubBlocks = Object.entries(blockSubBlocks).reduce(
|
||||
(subAcc, [subBlockId, subBlock]) => {
|
||||
// Skip if subBlock is undefined
|
||||
if (!subBlock) {
|
||||
@@ -37,9 +46,8 @@ export function mergeSubblockState(
|
||||
// If workflowId is provided, use it to get the value
|
||||
if (workflowId) {
|
||||
// Try to get the value from the subblock store for this specific workflow
|
||||
const workflowValues = subBlockStore.workflowValues[workflowId]
|
||||
if (workflowValues && workflowValues[id]) {
|
||||
storedValue = workflowValues[id][subBlockId]
|
||||
if (blockValues[subBlockId] !== undefined) {
|
||||
storedValue = blockValues[subBlockId]
|
||||
}
|
||||
} else {
|
||||
// Fall back to the active workflow if no workflowId is provided
|
||||
@@ -57,6 +65,19 @@ export function mergeSubblockState(
|
||||
{} as Record<string, SubBlockState>
|
||||
)
|
||||
|
||||
// Add any values that exist in the store but aren't in the block structure
|
||||
// This handles cases where block config has been updated but values still exist
|
||||
Object.entries(blockValues).forEach(([subBlockId, value]) => {
|
||||
if (!mergedSubBlocks[subBlockId] && value !== null && value !== undefined) {
|
||||
// Create a minimal subblock structure
|
||||
mergedSubBlocks[subBlockId] = {
|
||||
id: subBlockId,
|
||||
type: 'short-input', // Default type that's safe to use
|
||||
value: value,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Return the full block state with updated subBlocks
|
||||
acc[id] = {
|
||||
...block,
|
||||
|
||||
Reference in New Issue
Block a user