Compare commits

..

1 Commits

Author SHA1 Message Date
waleed
09cea81ae3 feat(trigger): for all triggers, remove save and delete buttons and autosave config 2025-12-16 17:35:09 -08:00
107 changed files with 2054 additions and 2503 deletions

View File

@@ -6,22 +6,7 @@ import {
workflowDeploymentVersion,
workflowExecutionLogs,
} from '@sim/db/schema'
import {
and,
desc,
eq,
gt,
gte,
inArray,
isNotNull,
isNull,
lt,
lte,
ne,
or,
type SQL,
sql,
} from 'drizzle-orm'
import { and, desc, eq, gte, inArray, isNotNull, isNull, lte, or, type SQL, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
@@ -37,19 +22,14 @@ const QueryParamsSchema = z.object({
limit: z.coerce.number().optional().default(100),
offset: z.coerce.number().optional().default(0),
level: z.string().optional(),
workflowIds: z.string().optional(),
folderIds: z.string().optional(),
triggers: z.string().optional(),
workflowIds: z.string().optional(), // Comma-separated list of workflow IDs
folderIds: z.string().optional(), // Comma-separated list of folder IDs
triggers: z.string().optional(), // Comma-separated list of trigger types
startDate: z.string().optional(),
endDate: z.string().optional(),
search: z.string().optional(),
workflowName: z.string().optional(),
folderName: z.string().optional(),
executionId: z.string().optional(),
costOperator: z.enum(['=', '>', '<', '>=', '<=', '!=']).optional(),
costValue: z.coerce.number().optional(),
durationOperator: z.enum(['=', '>', '<', '>=', '<=', '!=']).optional(),
durationValue: z.coerce.number().optional(),
workspaceId: z.string(),
})
@@ -69,6 +49,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const params = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries()))
// Conditionally select columns based on detail level to optimize performance
const selectColumns =
params.details === 'full'
? {
@@ -82,9 +63,9 @@ export async function GET(request: NextRequest) {
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
executionData: workflowExecutionLogs.executionData,
executionData: workflowExecutionLogs.executionData, // Large field - only in full mode
cost: workflowExecutionLogs.cost,
files: workflowExecutionLogs.files,
files: workflowExecutionLogs.files, // Large field - only in full mode
createdAt: workflowExecutionLogs.createdAt,
workflowName: workflow.name,
workflowDescription: workflow.description,
@@ -101,6 +82,7 @@ export async function GET(request: NextRequest) {
deploymentVersionName: workflowDeploymentVersion.name,
}
: {
// Basic mode - exclude large fields for better performance
id: workflowExecutionLogs.id,
workflowId: workflowExecutionLogs.workflowId,
executionId: workflowExecutionLogs.executionId,
@@ -111,9 +93,9 @@ export async function GET(request: NextRequest) {
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
executionData: sql<null>`NULL`,
executionData: sql<null>`NULL`, // Exclude large execution data in basic mode
cost: workflowExecutionLogs.cost,
files: sql<null>`NULL`,
files: sql<null>`NULL`, // Exclude files in basic mode
createdAt: workflowExecutionLogs.createdAt,
workflowName: workflow.name,
workflowDescription: workflow.description,
@@ -127,7 +109,7 @@ export async function GET(request: NextRequest) {
pausedTotalPauseCount: pausedExecutions.totalPauseCount,
pausedResumedCount: pausedExecutions.resumedCount,
deploymentVersion: workflowDeploymentVersion.version,
deploymentVersionName: sql<null>`NULL`,
deploymentVersionName: sql<null>`NULL`, // Only needed in full mode for details panel
}
const baseQuery = db
@@ -157,28 +139,34 @@ export async function GET(request: NextRequest) {
)
)
// Build additional conditions for the query
let conditions: SQL | undefined
// Filter by level with support for derived statuses (running, pending)
if (params.level && params.level !== 'all') {
const levels = params.level.split(',').filter(Boolean)
const levelConditions: SQL[] = []
for (const level of levels) {
if (level === 'error') {
// Direct database field
levelConditions.push(eq(workflowExecutionLogs.level, 'error'))
} else if (level === 'info') {
// Completed info logs only (not running, not pending)
const condition = and(
eq(workflowExecutionLogs.level, 'info'),
isNotNull(workflowExecutionLogs.endedAt)
)
if (condition) levelConditions.push(condition)
} else if (level === 'running') {
// Running logs: info level with no endedAt
const condition = and(
eq(workflowExecutionLogs.level, 'info'),
isNull(workflowExecutionLogs.endedAt)
)
if (condition) levelConditions.push(condition)
} else if (level === 'pending') {
// Pending logs: info level with pause status indicators
const condition = and(
eq(workflowExecutionLogs.level, 'info'),
or(
@@ -201,6 +189,7 @@ export async function GET(request: NextRequest) {
}
}
// Filter by specific workflow IDs
if (params.workflowIds) {
const workflowIds = params.workflowIds.split(',').filter(Boolean)
if (workflowIds.length > 0) {
@@ -208,6 +197,7 @@ export async function GET(request: NextRequest) {
}
}
// Filter by folder IDs
if (params.folderIds) {
const folderIds = params.folderIds.split(',').filter(Boolean)
if (folderIds.length > 0) {
@@ -215,6 +205,7 @@ export async function GET(request: NextRequest) {
}
}
// Filter by triggers
if (params.triggers) {
const triggers = params.triggers.split(',').filter(Boolean)
if (triggers.length > 0 && !triggers.includes('all')) {
@@ -222,6 +213,7 @@ export async function GET(request: NextRequest) {
}
}
// Filter by date range
if (params.startDate) {
conditions = and(
conditions,
@@ -232,79 +224,33 @@ export async function GET(request: NextRequest) {
conditions = and(conditions, lte(workflowExecutionLogs.startedAt, new Date(params.endDate)))
}
// Filter by search query
if (params.search) {
const searchTerm = `%${params.search}%`
// With message removed, restrict search to executionId only
conditions = and(conditions, sql`${workflowExecutionLogs.executionId} ILIKE ${searchTerm}`)
}
// Filter by workflow name (from advanced search input)
if (params.workflowName) {
const nameTerm = `%${params.workflowName}%`
conditions = and(conditions, sql`${workflow.name} ILIKE ${nameTerm}`)
}
// Filter by folder name (best-effort text match when present on workflows)
if (params.folderName) {
const folderTerm = `%${params.folderName}%`
conditions = and(conditions, sql`${workflow.name} ILIKE ${folderTerm}`)
}
if (params.executionId) {
conditions = and(conditions, eq(workflowExecutionLogs.executionId, params.executionId))
}
if (params.costOperator && params.costValue !== undefined) {
const costField = sql`(${workflowExecutionLogs.cost}->>'total')::numeric`
switch (params.costOperator) {
case '=':
conditions = and(conditions, sql`${costField} = ${params.costValue}`)
break
case '>':
conditions = and(conditions, sql`${costField} > ${params.costValue}`)
break
case '<':
conditions = and(conditions, sql`${costField} < ${params.costValue}`)
break
case '>=':
conditions = and(conditions, sql`${costField} >= ${params.costValue}`)
break
case '<=':
conditions = and(conditions, sql`${costField} <= ${params.costValue}`)
break
case '!=':
conditions = and(conditions, sql`${costField} != ${params.costValue}`)
break
}
}
if (params.durationOperator && params.durationValue !== undefined) {
const durationField = workflowExecutionLogs.totalDurationMs
switch (params.durationOperator) {
case '=':
conditions = and(conditions, eq(durationField, params.durationValue))
break
case '>':
conditions = and(conditions, gt(durationField, params.durationValue))
break
case '<':
conditions = and(conditions, lt(durationField, params.durationValue))
break
case '>=':
conditions = and(conditions, gte(durationField, params.durationValue))
break
case '<=':
conditions = and(conditions, lte(durationField, params.durationValue))
break
case '!=':
conditions = and(conditions, ne(durationField, params.durationValue))
break
}
}
// Execute the query using the optimized join
const logs = await baseQuery
.where(conditions)
.orderBy(desc(workflowExecutionLogs.startedAt))
.limit(params.limit)
.offset(params.offset)
// Get total count for pagination using the same join structure
const countQuery = db
.select({ count: sql<number>`count(*)` })
.from(workflowExecutionLogs)
@@ -333,10 +279,13 @@ export async function GET(request: NextRequest) {
const count = countResult[0]?.count || 0
// Block executions are now extracted from trace spans instead of separate table
const blockExecutionsByExecution: Record<string, any[]> = {}
// Create clean trace spans from block executions
const createTraceSpans = (blockExecutions: any[]) => {
return blockExecutions.map((block, index) => {
// For error blocks, include error information in the output
let output = block.outputData
if (block.status === 'error' && block.errorMessage) {
output = {
@@ -365,6 +314,7 @@ export async function GET(request: NextRequest) {
})
}
// Extract cost information from block executions
const extractCostSummary = (blockExecutions: any[]) => {
let totalCost = 0
let totalInputCost = 0
@@ -383,6 +333,7 @@ export async function GET(request: NextRequest) {
totalPromptTokens += block.cost.tokens?.prompt || 0
totalCompletionTokens += block.cost.tokens?.completion || 0
// Track per-model costs
if (block.cost.model) {
if (!models.has(block.cost.model)) {
models.set(block.cost.model, {
@@ -412,29 +363,34 @@ export async function GET(request: NextRequest) {
prompt: totalPromptTokens,
completion: totalCompletionTokens,
},
models: Object.fromEntries(models),
models: Object.fromEntries(models), // Convert Map to object for JSON serialization
}
}
// Transform to clean log format with workflow data included
const enhancedLogs = logs.map((log) => {
const blockExecutions = blockExecutionsByExecution[log.executionId] || []
// Only process trace spans and detailed cost in full mode
let traceSpans = []
let finalOutput: any
let costSummary = (log.cost as any) || { total: 0 }
if (params.details === 'full' && log.executionData) {
// Use stored trace spans if available, otherwise create from block executions
const storedTraceSpans = (log.executionData as any)?.traceSpans
traceSpans =
storedTraceSpans && Array.isArray(storedTraceSpans) && storedTraceSpans.length > 0
? storedTraceSpans
: createTraceSpans(blockExecutions)
// Prefer stored cost JSON; otherwise synthesize from blocks
costSummary =
log.cost && Object.keys(log.cost as any).length > 0
? (log.cost as any)
: extractCostSummary(blockExecutions)
// Include finalOutput if present on executionData
try {
const fo = (log.executionData as any)?.finalOutput
if (fo !== undefined) finalOutput = fo

View File

@@ -2,9 +2,11 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Search, X } from 'lucide-react'
import { Badge, Popover, PopoverAnchor, PopoverContent } from '@/components/emcn'
import { useParams } from 'next/navigation'
import { Button, Popover, PopoverAnchor, PopoverContent } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
import { createLogger } from '@/lib/logs/console/logger'
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
import { type ParsedFilter, parseQuery } from '@/lib/logs/query-parser'
import {
type FolderData,
@@ -16,15 +18,7 @@ import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-sea
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
function truncateFilterValue(field: string, value: string): string {
if ((field === 'executionId' || field === 'workflowId') && value.length > 12) {
return `...${value.slice(-6)}`
}
if (value.length > 20) {
return `${value.slice(0, 17)}...`
}
return value
}
const logger = createLogger('AutocompleteSearch')
interface AutocompleteSearchProps {
value: string
@@ -41,8 +35,11 @@ export function AutocompleteSearch({
className,
onOpenChange,
}: AutocompleteSearchProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const workflows = useWorkflowRegistry((state) => state.workflows)
const folders = useFolderStore((state) => state.folders)
const [triggersData, setTriggersData] = useState<TriggerData[]>([])
const workflowsData = useMemo<WorkflowData[]>(() => {
return Object.values(workflows).map((w) => ({
@@ -59,13 +56,32 @@ export function AutocompleteSearch({
}))
}, [folders])
const triggersData = useMemo<TriggerData[]>(() => {
return getTriggerOptions().map((t) => ({
value: t.value,
label: t.label,
color: t.color,
}))
}, [])
useEffect(() => {
if (!workspaceId) return
const fetchTriggers = async () => {
try {
const response = await fetch(`/api/logs/triggers?workspaceId=${workspaceId}`)
if (!response.ok) return
const data = await response.json()
const triggers: TriggerData[] = data.triggers.map((trigger: string) => {
const metadata = getIntegrationMetadata(trigger)
return {
value: trigger,
label: metadata.label,
color: metadata.color,
}
})
setTriggersData(triggers)
} catch (error) {
logger.error('Failed to fetch triggers:', error)
}
}
fetchTriggers()
}, [workspaceId])
const suggestionEngine = useMemo(() => {
return new SearchSuggestions(workflowsData, foldersData, triggersData)
@@ -87,6 +103,7 @@ export function AutocompleteSearch({
suggestions,
sections,
highlightedIndex,
highlightedBadgeIndex,
inputRef,
dropdownRef,
handleInputChange,
@@ -105,6 +122,7 @@ export function AutocompleteSearch({
const lastExternalValue = useRef(value)
useEffect(() => {
// Only re-initialize if value changed externally (not from user typing)
if (value !== lastExternalValue.current) {
lastExternalValue.current = value
const parsed = parseQuery(value)
@@ -112,6 +130,7 @@ export function AutocompleteSearch({
}
}, [value, initializeFromQuery])
// Initial sync on mount
useEffect(() => {
if (value) {
const parsed = parseQuery(value)
@@ -170,49 +189,40 @@ export function AutocompleteSearch({
<div className='flex flex-1 items-center gap-[6px] overflow-x-auto pr-[6px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{/* Applied Filter Badges */}
{appliedFilters.map((filter, index) => (
<Badge
<Button
key={`${filter.field}-${filter.value}-${index}`}
variant='outline'
role='button'
tabIndex={0}
className='h-6 shrink-0 cursor-pointer whitespace-nowrap rounded-md px-2 text-[11px]'
onClick={() => removeBadge(index)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
removeBadge(index)
}
className={cn(
'h-6 flex-shrink-0 gap-1 rounded-[6px] px-2 text-[11px]',
highlightedBadgeIndex === index && 'border'
)}
onClick={(e) => {
e.preventDefault()
removeBadge(index)
}}
>
<span className='text-[var(--text-muted)]'>{filter.field}:</span>
<span className='text-[var(--text-primary)]'>
{filter.operator !== '=' && filter.operator}
{truncateFilterValue(filter.field, filter.originalValue)}
{filter.originalValue}
</span>
<X className='h-3 w-3 shrink-0' />
</Badge>
<X className='h-3 w-3' />
</Button>
))}
{/* Text Search Badge (if present) */}
{hasTextSearch && (
<Badge
<Button
variant='outline'
role='button'
tabIndex={0}
className='h-6 shrink-0 cursor-pointer whitespace-nowrap rounded-md px-2 text-[11px]'
onClick={() => handleFiltersChange(appliedFilters, '')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleFiltersChange(appliedFilters, '')
}
className='h-6 flex-shrink-0 gap-1 rounded-[6px] px-2 text-[11px]'
onClick={(e) => {
e.preventDefault()
handleFiltersChange(appliedFilters, '')
}}
>
<span className='max-w-[150px] truncate text-[var(--text-primary)]'>
"{textSearch}"
</span>
<X className='h-3 w-3 shrink-0' />
</Badge>
<span className='text-[var(--text-primary)]'>"{textSearch}"</span>
<X className='h-3 w-3' />
</Button>
)}
{/* Input - only current typing */}
@@ -251,8 +261,9 @@ export function AutocompleteSearch({
sideOffset={4}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className='max-h-96 overflow-y-auto px-1'>
<div className='max-h-96 overflow-y-auto'>
{sections.length > 0 ? (
// Multi-section layout
<div className='py-1'>
{/* Show all results (no header) */}
{suggestions[0]?.category === 'show-all' && (
@@ -260,9 +271,9 @@ export function AutocompleteSearch({
key={suggestions[0].id}
data-index={0}
className={cn(
'w-full rounded-[6px] px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--border-focus)]',
'hover:bg-[var(--surface-9)]',
highlightedIndex === 0 && 'bg-[var(--surface-9)]'
'w-full px-3 py-1.5 text-left transition-colors focus:outline-none',
'hover:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-9)]',
highlightedIndex === 0 && 'bg-[var(--surface-9)] dark:bg-[var(--surface-9)]'
)}
onMouseEnter={() => setHighlightedIndex(0)}
onMouseDown={(e) => {
@@ -276,7 +287,7 @@ export function AutocompleteSearch({
{sections.map((section) => (
<div key={section.title}>
<div className='px-3 py-1.5 font-medium text-[12px] text-[var(--text-tertiary)] uppercase tracking-wide'>
<div className='border-[var(--divider)] border-t px-3 py-1.5 font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
{section.title}
</div>
{section.suggestions.map((suggestion) => {
@@ -290,9 +301,9 @@ export function AutocompleteSearch({
key={suggestion.id}
data-index={index}
className={cn(
'w-full rounded-[6px] px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--border-focus)]',
'hover:bg-[var(--surface-9)]',
isHighlighted && 'bg-[var(--surface-9)]'
'w-full px-3 py-1.5 text-left transition-colors focus:outline-none',
'hover:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-9)]',
isHighlighted && 'bg-[var(--surface-9)] dark:bg-[var(--surface-9)]'
)}
onMouseEnter={() => setHighlightedIndex(index)}
onMouseDown={(e) => {
@@ -301,11 +312,19 @@ export function AutocompleteSearch({
}}
>
<div className='flex items-center justify-between gap-3'>
<div className='min-w-0 flex-1 truncate text-[13px]'>
{suggestion.label}
<div className='flex min-w-0 flex-1 items-center gap-2'>
{suggestion.category === 'trigger' && suggestion.color && (
<div
className='h-2 w-2 flex-shrink-0 rounded-full'
style={{ backgroundColor: suggestion.color }}
/>
)}
<div className='min-w-0 flex-1 truncate text-[13px]'>
{suggestion.label}
</div>
</div>
{suggestion.value !== suggestion.label && (
<div className='shrink-0 font-mono text-[11px] text-[var(--text-muted)]'>
<div className='flex-shrink-0 font-mono text-[11px] text-[var(--text-muted)]'>
{suggestion.category === 'workflow' ||
suggestion.category === 'folder'
? `${suggestion.category}:`
@@ -323,7 +342,7 @@ export function AutocompleteSearch({
// Single section layout
<div className='py-1'>
{suggestionType === 'filters' && (
<div className='px-3 py-1.5 font-medium text-[12px] text-[var(--text-tertiary)] uppercase tracking-wide'>
<div className='border-[var(--divider)] border-b px-3 py-1.5 font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
SUGGESTED FILTERS
</div>
)}
@@ -333,9 +352,10 @@ export function AutocompleteSearch({
key={suggestion.id}
data-index={index}
className={cn(
'w-full rounded-[6px] px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--border-focus)]',
'hover:bg-[var(--surface-9)]',
index === highlightedIndex && 'bg-[var(--surface-9)]'
'w-full px-3 py-1.5 text-left transition-colors focus:outline-none',
'hover:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-9)]',
index === highlightedIndex &&
'bg-[var(--surface-9)] dark:bg-[var(--surface-9)]'
)}
onMouseEnter={() => setHighlightedIndex(index)}
onMouseDown={(e) => {
@@ -344,9 +364,17 @@ export function AutocompleteSearch({
}}
>
<div className='flex items-center justify-between gap-3'>
<div className='min-w-0 flex-1 text-[13px]'>{suggestion.label}</div>
<div className='flex min-w-0 flex-1 items-center gap-2'>
{suggestion.category === 'trigger' && suggestion.color && (
<div
className='h-2 w-2 flex-shrink-0 rounded-full'
style={{ backgroundColor: suggestion.color }}
/>
)}
<div className='min-w-0 flex-1 text-[13px]'>{suggestion.label}</div>
</div>
{suggestion.description && (
<div className='shrink-0 text-[11px] text-[var(--text-muted)]'>
<div className='flex-shrink-0 text-[11px] text-[var(--text-muted)]'>
{suggestion.value}
</div>
)}

View File

@@ -21,15 +21,21 @@ export function useSearchState({
const [currentInput, setCurrentInput] = useState('')
const [textSearch, setTextSearch] = useState('')
// Dropdown state
const [isOpen, setIsOpen] = useState(false)
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
const [sections, setSections] = useState<SuggestionSection[]>([])
const [highlightedIndex, setHighlightedIndex] = useState(-1)
// Badge interaction
const [highlightedBadgeIndex, setHighlightedBadgeIndex] = useState<number | null>(null)
// Refs
const inputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<NodeJS.Timeout | null>(null)
// Update suggestions when input changes
const updateSuggestions = useCallback(
(input: string) => {
const suggestionGroup = getSuggestions(input)
@@ -49,10 +55,13 @@ export function useSearchState({
[getSuggestions]
)
// Handle input changes
const handleInputChange = useCallback(
(value: string) => {
setCurrentInput(value)
setHighlightedBadgeIndex(null) // Clear badge highlight on any input
// Debounce suggestion updates
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
@@ -64,9 +73,11 @@ export function useSearchState({
[updateSuggestions, debounceMs]
)
// Handle suggestion selection
const handleSuggestionSelect = useCallback(
(suggestion: Suggestion) => {
if (suggestion.category === 'show-all') {
// Treat as text search
setTextSearch(suggestion.value)
setCurrentInput('')
setIsOpen(false)
@@ -74,12 +85,15 @@ export function useSearchState({
return
}
// Check if this is a filter-key suggestion (ends with ':')
if (suggestion.category === 'filters' && suggestion.value.endsWith(':')) {
// Set input to the filter key and keep dropdown open for values
setCurrentInput(suggestion.value)
updateSuggestions(suggestion.value)
return
}
// For filter values, workflows, folders - add as a filter
const newFilter: ParsedFilter = {
field: suggestion.value.split(':')[0] as any,
operator: '=',
@@ -96,12 +110,15 @@ export function useSearchState({
setCurrentInput('')
setTextSearch('')
// Notify parent
onFiltersChange(updatedFilters, '')
// Focus back on input and reopen dropdown with empty suggestions
if (inputRef.current) {
inputRef.current.focus()
}
// Show filter keys dropdown again after selection
setTimeout(() => {
updateSuggestions('')
}, 50)
@@ -109,10 +126,12 @@ export function useSearchState({
[appliedFilters, onFiltersChange, updateSuggestions]
)
// Remove a badge
const removeBadge = useCallback(
(index: number) => {
const updatedFilters = appliedFilters.filter((_, i) => i !== index)
setAppliedFilters(updatedFilters)
setHighlightedBadgeIndex(null)
onFiltersChange(updatedFilters, textSearch)
if (inputRef.current) {
@@ -122,22 +141,39 @@ export function useSearchState({
[appliedFilters, textSearch, onFiltersChange]
)
// Handle keyboard navigation
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
// Backspace on empty input - badge deletion
if (event.key === 'Backspace' && currentInput === '') {
if (appliedFilters.length > 0) {
event.preventDefault()
removeBadge(appliedFilters.length - 1)
event.preventDefault()
if (highlightedBadgeIndex !== null) {
// Delete highlighted badge
removeBadge(highlightedBadgeIndex)
} else if (appliedFilters.length > 0) {
// Highlight last badge
setHighlightedBadgeIndex(appliedFilters.length - 1)
}
return
}
// Clear badge highlight on any other key when not in dropdown navigation
if (
highlightedBadgeIndex !== null &&
!['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)
) {
setHighlightedBadgeIndex(null)
}
// Enter key
if (event.key === 'Enter') {
event.preventDefault()
if (isOpen && highlightedIndex >= 0 && suggestions[highlightedIndex]) {
handleSuggestionSelect(suggestions[highlightedIndex])
} else if (currentInput.trim()) {
// Submit current input as text search
setTextSearch(currentInput.trim())
setCurrentInput('')
setIsOpen(false)
@@ -146,6 +182,7 @@ export function useSearchState({
return
}
// Dropdown navigation
if (!isOpen) return
switch (event.key) {
@@ -179,6 +216,7 @@ export function useSearchState({
},
[
currentInput,
highlightedBadgeIndex,
appliedFilters,
isOpen,
highlightedIndex,
@@ -189,10 +227,12 @@ export function useSearchState({
]
)
// Handle focus
const handleFocus = useCallback(() => {
updateSuggestions(currentInput)
}, [currentInput, updateSuggestions])
// Handle blur
const handleBlur = useCallback(() => {
setTimeout(() => {
setIsOpen(false)
@@ -200,6 +240,7 @@ export function useSearchState({
}, 150)
}, [])
// Clear all filters
const clearAll = useCallback(() => {
setAppliedFilters([])
setCurrentInput('')
@@ -212,6 +253,7 @@ export function useSearchState({
}
}, [onFiltersChange])
// Initialize from external value (URL params, etc.)
const initializeFromQuery = useCallback((query: string, filters: ParsedFilter[]) => {
setAppliedFilters(filters)
setTextSearch(query)
@@ -219,6 +261,7 @@ export function useSearchState({
}, [])
return {
// State
appliedFilters,
currentInput,
textSearch,
@@ -226,10 +269,13 @@ export function useSearchState({
suggestions,
sections,
highlightedIndex,
highlightedBadgeIndex,
// Refs
inputRef,
dropdownRef,
// Handlers
handleInputChange,
handleSuggestionSelect,
handleKeyDown,
@@ -239,6 +285,7 @@ export function useSearchState({
clearAll,
initializeFromQuery,
// Setters for external control
setHighlightedIndex,
}
}

View File

@@ -0,0 +1,86 @@
import { Button } from '@/components/emcn'
import { Alert, AlertDescription } from '@/components/ui/alert'
import type { SaveStatus } from '@/hooks/use-auto-save'
interface SaveStatusIndicatorProps {
/** Current save status */
status: SaveStatus
/** Error message to display */
errorMessage: string | null
/** Text to show while saving (e.g., "Saving schedule...") */
savingText?: string
/** Text to show while loading (e.g., "Loading schedule...") */
loadingText?: string
/** Whether to show loading indicator */
isLoading?: boolean
/** Callback when retry button is clicked */
onRetry?: () => void
/** Whether retry is disabled (e.g., during saving) */
retryDisabled?: boolean
/** Number of retry attempts made */
retryCount?: number
/** Maximum retry attempts allowed */
maxRetries?: number
}
/**
* Shared component for displaying save status indicators.
* Shows saving spinner, error alerts with retry, and loading indicators.
*/
export function SaveStatusIndicator({
status,
errorMessage,
savingText = 'Saving...',
loadingText = 'Loading...',
isLoading = false,
onRetry,
retryDisabled = false,
retryCount = 0,
maxRetries = 3,
}: SaveStatusIndicatorProps) {
const maxRetriesReached = retryCount >= maxRetries
return (
<>
{/* Saving indicator */}
{status === 'saving' && (
<div className='flex items-center gap-2 text-muted-foreground text-sm'>
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
{savingText}
</div>
)}
{/* Error message with retry */}
{errorMessage && (
<Alert variant='destructive'>
<AlertDescription className='flex items-center justify-between'>
<span>
{errorMessage}
{maxRetriesReached && (
<span className='ml-1 text-xs opacity-75'>(Max retries reached)</span>
)}
</span>
{onRetry && (
<Button
variant='ghost'
onClick={onRetry}
disabled={retryDisabled || status === 'saving'}
className='ml-2 h-6 px-2 text-xs'
>
Retry
</Button>
)}
</AlertDescription>
</Alert>
)}
{/* Loading indicator */}
{isLoading && status !== 'saving' && (
<div className='flex items-center gap-2 text-muted-foreground text-sm'>
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
{loadingText}
</div>
)}
</>
)
}

View File

@@ -1,11 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { cn } from '@/lib/core/utils/cn'
import { createLogger } from '@/lib/logs/console/logger'
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
import { SaveStatusIndicator } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/save-status-indicator/save-status-indicator'
import { useAutoSave } from '@/hooks/use-auto-save'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useScheduleManagement } from '@/hooks/use-schedule-management'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -18,16 +16,9 @@ interface ScheduleSaveProps {
disabled?: boolean
}
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
export function ScheduleSave({ blockId, isPreview = false, disabled = false }: ScheduleSaveProps) {
const params = useParams()
const workflowId = params.workflowId as string
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [deleteStatus, setDeleteStatus] = useState<'idle' | 'deleting'>('idle')
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [scheduleStatus, setScheduleStatus] = useState<'active' | 'disabled' | null>(null)
const [nextRunAt, setNextRunAt] = useState<Date | null>(null)
const [lastRanAt, setLastRanAt] = useState<Date | null>(null)
const [failedCount, setFailedCount] = useState<number>(0)
@@ -36,7 +27,7 @@ export function ScheduleSave({ blockId, isPreview = false, disabled = false }: S
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const { scheduleId, saveConfig, deleteConfig, isSaving } = useScheduleManagement({
const { scheduleId, saveConfig, isSaving } = useScheduleManagement({
blockId,
isPreview,
})
@@ -56,13 +47,8 @@ export function ScheduleSave({ blockId, isPreview = false, disabled = false }: S
)
const scheduleTimezone = useSubBlockStore((state) => state.getValue(blockId, 'timezone'))
const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => {
const missingFields: string[] = []
if (!scheduleType) {
missingFields.push('Frequency')
return { valid: false, missingFields }
}
const validateRequiredFields = useCallback((): boolean => {
if (!scheduleType) return false
switch (scheduleType) {
case 'minutes': {
@@ -73,7 +59,7 @@ export function ScheduleSave({ blockId, isPreview = false, disabled = false }: S
minutesNum < 1 ||
minutesNum > 1440
) {
missingFields.push('Minutes Interval (must be 1-1440)')
return false
}
break
}
@@ -87,48 +73,39 @@ export function ScheduleSave({ blockId, isPreview = false, disabled = false }: S
hourlyNum < 0 ||
hourlyNum > 59
) {
missingFields.push('Minute (must be 0-59)')
return false
}
break
}
case 'daily':
if (!scheduleDailyTime) {
missingFields.push('Time')
}
if (!scheduleDailyTime) return false
break
case 'weekly':
if (!scheduleWeeklyDay) {
missingFields.push('Day of Week')
}
if (!scheduleWeeklyTime) {
missingFields.push('Time')
}
if (!scheduleWeeklyDay || !scheduleWeeklyTime) return false
break
case 'monthly': {
const monthlyNum = Number(scheduleMonthlyDay)
if (!scheduleMonthlyDay || Number.isNaN(monthlyNum) || monthlyNum < 1 || monthlyNum > 31) {
missingFields.push('Day of Month (must be 1-31)')
}
if (!scheduleMonthlyTime) {
missingFields.push('Time')
if (
!scheduleMonthlyDay ||
Number.isNaN(monthlyNum) ||
monthlyNum < 1 ||
monthlyNum > 31 ||
!scheduleMonthlyTime
) {
return false
}
break
}
case 'custom':
if (!scheduleCronExpression) {
missingFields.push('Cron Expression')
}
if (!scheduleCronExpression) return false
break
}
if (!scheduleTimezone && scheduleType !== 'minutes' && scheduleType !== 'hourly') {
missingFields.push('Timezone')
return false
}
return {
valid: missingFields.length === 0,
missingFields,
}
return true
}, [
scheduleType,
scheduleMinutesInterval,
@@ -160,7 +137,7 @@ export function ScheduleSave({ blockId, isPreview = false, disabled = false }: S
const subscribedSubBlockValues = useSubBlockStore(
useCallback(
(state) => {
const values: Record<string, any> = {}
const values: Record<string, unknown> = {}
requiredSubBlockIds.forEach((subBlockId) => {
const value = state.getValue(blockId, subBlockId)
if (value !== null && value !== undefined && value !== '') {
@@ -173,52 +150,57 @@ export function ScheduleSave({ blockId, isPreview = false, disabled = false }: S
)
)
const previousValuesRef = useRef<Record<string, any>>({})
const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const configFingerprint = useMemo(() => {
return JSON.stringify(subscribedSubBlockValues)
}, [subscribedSubBlockValues])
const handleSaveSuccess = useCallback(
async (result: { success: boolean; nextRunAt?: string; cronExpression?: string }) => {
const scheduleIdValue = useSubBlockStore.getState().getValue(blockId, 'scheduleId')
collaborativeSetSubblockValue(blockId, 'scheduleId', scheduleIdValue)
if (result.nextRunAt) {
setNextRunAt(new Date(result.nextRunAt))
}
await fetchScheduleStatus()
if (result.cronExpression) {
setSavedCronExpression(result.cronExpression)
}
},
[blockId, collaborativeSetSubblockValue]
)
const {
saveStatus,
errorMessage,
retryCount,
maxRetries,
triggerSave,
onConfigChange,
markInitialLoadComplete,
} = useAutoSave({
disabled: isPreview || disabled,
isExternallySaving: isSaving,
validate: validateRequiredFields,
onSave: saveConfig,
onSaveSuccess: handleSaveSuccess,
loggerName: 'ScheduleSave',
})
useEffect(() => {
if (saveStatus !== 'error') {
previousValuesRef.current = subscribedSubBlockValues
return
onConfigChange(configFingerprint)
}, [configFingerprint, onConfigChange])
useEffect(() => {
if (!isLoadingStatus && scheduleId) {
return markInitialLoadComplete(configFingerprint)
}
const hasChanges = Object.keys(subscribedSubBlockValues).some(
(key) =>
previousValuesRef.current[key] !== (subscribedSubBlockValues as Record<string, any>)[key]
)
if (!hasChanges) {
return
if (!scheduleId && !isLoadingStatus) {
return markInitialLoadComplete(configFingerprint)
}
if (validationTimeoutRef.current) {
clearTimeout(validationTimeoutRef.current)
}
validationTimeoutRef.current = setTimeout(() => {
const validation = validateRequiredFields()
if (validation.valid) {
setErrorMessage(null)
setSaveStatus('idle')
logger.debug('Error cleared after validation passed', { blockId })
} else {
setErrorMessage(`Missing required fields: ${validation.missingFields.join(', ')}`)
logger.debug('Error message updated', {
blockId,
missingFields: validation.missingFields,
})
}
previousValuesRef.current = subscribedSubBlockValues
}, 300)
return () => {
if (validationTimeoutRef.current) {
clearTimeout(validationTimeoutRef.current)
}
}
}, [blockId, subscribedSubBlockValues, saveStatus, validateRequiredFields])
}, [isLoadingStatus, scheduleId, configFingerprint, markInitialLoadComplete])
const fetchScheduleStatus = useCallback(async () => {
if (!scheduleId || isPreview) return
@@ -231,7 +213,6 @@ export function ScheduleSave({ blockId, isPreview = false, disabled = false }: S
if (response.ok) {
const data = await response.json()
if (data.schedule) {
setScheduleStatus(data.schedule.status)
setNextRunAt(data.schedule.nextRunAt ? new Date(data.schedule.nextRunAt) : null)
setLastRanAt(data.schedule.lastRanAt ? new Date(data.schedule.lastRanAt) : null)
setFailedCount(data.schedule.failedCount || 0)
@@ -251,249 +232,82 @@ export function ScheduleSave({ blockId, isPreview = false, disabled = false }: S
}
}, [scheduleId, isPreview, fetchScheduleStatus])
const handleSave = async () => {
if (isPreview || disabled) return
setSaveStatus('saving')
setErrorMessage(null)
try {
const validation = validateRequiredFields()
if (!validation.valid) {
setErrorMessage(`Missing required fields: ${validation.missingFields.join(', ')}`)
setSaveStatus('error')
return
}
const result = await saveConfig()
if (!result.success) {
throw new Error('Save config returned false')
}
setSaveStatus('saved')
setErrorMessage(null)
const scheduleIdValue = useSubBlockStore.getState().getValue(blockId, 'scheduleId')
collaborativeSetSubblockValue(blockId, 'scheduleId', scheduleIdValue)
if (result.nextRunAt) {
setNextRunAt(new Date(result.nextRunAt))
setScheduleStatus('active')
}
// Fetch additional status info, then apply cron from save result to prevent stale data
await fetchScheduleStatus()
if (result.cronExpression) {
setSavedCronExpression(result.cronExpression)
}
setTimeout(() => {
setSaveStatus('idle')
}, 2000)
logger.info('Schedule configuration saved successfully', {
blockId,
hasScheduleId: !!scheduleId,
})
} catch (error: any) {
setSaveStatus('error')
setErrorMessage(error.message || 'An error occurred while saving.')
logger.error('Error saving schedule config', { error })
}
if (isPreview) {
return null
}
const handleDelete = async () => {
if (isPreview || disabled) return
const hasScheduleInfo = scheduleId || isLoadingStatus || saveStatus === 'saving' || errorMessage
setShowDeleteDialog(false)
setDeleteStatus('deleting')
try {
const success = await deleteConfig()
if (!success) {
throw new Error('Failed to delete schedule')
}
setScheduleStatus(null)
setNextRunAt(null)
setLastRanAt(null)
setFailedCount(0)
collaborativeSetSubblockValue(blockId, 'scheduleId', null)
logger.info('Schedule deleted successfully', { blockId })
} catch (error: any) {
setErrorMessage(error.message || 'An error occurred while deleting.')
logger.error('Error deleting schedule', { error })
} finally {
setDeleteStatus('idle')
}
}
const handleDeleteConfirm = () => {
handleDelete()
}
const handleToggleStatus = async () => {
if (!scheduleId || isPreview || disabled) return
try {
const action = scheduleStatus === 'active' ? 'disable' : 'reactivate'
const response = await fetch(`/api/schedules/${scheduleId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
})
if (response.ok) {
await fetchScheduleStatus()
logger.info(`Schedule ${action}d successfully`, { scheduleId })
} else {
throw new Error(`Failed to ${action} schedule`)
}
} catch (error: any) {
setErrorMessage(
error.message ||
`An error occurred while ${scheduleStatus === 'active' ? 'disabling' : 'reactivating'} the schedule.`
)
logger.error('Error toggling schedule status', { error })
}
if (!hasScheduleInfo) {
return null
}
return (
<div className='mt-2'>
<div className='flex gap-2'>
<Button
variant='default'
onClick={handleSave}
disabled={disabled || isPreview || isSaving || saveStatus === 'saving' || isLoadingStatus}
className={cn(
'h-9 flex-1 rounded-[8px] transition-all duration-200',
saveStatus === 'saved' && 'bg-green-600 hover:bg-green-700',
saveStatus === 'error' && 'bg-red-600 hover:bg-red-700'
)}
>
{saveStatus === 'saving' && (
<>
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
Saving...
</>
)}
{saveStatus === 'saved' && 'Saved'}
{saveStatus === 'idle' && (scheduleId ? 'Update Schedule' : 'Save Schedule')}
{saveStatus === 'error' && 'Error'}
</Button>
<div className='space-y-1 pb-4'>
<SaveStatusIndicator
status={saveStatus}
errorMessage={errorMessage}
savingText='Saving schedule...'
loadingText='Loading schedule...'
isLoading={isLoadingStatus}
onRetry={triggerSave}
retryDisabled={isSaving}
retryCount={retryCount}
maxRetries={maxRetries}
/>
{scheduleId && (
<Button
variant='default'
onClick={() => setShowDeleteDialog(true)}
disabled={disabled || isPreview || deleteStatus === 'deleting' || isSaving}
className='h-9 rounded-[8px] px-3'
>
{deleteStatus === 'deleting' ? (
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
) : (
<Trash className='h-[14px] w-[14px]' />
)}
</Button>
)}
</div>
{errorMessage && (
<Alert variant='destructive' className='mt-2'>
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
{scheduleId && (scheduleStatus || isLoadingStatus || nextRunAt) && (
<div className='mt-2 space-y-1'>
{isLoadingStatus ? (
<div className='flex items-center gap-2 text-muted-foreground text-sm'>
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
Loading schedule status...
</div>
) : (
<>
{failedCount > 0 && (
<div className='flex items-center gap-2'>
<span className='text-destructive text-sm'>
{failedCount} failed run{failedCount !== 1 ? 's' : ''}
</span>
</div>
)}
{savedCronExpression && (
<p className='text-muted-foreground text-sm'>
Runs{' '}
{parseCronToHumanReadable(
savedCronExpression,
scheduleTimezone || 'UTC'
).toLowerCase()}
</p>
)}
{nextRunAt && (
<p className='text-sm'>
<span className='font-medium'>Next run:</span>{' '}
{nextRunAt.toLocaleString('en-US', {
timeZone: scheduleTimezone || 'UTC',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})}{' '}
{scheduleTimezone || 'UTC'}
</p>
)}
{lastRanAt && (
<p className='text-muted-foreground text-sm'>
<span className='font-medium'>Last ran:</span>{' '}
{lastRanAt.toLocaleString('en-US', {
timeZone: scheduleTimezone || 'UTC',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})}{' '}
{scheduleTimezone || 'UTC'}
</p>
)}
</>
)}
</div>
)}
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent size='sm'>
<ModalHeader>Delete Schedule</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Are you sure you want to delete this schedule configuration? This will stop the
workflow from running automatically.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
{/* Schedule status info */}
{scheduleId && !isLoadingStatus && saveStatus !== 'saving' && (
<>
{failedCount > 0 && (
<p className='text-destructive text-sm'>
{failedCount} failed run{failedCount !== 1 ? 's' : ''}
</p>
</ModalBody>
<ModalFooter>
<Button variant='active' onClick={() => setShowDeleteDialog(false)}>
Cancel
</Button>
<Button
variant='primary'
onClick={handleDeleteConfirm}
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
{savedCronExpression && (
<p className='text-muted-foreground text-sm'>
Runs{' '}
{parseCronToHumanReadable(
savedCronExpression,
scheduleTimezone || 'UTC'
).toLowerCase()}
</p>
)}
{nextRunAt && (
<p className='text-sm'>
<span className='font-medium'>Next run:</span>{' '}
{nextRunAt.toLocaleString('en-US', {
timeZone: scheduleTimezone || 'UTC',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})}{' '}
{scheduleTimezone || 'UTC'}
</p>
)}
{lastRanAt && (
<p className='text-muted-foreground text-sm'>
<span className='font-medium'>Last ran:</span>{' '}
{lastRanAt.toLocaleString('en-US', {
timeZone: scheduleTimezone || 'UTC',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})}{' '}
{scheduleTimezone || 'UTC'}
</p>
)}
</>
)}
</div>
)
}

View File

@@ -1,23 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn/components'
import { Trash } from '@/components/emcn/icons/trash'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { cn } from '@/lib/core/utils/cn'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/emcn/components'
import { createLogger } from '@/lib/logs/console/logger'
import { SaveStatusIndicator } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/save-status-indicator/save-status-indicator'
import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input'
import { useAutoSave } from '@/hooks/use-auto-save'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useTriggerConfigAggregation } from '@/hooks/use-trigger-config-aggregation'
import { getTriggerConfigAggregation } from '@/hooks/use-trigger-config-aggregation'
import { useWebhookManagement } from '@/hooks/use-webhook-management'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getTrigger, isTriggerValid } from '@/triggers'
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants'
import { ShortInput } from '../short-input/short-input'
const logger = createLogger('TriggerSave')
@@ -29,8 +21,6 @@ interface TriggerSaveProps {
disabled?: boolean
}
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
export function TriggerSave({
blockId,
subBlockId,
@@ -38,11 +28,8 @@ export function TriggerSave({
isPreview = false,
disabled = false,
}: TriggerSaveProps) {
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [deleteStatus, setDeleteStatus] = useState<'idle' | 'deleting'>('idle')
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isGeneratingTestUrl, setIsGeneratingTestUrl] = useState(false)
const [testUrlError, setTestUrlError] = useState<string | null>(null)
const storedTestUrl = useSubBlockStore((state) => state.getValue(blockId, 'testUrl'))
const storedTestUrlExpiresAt = useSubBlockStore((state) =>
@@ -70,13 +57,12 @@ export function TriggerSave({
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const { webhookId, saveConfig, deleteConfig, isLoading } = useWebhookManagement({
const { webhookId, saveConfig, isLoading } = useWebhookManagement({
blockId,
triggerId: effectiveTriggerId,
isPreview,
})
const triggerConfig = useSubBlockStore((state) => state.getValue(blockId, 'triggerConfig'))
const triggerCredentials = useSubBlockStore((state) =>
state.getValue(blockId, 'triggerCredentials')
)
@@ -87,40 +73,26 @@ export function TriggerSave({
const hasWebhookUrlDisplay =
triggerDef?.subBlocks.some((sb) => sb.id === 'webhookUrlDisplay') ?? false
const validateRequiredFields = useCallback(
(
configToCheck: Record<string, any> | null | undefined
): { valid: boolean; missingFields: string[] } => {
if (!triggerDef) {
return { valid: true, missingFields: [] }
const validateRequiredFields = useCallback((): boolean => {
if (!triggerDef) return true
const aggregatedConfig = getTriggerConfigAggregation(blockId, effectiveTriggerId)
const requiredSubBlocks = triggerDef.subBlocks.filter(
(sb) => sb.required && sb.mode === 'trigger' && !SYSTEM_SUBBLOCK_IDS.includes(sb.id)
)
for (const subBlock of requiredSubBlocks) {
if (subBlock.id === 'triggerCredentials') {
if (!triggerCredentials) return false
} else {
const value = aggregatedConfig?.[subBlock.id]
if (value === undefined || value === null || value === '') return false
}
}
const missingFields: string[] = []
triggerDef.subBlocks
.filter(
(sb) => sb.required && sb.mode === 'trigger' && !SYSTEM_SUBBLOCK_IDS.includes(sb.id)
)
.forEach((subBlock) => {
if (subBlock.id === 'triggerCredentials') {
if (!triggerCredentials) {
missingFields.push(subBlock.title || 'Credentials')
}
} else {
const value = configToCheck?.[subBlock.id]
if (value === undefined || value === null || value === '') {
missingFields.push(subBlock.title || subBlock.id)
}
}
})
return {
valid: missingFields.length === 0,
missingFields,
}
},
[triggerDef, triggerCredentials]
)
return true
}, [triggerDef, triggerCredentials, blockId, effectiveTriggerId])
const requiredSubBlockIds = useMemo(() => {
if (!triggerDef) return []
@@ -133,11 +105,11 @@ export function TriggerSave({
useCallback(
(state) => {
if (!triggerDef) return {}
const values: Record<string, any> = {}
requiredSubBlockIds.forEach((subBlockId) => {
const value = state.getValue(blockId, subBlockId)
const values: Record<string, unknown> = {}
requiredSubBlockIds.forEach((id) => {
const value = state.getValue(blockId, id)
if (value !== null && value !== undefined && value !== '') {
values[subBlockId] = value
values[id] = value
}
})
return values
@@ -146,69 +118,9 @@ export function TriggerSave({
)
)
const previousValuesRef = useRef<Record<string, any>>({})
const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (saveStatus !== 'error' || !triggerDef) {
previousValuesRef.current = subscribedSubBlockValues
return
}
const hasChanges = Object.keys(subscribedSubBlockValues).some(
(key) =>
previousValuesRef.current[key] !== (subscribedSubBlockValues as Record<string, any>)[key]
)
if (!hasChanges) {
return
}
if (validationTimeoutRef.current) {
clearTimeout(validationTimeoutRef.current)
}
validationTimeoutRef.current = setTimeout(() => {
const aggregatedConfig = useTriggerConfigAggregation(blockId, effectiveTriggerId)
if (aggregatedConfig) {
useSubBlockStore.getState().setValue(blockId, 'triggerConfig', aggregatedConfig)
}
const validation = validateRequiredFields(aggregatedConfig)
if (validation.valid) {
setErrorMessage(null)
setSaveStatus('idle')
logger.debug('Error cleared after validation passed', {
blockId,
triggerId: effectiveTriggerId,
})
} else {
setErrorMessage(`Missing required fields: ${validation.missingFields.join(', ')}`)
logger.debug('Error message updated', {
blockId,
triggerId: effectiveTriggerId,
missingFields: validation.missingFields,
})
}
previousValuesRef.current = subscribedSubBlockValues
}, 300)
return () => {
if (validationTimeoutRef.current) {
clearTimeout(validationTimeoutRef.current)
}
}
}, [
blockId,
effectiveTriggerId,
triggerDef,
subscribedSubBlockValues,
saveStatus,
validateRequiredFields,
])
const configFingerprint = useMemo(() => {
return JSON.stringify({ ...subscribedSubBlockValues, triggerCredentials })
}, [subscribedSubBlockValues, triggerCredentials])
useEffect(() => {
if (isTestUrlExpired && storedTestUrl) {
@@ -217,69 +129,63 @@ export function TriggerSave({
}
}, [blockId, isTestUrlExpired, storedTestUrl])
const handleSave = async () => {
if (isPreview || disabled) return
const handleSave = useCallback(async () => {
const aggregatedConfig = getTriggerConfigAggregation(blockId, effectiveTriggerId)
setSaveStatus('saving')
setErrorMessage(null)
try {
const aggregatedConfig = useTriggerConfigAggregation(blockId, effectiveTriggerId)
if (aggregatedConfig) {
useSubBlockStore.getState().setValue(blockId, 'triggerConfig', aggregatedConfig)
logger.debug('Stored aggregated trigger config', {
blockId,
triggerId: effectiveTriggerId,
aggregatedConfig,
})
}
const validation = validateRequiredFields(aggregatedConfig)
if (!validation.valid) {
setErrorMessage(`Missing required fields: ${validation.missingFields.join(', ')}`)
setSaveStatus('error')
return
}
const success = await saveConfig()
if (!success) {
throw new Error('Save config returned false')
}
setSaveStatus('saved')
setErrorMessage(null)
const savedWebhookId = useSubBlockStore.getState().getValue(blockId, 'webhookId')
const savedTriggerPath = useSubBlockStore.getState().getValue(blockId, 'triggerPath')
const savedTriggerId = useSubBlockStore.getState().getValue(blockId, 'triggerId')
const savedTriggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig')
collaborativeSetSubblockValue(blockId, 'webhookId', savedWebhookId)
collaborativeSetSubblockValue(blockId, 'triggerPath', savedTriggerPath)
collaborativeSetSubblockValue(blockId, 'triggerId', savedTriggerId)
collaborativeSetSubblockValue(blockId, 'triggerConfig', savedTriggerConfig)
setTimeout(() => {
setSaveStatus('idle')
}, 2000)
logger.info('Trigger configuration saved successfully', {
blockId,
triggerId: effectiveTriggerId,
hasWebhookId: !!webhookId,
})
} catch (error: any) {
setSaveStatus('error')
setErrorMessage(error.message || 'An error occurred while saving.')
logger.error('Error saving trigger configuration', { error })
if (aggregatedConfig) {
useSubBlockStore.getState().setValue(blockId, 'triggerConfig', aggregatedConfig)
}
}
return saveConfig()
}, [blockId, effectiveTriggerId, saveConfig])
const handleSaveSuccess = useCallback(() => {
const savedWebhookId = useSubBlockStore.getState().getValue(blockId, 'webhookId')
const savedTriggerPath = useSubBlockStore.getState().getValue(blockId, 'triggerPath')
const savedTriggerId = useSubBlockStore.getState().getValue(blockId, 'triggerId')
const savedTriggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig')
collaborativeSetSubblockValue(blockId, 'webhookId', savedWebhookId)
collaborativeSetSubblockValue(blockId, 'triggerPath', savedTriggerPath)
collaborativeSetSubblockValue(blockId, 'triggerId', savedTriggerId)
collaborativeSetSubblockValue(blockId, 'triggerConfig', savedTriggerConfig)
}, [blockId, collaborativeSetSubblockValue])
const {
saveStatus,
errorMessage,
retryCount,
maxRetries,
triggerSave,
onConfigChange,
markInitialLoadComplete,
} = useAutoSave({
disabled: isPreview || disabled || !triggerDef,
isExternallySaving: isLoading,
validate: validateRequiredFields,
onSave: handleSave,
onSaveSuccess: handleSaveSuccess,
loggerName: 'TriggerSave',
})
useEffect(() => {
onConfigChange(configFingerprint)
}, [configFingerprint, onConfigChange])
useEffect(() => {
if (!isLoading && webhookId) {
return markInitialLoadComplete(configFingerprint)
}
if (!webhookId && !isLoading) {
return markInitialLoadComplete(configFingerprint)
}
}, [isLoading, webhookId, configFingerprint, markInitialLoadComplete])
const generateTestUrl = async () => {
if (!webhookId) return
try {
setIsGeneratingTestUrl(true)
setTestUrlError(null)
const res = await fetch(`/api/webhooks/${webhookId}/test-url`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -296,7 +202,7 @@ export function TriggerSave({
collaborativeSetSubblockValue(blockId, 'testUrlExpiresAt', json.expiresAt)
} catch (e) {
logger.error('Failed to generate test webhook URL', { error: e })
setErrorMessage(
setTestUrlError(
e instanceof Error ? e.message : 'Failed to generate test URL. Please try again.'
)
} finally {
@@ -304,114 +210,49 @@ export function TriggerSave({
}
}
const handleDeleteClick = () => {
if (isPreview || disabled || !webhookId) return
setShowDeleteDialog(true)
}
const handleDeleteConfirm = async () => {
setShowDeleteDialog(false)
setDeleteStatus('deleting')
setErrorMessage(null)
try {
const success = await deleteConfig()
if (success) {
setDeleteStatus('idle')
setSaveStatus('idle')
setErrorMessage(null)
useSubBlockStore.getState().setValue(blockId, 'testUrl', null)
useSubBlockStore.getState().setValue(blockId, 'testUrlExpiresAt', null)
collaborativeSetSubblockValue(blockId, 'triggerPath', '')
collaborativeSetSubblockValue(blockId, 'webhookId', null)
collaborativeSetSubblockValue(blockId, 'triggerConfig', null)
collaborativeSetSubblockValue(blockId, 'testUrl', null)
collaborativeSetSubblockValue(blockId, 'testUrlExpiresAt', null)
logger.info('Trigger configuration deleted successfully', {
blockId,
triggerId: effectiveTriggerId,
})
} else {
setDeleteStatus('idle')
setErrorMessage('Failed to delete trigger configuration.')
logger.error('Failed to delete trigger configuration')
}
} catch (error: any) {
setDeleteStatus('idle')
setErrorMessage(error.message || 'An error occurred while deleting.')
logger.error('Error deleting trigger configuration', { error })
}
}
if (isPreview) {
return null
}
const isProcessing = saveStatus === 'saving' || deleteStatus === 'deleting' || isLoading
const isProcessing = saveStatus === 'saving' || isLoading
const displayError = errorMessage || testUrlError
const hasStatusIndicator = isLoading || saveStatus === 'saving' || displayError
const hasTestUrlSection =
webhookId && hasWebhookUrlDisplay && !isLoading && saveStatus !== 'saving'
if (!hasStatusIndicator && !hasTestUrlSection) {
return null
}
return (
<div id={`${blockId}-${subBlockId}`}>
<div className='flex gap-2'>
<Button
variant='default'
onClick={handleSave}
disabled={disabled || isProcessing}
className={cn(
'h-[32px] flex-1 rounded-[8px] px-[12px] transition-all duration-200',
saveStatus === 'saved' && 'bg-green-600 hover:bg-green-700',
saveStatus === 'error' && 'bg-red-600 hover:bg-red-700'
)}
>
{saveStatus === 'saving' && (
<>
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
Saving...
</>
)}
{saveStatus === 'saved' && 'Saved'}
{saveStatus === 'error' && 'Error'}
{saveStatus === 'idle' && (webhookId ? 'Update Configuration' : 'Save Configuration')}
</Button>
<div id={`${blockId}-${subBlockId}`} className='space-y-2 pb-4'>
<SaveStatusIndicator
status={saveStatus}
errorMessage={displayError}
savingText='Saving trigger...'
loadingText='Loading trigger...'
isLoading={isLoading}
onRetry={testUrlError ? () => setTestUrlError(null) : triggerSave}
retryDisabled={isProcessing}
retryCount={retryCount}
maxRetries={maxRetries}
/>
{webhookId && (
<Button
variant='default'
onClick={handleDeleteClick}
disabled={disabled || isProcessing}
className='h-[32px] rounded-[8px] px-[12px]'
>
{deleteStatus === 'deleting' ? (
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
) : (
<Trash className='h-[14px] w-[14px]' />
)}
</Button>
)}
</div>
{errorMessage && (
<Alert variant='destructive' className='mt-2'>
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
{webhookId && hasWebhookUrlDisplay && (
<div className='mt-2 space-y-1'>
{/* Test webhook URL section */}
{webhookId && hasWebhookUrlDisplay && !isLoading && saveStatus !== 'saving' && (
<div className='space-y-1'>
<div className='flex items-center justify-between'>
<span className='font-medium text-sm'>Test Webhook URL</span>
<Button
variant='outline'
variant='ghost'
onClick={generateTestUrl}
disabled={isGeneratingTestUrl || isProcessing}
className='h-[32px] rounded-[8px] px-[12px]'
className='h-6 px-2 py-1 text-[11px]'
>
{isGeneratingTestUrl ? (
<>
<div className='mr-2 h-3 w-3 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
<div className='mr-1.5 h-2.5 w-2.5 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
Generating
</>
) : testUrl ? (
@@ -450,31 +291,6 @@ export function TriggerSave({
)}
</div>
)}
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent size='sm'>
<ModalHeader>Delete Trigger</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Are you sure you want to delete this trigger configuration? This will remove the
webhook and stop all incoming triggers.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button variant='active' onClick={() => setShowDeleteDialog(false)}>
Cancel
</Button>
<Button
variant='primary'
onClick={handleDeleteConfirm}
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
)
}

View File

@@ -293,7 +293,6 @@ function SubBlockComponent({
setIsValidJson(isValid)
}
// Check if wand is enabled for this sub-block
const isWandEnabled = config.wandConfig?.enabled ?? false
/**
@@ -816,6 +815,13 @@ function SubBlockComponent({
}
}
// Render without wrapper for components that may return null
const noWrapper =
config.noWrapper || config.type === 'trigger-save' || config.type === 'schedule-save'
if (noWrapper) {
return renderInput()
}
return (
<div onMouseDown={handleMouseDown} className='flex flex-col gap-[10px]'>
{renderLabel(

View File

@@ -336,6 +336,26 @@ export function Editor() {
subBlockState
)
const isNoWrapper =
subBlock.noWrapper ||
subBlock.type === 'trigger-save' ||
subBlock.type === 'schedule-save'
if (isNoWrapper) {
return (
<SubBlock
key={stableKey}
blockId={currentBlockId}
config={subBlock}
isPreview={false}
subBlockValues={subBlockState}
disabled={!userPermissions.canEdit}
fieldDiffStatus={undefined}
allowExpandInPreview={false}
/>
)
}
return (
<div key={stableKey}>
<SubBlock

View File

@@ -26,7 +26,7 @@ const SUBFLOW_CONFIG = {
},
typeKey: 'loopType' as const,
storeKey: 'loops' as const,
maxIterations: 1000,
maxIterations: 100,
configKeys: {
iterations: 'iterations' as const,
items: 'forEachItems' as const,

View File

@@ -1,6 +1,8 @@
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@/lib/logs/console/logger'
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { ScheduleInfo } from '../types'
const logger = createLogger('useScheduleInfo')
@@ -32,9 +34,20 @@ export function useScheduleInfo(
blockType: string,
workflowId: string
): UseScheduleInfoReturn {
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const [isLoading, setIsLoading] = useState(false)
const [scheduleInfo, setScheduleInfo] = useState<ScheduleInfo | null>(null)
const scheduleIdFromStore = useSubBlockStore(
useCallback(
(state) => {
if (!activeWorkflowId) return null
return state.workflowValues[activeWorkflowId]?.[blockId]?.scheduleId as string | null
},
[activeWorkflowId, blockId]
)
)
const fetchScheduleInfo = useCallback(
async (wfId: string) => {
if (!wfId) return
@@ -143,22 +156,10 @@ export function useScheduleInfo(
setIsLoading(false)
}
const handleScheduleUpdate = (event: CustomEvent) => {
if (event.detail?.workflowId === workflowId && event.detail?.blockId === blockId) {
logger.debug('Schedule update event received, refetching schedule info')
if (blockType === 'schedule') {
fetchScheduleInfo(workflowId)
}
}
}
window.addEventListener('schedule-updated', handleScheduleUpdate as EventListener)
return () => {
setIsLoading(false)
window.removeEventListener('schedule-updated', handleScheduleUpdate as EventListener)
}
}, [blockType, workflowId, blockId, fetchScheduleInfo])
}, [blockType, workflowId, blockId, scheduleIdFromStore, fetchScheduleInfo])
return {
scheduleInfo,

View File

@@ -44,7 +44,11 @@ export function useWebhookInfo(blockId: string, workflowId: string): UseWebhookI
useCallback(
(state) => {
const blockValues = state.workflowValues[activeWorkflowId || '']?.[blockId]
return !!(blockValues?.webhookProvider && blockValues?.webhookPath)
// Check for webhookId (set by trigger auto-save) or webhookProvider+webhookPath (legacy)
return !!(
blockValues?.webhookId ||
(blockValues?.webhookProvider && blockValues?.webhookPath)
)
},
[activeWorkflowId, blockId]
)
@@ -72,6 +76,16 @@ export function useWebhookInfo(blockId: string, workflowId: string): UseWebhookI
)
)
const webhookIdFromStore = useSubBlockStore(
useCallback(
(state) => {
if (!activeWorkflowId) return null
return state.workflowValues[activeWorkflowId]?.[blockId]?.webhookId as string | null
},
[activeWorkflowId, blockId]
)
)
const fetchWebhookStatus = useCallback(async () => {
if (!workflowId || !blockId || !isWebhookConfigured) {
setWebhookStatus({ isDisabled: false, webhookId: undefined })
@@ -114,7 +128,7 @@ export function useWebhookInfo(blockId: string, workflowId: string): UseWebhookI
useEffect(() => {
fetchWebhookStatus()
}, [fetchWebhookStatus])
}, [fetchWebhookStatus, webhookIdFromStore])
const reactivateWebhook = useCallback(
async (webhookId: string) => {

View File

@@ -550,9 +550,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
const currentStoreBlock = currentWorkflow.getBlockById(id)
const isStarterBlock = type === 'starter'
const isWebhookTriggerBlock = type === 'webhook' || type === 'generic_webhook'
/**
* Subscribe to this block's subblock values to track changes for conditional rendering
* of subblocks based on their conditions.
@@ -808,7 +805,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
updateNodeInternals(id)
}, [horizontalHandles, id, updateNodeInternals])
const showWebhookIndicator = (isStarterBlock || isWebhookTriggerBlock) && isWebhookConfigured
const showWebhookIndicator = displayTriggerMode && isWebhookConfigured
const shouldShowScheduleBadge =
type === 'schedule' && !isLoadingScheduleInfo && scheduleInfo !== null
const userPermissions = useUserPermissionsContext()
@@ -909,28 +906,30 @@ export const WorkflowBlock = memo(function WorkflowBlock({
)}
{!isEnabled && <Badge>disabled</Badge>}
{type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && (
{type === 'schedule' && shouldShowScheduleBadge && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Badge
variant='outline'
className='cursor-pointer'
className={scheduleInfo?.isDisabled ? 'cursor-pointer' : ''}
style={{
borderColor: 'var(--warning)',
color: 'var(--warning)',
borderColor: scheduleInfo?.isDisabled ? 'var(--warning)' : 'var(--success)',
color: scheduleInfo?.isDisabled ? 'var(--warning)' : 'var(--success)',
}}
onClick={(e) => {
e.stopPropagation()
if (scheduleInfo?.id) {
if (scheduleInfo?.isDisabled && scheduleInfo?.id) {
reactivateSchedule(scheduleInfo.id)
}
}}
>
disabled
{scheduleInfo?.isDisabled ? 'disabled' : 'active'}
</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<span className='text-sm'>Click to reactivate</span>
{scheduleInfo?.isDisabled
? 'Click to reactivate'
: scheduleInfo?.scheduleTiming || 'Schedule is active'}
</Tooltip.Content>
</Tooltip.Root>
)}
@@ -940,47 +939,27 @@ export const WorkflowBlock = memo(function WorkflowBlock({
<Tooltip.Trigger asChild>
<Badge
variant='outline'
className='bg-[var(--brand-tertiary)] text-[var(--brand-tertiary)]'
>
<div className='relative flex items-center justify-center'>
<div className='197, 94, 0.2)] absolute h-3 w-3 rounded-full bg-[rgba(34,' />
<div className='relative h-2 w-2 rounded-full bg-[var(--brand-tertiary)]' />
</div>
Webhook
</Badge>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-[300px] p-4'>
{webhookProvider && webhookPath ? (
<>
<p className='text-sm'>{getProviderName(webhookProvider)} Webhook</p>
<p className='mt-1 text-muted-foreground text-xs'>Path: {webhookPath}</p>
</>
) : (
<p className='text-muted-foreground text-sm'>
This workflow is triggered by a webhook.
</p>
)}
</Tooltip.Content>
</Tooltip.Root>
)}
{isWebhookConfigured && isWebhookDisabled && webhookId && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Badge
variant='outline'
className='cursor-pointer'
style={{ borderColor: 'var(--warning)', color: 'var(--warning)' }}
className={isWebhookDisabled && webhookId ? 'cursor-pointer' : ''}
style={{
borderColor: isWebhookDisabled ? 'var(--warning)' : 'var(--success)',
color: isWebhookDisabled ? 'var(--warning)' : 'var(--success)',
}}
onClick={(e) => {
e.stopPropagation()
reactivateWebhook(webhookId)
if (isWebhookDisabled && webhookId) {
reactivateWebhook(webhookId)
}
}}
>
disabled
{isWebhookDisabled ? 'disabled' : 'active'}
</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<span className='text-sm'>Click to reactivate</span>
{isWebhookDisabled
? 'Click to reactivate'
: webhookProvider
? `${getProviderName(webhookProvider)} Webhook`
: 'Trigger is active'}
</Tooltip.Content>
</Tooltip.Root>
)}

View File

@@ -655,7 +655,6 @@ export function useWorkflowExecution() {
setExecutor,
setPendingBlocks,
setActiveBlocks,
workflows,
]
)

View File

@@ -215,6 +215,7 @@ export interface SubBlockConfig {
connectionDroppable?: boolean
hidden?: boolean
hideFromPreview?: boolean // Hide this subblock from the workflow block preview
noWrapper?: boolean // Render the input directly without wrapper div
requiresFeature?: string // Environment variable name that must be truthy for this subblock to be visible
description?: string
value?: (params: Record<string, any>) => string

View File

@@ -0,0 +1,236 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('useAutoSave')
/** Auto-save debounce delay in milliseconds */
const AUTO_SAVE_DEBOUNCE_MS = 1500
/** Delay before enabling auto-save after initial load */
const INITIAL_LOAD_DELAY_MS = 500
/** Default maximum retry attempts */
const DEFAULT_MAX_RETRIES = 3
/** Delay before resetting save status to idle after successful save */
const SAVED_STATUS_DISPLAY_MS = 2000
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
export interface SaveConfigResult {
success: boolean
}
interface UseAutoSaveOptions<T extends SaveConfigResult = SaveConfigResult> {
/** Whether auto-save is disabled (e.g., in preview mode) */
disabled?: boolean
/** Whether a save operation is already in progress externally */
isExternallySaving?: boolean
/** Maximum retry attempts (default: 3) */
maxRetries?: number
/** Validate config before saving, return true if valid */
validate: () => boolean
/** Perform the save operation */
onSave: () => Promise<T>
/** Optional callback after successful save */
onSaveSuccess?: (result: T) => void
/** Optional callback after failed save */
onSaveError?: (error: Error) => void
/** Logger name for debugging */
loggerName?: string
}
interface UseAutoSaveReturn {
/** Current save status */
saveStatus: SaveStatus
/** Error message if save failed */
errorMessage: string | null
/** Current retry count */
retryCount: number
/** Maximum retries allowed */
maxRetries: number
/** Whether max retries has been reached */
maxRetriesReached: boolean
/** Trigger an immediate save attempt (for retry button) */
triggerSave: () => Promise<void>
/** Call this when config changes to trigger debounced save */
onConfigChange: (configFingerprint: string) => void
/** Call this when initial load completes to enable auto-save */
markInitialLoadComplete: (currentFingerprint: string) => void
}
/**
* Shared hook for auto-saving configuration with debouncing, retry limits, and status management.
*
* @example
* ```tsx
* const { saveStatus, errorMessage, triggerSave, onConfigChange, markInitialLoadComplete } = useAutoSave({
* disabled: isPreview,
* isExternallySaving: isSaving,
* validate: () => validateRequiredFields(),
* onSave: async () => saveConfig(),
* onSaveSuccess: (result) => { ... },
* })
*
* // When config fingerprint changes
* useEffect(() => {
* onConfigChange(configFingerprint)
* }, [configFingerprint, onConfigChange])
*
* // When initial data loads
* useEffect(() => {
* if (!isLoading && dataId) {
* markInitialLoadComplete(configFingerprint)
* }
* }, [isLoading, dataId, configFingerprint, markInitialLoadComplete])
* ```
*/
export function useAutoSave<T extends SaveConfigResult = SaveConfigResult>({
disabled = false,
isExternallySaving = false,
maxRetries = DEFAULT_MAX_RETRIES,
validate,
onSave,
onSaveSuccess,
onSaveError,
loggerName,
}: UseAutoSaveOptions<T>): UseAutoSaveReturn {
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [retryCount, setRetryCount] = useState(0)
const autoSaveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const lastSavedConfigRef = useRef<string | null>(null)
const isInitialLoadRef = useRef(true)
const currentFingerprintRef = useRef<string | null>(null)
// Clear any pending timeout on unmount
useEffect(() => {
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current)
}
}
}, [])
const performSave = useCallback(async () => {
if (disabled || isExternallySaving) return
// Final validation check before saving
if (!validate()) {
setSaveStatus('idle')
return
}
setSaveStatus('saving')
setErrorMessage(null)
try {
const result = await onSave()
if (!result.success) {
throw new Error('Save operation returned unsuccessful result')
}
// Update last saved config to current
lastSavedConfigRef.current = currentFingerprintRef.current
setSaveStatus('saved')
setErrorMessage(null)
setRetryCount(0) // Reset retry count on success
if (onSaveSuccess) {
onSaveSuccess(result)
}
// Reset to idle after display duration
setTimeout(() => {
setSaveStatus('idle')
}, SAVED_STATUS_DISPLAY_MS)
if (loggerName) {
logger.info(`${loggerName}: Auto-save completed successfully`)
}
} catch (error: unknown) {
setSaveStatus('error')
const message = error instanceof Error ? error.message : 'An error occurred while saving.'
setErrorMessage(message)
setRetryCount((prev) => prev + 1)
if (onSaveError && error instanceof Error) {
onSaveError(error)
}
if (loggerName) {
logger.error(`${loggerName}: Auto-save failed`, { error })
}
}
}, [disabled, isExternallySaving, validate, onSave, onSaveSuccess, onSaveError, loggerName])
const onConfigChange = useCallback(
(configFingerprint: string) => {
currentFingerprintRef.current = configFingerprint
if (disabled) return
// Clear any existing timeout
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current)
}
// Skip if initial load hasn't completed
if (isInitialLoadRef.current) return
// Skip if already saving
if (saveStatus === 'saving' || isExternallySaving) return
// Clear error if validation now passes
if (saveStatus === 'error' && validate()) {
setErrorMessage(null)
setSaveStatus('idle')
setRetryCount(0) // Reset retry count when config changes
}
// Skip if config hasn't changed
if (configFingerprint === lastSavedConfigRef.current) return
// Skip if validation fails
if (!validate()) return
// Schedule debounced save
autoSaveTimeoutRef.current = setTimeout(() => {
if (loggerName) {
logger.debug(`${loggerName}: Triggering debounced auto-save`)
}
performSave()
}, AUTO_SAVE_DEBOUNCE_MS)
},
[disabled, saveStatus, isExternallySaving, validate, performSave, loggerName]
)
const markInitialLoadComplete = useCallback((currentFingerprint: string) => {
// Delay before enabling auto-save to prevent immediate trigger
const timer = setTimeout(() => {
isInitialLoadRef.current = false
lastSavedConfigRef.current = currentFingerprint
currentFingerprintRef.current = currentFingerprint
}, INITIAL_LOAD_DELAY_MS)
return () => clearTimeout(timer)
}, [])
const triggerSave = useCallback(async () => {
// Allow retry even if max retries reached (manual trigger)
await performSave()
}, [performSave])
return {
saveStatus,
errorMessage,
retryCount,
maxRetries,
maxRetriesReached: retryCount >= maxRetries,
triggerSave,
onConfigChange,
markInitialLoadComplete,
}
}

View File

@@ -1540,7 +1540,7 @@ export function useCollaborativeWorkflow() {
const config = {
id: nodeId,
nodes: childNodes,
iterations: Math.max(1, Math.min(1000, count)), // Clamp between 1-1000 for loops
iterations: Math.max(1, Math.min(100, count)), // Clamp between 1-100 for loops
loopType: currentLoopType,
forEachItems: currentCollection,
}

View File

@@ -23,15 +23,10 @@ interface ScheduleManagementState {
isLoading: boolean
isSaving: boolean
saveConfig: () => Promise<SaveConfigResult>
deleteConfig: () => Promise<boolean>
}
/**
* Hook to manage schedule lifecycle for schedule blocks
* Handles:
* - Loading existing schedules from the API
* - Saving schedule configurations
* - Deleting schedule configurations
*/
export function useScheduleManagement({
blockId,
@@ -45,8 +40,6 @@ export function useScheduleManagement({
)
const isLoading = useSubBlockStore((state) => state.loadingSchedules.has(blockId))
const isChecked = useSubBlockStore((state) => state.checkedSchedules.has(blockId))
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
@@ -183,49 +176,10 @@ export function useScheduleManagement({
}
}
const deleteConfig = async (): Promise<boolean> => {
if (isPreview || !scheduleId) {
return false
}
try {
setIsSaving(true)
const response = await fetch(`/api/schedules/${scheduleId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspaceId: params.workspaceId as string,
}),
})
if (!response.ok) {
logger.error('Failed to delete schedule')
return false
}
useSubBlockStore.getState().setValue(blockId, 'scheduleId', null)
useSubBlockStore.setState((state) => {
const newSet = new Set(state.checkedSchedules)
newSet.delete(blockId)
return { checkedSchedules: newSet }
})
logger.info('Schedule deleted successfully')
return true
} catch (error) {
logger.error('Error deleting schedule:', error)
return false
} finally {
setIsSaving(false)
}
}
return {
scheduleId,
isLoading,
isSaving,
saveConfig,
deleteConfig,
}
}

View File

@@ -3,7 +3,7 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getTrigger, isTriggerValid } from '@/triggers'
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants'
const logger = createLogger('useTriggerConfigAggregation')
const logger = createLogger('getTriggerConfigAggregation')
/**
* Maps old trigger config field names to new subblock IDs for backward compatibility.
@@ -34,7 +34,7 @@ function mapOldFieldNameToNewSubBlockId(oldFieldName: string): string {
* @returns The aggregated config object, or null if no valid config
*/
export function useTriggerConfigAggregation(
export function getTriggerConfigAggregation(
blockId: string,
triggerId: string | undefined
): Record<string, any> | null {

View File

@@ -16,14 +16,18 @@ interface UseWebhookManagementProps {
isPreview?: boolean
}
interface SaveConfigResult {
success: boolean
webhookId?: string
}
interface WebhookManagementState {
webhookUrl: string
webhookPath: string
webhookId: string | null
isLoading: boolean
isSaving: boolean
saveConfig: () => Promise<boolean>
deleteConfig: () => Promise<boolean>
saveConfig: () => Promise<SaveConfigResult>
}
/**
@@ -81,10 +85,6 @@ function resolveEffectiveTriggerId(
/**
* Hook to manage webhook lifecycle for trigger blocks
* Handles:
* - Pre-generating webhook URLs based on blockId (without creating webhook)
* - Loading existing webhooks from the API
* - Saving and deleting webhook configurations
*/
export function useWebhookManagement({
blockId,
@@ -103,7 +103,6 @@ export function useWebhookManagement({
useCallback((state) => state.getValue(blockId, 'triggerPath') as string | null, [blockId])
)
const isLoading = useSubBlockStore((state) => state.loadingWebhooks.has(blockId))
const isChecked = useSubBlockStore((state) => state.checkedWebhooks.has(blockId))
const webhookUrl = useMemo(() => {
if (!webhookPath) {
@@ -211,9 +210,9 @@ export function useWebhookManagement({
const createWebhook = async (
effectiveTriggerId: string | undefined,
selectedCredentialId: string | null
): Promise<boolean> => {
): Promise<SaveConfigResult> => {
if (!triggerDef || !effectiveTriggerId) {
return false
return { success: false }
}
const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig')
@@ -266,14 +265,14 @@ export function useWebhookManagement({
blockId,
})
return true
return { success: true, webhookId: savedWebhookId }
}
const updateWebhook = async (
webhookIdToUpdate: string,
effectiveTriggerId: string | undefined,
selectedCredentialId: string | null
): Promise<boolean> => {
): Promise<SaveConfigResult> => {
const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig')
const response = await fetch(`/api/webhooks/${webhookIdToUpdate}`, {
@@ -310,12 +309,12 @@ export function useWebhookManagement({
}
logger.info('Trigger config saved successfully', { blockId, webhookId: webhookIdToUpdate })
return true
return { success: true, webhookId: webhookIdToUpdate }
}
const saveConfig = async (): Promise<boolean> => {
const saveConfig = async (): Promise<SaveConfigResult> => {
if (isPreview || !triggerDef) {
return false
return { success: false }
}
const effectiveTriggerId = resolveEffectiveTriggerId(blockId, triggerId)
@@ -339,41 +338,6 @@ export function useWebhookManagement({
}
}
const deleteConfig = async (): Promise<boolean> => {
if (isPreview || !webhookId) {
return false
}
try {
setIsSaving(true)
const response = await fetch(`/api/webhooks/${webhookId}`, {
method: 'DELETE',
})
if (!response.ok) {
logger.error('Failed to delete webhook')
return false
}
useSubBlockStore.getState().setValue(blockId, 'triggerPath', '')
useSubBlockStore.getState().setValue(blockId, 'webhookId', null)
useSubBlockStore.setState((state) => {
const newSet = new Set(state.checkedWebhooks)
newSet.delete(blockId)
return { checkedWebhooks: newSet }
})
logger.info('Webhook deleted successfully')
return true
} catch (error) {
logger.error('Error deleting webhook:', error)
return false
} finally {
setIsSaving(false)
}
}
return {
webhookUrl,
webhookPath: webhookPath || blockId,
@@ -381,6 +345,5 @@ export function useWebhookManagement({
isLoading,
isSaving,
saveConfig,
deleteConfig,
}
}

View File

@@ -23,8 +23,6 @@ const FILTER_FIELDS = {
workflow: 'string',
trigger: 'string',
execution: 'string',
executionId: 'string',
workflowId: 'string',
id: 'string',
cost: 'number',
duration: 'number',
@@ -217,13 +215,11 @@ export function queryToApiParams(parsedQuery: ParsedQuery): Record<string, strin
break
case 'cost':
params.costOperator = filter.operator
params.costValue = String(filter.value)
params[`cost_${filter.operator}_${filter.value}`] = 'true'
break
case 'duration':
params.durationOperator = filter.operator
params.durationValue = String(filter.value)
params[`duration_${filter.operator}_${filter.value}`] = 'true'
break
}
}

View File

@@ -38,6 +38,8 @@ export const FILTER_DEFINITIONS: FilterDefinition[] = [
{ value: 'info', label: 'Info', description: 'Info logs only' },
],
},
// Note: Trigger options are now dynamically populated from active logs
// Core types are included by default, integration triggers are added from actual log data
{
key: 'cost',
label: 'Cost',
@@ -80,6 +82,14 @@ export const FILTER_DEFINITIONS: FilterDefinition[] = [
},
]
const CORE_TRIGGERS: TriggerData[] = [
{ value: 'api', label: 'API', color: '#3b82f6' },
{ value: 'manual', label: 'Manual', color: '#6b7280' },
{ value: 'webhook', label: 'Webhook', color: '#f97316' },
{ value: 'chat', label: 'Chat', color: '#8b5cf6' },
{ value: 'schedule', label: 'Schedule', color: '#10b981' },
]
export class SearchSuggestions {
private workflowsData: WorkflowData[]
private foldersData: FolderData[]
@@ -106,10 +116,10 @@ export class SearchSuggestions {
}
/**
* Get all triggers from registry data
* Get all triggers (core + integrations)
*/
private getAllTriggers(): TriggerData[] {
return this.triggersData
return [...CORE_TRIGGERS, ...this.triggersData]
}
/**
@@ -118,20 +128,24 @@ export class SearchSuggestions {
getSuggestions(input: string): SuggestionGroup | null {
const trimmed = input.trim()
// Empty input → show all filter keys
if (!trimmed) {
return this.getFilterKeysList()
}
// Input ends with ':' → show values for that key
if (trimmed.endsWith(':')) {
const key = trimmed.slice(0, -1)
return this.getFilterValues(key)
}
// Input contains ':' → filter value context
if (trimmed.includes(':')) {
const [key, partial] = trimmed.split(':')
return this.getFilterValues(key, partial)
}
// Plain text → multi-section results
return this.getMultiSectionResults(trimmed)
}
@@ -141,6 +155,7 @@ export class SearchSuggestions {
private getFilterKeysList(): SuggestionGroup {
const suggestions: Suggestion[] = []
// Add all filter keys
for (const filter of FILTER_DEFINITIONS) {
suggestions.push({
id: `filter-key-${filter.key}`,
@@ -151,6 +166,7 @@ export class SearchSuggestions {
})
}
// Add trigger key (always available - core types + integrations)
suggestions.push({
id: 'filter-key-trigger',
value: 'trigger:',
@@ -159,6 +175,7 @@ export class SearchSuggestions {
category: 'filters',
})
// Add workflow and folder keys
if (this.workflowsData.length > 0) {
suggestions.push({
id: 'filter-key-workflow',
@@ -232,10 +249,12 @@ export class SearchSuggestions {
: null
}
// Trigger filter values (core + integrations)
if (key === 'trigger') {
const allTriggers = this.getAllTriggers()
const suggestions = allTriggers
.filter((t) => !partial || t.label.toLowerCase().includes(partial.toLowerCase()))
.slice(0, 15) // Show more since we have core + integrations
.map((t) => ({
id: `filter-value-trigger-${t.value}`,
value: `trigger:${t.value}`,
@@ -254,9 +273,11 @@ export class SearchSuggestions {
: null
}
// Workflow filter values
if (key === 'workflow') {
const suggestions = this.workflowsData
.filter((w) => !partial || w.name.toLowerCase().includes(partial.toLowerCase()))
.slice(0, 8)
.map((w) => ({
id: `filter-value-workflow-${w.id}`,
value: `workflow:"${w.name}"`,
@@ -274,9 +295,11 @@ export class SearchSuggestions {
: null
}
// Folder filter values
if (key === 'folder') {
const suggestions = this.foldersData
.filter((f) => !partial || f.name.toLowerCase().includes(partial.toLowerCase()))
.slice(0, 8)
.map((f) => ({
id: `filter-value-folder-${f.id}`,
value: `folder:"${f.name}"`,
@@ -303,6 +326,7 @@ export class SearchSuggestions {
const sections: Array<{ title: string; suggestions: Suggestion[] }> = []
const allSuggestions: Suggestion[] = []
// Show all results option
const showAllSuggestion: Suggestion = {
id: 'show-all',
value: query,
@@ -311,6 +335,7 @@ export class SearchSuggestions {
}
allSuggestions.push(showAllSuggestion)
// Match filter values (e.g., "info" → "Status: Info")
const matchingFilterValues = this.getMatchingFilterValues(query)
if (matchingFilterValues.length > 0) {
sections.push({
@@ -320,6 +345,7 @@ export class SearchSuggestions {
allSuggestions.push(...matchingFilterValues)
}
// Match triggers
const matchingTriggers = this.getMatchingTriggers(query)
if (matchingTriggers.length > 0) {
sections.push({
@@ -329,6 +355,7 @@ export class SearchSuggestions {
allSuggestions.push(...matchingTriggers)
}
// Match workflows
const matchingWorkflows = this.getMatchingWorkflows(query)
if (matchingWorkflows.length > 0) {
sections.push({
@@ -338,6 +365,7 @@ export class SearchSuggestions {
allSuggestions.push(...matchingWorkflows)
}
// Match folders
const matchingFolders = this.getMatchingFolders(query)
if (matchingFolders.length > 0) {
sections.push({
@@ -347,6 +375,7 @@ export class SearchSuggestions {
allSuggestions.push(...matchingFolders)
}
// Add filter keys if no specific matches
if (
matchingFilterValues.length === 0 &&
matchingTriggers.length === 0 &&

View File

@@ -18,70 +18,11 @@ import { executeTool } from '@/tools'
const logger = createLogger('AzureOpenAIProvider')
/**
* Determines if the API version uses the Responses API (2025+) or Chat Completions API
*/
function useResponsesApi(apiVersion: string): boolean {
// 2025-* versions use the Responses API
// 2024-* and earlier versions use the Chat Completions API
return apiVersion.startsWith('2025-')
}
/**
* Helper function to convert an Azure OpenAI Responses API stream to a standard ReadableStream
* and collect completion metrics
*/
function createReadableStreamFromResponsesApiStream(
responsesStream: any,
onComplete?: (content: string, usage?: any) => void
): ReadableStream {
let fullContent = ''
let usageData: any = null
return new ReadableStream({
async start(controller) {
try {
for await (const event of responsesStream) {
if (event.usage) {
usageData = event.usage
}
if (event.type === 'response.output_text.delta') {
const content = event.delta || ''
if (content) {
fullContent += content
controller.enqueue(new TextEncoder().encode(content))
}
} else if (event.type === 'response.content_part.delta') {
const content = event.delta?.text || ''
if (content) {
fullContent += content
controller.enqueue(new TextEncoder().encode(content))
}
} else if (event.type === 'response.completed' || event.type === 'response.done') {
if (event.response?.usage) {
usageData = event.response.usage
}
}
}
if (onComplete) {
onComplete(fullContent, usageData)
}
controller.close()
} catch (error) {
controller.error(error)
}
},
})
}
/**
* Helper function to convert an Azure OpenAI stream to a standard ReadableStream
* and collect completion metrics
*/
function createReadableStreamFromChatCompletionsStream(
function createReadableStreamFromAzureOpenAIStream(
azureOpenAIStream: any,
onComplete?: (content: string, usage?: any) => void
): ReadableStream {
@@ -92,6 +33,7 @@ function createReadableStreamFromChatCompletionsStream(
async start(controller) {
try {
for await (const chunk of azureOpenAIStream) {
// Check for usage data in the final chunk
if (chunk.usage) {
usageData = chunk.usage
}
@@ -103,6 +45,7 @@ function createReadableStreamFromChatCompletionsStream(
}
}
// Once stream is complete, call the completion callback with the final content and usage
if (onComplete) {
onComplete(fullContent, usageData)
}
@@ -115,430 +58,6 @@ function createReadableStreamFromChatCompletionsStream(
})
}
/**
* Executes a request using the Responses API (for 2025+ API versions)
*/
async function executeWithResponsesApi(
azureOpenAI: AzureOpenAI,
request: ProviderRequest,
deploymentName: string,
providerStartTime: number,
providerStartTimeISO: string
): Promise<ProviderResponse | StreamingExecution> {
const inputMessages: any[] = []
if (request.context) {
inputMessages.push({
role: 'user',
content: request.context,
})
}
if (request.messages) {
inputMessages.push(...request.messages)
}
const tools = request.tools?.length
? request.tools.map((tool) => ({
type: 'function' as const,
function: {
name: tool.id,
description: tool.description,
parameters: tool.parameters,
},
}))
: undefined
const payload: any = {
model: deploymentName,
input: inputMessages.length > 0 ? inputMessages : request.systemPrompt || '',
}
if (request.systemPrompt) {
payload.instructions = request.systemPrompt
}
if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens !== undefined) payload.max_output_tokens = request.maxTokens
if (request.reasoningEffort !== undefined) {
payload.reasoning = { effort: request.reasoningEffort }
}
if (request.responseFormat) {
payload.text = {
format: {
type: 'json_schema',
json_schema: {
name: request.responseFormat.name || 'response_schema',
schema: request.responseFormat.schema || request.responseFormat,
strict: request.responseFormat.strict !== false,
},
},
}
logger.info('Added JSON schema text format to Responses API request')
}
if (tools?.length) {
payload.tools = tools
const forcedTools = request.tools?.filter((t) => t.usageControl === 'force') || []
if (forcedTools.length > 0) {
if (forcedTools.length === 1) {
payload.tool_choice = {
type: 'function',
function: { name: forcedTools[0].id },
}
} else {
payload.tool_choice = 'required'
}
} else {
payload.tool_choice = 'auto'
}
logger.info('Responses API request configuration:', {
toolCount: tools.length,
model: deploymentName,
})
}
try {
if (request.stream && (!tools || tools.length === 0)) {
logger.info('Using streaming response for Responses API request')
const streamResponse = await (azureOpenAI as any).responses.create({
...payload,
stream: true,
})
const tokenUsage = {
prompt: 0,
completion: 0,
total: 0,
}
const streamingResult = {
stream: createReadableStreamFromResponsesApiStream(streamResponse, (content, usage) => {
streamingResult.execution.output.content = content
const streamEndTime = Date.now()
const streamEndTimeISO = new Date(streamEndTime).toISOString()
if (streamingResult.execution.output.providerTiming) {
streamingResult.execution.output.providerTiming.endTime = streamEndTimeISO
streamingResult.execution.output.providerTiming.duration =
streamEndTime - providerStartTime
if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) {
streamingResult.execution.output.providerTiming.timeSegments[0].endTime =
streamEndTime
streamingResult.execution.output.providerTiming.timeSegments[0].duration =
streamEndTime - providerStartTime
}
}
if (usage) {
streamingResult.execution.output.tokens = {
prompt: usage.input_tokens || usage.prompt_tokens || 0,
completion: usage.output_tokens || usage.completion_tokens || 0,
total:
(usage.input_tokens || usage.prompt_tokens || 0) +
(usage.output_tokens || usage.completion_tokens || 0),
}
}
}),
execution: {
success: true,
output: {
content: '',
model: request.model,
tokens: tokenUsage,
toolCalls: undefined,
providerTiming: {
startTime: providerStartTimeISO,
endTime: new Date().toISOString(),
duration: Date.now() - providerStartTime,
timeSegments: [
{
type: 'model',
name: 'Streaming response',
startTime: providerStartTime,
endTime: Date.now(),
duration: Date.now() - providerStartTime,
},
],
},
},
logs: [],
metadata: {
startTime: providerStartTimeISO,
endTime: new Date().toISOString(),
duration: Date.now() - providerStartTime,
},
},
} as StreamingExecution
return streamingResult
}
const initialCallTime = Date.now()
let currentResponse = await (azureOpenAI as any).responses.create(payload)
const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.output_text || ''
const tokens = {
prompt: currentResponse.usage?.input_tokens || 0,
completion: currentResponse.usage?.output_tokens || 0,
total:
(currentResponse.usage?.input_tokens || 0) + (currentResponse.usage?.output_tokens || 0),
}
const toolCalls: any[] = []
const toolResults: any[] = []
let iterationCount = 0
const MAX_ITERATIONS = 10
let modelTime = firstResponseTime
let toolsTime = 0
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: 'Initial response',
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
while (iterationCount < MAX_ITERATIONS) {
const toolCallsInResponse =
currentResponse.output?.filter((item: any) => item.type === 'function_call') || []
if (toolCallsInResponse.length === 0) {
break
}
logger.info(
`Processing ${toolCallsInResponse.length} tool calls (iteration ${iterationCount + 1}/${MAX_ITERATIONS})`
)
const toolsStartTime = Date.now()
for (const toolCall of toolCallsInResponse) {
try {
const toolName = toolCall.name
const toolArgs =
typeof toolCall.arguments === 'string'
? JSON.parse(toolCall.arguments)
: toolCall.arguments
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) continue
const toolCallStartTime = Date.now()
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, true)
const toolCallEndTime = Date.now()
const toolCallDuration = toolCallEndTime - toolCallStartTime
timeSegments.push({
type: 'tool',
name: toolName,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallDuration,
})
let resultContent: any
if (result.success) {
toolResults.push(result.output)
resultContent = result.output
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(toolCallStartTime).toISOString(),
endTime: new Date(toolCallEndTime).toISOString(),
duration: toolCallDuration,
result: resultContent,
success: result.success,
})
// Add function call output to input for next request
inputMessages.push({
type: 'function_call_output',
call_id: toolCall.call_id || toolCall.id,
output: JSON.stringify(resultContent),
})
} catch (error) {
logger.error('Error processing tool call:', {
error,
toolName: toolCall?.name,
})
}
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
// Make the next request
const nextModelStartTime = Date.now()
const nextPayload = {
...payload,
input: inputMessages,
tool_choice: 'auto',
}
currentResponse = await (azureOpenAI as any).responses.create(nextPayload)
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: `Model response (iteration ${iterationCount + 1})`,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
modelTime += thisModelTime
// Update content
if (currentResponse.output_text) {
content = currentResponse.output_text
}
// Update token counts
if (currentResponse.usage) {
tokens.prompt += currentResponse.usage.input_tokens || 0
tokens.completion += currentResponse.usage.output_tokens || 0
tokens.total = tokens.prompt + tokens.completion
}
iterationCount++
}
// Handle streaming for final response after tool processing
if (request.stream) {
logger.info('Using streaming for final response after tool processing (Responses API)')
const streamingPayload = {
...payload,
input: inputMessages,
tool_choice: 'auto',
stream: true,
}
const streamResponse = await (azureOpenAI as any).responses.create(streamingPayload)
const streamingResult = {
stream: createReadableStreamFromResponsesApiStream(streamResponse, (content, usage) => {
streamingResult.execution.output.content = content
if (usage) {
streamingResult.execution.output.tokens = {
prompt: usage.input_tokens || tokens.prompt,
completion: usage.output_tokens || tokens.completion,
total:
(usage.input_tokens || tokens.prompt) + (usage.output_tokens || tokens.completion),
}
}
}),
execution: {
success: true,
output: {
content: '',
model: request.model,
tokens: {
prompt: tokens.prompt,
completion: tokens.completion,
total: tokens.total,
},
toolCalls:
toolCalls.length > 0
? {
list: toolCalls,
count: toolCalls.length,
}
: undefined,
providerTiming: {
startTime: providerStartTimeISO,
endTime: new Date().toISOString(),
duration: Date.now() - providerStartTime,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
},
logs: [],
metadata: {
startTime: providerStartTimeISO,
endTime: new Date().toISOString(),
duration: Date.now() - providerStartTime,
},
},
} as StreamingExecution
return streamingResult
}
// Calculate overall timing
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
return {
content,
model: request.model,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
logger.error('Error in Responses API request:', {
error,
duration: totalDuration,
})
const enhancedError = new Error(error instanceof Error ? error.message : String(error))
// @ts-ignore - Adding timing property to the error
enhancedError.timing = {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
}
throw enhancedError
}
}
/**
* Azure OpenAI provider configuration
*/
@@ -566,7 +85,8 @@ export const azureOpenAIProvider: ProviderConfig = {
// Extract Azure-specific configuration from request or environment
// Priority: request parameters > environment variables
const azureEndpoint = request.azureEndpoint || env.AZURE_OPENAI_ENDPOINT
const azureApiVersion = request.azureApiVersion || env.AZURE_OPENAI_API_VERSION || '2024-10-21'
const azureApiVersion =
request.azureApiVersion || env.AZURE_OPENAI_API_VERSION || '2024-07-01-preview'
if (!azureEndpoint) {
throw new Error(
@@ -581,34 +101,6 @@ export const azureOpenAIProvider: ProviderConfig = {
endpoint: azureEndpoint,
})
// Build deployment name - use deployment name instead of model name
const deploymentName = (request.model || 'azure/gpt-4o').replace('azure/', '')
// Start execution timer for the entire provider execution
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
// Check if we should use the Responses API (2025+ versions)
if (useResponsesApi(azureApiVersion)) {
logger.info('Using Responses API for Azure OpenAI request', {
apiVersion: azureApiVersion,
model: deploymentName,
})
return executeWithResponsesApi(
azureOpenAI,
request,
deploymentName,
providerStartTime,
providerStartTimeISO
)
}
// Continue with Chat Completions API for 2024 and earlier versions
logger.info('Using Chat Completions API for Azure OpenAI request', {
apiVersion: azureApiVersion,
model: deploymentName,
})
// Start with an empty array for all messages
const allMessages = []
@@ -645,7 +137,8 @@ export const azureOpenAIProvider: ProviderConfig = {
}))
: undefined
// Build the request payload
// Build the request payload - use deployment name instead of model name
const deploymentName = (request.model || 'azure/gpt-4o').replace('azure/', '')
const payload: any = {
model: deploymentName, // Azure OpenAI uses deployment name
messages: allMessages,
@@ -702,16 +195,23 @@ export const azureOpenAIProvider: ProviderConfig = {
}
}
// Start execution timer for the entire provider execution
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
// Check if we can stream directly (no tools required)
if (request.stream && (!tools || tools.length === 0)) {
logger.info('Using streaming response for Azure OpenAI request')
// Create a streaming request with token usage tracking
const streamResponse = await azureOpenAI.chat.completions.create({
...payload,
stream: true,
stream_options: { include_usage: true },
})
// Start collecting token usage from the stream
const tokenUsage = {
prompt: 0,
completion: 0,
@@ -720,44 +220,47 @@ export const azureOpenAIProvider: ProviderConfig = {
let _streamContent = ''
// Create a StreamingExecution response with a callback to update content and tokens
const streamingResult = {
stream: createReadableStreamFromChatCompletionsStream(
streamResponse,
(content, usage) => {
_streamContent = content
streamingResult.execution.output.content = content
stream: createReadableStreamFromAzureOpenAIStream(streamResponse, (content, usage) => {
// Update the execution data with the final content and token usage
_streamContent = content
streamingResult.execution.output.content = content
const streamEndTime = Date.now()
const streamEndTimeISO = new Date(streamEndTime).toISOString()
// Update the timing information with the actual completion time
const streamEndTime = Date.now()
const streamEndTimeISO = new Date(streamEndTime).toISOString()
if (streamingResult.execution.output.providerTiming) {
streamingResult.execution.output.providerTiming.endTime = streamEndTimeISO
streamingResult.execution.output.providerTiming.duration =
if (streamingResult.execution.output.providerTiming) {
streamingResult.execution.output.providerTiming.endTime = streamEndTimeISO
streamingResult.execution.output.providerTiming.duration =
streamEndTime - providerStartTime
// Update the time segment as well
if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) {
streamingResult.execution.output.providerTiming.timeSegments[0].endTime =
streamEndTime
streamingResult.execution.output.providerTiming.timeSegments[0].duration =
streamEndTime - providerStartTime
if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) {
streamingResult.execution.output.providerTiming.timeSegments[0].endTime =
streamEndTime
streamingResult.execution.output.providerTiming.timeSegments[0].duration =
streamEndTime - providerStartTime
}
}
if (usage) {
const newTokens = {
prompt: usage.prompt_tokens || tokenUsage.prompt,
completion: usage.completion_tokens || tokenUsage.completion,
total: usage.total_tokens || tokenUsage.total,
}
streamingResult.execution.output.tokens = newTokens
}
}
),
// Update token usage if available from the stream
if (usage) {
const newTokens = {
prompt: usage.prompt_tokens || tokenUsage.prompt,
completion: usage.completion_tokens || tokenUsage.completion,
total: usage.total_tokens || tokenUsage.total,
}
streamingResult.execution.output.tokens = newTokens
}
// We don't need to estimate tokens here as logger.ts will handle that
}),
execution: {
success: true,
output: {
content: '',
content: '', // Will be filled by the stream completion callback
model: request.model,
tokens: tokenUsage,
toolCalls: undefined,
@@ -775,8 +278,9 @@ export const azureOpenAIProvider: ProviderConfig = {
},
],
},
// Cost will be calculated in logger
},
logs: [],
logs: [], // No block logs for direct streaming
metadata: {
startTime: providerStartTimeISO,
endTime: new Date().toISOString(),
@@ -785,16 +289,21 @@ export const azureOpenAIProvider: ProviderConfig = {
},
} as StreamingExecution
// Return the streaming execution object with explicit casting
return streamingResult as StreamingExecution
}
// Make the initial API request
const initialCallTime = Date.now()
// Track the original tool_choice for forced tool tracking
const originalToolChoice = payload.tool_choice
// Track forced tools and their usage
const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = []
// Helper function to check for forced tool usage in responses
const checkForForcedToolUsage = (
response: any,
toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any }
@@ -818,6 +327,7 @@ export const azureOpenAIProvider: ProviderConfig = {
const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || ''
// Collect token information but don't calculate costs - that will be done in logger.ts
const tokens = {
prompt: currentResponse.usage?.prompt_tokens || 0,
completion: currentResponse.usage?.completion_tokens || 0,
@@ -827,13 +337,16 @@ export const azureOpenAIProvider: ProviderConfig = {
const toolResults = []
const currentMessages = [...allMessages]
let iterationCount = 0
const MAX_ITERATIONS = 10
const MAX_ITERATIONS = 10 // Prevent infinite loops
// Track time spent in model vs tools
let modelTime = firstResponseTime
let toolsTime = 0
// Track if a forced tool has been used
let hasUsedForcedTool = false
// Track each model and tool call segment with timestamps
const timeSegments: TimeSegment[] = [
{
type: 'model',
@@ -844,9 +357,11 @@ export const azureOpenAIProvider: ProviderConfig = {
},
]
// Check if a forced tool was used in the first response
checkForForcedToolUsage(currentResponse, originalToolChoice)
while (iterationCount < MAX_ITERATIONS) {
// Check for tool calls
const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls
if (!toolCallsInResponse || toolCallsInResponse.length === 0) {
break
@@ -856,16 +371,20 @@ export const azureOpenAIProvider: ProviderConfig = {
`Processing ${toolCallsInResponse.length} tool calls (iteration ${iterationCount + 1}/${MAX_ITERATIONS})`
)
// Track time for tool calls in this batch
const toolsStartTime = Date.now()
// Process each tool call
for (const toolCall of toolCallsInResponse) {
try {
const toolName = toolCall.function.name
const toolArgs = JSON.parse(toolCall.function.arguments)
// Get the tool from the tools registry
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) continue
// Execute the tool
const toolCallStartTime = Date.now()
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
@@ -874,6 +393,7 @@ export const azureOpenAIProvider: ProviderConfig = {
const toolCallEndTime = Date.now()
const toolCallDuration = toolCallEndTime - toolCallStartTime
// Add to time segments for both success and failure
timeSegments.push({
type: 'tool',
name: toolName,
@@ -882,11 +402,13 @@ export const azureOpenAIProvider: ProviderConfig = {
duration: toolCallDuration,
})
// Prepare result content for the LLM
let resultContent: any
if (result.success) {
toolResults.push(result.output)
resultContent = result.output
} else {
// Include error information so LLM can respond appropriately
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
@@ -904,6 +426,7 @@ export const azureOpenAIProvider: ProviderConfig = {
success: result.success,
})
// Add the tool call and result to messages (both success and failure)
currentMessages.push({
role: 'assistant',
content: null,
@@ -932,38 +455,48 @@ export const azureOpenAIProvider: ProviderConfig = {
}
}
// Calculate tool call time for this iteration
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
// Make the next request with updated messages
const nextPayload = {
...payload,
messages: currentMessages,
}
// Update tool_choice based on which forced tools have been used
if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) {
// If we have remaining forced tools, get the next one to force
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) {
// Force the next tool
nextPayload.tool_choice = {
type: 'function',
function: { name: remainingTools[0] },
}
logger.info(`Forcing next tool: ${remainingTools[0]}`)
} else {
// All forced tools have been used, switch to auto
nextPayload.tool_choice = 'auto'
logger.info('All forced tools have been used, switching to auto tool_choice')
}
}
// Time the next model call
const nextModelStartTime = Date.now()
// Make the next request
currentResponse = await azureOpenAI.chat.completions.create(nextPayload)
// Check if any forced tools were used in this response
checkForForcedToolUsage(currentResponse, nextPayload.tool_choice)
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
// Add to time segments
timeSegments.push({
type: 'model',
name: `Model response (iteration ${iterationCount + 1})`,
@@ -972,12 +505,15 @@ export const azureOpenAIProvider: ProviderConfig = {
duration: thisModelTime,
})
// Add to model time
modelTime += thisModelTime
// Update content if we have a text response
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
// Update token counts
if (currentResponse.usage) {
tokens.prompt += currentResponse.usage.prompt_tokens || 0
tokens.completion += currentResponse.usage.completion_tokens || 0
@@ -987,43 +523,46 @@ export const azureOpenAIProvider: ProviderConfig = {
iterationCount++
}
// After all tool processing complete, if streaming was requested, use streaming for the final response
if (request.stream) {
logger.info('Using streaming for final response after tool processing')
// When streaming after tool calls with forced tools, make sure tool_choice is set to 'auto'
// This prevents Azure OpenAI API from trying to force tool usage again in the final streaming response
const streamingPayload = {
...payload,
messages: currentMessages,
tool_choice: 'auto',
tool_choice: 'auto', // Always use 'auto' for the streaming response after tool calls
stream: true,
stream_options: { include_usage: true },
}
const streamResponse = await azureOpenAI.chat.completions.create(streamingPayload)
// Create the StreamingExecution object with all collected data
let _streamContent = ''
const streamingResult = {
stream: createReadableStreamFromChatCompletionsStream(
streamResponse,
(content, usage) => {
_streamContent = content
streamingResult.execution.output.content = content
stream: createReadableStreamFromAzureOpenAIStream(streamResponse, (content, usage) => {
// Update the execution data with the final content and token usage
_streamContent = content
streamingResult.execution.output.content = content
if (usage) {
const newTokens = {
prompt: usage.prompt_tokens || tokens.prompt,
completion: usage.completion_tokens || tokens.completion,
total: usage.total_tokens || tokens.total,
}
streamingResult.execution.output.tokens = newTokens
// Update token usage if available from the stream
if (usage) {
const newTokens = {
prompt: usage.prompt_tokens || tokens.prompt,
completion: usage.completion_tokens || tokens.completion,
total: usage.total_tokens || tokens.total,
}
streamingResult.execution.output.tokens = newTokens
}
),
}),
execution: {
success: true,
output: {
content: '',
content: '', // Will be filled by the callback
model: request.model,
tokens: {
prompt: tokens.prompt,
@@ -1058,9 +597,11 @@ export const azureOpenAIProvider: ProviderConfig = {
},
} as StreamingExecution
// Return the streaming execution object with explicit casting
return streamingResult as StreamingExecution
}
// Calculate overall timing
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
@@ -1081,8 +622,10 @@ export const azureOpenAIProvider: ProviderConfig = {
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
// We're not calculating cost here as it will be handled in logger.ts
}
} catch (error) {
// Include timing information even for errors
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
@@ -1092,6 +635,7 @@ export const azureOpenAIProvider: ProviderConfig = {
duration: totalDuration,
})
// Create a new error with timing information
const enhancedError = new Error(error instanceof Error ? error.message : String(error))
// @ts-ignore - Adding timing property to the error
enhancedError.timing = {

View File

@@ -22,9 +22,10 @@ describe('workflow store', () => {
})
describe('loop management', () => {
it.concurrent('should regenerate loops when updateLoopCount is called', () => {
it('should regenerate loops when updateLoopCount is called', () => {
const { addBlock, updateLoopCount } = useWorkflowStore.getState()
// Add a loop block
addBlock(
'loop1',
'loop',
@@ -37,19 +38,23 @@ describe('workflow store', () => {
}
)
// Update loop count
updateLoopCount('loop1', 10)
const state = useWorkflowStore.getState()
// Check that block data was updated
expect(state.blocks.loop1?.data?.count).toBe(10)
// Check that loops were regenerated
expect(state.loops.loop1).toBeDefined()
expect(state.loops.loop1.iterations).toBe(10)
})
it.concurrent('should regenerate loops when updateLoopType is called', () => {
it('should regenerate loops when updateLoopType is called', () => {
const { addBlock, updateLoopType } = useWorkflowStore.getState()
// Add a loop block
addBlock(
'loop1',
'loop',
@@ -62,20 +67,24 @@ describe('workflow store', () => {
}
)
// Update loop type
updateLoopType('loop1', 'forEach')
const state = useWorkflowStore.getState()
// Check that block data was updated
expect(state.blocks.loop1?.data?.loopType).toBe('forEach')
// Check that loops were regenerated with forEach items
expect(state.loops.loop1).toBeDefined()
expect(state.loops.loop1.loopType).toBe('forEach')
expect(state.loops.loop1.forEachItems).toBe('["a", "b", "c"]')
expect(state.loops.loop1.forEachItems).toEqual(['a', 'b', 'c'])
})
it.concurrent('should regenerate loops when updateLoopCollection is called', () => {
it('should regenerate loops when updateLoopCollection is called', () => {
const { addBlock, updateLoopCollection } = useWorkflowStore.getState()
// Add a forEach loop block
addBlock(
'loop1',
'loop',
@@ -87,19 +96,23 @@ describe('workflow store', () => {
}
)
// Update loop collection
updateLoopCollection('loop1', '["item1", "item2", "item3"]')
const state = useWorkflowStore.getState()
// Check that block data was updated
expect(state.blocks.loop1?.data?.collection).toBe('["item1", "item2", "item3"]')
// Check that loops were regenerated with new items
expect(state.loops.loop1).toBeDefined()
expect(state.loops.loop1.forEachItems).toBe('["item1", "item2", "item3"]')
expect(state.loops.loop1.forEachItems).toEqual(['item1', 'item2', 'item3'])
})
it.concurrent('should clamp loop count between 1 and 1000', () => {
it('should clamp loop count between 1 and 100', () => {
const { addBlock, updateLoopCount } = useWorkflowStore.getState()
// Add a loop block
addBlock(
'loop1',
'loop',
@@ -112,10 +125,12 @@ describe('workflow store', () => {
}
)
updateLoopCount('loop1', 1500)
// Try to set count above max
updateLoopCount('loop1', 150)
let state = useWorkflowStore.getState()
expect(state.blocks.loop1?.data?.count).toBe(1000)
expect(state.blocks.loop1?.data?.count).toBe(100)
// Try to set count below min
updateLoopCount('loop1', 0)
state = useWorkflowStore.getState()
expect(state.blocks.loop1?.data?.count).toBe(1)
@@ -123,9 +138,10 @@ describe('workflow store', () => {
})
describe('parallel management', () => {
it.concurrent('should regenerate parallels when updateParallelCount is called', () => {
it('should regenerate parallels when updateParallelCount is called', () => {
const { addBlock, updateParallelCount } = useWorkflowStore.getState()
// Add a parallel block
addBlock(
'parallel1',
'parallel',
@@ -137,19 +153,23 @@ describe('workflow store', () => {
}
)
// Update parallel count
updateParallelCount('parallel1', 5)
const state = useWorkflowStore.getState()
// Check that block data was updated
expect(state.blocks.parallel1?.data?.count).toBe(5)
// Check that parallels were regenerated
expect(state.parallels.parallel1).toBeDefined()
expect(state.parallels.parallel1.distribution).toBe('')
})
it.concurrent('should regenerate parallels when updateParallelCollection is called', () => {
it('should regenerate parallels when updateParallelCollection is called', () => {
const { addBlock, updateParallelCollection } = useWorkflowStore.getState()
// Add a parallel block
addBlock(
'parallel1',
'parallel',
@@ -162,22 +182,27 @@ describe('workflow store', () => {
}
)
// Update parallel collection
updateParallelCollection('parallel1', '["item1", "item2", "item3"]')
const state = useWorkflowStore.getState()
// Check that block data was updated
expect(state.blocks.parallel1?.data?.collection).toBe('["item1", "item2", "item3"]')
// Check that parallels were regenerated
expect(state.parallels.parallel1).toBeDefined()
expect(state.parallels.parallel1.distribution).toBe('["item1", "item2", "item3"]')
// Verify that the parallel count matches the collection size
const parsedDistribution = JSON.parse(state.parallels.parallel1.distribution as string)
expect(parsedDistribution).toHaveLength(3)
})
it.concurrent('should clamp parallel count between 1 and 20', () => {
it('should clamp parallel count between 1 and 20', () => {
const { addBlock, updateParallelCount } = useWorkflowStore.getState()
// Add a parallel block
addBlock(
'parallel1',
'parallel',
@@ -189,18 +214,21 @@ describe('workflow store', () => {
}
)
// Try to set count above max
updateParallelCount('parallel1', 100)
let state = useWorkflowStore.getState()
expect(state.blocks.parallel1?.data?.count).toBe(20)
// Try to set count below min
updateParallelCount('parallel1', 0)
state = useWorkflowStore.getState()
expect(state.blocks.parallel1?.data?.count).toBe(1)
})
it.concurrent('should regenerate parallels when updateParallelType is called', () => {
it('should regenerate parallels when updateParallelType is called', () => {
const { addBlock, updateParallelType } = useWorkflowStore.getState()
// Add a parallel block with default collection type
addBlock(
'parallel1',
'parallel',
@@ -213,40 +241,50 @@ describe('workflow store', () => {
}
)
// Update parallel type to count
updateParallelType('parallel1', 'count')
const state = useWorkflowStore.getState()
// Check that block data was updated
expect(state.blocks.parallel1?.data?.parallelType).toBe('count')
// Check that parallels were regenerated with new type
expect(state.parallels.parallel1).toBeDefined()
expect(state.parallels.parallel1.parallelType).toBe('count')
})
})
describe('mode switching', () => {
it.concurrent('should toggle advanced mode on a block', () => {
it('should toggle advanced mode on a block', () => {
const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState()
// Add an agent block
addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 })
// Initially should be in basic mode (advancedMode: false)
let state = useWorkflowStore.getState()
expect(state.blocks.agent1?.advancedMode).toBe(false)
// Toggle to advanced mode
toggleBlockAdvancedMode('agent1')
state = useWorkflowStore.getState()
expect(state.blocks.agent1?.advancedMode).toBe(true)
// Toggle back to basic mode
toggleBlockAdvancedMode('agent1')
state = useWorkflowStore.getState()
expect(state.blocks.agent1?.advancedMode).toBe(false)
})
it.concurrent('should preserve systemPrompt and userPrompt when switching modes', () => {
it('should preserve systemPrompt and userPrompt when switching modes', () => {
const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState()
const { setState: setSubBlockState } = useSubBlockStore
// Set up a mock active workflow
useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' })
// Add an agent block
addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 })
// Set initial values in basic mode
setSubBlockState({
workflowValues: {
'test-workflow': {
@@ -257,7 +295,9 @@ describe('workflow store', () => {
},
},
})
// Toggle to advanced mode
toggleBlockAdvancedMode('agent1')
// Check that prompts are preserved in advanced mode
let subBlockState = useSubBlockStore.getState()
expect(subBlockState.workflowValues['test-workflow'].agent1.systemPrompt).toBe(
'You are a helpful assistant'
@@ -265,7 +305,9 @@ describe('workflow store', () => {
expect(subBlockState.workflowValues['test-workflow'].agent1.userPrompt).toBe(
'Hello, how are you?'
)
// Toggle back to basic mode
toggleBlockAdvancedMode('agent1')
// Check that prompts are still preserved
subBlockState = useSubBlockStore.getState()
expect(subBlockState.workflowValues['test-workflow'].agent1.systemPrompt).toBe(
'You are a helpful assistant'
@@ -275,16 +317,20 @@ describe('workflow store', () => {
)
})
it.concurrent('should preserve memories when switching from advanced to basic mode', () => {
it('should preserve memories when switching from advanced to basic mode', () => {
const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState()
const { setState: setSubBlockState } = useSubBlockStore
// Set up a mock active workflow
useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' })
// Add an agent block in advanced mode
addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 })
// First toggle to advanced mode
toggleBlockAdvancedMode('agent1')
// Set values including memories
setSubBlockState({
workflowValues: {
'test-workflow': {
@@ -300,8 +346,10 @@ describe('workflow store', () => {
},
})
// Toggle back to basic mode
toggleBlockAdvancedMode('agent1')
// Check that prompts and memories are all preserved
const subBlockState = useSubBlockStore.getState()
expect(subBlockState.workflowValues['test-workflow'].agent1.systemPrompt).toBe(
'You are a helpful assistant'
@@ -315,50 +363,52 @@ describe('workflow store', () => {
])
})
it.concurrent('should handle mode switching when no subblock values exist', () => {
it('should handle mode switching when no subblock values exist', () => {
const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState()
// Set up a mock active workflow
useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' })
// Add an agent block
addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 })
// Toggle modes without any subblock values set
expect(useWorkflowStore.getState().blocks.agent1?.advancedMode).toBe(false)
expect(() => toggleBlockAdvancedMode('agent1')).not.toThrow()
// Verify the mode changed
const state = useWorkflowStore.getState()
expect(state.blocks.agent1?.advancedMode).toBe(true)
})
it.concurrent('should not throw when toggling non-existent block', () => {
it('should not throw when toggling non-existent block', () => {
const { toggleBlockAdvancedMode } = useWorkflowStore.getState()
// Try to toggle a block that doesn't exist
expect(() => toggleBlockAdvancedMode('non-existent')).not.toThrow()
})
})
describe('addBlock with blockProperties', () => {
it.concurrent(
'should create a block with default properties when no blockProperties provided',
() => {
const { addBlock } = useWorkflowStore.getState()
it('should create a block with default properties when no blockProperties provided', () => {
const { addBlock } = useWorkflowStore.getState()
addBlock('agent1', 'agent', 'Test Agent', { x: 100, y: 200 })
addBlock('agent1', 'agent', 'Test Agent', { x: 100, y: 200 })
const state = useWorkflowStore.getState()
const block = state.blocks.agent1
const state = useWorkflowStore.getState()
const block = state.blocks.agent1
expect(block).toBeDefined()
expect(block.id).toBe('agent1')
expect(block.type).toBe('agent')
expect(block.name).toBe('Test Agent')
expect(block.position).toEqual({ x: 100, y: 200 })
expect(block.enabled).toBe(true)
expect(block.horizontalHandles).toBe(true)
expect(block.height).toBe(0)
}
)
expect(block).toBeDefined()
expect(block.id).toBe('agent1')
expect(block.type).toBe('agent')
expect(block.name).toBe('Test Agent')
expect(block.position).toEqual({ x: 100, y: 200 })
expect(block.enabled).toBe(true)
expect(block.horizontalHandles).toBe(true)
expect(block.height).toBe(0)
})
it.concurrent('should create a block with custom blockProperties for regular blocks', () => {
it('should create a block with custom blockProperties for regular blocks', () => {
const { addBlock } = useWorkflowStore.getState()
addBlock(
@@ -474,8 +524,10 @@ describe('workflow store', () => {
it('should handle blockProperties with parent relationships', () => {
const { addBlock } = useWorkflowStore.getState()
// First add a parent loop block
addBlock('loop1', 'loop', 'Parent Loop', { x: 0, y: 0 })
// Then add a child block with custom properties
addBlock(
'agent1',
'agent',
@@ -519,7 +571,7 @@ describe('workflow store', () => {
addBlock('block3', 'trigger', 'Start', { x: 200, y: 0 })
})
it.concurrent('should have test blocks set up correctly', () => {
it('should have test blocks set up correctly', () => {
const state = useWorkflowStore.getState()
expect(state.blocks.block1).toBeDefined()
@@ -530,7 +582,7 @@ describe('workflow store', () => {
expect(state.blocks.block3.name).toBe('Start')
})
it.concurrent('should successfully rename a block when no conflicts exist', () => {
it('should successfully rename a block when no conflicts exist', () => {
const { updateBlockName } = useWorkflowStore.getState()
const result = updateBlockName('block1', 'Data Processor')
@@ -541,21 +593,18 @@ describe('workflow store', () => {
expect(state.blocks.block1.name).toBe('Data Processor')
})
it.concurrent(
'should allow renaming a block to a different case/spacing of its current name',
() => {
const { updateBlockName } = useWorkflowStore.getState()
it('should allow renaming a block to a different case/spacing of its current name', () => {
const { updateBlockName } = useWorkflowStore.getState()
const result = updateBlockName('block1', 'column ad')
const result = updateBlockName('block1', 'column ad')
expect(result.success).toBe(true)
expect(result.success).toBe(true)
const state = useWorkflowStore.getState()
expect(state.blocks.block1.name).toBe('column ad')
}
)
const state = useWorkflowStore.getState()
expect(state.blocks.block1.name).toBe('column ad')
})
it.concurrent('should prevent renaming when another block has the same normalized name', () => {
it('should prevent renaming when another block has the same normalized name', () => {
const { updateBlockName } = useWorkflowStore.getState()
const result = updateBlockName('block2', 'Column AD')
@@ -566,35 +615,29 @@ describe('workflow store', () => {
expect(state.blocks.block2.name).toBe('Employee Length')
})
it.concurrent(
'should prevent renaming when another block has a name that normalizes to the same value',
() => {
const { updateBlockName } = useWorkflowStore.getState()
it('should prevent renaming when another block has a name that normalizes to the same value', () => {
const { updateBlockName } = useWorkflowStore.getState()
const result = updateBlockName('block2', 'columnad')
const result = updateBlockName('block2', 'columnad')
expect(result.success).toBe(false)
expect(result.success).toBe(false)
const state = useWorkflowStore.getState()
expect(state.blocks.block2.name).toBe('Employee Length')
}
)
const state = useWorkflowStore.getState()
expect(state.blocks.block2.name).toBe('Employee Length')
})
it.concurrent(
'should prevent renaming when another block has a similar name with different spacing',
() => {
const { updateBlockName } = useWorkflowStore.getState()
it('should prevent renaming when another block has a similar name with different spacing', () => {
const { updateBlockName } = useWorkflowStore.getState()
const result = updateBlockName('block3', 'employee length')
const result = updateBlockName('block3', 'employee length')
expect(result.success).toBe(false)
expect(result.success).toBe(false)
const state = useWorkflowStore.getState()
expect(state.blocks.block3.name).toBe('Start')
}
)
const state = useWorkflowStore.getState()
expect(state.blocks.block3.name).toBe('Start')
})
it.concurrent('should handle edge cases with empty or whitespace-only names', () => {
it('should handle edge cases with empty or whitespace-only names', () => {
const { updateBlockName } = useWorkflowStore.getState()
const result1 = updateBlockName('block1', '')
@@ -608,7 +651,7 @@ describe('workflow store', () => {
expect(state.blocks.block2.name).toBe(' ')
})
it.concurrent('should return false when trying to rename a non-existent block', () => {
it('should return false when trying to rename a non-existent block', () => {
const { updateBlockName } = useWorkflowStore.getState()
const result = updateBlockName('nonexistent', 'New Name')

View File

@@ -850,7 +850,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
...block,
data: {
...block.data,
count: Math.max(1, Math.min(1000, count)), // Clamp between 1-1000
count: Math.max(1, Math.min(100, count)), // Clamp between 1-100
},
},
}

View File

@@ -3,7 +3,7 @@ import type { BlockState } from '@/stores/workflows/workflow/types'
import { convertLoopBlockToLoop } from '@/stores/workflows/workflow/utils'
describe('convertLoopBlockToLoop', () => {
it.concurrent('should keep JSON array string as-is for forEach loops', () => {
it.concurrent('should parse JSON array string for forEach loops', () => {
const blocks: Record<string, BlockState> = {
loop1: {
id: 'loop1',
@@ -25,11 +25,11 @@ describe('convertLoopBlockToLoop', () => {
expect(result).toBeDefined()
expect(result?.loopType).toBe('forEach')
expect(result?.forEachItems).toBe('["item1", "item2", "item3"]')
expect(result?.forEachItems).toEqual(['item1', 'item2', 'item3'])
expect(result?.iterations).toBe(10)
})
it.concurrent('should keep JSON object string as-is for forEach loops', () => {
it.concurrent('should parse JSON object string for forEach loops', () => {
const blocks: Record<string, BlockState> = {
loop1: {
id: 'loop1',
@@ -51,7 +51,7 @@ describe('convertLoopBlockToLoop', () => {
expect(result).toBeDefined()
expect(result?.loopType).toBe('forEach')
expect(result?.forEachItems).toBe('{"key1": "value1", "key2": "value2"}')
expect(result?.forEachItems).toEqual({ key1: 'value1', key2: 'value2' })
})
it.concurrent('should keep string as-is if not valid JSON', () => {
@@ -125,6 +125,7 @@ describe('convertLoopBlockToLoop', () => {
expect(result).toBeDefined()
expect(result?.loopType).toBe('for')
expect(result?.iterations).toBe(5)
expect(result?.forEachItems).toBe('["should", "not", "matter"]')
// For 'for' loops, the collection is still parsed in case it's later changed to forEach
expect(result?.forEachItems).toEqual(['should', 'not', 'matter'])
})
})

View File

@@ -25,8 +25,28 @@ export function convertLoopBlockToLoop(
loopType,
}
loop.forEachItems = loopBlock.data?.collection || ''
// Load ALL fields regardless of current loop type
// This allows switching between loop types without losing data
// For for/forEach loops, read from collection (block data) and map to forEachItems (loops store)
let forEachItems: any = loopBlock.data?.collection || ''
if (typeof forEachItems === 'string' && forEachItems.trim()) {
const trimmed = forEachItems.trim()
// Try to parse if it looks like JSON
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
try {
forEachItems = JSON.parse(trimmed)
} catch {
// Keep as string if parsing fails - will be evaluated at runtime
}
}
}
loop.forEachItems = forEachItems
// For while loops, use whileCondition
loop.whileCondition = loopBlock.data?.whileCondition || ''
// For do-while loops, use doWhileCondition
loop.doWhileCondition = loopBlock.data?.doWhileCondition || ''
return loop
@@ -46,13 +66,16 @@ export function convertParallelBlockToParallel(
const parallelBlock = blocks[parallelBlockId]
if (!parallelBlock || parallelBlock.type !== 'parallel') return undefined
// Get the parallel type from block data, defaulting to 'count' for consistency
const parallelType = parallelBlock.data?.parallelType || 'count'
// Validate parallelType against allowed values
const validParallelTypes = ['collection', 'count'] as const
const validatedParallelType = validParallelTypes.includes(parallelType as any)
? parallelType
: 'collection'
// Only set distribution if it's a collection-based parallel
const distribution =
validatedParallelType === 'collection' ? parallelBlock.data?.collection || '' : ''
@@ -116,6 +139,7 @@ export function findAllDescendantNodes(
export function generateLoopBlocks(blocks: Record<string, BlockState>): Record<string, Loop> {
const loops: Record<string, Loop> = {}
// Find all loop nodes
Object.entries(blocks)
.filter(([_, block]) => block.type === 'loop')
.forEach(([id, block]) => {
@@ -139,6 +163,7 @@ export function generateParallelBlocks(
): Record<string, Parallel> {
const parallels: Record<string, Parallel> = {}
// Find all parallel nodes
Object.entries(blocks)
.filter(([_, block]) => block.type === 'parallel')
.forEach(([id, block]) => {

View File

@@ -47,6 +47,14 @@ export const airtableWebhookTrigger: TriggerConfig = {
defaultValue: false,
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'airtable_webhook',
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -67,14 +75,6 @@ export const airtableWebhookTrigger: TriggerConfig = {
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'airtable_webhook',
},
],
outputs: {

View File

@@ -38,6 +38,18 @@ export const calendlyInviteeCanceledTrigger: TriggerConfig = {
value: 'calendly_invitee_canceled',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'calendly_invitee_canceled',
condition: {
field: 'selectedTriggerId',
value: 'calendly_invitee_canceled',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -47,7 +59,7 @@ export const calendlyInviteeCanceledTrigger: TriggerConfig = {
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
'The webhook will be automatically created in Calendly when you save this trigger.',
'The webhook will be automatically created in Calendly once you complete the configuration above.',
'This webhook triggers when an invitee cancels an event. The payload includes cancellation details and reason.',
]
.map(
@@ -61,18 +73,6 @@ export const calendlyInviteeCanceledTrigger: TriggerConfig = {
value: 'calendly_invitee_canceled',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'calendly_invitee_canceled',
condition: {
field: 'selectedTriggerId',
value: 'calendly_invitee_canceled',
},
},
],
outputs: buildInviteeOutputs(),

View File

@@ -47,6 +47,18 @@ export const calendlyInviteeCreatedTrigger: TriggerConfig = {
value: 'calendly_invitee_created',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'calendly_invitee_created',
condition: {
field: 'selectedTriggerId',
value: 'calendly_invitee_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -56,7 +68,7 @@ export const calendlyInviteeCreatedTrigger: TriggerConfig = {
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
'The webhook will be automatically created in Calendly when you save this trigger.',
'The webhook will be automatically created in Calendly once you complete the configuration above.',
'This webhook triggers when an invitee schedules a new event. Rescheduling triggers both cancellation and creation events.',
]
.map(
@@ -70,18 +82,6 @@ export const calendlyInviteeCreatedTrigger: TriggerConfig = {
value: 'calendly_invitee_created',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'calendly_invitee_created',
condition: {
field: 'selectedTriggerId',
value: 'calendly_invitee_created',
},
},
],
outputs: buildInviteeOutputs(),

View File

@@ -38,6 +38,18 @@ export const calendlyRoutingFormSubmittedTrigger: TriggerConfig = {
value: 'calendly_routing_form_submitted',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'calendly_routing_form_submitted',
condition: {
field: 'selectedTriggerId',
value: 'calendly_routing_form_submitted',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -47,7 +59,7 @@ export const calendlyRoutingFormSubmittedTrigger: TriggerConfig = {
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
'The webhook will be automatically created in Calendly when you save this trigger.',
'The webhook will be automatically created in Calendly once you complete the configuration above.',
'This webhook triggers when someone submits a routing form, regardless of whether they book an event.',
]
.map(
@@ -61,18 +73,6 @@ export const calendlyRoutingFormSubmittedTrigger: TriggerConfig = {
value: 'calendly_routing_form_submitted',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'calendly_routing_form_submitted',
condition: {
field: 'selectedTriggerId',
value: 'calendly_routing_form_submitted',
},
},
],
outputs: buildRoutingFormOutputs(),

View File

@@ -37,6 +37,18 @@ export const calendlyWebhookTrigger: TriggerConfig = {
value: 'calendly_webhook',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'calendly_webhook',
condition: {
field: 'selectedTriggerId',
value: 'calendly_webhook',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -46,7 +58,7 @@ export const calendlyWebhookTrigger: TriggerConfig = {
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
'The webhook will be automatically created in Calendly when you save this trigger.',
'The webhook will be automatically created in Calendly once you complete the configuration above.',
'This webhook subscribes to all Calendly events (invitee created, invitee canceled, and routing form submitted). Use the <code>event</code> field in the payload to determine the event type.',
]
.map(
@@ -60,18 +72,6 @@ export const calendlyWebhookTrigger: TriggerConfig = {
value: 'calendly_webhook',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'calendly_webhook',
condition: {
field: 'selectedTriggerId',
value: 'calendly_webhook',
},
},
],
outputs: {

View File

@@ -56,6 +56,14 @@ export const genericWebhookTrigger: TriggerConfig = {
'Define the expected JSON input schema for this webhook (optional). Use type "files" for file uploads.',
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'generic_webhook',
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -76,14 +84,6 @@ export const genericWebhookTrigger: TriggerConfig = {
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'generic_webhook',
},
],
outputs: {},

View File

@@ -75,6 +75,18 @@ export const githubIssueClosedTrigger: TriggerConfig = {
value: 'github_issue_closed',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_issue_closed',
condition: {
field: 'selectedTriggerId',
value: 'github_issue_closed',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -101,18 +113,6 @@ export const githubIssueClosedTrigger: TriggerConfig = {
value: 'github_issue_closed',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_issue_closed',
condition: {
field: 'selectedTriggerId',
value: 'github_issue_closed',
},
},
],
outputs: {

View File

@@ -75,6 +75,18 @@ export const githubIssueCommentTrigger: TriggerConfig = {
value: 'github_issue_comment',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_issue_comment',
condition: {
field: 'selectedTriggerId',
value: 'github_issue_comment',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -102,18 +114,6 @@ export const githubIssueCommentTrigger: TriggerConfig = {
value: 'github_issue_comment',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_issue_comment',
condition: {
field: 'selectedTriggerId',
value: 'github_issue_comment',
},
},
],
outputs: {

View File

@@ -96,6 +96,18 @@ export const githubIssueOpenedTrigger: TriggerConfig = {
value: 'github_issue_opened',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_issue_opened',
condition: {
field: 'selectedTriggerId',
value: 'github_issue_opened',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -122,18 +134,6 @@ export const githubIssueOpenedTrigger: TriggerConfig = {
value: 'github_issue_opened',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_issue_opened',
condition: {
field: 'selectedTriggerId',
value: 'github_issue_opened',
},
},
],
outputs: {

View File

@@ -76,6 +76,18 @@ export const githubPRClosedTrigger: TriggerConfig = {
value: 'github_pr_closed',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_pr_closed',
condition: {
field: 'selectedTriggerId',
value: 'github_pr_closed',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -102,18 +114,6 @@ export const githubPRClosedTrigger: TriggerConfig = {
value: 'github_pr_closed',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_pr_closed',
condition: {
field: 'selectedTriggerId',
value: 'github_pr_closed',
},
},
],
outputs: {

View File

@@ -75,6 +75,18 @@ export const githubPRCommentTrigger: TriggerConfig = {
value: 'github_pr_comment',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_pr_comment',
condition: {
field: 'selectedTriggerId',
value: 'github_pr_comment',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -102,18 +114,6 @@ export const githubPRCommentTrigger: TriggerConfig = {
value: 'github_pr_comment',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_pr_comment',
condition: {
field: 'selectedTriggerId',
value: 'github_pr_comment',
},
},
],
outputs: {

View File

@@ -75,6 +75,18 @@ export const githubPRMergedTrigger: TriggerConfig = {
value: 'github_pr_merged',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_pr_merged',
condition: {
field: 'selectedTriggerId',
value: 'github_pr_merged',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -101,18 +113,6 @@ export const githubPRMergedTrigger: TriggerConfig = {
value: 'github_pr_merged',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_pr_merged',
condition: {
field: 'selectedTriggerId',
value: 'github_pr_merged',
},
},
],
outputs: {

View File

@@ -75,6 +75,18 @@ export const githubPROpenedTrigger: TriggerConfig = {
value: 'github_pr_opened',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_pr_opened',
condition: {
field: 'selectedTriggerId',
value: 'github_pr_opened',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -101,18 +113,6 @@ export const githubPROpenedTrigger: TriggerConfig = {
value: 'github_pr_opened',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_pr_opened',
condition: {
field: 'selectedTriggerId',
value: 'github_pr_opened',
},
},
],
outputs: {

View File

@@ -76,6 +76,18 @@ export const githubPRReviewedTrigger: TriggerConfig = {
value: 'github_pr_reviewed',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_pr_reviewed',
condition: {
field: 'selectedTriggerId',
value: 'github_pr_reviewed',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -102,18 +114,6 @@ export const githubPRReviewedTrigger: TriggerConfig = {
value: 'github_pr_reviewed',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_pr_reviewed',
condition: {
field: 'selectedTriggerId',
value: 'github_pr_reviewed',
},
},
],
outputs: {

View File

@@ -75,6 +75,18 @@ export const githubPushTrigger: TriggerConfig = {
value: 'github_push',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_push',
condition: {
field: 'selectedTriggerId',
value: 'github_push',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -101,18 +113,6 @@ export const githubPushTrigger: TriggerConfig = {
value: 'github_push',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_push',
condition: {
field: 'selectedTriggerId',
value: 'github_push',
},
},
],
outputs: {

View File

@@ -75,6 +75,18 @@ export const githubReleasePublishedTrigger: TriggerConfig = {
value: 'github_release_published',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_release_published',
condition: {
field: 'selectedTriggerId',
value: 'github_release_published',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -101,18 +113,6 @@ export const githubReleasePublishedTrigger: TriggerConfig = {
value: 'github_release_published',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_release_published',
condition: {
field: 'selectedTriggerId',
value: 'github_release_published',
},
},
],
outputs: {

View File

@@ -72,6 +72,18 @@ export const githubWebhookTrigger: TriggerConfig = {
value: 'github_webhook',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_webhook',
condition: {
field: 'selectedTriggerId',
value: 'github_webhook',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -98,18 +110,6 @@ export const githubWebhookTrigger: TriggerConfig = {
value: 'github_webhook',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_webhook',
condition: {
field: 'selectedTriggerId',
value: 'github_webhook',
},
},
],
outputs: {

View File

@@ -76,6 +76,18 @@ export const githubWorkflowRunTrigger: TriggerConfig = {
value: 'github_workflow_run',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_workflow_run',
condition: {
field: 'selectedTriggerId',
value: 'github_workflow_run',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -102,18 +114,6 @@ export const githubWorkflowRunTrigger: TriggerConfig = {
value: 'github_workflow_run',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_workflow_run',
condition: {
field: 'selectedTriggerId',
value: 'github_workflow_run',
},
},
],
outputs: {

View File

@@ -104,6 +104,14 @@ export const gmailPollingTrigger: TriggerConfig = {
required: false,
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'gmail_poller',
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -121,14 +129,6 @@ export const gmailPollingTrigger: TriggerConfig = {
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'gmail_poller',
},
],
outputs: {

View File

@@ -59,6 +59,14 @@ export const googleFormsWebhookTrigger: TriggerConfig = {
defaultValue: true,
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'google_forms_webhook',
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -86,12 +94,12 @@ export const googleFormsWebhookTrigger: TriggerConfig = {
const script = `function onFormSubmit(e) {
const WEBHOOK_URL = "{{WEBHOOK_URL}}";
const SHARED_SECRET = "{{SHARED_SECRET}}";
try {
const form = FormApp.getActiveForm();
const formResponse = e.response;
const itemResponses = formResponse.getItemResponses();
// Build answers object
const answers = {};
for (var i = 0; i < itemResponses.length; i++) {
@@ -100,7 +108,7 @@ export const googleFormsWebhookTrigger: TriggerConfig = {
const answer = itemResponse.getResponse();
answers[question] = answer;
}
// Build payload
const payload = {
provider: "google_forms",
@@ -110,7 +118,7 @@ export const googleFormsWebhookTrigger: TriggerConfig = {
lastSubmittedTime: formResponse.getTimestamp().toISOString(),
answers: answers
};
// Send to webhook
const options = {
method: "post",
@@ -121,9 +129,9 @@ export const googleFormsWebhookTrigger: TriggerConfig = {
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(WEBHOOK_URL, options);
if (response.getResponseCode() !== 200) {
Logger.log("Webhook failed: " + response.getContentText());
} else {
@@ -145,14 +153,6 @@ export const googleFormsWebhookTrigger: TriggerConfig = {
description: 'Copy this code and paste it into your Google Forms Apps Script editor',
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'google_forms_webhook',
},
],
outputs: {

View File

@@ -93,6 +93,17 @@ export const hubspotCompanyCreatedTrigger: TriggerConfig = {
value: 'hubspot_company_created',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_company_created',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_company_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -156,17 +167,6 @@ export const hubspotCompanyCreatedTrigger: TriggerConfig = {
value: 'hubspot_company_created',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_company_created',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_company_created',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',

View File

@@ -93,6 +93,17 @@ export const hubspotCompanyDeletedTrigger: TriggerConfig = {
value: 'hubspot_company_deleted',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_company_deleted',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_company_deleted',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -156,17 +167,6 @@ export const hubspotCompanyDeletedTrigger: TriggerConfig = {
value: 'hubspot_company_deleted',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_company_deleted',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_company_deleted',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',

View File

@@ -107,6 +107,17 @@ export const hubspotCompanyPropertyChangedTrigger: TriggerConfig = {
value: 'hubspot_company_property_changed',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_company_property_changed',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_company_property_changed',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -170,17 +181,6 @@ export const hubspotCompanyPropertyChangedTrigger: TriggerConfig = {
value: 'hubspot_company_property_changed',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_company_property_changed',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_company_property_changed',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',

View File

@@ -93,6 +93,17 @@ export const hubspotContactCreatedTrigger: TriggerConfig = {
value: 'hubspot_contact_created',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_contact_created',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_contact_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -156,17 +167,6 @@ export const hubspotContactCreatedTrigger: TriggerConfig = {
value: 'hubspot_contact_created',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_contact_created',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_contact_created',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',

View File

@@ -93,6 +93,17 @@ export const hubspotContactDeletedTrigger: TriggerConfig = {
value: 'hubspot_contact_deleted',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_contact_deleted',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_contact_deleted',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -156,17 +167,6 @@ export const hubspotContactDeletedTrigger: TriggerConfig = {
value: 'hubspot_contact_deleted',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_contact_deleted',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_contact_deleted',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',

View File

@@ -94,6 +94,17 @@ export const hubspotContactPrivacyDeletedTrigger: TriggerConfig = {
value: 'hubspot_contact_privacy_deleted',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_contact_privacy_deleted',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_contact_privacy_deleted',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -157,17 +168,6 @@ export const hubspotContactPrivacyDeletedTrigger: TriggerConfig = {
value: 'hubspot_contact_privacy_deleted',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_contact_privacy_deleted',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_contact_privacy_deleted',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',

View File

@@ -107,6 +107,17 @@ export const hubspotContactPropertyChangedTrigger: TriggerConfig = {
value: 'hubspot_contact_property_changed',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_contact_property_changed',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_contact_property_changed',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -170,17 +181,6 @@ export const hubspotContactPropertyChangedTrigger: TriggerConfig = {
value: 'hubspot_contact_property_changed',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_contact_property_changed',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_contact_property_changed',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',

View File

@@ -93,6 +93,17 @@ export const hubspotConversationCreationTrigger: TriggerConfig = {
value: 'hubspot_conversation_creation',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_conversation_creation',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_conversation_creation',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -156,17 +167,6 @@ export const hubspotConversationCreationTrigger: TriggerConfig = {
value: 'hubspot_conversation_creation',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_conversation_creation',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_conversation_creation',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',

View File

@@ -93,6 +93,17 @@ export const hubspotConversationDeletionTrigger: TriggerConfig = {
value: 'hubspot_conversation_deletion',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_conversation_deletion',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_conversation_deletion',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -156,17 +167,6 @@ export const hubspotConversationDeletionTrigger: TriggerConfig = {
value: 'hubspot_conversation_deletion',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_conversation_deletion',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_conversation_deletion',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',

View File

@@ -93,6 +93,17 @@ export const hubspotConversationNewMessageTrigger: TriggerConfig = {
value: 'hubspot_conversation_new_message',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_conversation_new_message',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_conversation_new_message',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -156,17 +167,6 @@ export const hubspotConversationNewMessageTrigger: TriggerConfig = {
value: 'hubspot_conversation_new_message',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_conversation_new_message',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_conversation_new_message',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',

View File

@@ -94,6 +94,17 @@ export const hubspotConversationPrivacyDeletionTrigger: TriggerConfig = {
value: 'hubspot_conversation_privacy_deletion',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_conversation_privacy_deletion',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_conversation_privacy_deletion',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -157,17 +168,6 @@ export const hubspotConversationPrivacyDeletionTrigger: TriggerConfig = {
value: 'hubspot_conversation_privacy_deletion',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_conversation_privacy_deletion',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_conversation_privacy_deletion',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',

View File

@@ -107,6 +107,17 @@ export const hubspotConversationPropertyChangedTrigger: TriggerConfig = {
value: 'hubspot_conversation_property_changed',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_conversation_property_changed',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_conversation_property_changed',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -170,17 +181,6 @@ export const hubspotConversationPropertyChangedTrigger: TriggerConfig = {
value: 'hubspot_conversation_property_changed',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_conversation_property_changed',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_conversation_property_changed',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',

View File

@@ -93,6 +93,17 @@ export const hubspotDealCreatedTrigger: TriggerConfig = {
value: 'hubspot_deal_created',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_deal_created',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_deal_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -156,17 +167,6 @@ export const hubspotDealCreatedTrigger: TriggerConfig = {
value: 'hubspot_deal_created',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_deal_created',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_deal_created',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',

View File

@@ -93,6 +93,17 @@ export const hubspotDealDeletedTrigger: TriggerConfig = {
value: 'hubspot_deal_deleted',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_deal_deleted',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_deal_deleted',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -156,17 +167,6 @@ export const hubspotDealDeletedTrigger: TriggerConfig = {
value: 'hubspot_deal_deleted',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_deal_deleted',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_deal_deleted',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',

View File

@@ -107,6 +107,17 @@ export const hubspotDealPropertyChangedTrigger: TriggerConfig = {
value: 'hubspot_deal_property_changed',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_deal_property_changed',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_deal_property_changed',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -170,17 +181,6 @@ export const hubspotDealPropertyChangedTrigger: TriggerConfig = {
value: 'hubspot_deal_property_changed',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_deal_property_changed',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_deal_property_changed',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',

View File

@@ -93,6 +93,17 @@ export const hubspotTicketCreatedTrigger: TriggerConfig = {
value: 'hubspot_ticket_created',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_ticket_created',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_ticket_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -156,17 +167,6 @@ export const hubspotTicketCreatedTrigger: TriggerConfig = {
value: 'hubspot_ticket_created',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_ticket_created',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_ticket_created',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',

View File

@@ -93,6 +93,17 @@ export const hubspotTicketDeletedTrigger: TriggerConfig = {
value: 'hubspot_ticket_deleted',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_ticket_deleted',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_ticket_deleted',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -156,17 +167,6 @@ export const hubspotTicketDeletedTrigger: TriggerConfig = {
value: 'hubspot_ticket_deleted',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_ticket_deleted',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_ticket_deleted',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',

View File

@@ -107,6 +107,17 @@ export const hubspotTicketPropertyChangedTrigger: TriggerConfig = {
value: 'hubspot_ticket_property_changed',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_ticket_property_changed',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_ticket_property_changed',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -170,17 +181,6 @@ export const hubspotTicketPropertyChangedTrigger: TriggerConfig = {
value: 'hubspot_ticket_property_changed',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'hubspot_ticket_property_changed',
condition: {
field: 'selectedTriggerId',
value: 'hubspot_ticket_property_changed',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',

View File

@@ -82,7 +82,7 @@ export function hubspotSetupInstructions(eventType: string, additionalNotes?: st
'<strong>Step 3: Configure OAuth Settings</strong><br/>After creating your app via CLI, configure it to add the OAuth Redirect URL: <code>https://www.sim.ai/api/auth/oauth2/callback/hubspot</code>. Then retrieve your <strong>Client ID</strong> and <strong>Client Secret</strong> from your app configuration and enter them in the fields above.',
"<strong>Step 4: Get App ID and Developer API Key</strong><br/>In your HubSpot developer account, find your <strong>App ID</strong> (shown below your app name) and your <strong>Developer API Key</strong> (in app settings). You'll need both for the next steps.",
'<strong>Step 5: Set Required Scopes</strong><br/>Configure your app to include the required OAuth scope: <code>crm.objects.contacts.read</code>',
'<strong>Step 6: Save Configuration in Sim</strong><br/>Click the <strong>"Save Configuration"</strong> button below. This will generate your unique webhook URL.',
'<strong>Step 6: Save Configuration in Sim</strong><br/>Your unique webhook URL will be generated automatically once you complete the configuration above.',
'<strong>Step 7: Configure Webhook in HubSpot via API</strong><br/>After saving above, copy the <strong>Webhook URL</strong> and run the two curl commands below (replace <code>{YOUR_APP_ID}</code>, <code>{YOUR_DEVELOPER_API_KEY}</code>, and <code>{YOUR_WEBHOOK_URL_FROM_ABOVE}</code> with your actual values).',
"<strong>Step 8: Test Your Webhook</strong><br/>Create or modify a contact in HubSpot to trigger the webhook. Check your workflow execution logs in Sim to verify it's working.",
]

View File

@@ -56,18 +56,6 @@ export const jiraIssueCommentedTrigger: TriggerConfig = {
value: 'jira_issue_commented',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: jiraSetupInstructions('comment_created'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'jira_issue_commented',
},
},
{
id: 'triggerSave',
title: '',
@@ -80,6 +68,18 @@ export const jiraIssueCommentedTrigger: TriggerConfig = {
value: 'jira_issue_commented',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: jiraSetupInstructions('comment_created'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'jira_issue_commented',
},
},
],
outputs: buildCommentOutputs(),

View File

@@ -65,18 +65,6 @@ export const jiraIssueCreatedTrigger: TriggerConfig = {
value: 'jira_issue_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: jiraSetupInstructions('jira:issue_created'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'jira_issue_created',
},
},
{
id: 'triggerSave',
title: '',
@@ -89,6 +77,18 @@ export const jiraIssueCreatedTrigger: TriggerConfig = {
value: 'jira_issue_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: jiraSetupInstructions('jira:issue_created'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'jira_issue_created',
},
},
],
outputs: buildIssueOutputs(),

View File

@@ -56,18 +56,6 @@ export const jiraIssueDeletedTrigger: TriggerConfig = {
value: 'jira_issue_deleted',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: jiraSetupInstructions('jira:issue_deleted'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'jira_issue_deleted',
},
},
{
id: 'triggerSave',
title: '',
@@ -80,6 +68,18 @@ export const jiraIssueDeletedTrigger: TriggerConfig = {
value: 'jira_issue_deleted',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: jiraSetupInstructions('jira:issue_deleted'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'jira_issue_deleted',
},
},
],
outputs: buildIssueOutputs(),

View File

@@ -70,18 +70,6 @@ export const jiraIssueUpdatedTrigger: TriggerConfig = {
value: 'jira_issue_updated',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: jiraSetupInstructions('jira:issue_updated'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'jira_issue_updated',
},
},
{
id: 'triggerSave',
title: '',
@@ -94,6 +82,18 @@ export const jiraIssueUpdatedTrigger: TriggerConfig = {
value: 'jira_issue_updated',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: jiraSetupInstructions('jira:issue_updated'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'jira_issue_updated',
},
},
],
outputs: buildIssueUpdatedOutputs(),

View File

@@ -43,18 +43,6 @@ export const jiraWebhookTrigger: TriggerConfig = {
value: 'jira_webhook',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: jiraSetupInstructions('All Events'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'jira_webhook',
},
},
{
id: 'triggerSave',
title: '',
@@ -67,6 +55,18 @@ export const jiraWebhookTrigger: TriggerConfig = {
value: 'jira_webhook',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: jiraSetupInstructions('All Events'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'jira_webhook',
},
},
],
outputs: {

View File

@@ -56,18 +56,6 @@ export const jiraWorklogCreatedTrigger: TriggerConfig = {
value: 'jira_worklog_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: jiraSetupInstructions('worklog_created'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'jira_worklog_created',
},
},
{
id: 'triggerSave',
title: '',
@@ -80,6 +68,18 @@ export const jiraWorklogCreatedTrigger: TriggerConfig = {
value: 'jira_worklog_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: jiraSetupInstructions('worklog_created'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'jira_worklog_created',
},
},
],
outputs: buildWorklogOutputs(),

View File

@@ -39,18 +39,6 @@ export const linearCommentCreatedTrigger: TriggerConfig = {
value: 'linear_comment_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Comment (create)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_comment_created',
},
},
{
id: 'triggerSave',
title: '',
@@ -63,6 +51,18 @@ export const linearCommentCreatedTrigger: TriggerConfig = {
value: 'linear_comment_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Comment (create)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_comment_created',
},
},
],
outputs: buildCommentOutputs(),

View File

@@ -39,18 +39,6 @@ export const linearCommentUpdatedTrigger: TriggerConfig = {
value: 'linear_comment_updated',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Comment (update)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_comment_updated',
},
},
{
id: 'triggerSave',
title: '',
@@ -63,6 +51,18 @@ export const linearCommentUpdatedTrigger: TriggerConfig = {
value: 'linear_comment_updated',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Comment (update)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_comment_updated',
},
},
],
outputs: buildCommentOutputs(),

View File

@@ -39,18 +39,6 @@ export const linearCustomerRequestCreatedTrigger: TriggerConfig = {
value: 'linear_customer_request_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Customer Requests'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_customer_request_created',
},
},
{
id: 'triggerSave',
title: '',
@@ -63,6 +51,18 @@ export const linearCustomerRequestCreatedTrigger: TriggerConfig = {
value: 'linear_customer_request_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Customer Requests'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_customer_request_created',
},
},
],
outputs: buildCustomerRequestOutputs(),

View File

@@ -39,18 +39,6 @@ export const linearCustomerRequestUpdatedTrigger: TriggerConfig = {
value: 'linear_customer_request_updated',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('CustomerNeed (update)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_customer_request_updated',
},
},
{
id: 'triggerSave',
title: '',
@@ -63,6 +51,18 @@ export const linearCustomerRequestUpdatedTrigger: TriggerConfig = {
value: 'linear_customer_request_updated',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('CustomerNeed (update)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_customer_request_updated',
},
},
],
outputs: buildCustomerRequestOutputs(),

View File

@@ -39,18 +39,6 @@ export const linearCycleCreatedTrigger: TriggerConfig = {
value: 'linear_cycle_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Cycle (create)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_cycle_created',
},
},
{
id: 'triggerSave',
title: '',
@@ -63,6 +51,18 @@ export const linearCycleCreatedTrigger: TriggerConfig = {
value: 'linear_cycle_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Cycle (create)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_cycle_created',
},
},
],
outputs: buildCycleOutputs(),

View File

@@ -39,18 +39,6 @@ export const linearCycleUpdatedTrigger: TriggerConfig = {
value: 'linear_cycle_updated',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Cycle (update)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_cycle_updated',
},
},
{
id: 'triggerSave',
title: '',
@@ -63,6 +51,18 @@ export const linearCycleUpdatedTrigger: TriggerConfig = {
value: 'linear_cycle_updated',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Cycle (update)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_cycle_updated',
},
},
],
outputs: buildCycleOutputs(),

View File

@@ -52,18 +52,6 @@ export const linearIssueCreatedTrigger: TriggerConfig = {
value: 'linear_issue_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Issue (create)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_issue_created',
},
},
{
id: 'triggerSave',
title: '',
@@ -76,6 +64,18 @@ export const linearIssueCreatedTrigger: TriggerConfig = {
value: 'linear_issue_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Issue (create)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_issue_created',
},
},
],
outputs: buildIssueOutputs(),

View File

@@ -39,18 +39,6 @@ export const linearIssueRemovedTrigger: TriggerConfig = {
value: 'linear_issue_removed',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Issue (remove)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_issue_removed',
},
},
{
id: 'triggerSave',
title: '',
@@ -63,6 +51,18 @@ export const linearIssueRemovedTrigger: TriggerConfig = {
value: 'linear_issue_removed',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Issue (remove)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_issue_removed',
},
},
],
outputs: buildIssueOutputs(),

View File

@@ -39,18 +39,6 @@ export const linearIssueUpdatedTrigger: TriggerConfig = {
value: 'linear_issue_updated',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Issue (update)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_issue_updated',
},
},
{
id: 'triggerSave',
title: '',
@@ -63,6 +51,18 @@ export const linearIssueUpdatedTrigger: TriggerConfig = {
value: 'linear_issue_updated',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Issue (update)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_issue_updated',
},
},
],
outputs: buildIssueOutputs(),

View File

@@ -39,18 +39,6 @@ export const linearLabelCreatedTrigger: TriggerConfig = {
value: 'linear_label_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('IssueLabel (create)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_label_created',
},
},
{
id: 'triggerSave',
title: '',
@@ -63,6 +51,18 @@ export const linearLabelCreatedTrigger: TriggerConfig = {
value: 'linear_label_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('IssueLabel (create)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_label_created',
},
},
],
outputs: buildLabelOutputs(),

View File

@@ -39,18 +39,6 @@ export const linearLabelUpdatedTrigger: TriggerConfig = {
value: 'linear_label_updated',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('IssueLabel (update)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_label_updated',
},
},
{
id: 'triggerSave',
title: '',
@@ -63,6 +51,18 @@ export const linearLabelUpdatedTrigger: TriggerConfig = {
value: 'linear_label_updated',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('IssueLabel (update)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_label_updated',
},
},
],
outputs: buildLabelOutputs(),

View File

@@ -39,18 +39,6 @@ export const linearProjectCreatedTrigger: TriggerConfig = {
value: 'linear_project_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Project (create)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_project_created',
},
},
{
id: 'triggerSave',
title: '',
@@ -63,6 +51,18 @@ export const linearProjectCreatedTrigger: TriggerConfig = {
value: 'linear_project_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Project (create)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_project_created',
},
},
],
outputs: buildProjectOutputs(),

View File

@@ -39,18 +39,6 @@ export const linearProjectUpdateCreatedTrigger: TriggerConfig = {
value: 'linear_project_update_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('ProjectUpdate (create)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_project_update_created',
},
},
{
id: 'triggerSave',
title: '',
@@ -63,6 +51,18 @@ export const linearProjectUpdateCreatedTrigger: TriggerConfig = {
value: 'linear_project_update_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('ProjectUpdate (create)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_project_update_created',
},
},
],
outputs: buildProjectUpdateOutputs(),

View File

@@ -39,18 +39,6 @@ export const linearProjectUpdatedTrigger: TriggerConfig = {
value: 'linear_project_updated',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Project (update)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_project_updated',
},
},
{
id: 'triggerSave',
title: '',
@@ -63,6 +51,18 @@ export const linearProjectUpdatedTrigger: TriggerConfig = {
value: 'linear_project_updated',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Project (update)'),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'linear_project_updated',
},
},
],
outputs: buildProjectOutputs(),

View File

@@ -39,6 +39,18 @@ export const linearWebhookTrigger: TriggerConfig = {
value: 'linear_webhook',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'linear_webhook',
condition: {
field: 'selectedTriggerId',
value: 'linear_webhook',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -54,18 +66,6 @@ export const linearWebhookTrigger: TriggerConfig = {
value: 'linear_webhook',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'linear_webhook',
condition: {
field: 'selectedTriggerId',
value: 'linear_webhook',
},
},
],
outputs: {

View File

@@ -72,6 +72,18 @@ export const microsoftTeamsChatSubscriptionTrigger: TriggerConfig = {
value: 'microsoftteams_chat_subscription',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'microsoftteams_chat_subscription',
condition: {
field: 'selectedTriggerId',
value: 'microsoftteams_chat_subscription',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -93,18 +105,6 @@ export const microsoftTeamsChatSubscriptionTrigger: TriggerConfig = {
value: 'microsoftteams_chat_subscription',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'microsoftteams_chat_subscription',
condition: {
field: 'selectedTriggerId',
value: 'microsoftteams_chat_subscription',
},
},
],
outputs: {

View File

@@ -51,6 +51,18 @@ export const microsoftTeamsWebhookTrigger: TriggerConfig = {
value: 'microsoftteams_webhook',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'microsoftteams_webhook',
condition: {
field: 'selectedTriggerId',
value: 'microsoftteams_webhook',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -76,18 +88,6 @@ export const microsoftTeamsWebhookTrigger: TriggerConfig = {
value: 'microsoftteams_webhook',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'microsoftteams_webhook',
condition: {
field: 'selectedTriggerId',
value: 'microsoftteams_webhook',
},
},
],
outputs: {

View File

@@ -93,6 +93,14 @@ export const outlookPollingTrigger: TriggerConfig = {
required: false,
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'outlook_poller',
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -110,14 +118,6 @@ export const outlookPollingTrigger: TriggerConfig = {
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'outlook_poller',
},
],
outputs: {

View File

@@ -19,6 +19,14 @@ export const rssPollingTrigger: TriggerConfig = {
required: true,
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'rss_poller',
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -36,14 +44,6 @@ export const rssPollingTrigger: TriggerConfig = {
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'rss_poller',
},
],
outputs: {

View File

@@ -30,6 +30,14 @@ export const slackWebhookTrigger: TriggerConfig = {
required: true,
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'slack_webhook',
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -41,7 +49,7 @@ export const slackWebhookTrigger: TriggerConfig = {
'Go to "OAuth & Permissions" and add bot token scopes:<br><ul class="mt-1 ml-5 list-disc"><li><code>app_mentions:read</code> - For viewing messages that tag your bot with an @</li><li><code>chat:write</code> - To send messages to channels your bot is a part of</li></ul>',
'Go to "Event Subscriptions":<br><ul class="mt-1 ml-5 list-disc"><li>Enable events</li><li>Under "Subscribe to Bot Events", add <code>app_mention</code> to listen to messages that mention your bot</li><li>Paste the Webhook URL above into the "Request URL" field</li></ul>',
'Go to "Install App" in the left sidebar and install the app into your desired Slack workspace and channel.',
'Save changes in both Slack and here.',
'Save your changes in Slack. Your trigger will configure automatically once you complete the fields above.',
]
.map(
(instruction, index) =>
@@ -51,14 +59,6 @@ export const slackWebhookTrigger: TriggerConfig = {
hideFromPreview: true,
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'slack_webhook',
},
],
outputs: {

View File

@@ -165,6 +165,14 @@ export const stripeWebhookTrigger: TriggerConfig = {
password: true,
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'stripe_webhook',
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -178,7 +186,7 @@ export const stripeWebhookTrigger: TriggerConfig = {
'Click "Create Destination" to save',
'After creating the endpoint, click "Reveal" next to "Signing secret" and copy it',
'Paste the signing secret into the <strong>Webhook Signing Secret</strong> field above',
'Click "Save" to activate your webhook trigger',
'Your webhook trigger will activate automatically once you complete the configuration above',
]
.map(
(instruction, index) =>
@@ -187,14 +195,6 @@ export const stripeWebhookTrigger: TriggerConfig = {
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'stripe_webhook',
},
],
outputs: {

View File

@@ -30,6 +30,14 @@ export const telegramWebhookTrigger: TriggerConfig = {
required: true,
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'telegram_webhook',
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -38,7 +46,7 @@ export const telegramWebhookTrigger: TriggerConfig = {
defaultValue: [
'Message "/newbot" to <a href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer" class="text-muted-foreground underline transition-colors hover:text-muted-foreground/80">@BotFather</a> in Telegram to create a bot and copy its token.',
'Enter your Bot Token above.',
'Save settings and any message sent to your bot will trigger the workflow.',
'Once configured, any message sent to your bot will trigger the workflow.',
]
.map(
(instruction, index) =>
@@ -47,14 +55,6 @@ export const telegramWebhookTrigger: TriggerConfig = {
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'telegram_webhook',
},
],
outputs: {

View File

@@ -49,6 +49,14 @@ export const twilioVoiceWebhookTrigger: TriggerConfig = {
required: false,
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'twilio_voice_webhook',
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -69,14 +77,6 @@ export const twilioVoiceWebhookTrigger: TriggerConfig = {
.join('\n\n'),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'twilio_voice_webhook',
},
],
outputs: {

View File

@@ -61,6 +61,14 @@ export const typeformWebhookTrigger: TriggerConfig = {
defaultValue: false,
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'typeform_webhook',
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -71,7 +79,7 @@ export const typeformWebhookTrigger: TriggerConfig = {
'Find your Form ID in the URL when editing your form (e.g., <code>https://admin.typeform.com/form/ABC123/create</code> → Form ID is <code>ABC123</code>)',
'Fill in the form above with your Form ID and Personal Access Token',
'Optionally add a Webhook Secret for enhanced security - Sim will verify all incoming webhooks match this secret',
'Click "Save" below - Sim will automatically register the webhook with Typeform',
'Sim will automatically register the webhook with Typeform when you complete the configuration above',
'<strong>Note:</strong> Requires a Typeform PRO or PRO+ account to use webhooks',
]
.map(
@@ -81,14 +89,6 @@ export const typeformWebhookTrigger: TriggerConfig = {
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'typeform_webhook',
},
],
outputs: {

View File

@@ -53,6 +53,18 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = {
value: 'webflow_collection_item_changed',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'webflow_collection_item_changed',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_changed',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -77,18 +89,6 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = {
value: 'webflow_collection_item_changed',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'webflow_collection_item_changed',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_changed',
},
},
],
outputs: {

View File

@@ -66,6 +66,18 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = {
value: 'webflow_collection_item_created',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'webflow_collection_item_created',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_created',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -90,18 +102,6 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = {
value: 'webflow_collection_item_created',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'webflow_collection_item_created',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_created',
},
},
],
outputs: {

View File

@@ -53,6 +53,18 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = {
value: 'webflow_collection_item_deleted',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'webflow_collection_item_deleted',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_deleted',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -78,18 +90,6 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = {
value: 'webflow_collection_item_deleted',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'webflow_collection_item_deleted',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_deleted',
},
},
],
outputs: {

View File

@@ -40,6 +40,14 @@ export const webflowFormSubmissionTrigger: TriggerConfig = {
required: false,
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'webflow_form_submission',
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -61,14 +69,6 @@ export const webflowFormSubmissionTrigger: TriggerConfig = {
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'webflow_form_submission',
},
],
outputs: {

View File

@@ -31,6 +31,14 @@ export const whatsappWebhookTrigger: TriggerConfig = {
required: true,
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'whatsapp_webhook',
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
@@ -53,14 +61,6 @@ export const whatsappWebhookTrigger: TriggerConfig = {
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'whatsapp_webhook',
},
],
outputs: {

Some files were not shown because too many files have changed in this diff Show More