feat(triggers): modify triggers to use existing subblock system, webhook order of operations improvements (#1774)

* feat(triggers): make triggers use existing subblock system, need to still fix webhook URL on multiselect and add script in text subblock for google form

* minimize added subblocks, cleanup code, make triggers first-class subblock users

* remove multi select dropdown and add props to existing dropdown instead

* cleanup dropdown

* add socket op to delete external webhook connections on block delete

* establish external webhook before creating webhook DB record, surface better errors for ones that require external connections

* fix copy button in short-input

* revert environment.ts, cleanup

* add triggers registry, update copilot tool to reflect new trigger setup

* update trigger-save subblock

* clean

* cleanup

* remove unused subblock store op, update search modal to reflect list of triggers

* add init from workflow to subblock store to populate new subblock format from old triggers

* fix mapping of old names to new ones

* added debug logging

* remove all extraneous debug logging and added mapping for triggerConfig field names that were changed

* fix trigger config for triggers w/ multiple triggers

* edge cases for effectiveTriggerId

* cleaned up

* fix dropdown multiselect

* fix multiselect

* updated short-input copy button

* duplicate blocks in trigger mode

* ack PR comments
This commit is contained in:
Waleed
2025-10-31 11:38:59 -07:00
committed by GitHub
parent 70ff5394a4
commit e64129c1ad
62 changed files with 3973 additions and 3484 deletions

View File

@@ -5,9 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { getBaseUrl } from '@/lib/urls/utils'
import { generateRequestId } from '@/lib/utils'
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
const logger = createLogger('WebhookAPI')
@@ -245,219 +243,9 @@ export async function DELETE(
const foundWebhook = webhookData.webhook
// If it's an Airtable webhook, delete it from Airtable first
if (foundWebhook.provider === 'airtable') {
try {
const { baseId, externalId } = (foundWebhook.providerConfig || {}) as {
baseId?: string
externalId?: string
}
const { cleanupExternalWebhook } = await import('@/lib/webhooks/webhook-helpers')
await cleanupExternalWebhook(foundWebhook, webhookData.workflow, requestId)
if (!baseId) {
logger.warn(`[${requestId}] Missing baseId for Airtable webhook deletion.`, {
webhookId: id,
})
return NextResponse.json(
{ error: 'Missing baseId for Airtable webhook deletion' },
{ status: 400 }
)
}
// Get access token for the workflow owner
const userIdForToken = webhookData.workflow.userId
const accessToken = await getOAuthToken(userIdForToken, 'airtable')
if (!accessToken) {
logger.warn(
`[${requestId}] Could not retrieve Airtable access token for user ${userIdForToken}. Cannot delete webhook in Airtable.`,
{ webhookId: id }
)
return NextResponse.json(
{ error: 'Airtable access token not found for webhook deletion' },
{ status: 401 }
)
}
// Resolve externalId if missing by listing webhooks and matching our notificationUrl
let resolvedExternalId: string | undefined = externalId
if (!resolvedExternalId) {
try {
const expectedNotificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${foundWebhook.path}`
const listUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks`
const listResp = await fetch(listUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
const listBody = await listResp.json().catch(() => null)
if (listResp.ok && listBody && Array.isArray(listBody.webhooks)) {
const match = listBody.webhooks.find((w: any) => {
const url: string | undefined = w?.notificationUrl
if (!url) return false
// Prefer exact match; fallback to suffix match to handle origin/host remaps
return (
url === expectedNotificationUrl ||
url.endsWith(`/api/webhooks/trigger/${foundWebhook.path}`)
)
})
if (match?.id) {
resolvedExternalId = match.id as string
// Persist resolved externalId for future operations
try {
await db
.update(webhook)
.set({
providerConfig: {
...(foundWebhook.providerConfig || {}),
externalId: resolvedExternalId,
},
updatedAt: new Date(),
})
.where(eq(webhook.id, id))
} catch {
// non-fatal persistence error
}
logger.info(`[${requestId}] Resolved Airtable externalId by listing webhooks`, {
baseId,
externalId: resolvedExternalId,
})
} else {
logger.warn(`[${requestId}] Could not resolve Airtable externalId from list`, {
baseId,
expectedNotificationUrl,
})
}
} else {
logger.warn(`[${requestId}] Failed to list Airtable webhooks to resolve externalId`, {
baseId,
status: listResp.status,
body: listBody,
})
}
} catch (e: any) {
logger.warn(`[${requestId}] Error attempting to resolve Airtable externalId`, {
error: e?.message,
})
}
}
// If still not resolvable, skip remote deletion but proceed with local delete
if (!resolvedExternalId) {
logger.info(
`[${requestId}] Airtable externalId not found; skipping remote deletion and proceeding to remove local record`,
{ baseId }
)
}
if (resolvedExternalId) {
const airtableDeleteUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks/${resolvedExternalId}`
const airtableResponse = await fetch(airtableDeleteUrl, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
// Attempt to parse error body for better diagnostics
if (!airtableResponse.ok) {
let responseBody: any = null
try {
responseBody = await airtableResponse.json()
} catch {
// ignore parse errors
}
logger.error(
`[${requestId}] Failed to delete Airtable webhook in Airtable. Status: ${airtableResponse.status}`,
{ baseId, externalId: resolvedExternalId, response: responseBody }
)
return NextResponse.json(
{
error: 'Failed to delete webhook from Airtable',
details:
(responseBody && (responseBody.error?.message || responseBody.error)) ||
`Status ${airtableResponse.status}`,
},
{ status: 500 }
)
}
logger.info(`[${requestId}] Successfully deleted Airtable webhook in Airtable`, {
baseId,
externalId: resolvedExternalId,
})
}
} catch (error: any) {
logger.error(`[${requestId}] Error deleting Airtable webhook`, {
webhookId: id,
error: error.message,
stack: error.stack,
})
return NextResponse.json(
{ error: 'Failed to delete webhook from Airtable', details: error.message },
{ status: 500 }
)
}
}
// Delete Microsoft Teams subscription if applicable
if (foundWebhook.provider === 'microsoftteams') {
const { deleteTeamsSubscription } = await import('@/lib/webhooks/webhook-helpers')
logger.info(`[${requestId}] Deleting Teams subscription for webhook ${id}`)
await deleteTeamsSubscription(foundWebhook, webhookData.workflow, requestId)
// Don't fail webhook deletion if subscription cleanup fails
}
// Delete Telegram webhook if applicable
if (foundWebhook.provider === 'telegram') {
try {
const { botToken } = (foundWebhook.providerConfig || {}) as { botToken?: string }
if (!botToken) {
logger.warn(`[${requestId}] Missing botToken for Telegram webhook deletion.`, {
webhookId: id,
})
return NextResponse.json(
{ error: 'Missing botToken for Telegram webhook deletion' },
{ status: 400 }
)
}
const telegramApiUrl = `https://api.telegram.org/bot${botToken}/deleteWebhook`
const telegramResponse = await fetch(telegramApiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
const responseBody = await telegramResponse.json()
if (!telegramResponse.ok || !responseBody.ok) {
const errorMessage =
responseBody.description ||
`Failed to delete Telegram webhook. Status: ${telegramResponse.status}`
logger.error(`[${requestId}] ${errorMessage}`, { response: responseBody })
return NextResponse.json(
{ error: 'Failed to delete webhook from Telegram', details: errorMessage },
{ status: 500 }
)
}
logger.info(`[${requestId}] Successfully deleted Telegram webhook for webhook ${id}`)
} catch (error: any) {
logger.error(`[${requestId}] Error deleting Telegram webhook`, {
webhookId: id,
error: error.message,
stack: error.stack,
})
return NextResponse.json(
{ error: 'Failed to delete webhook from Telegram', details: error.message },
{ status: 500 }
)
}
}
// Delete the webhook from the database
await db.delete(webhook).where(eq(webhook.id, id))
logger.info(`[${requestId}] Successfully deleted webhook: ${id}`)

View File

@@ -254,61 +254,33 @@ export async function POST(request: NextRequest) {
let savedWebhook: any = null // Variable to hold the result of save/update
// Use the original provider config - Gmail/Outlook configuration functions will inject userId automatically
const finalProviderConfig = providerConfig
const finalProviderConfig = providerConfig || {}
if (targetWebhookId) {
logger.info(`[${requestId}] Updating existing webhook for path: ${finalPath}`, {
webhookId: targetWebhookId,
provider,
hasCredentialId: !!(finalProviderConfig as any)?.credentialId,
credentialId: (finalProviderConfig as any)?.credentialId,
})
const updatedResult = await db
.update(webhook)
.set({
blockId,
provider,
providerConfig: finalProviderConfig,
isActive: true,
updatedAt: new Date(),
})
.where(eq(webhook.id, targetWebhookId))
.returning()
savedWebhook = updatedResult[0]
logger.info(`[${requestId}] Webhook updated successfully`, {
webhookId: savedWebhook.id,
savedProviderConfig: savedWebhook.providerConfig,
})
} else {
// Create a new webhook
const webhookId = nanoid()
logger.info(`[${requestId}] Creating new webhook with ID: ${webhookId}`)
const newResult = await db
.insert(webhook)
.values({
id: webhookId,
workflowId,
blockId,
path: finalPath,
provider,
providerConfig: finalProviderConfig,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
savedWebhook = newResult[0]
}
// Create external subscriptions before saving to DB to prevent orphaned records
let externalSubscriptionId: string | undefined
let externalSubscriptionCreated = false
// --- Attempt to create webhook in Airtable if provider is 'airtable' ---
if (savedWebhook && provider === 'airtable') {
logger.info(
`[${requestId}] Airtable provider detected. Attempting to create webhook in Airtable.`
)
const createTempWebhookData = () => ({
id: targetWebhookId || nanoid(),
path: finalPath,
providerConfig: finalProviderConfig,
})
if (provider === 'airtable') {
logger.info(`[${requestId}] Creating Airtable subscription before saving to database`)
try {
await createAirtableWebhookSubscription(request, userId, savedWebhook, requestId)
externalSubscriptionId = await createAirtableWebhookSubscription(
request,
userId,
createTempWebhookData(),
requestId
)
if (externalSubscriptionId) {
finalProviderConfig.externalId = externalSubscriptionId
externalSubscriptionCreated = true
}
} catch (err) {
logger.error(`[${requestId}] Error creating Airtable webhook`, err)
logger.error(`[${requestId}] Error creating Airtable webhook subscription`, err)
return NextResponse.json(
{
error: 'Failed to create webhook in Airtable',
@@ -318,51 +290,130 @@ export async function POST(request: NextRequest) {
)
}
}
// --- End Airtable specific logic ---
// --- Microsoft Teams subscription setup ---
if (savedWebhook && provider === 'microsoftteams') {
if (provider === 'microsoftteams') {
const { createTeamsSubscription } = await import('@/lib/webhooks/webhook-helpers')
logger.info(`[${requestId}] Creating Teams subscription for webhook ${savedWebhook.id}`)
const success = await createTeamsSubscription(
request,
savedWebhook,
workflowRecord,
requestId
)
if (!success) {
logger.info(`[${requestId}] Creating Teams subscription before saving to database`)
try {
await createTeamsSubscription(request, createTempWebhookData(), workflowRecord, requestId)
externalSubscriptionCreated = true
} catch (err) {
logger.error(`[${requestId}] Error creating Teams subscription`, err)
return NextResponse.json(
{
error: 'Failed to create Teams subscription',
details: 'Could not create subscription with Microsoft Graph API',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
// --- End Teams subscription setup ---
// --- Telegram webhook setup ---
if (savedWebhook && provider === 'telegram') {
if (provider === 'telegram') {
const { createTelegramWebhook } = await import('@/lib/webhooks/webhook-helpers')
logger.info(`[${requestId}] Creating Telegram webhook for webhook ${savedWebhook.id}`)
const success = await createTelegramWebhook(request, savedWebhook, requestId)
if (!success) {
logger.info(`[${requestId}] Creating Telegram webhook before saving to database`)
try {
await createTelegramWebhook(request, createTempWebhookData(), requestId)
externalSubscriptionCreated = true
} catch (err) {
logger.error(`[${requestId}] Error creating Telegram webhook`, err)
return NextResponse.json(
{
error: 'Failed to create Telegram webhook',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
// --- End Telegram webhook setup ---
// --- Gmail webhook setup ---
if (provider === 'webflow') {
logger.info(`[${requestId}] Creating Webflow subscription before saving to database`)
try {
externalSubscriptionId = await createWebflowWebhookSubscription(
request,
userId,
createTempWebhookData(),
requestId
)
if (externalSubscriptionId) {
finalProviderConfig.externalId = externalSubscriptionId
externalSubscriptionCreated = true
}
} catch (err) {
logger.error(`[${requestId}] Error creating Webflow webhook subscription`, err)
return NextResponse.json(
{
error: 'Failed to create webhook in Webflow',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
// Now save to database (only if subscription succeeded or provider doesn't need external subscription)
try {
if (targetWebhookId) {
logger.info(`[${requestId}] Updating existing webhook for path: ${finalPath}`, {
webhookId: targetWebhookId,
provider,
hasCredentialId: !!(finalProviderConfig as any)?.credentialId,
credentialId: (finalProviderConfig as any)?.credentialId,
})
const updatedResult = await db
.update(webhook)
.set({
blockId,
provider,
providerConfig: finalProviderConfig,
isActive: true,
updatedAt: new Date(),
})
.where(eq(webhook.id, targetWebhookId))
.returning()
savedWebhook = updatedResult[0]
logger.info(`[${requestId}] Webhook updated successfully`, {
webhookId: savedWebhook.id,
savedProviderConfig: savedWebhook.providerConfig,
})
} else {
// Create a new webhook
const webhookId = nanoid()
logger.info(`[${requestId}] Creating new webhook with ID: ${webhookId}`)
const newResult = await db
.insert(webhook)
.values({
id: webhookId,
workflowId,
blockId,
path: finalPath,
provider,
providerConfig: finalProviderConfig,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
savedWebhook = newResult[0]
}
} catch (dbError) {
if (externalSubscriptionCreated) {
logger.error(`[${requestId}] DB save failed, cleaning up external subscription`, dbError)
try {
const { cleanupExternalWebhook } = await import('@/lib/webhooks/webhook-helpers')
await cleanupExternalWebhook(createTempWebhookData(), workflowRecord, requestId)
} catch (cleanupError) {
logger.error(
`[${requestId}] Failed to cleanup external subscription after DB save failure`,
cleanupError
)
}
}
throw dbError
}
// --- Gmail/Outlook webhook setup (these don't require external subscriptions, configure after DB save) ---
if (savedWebhook && provider === 'gmail') {
logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`)
try {
@@ -428,26 +479,6 @@ export async function POST(request: NextRequest) {
}
// --- End Outlook specific logic ---
// --- Webflow webhook setup ---
if (savedWebhook && provider === 'webflow') {
logger.info(
`[${requestId}] Webflow provider detected. Attempting to create webhook in Webflow.`
)
try {
await createWebflowWebhookSubscription(request, userId, savedWebhook, requestId)
} catch (err) {
logger.error(`[${requestId}] Error creating Webflow webhook`, err)
return NextResponse.json(
{
error: 'Failed to create webhook in Webflow',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
// --- End Webflow specific logic ---
const status = targetWebhookId ? 200 : 201
return NextResponse.json({ webhook: savedWebhook }, { status })
} catch (error: any) {
@@ -465,7 +496,7 @@ async function createAirtableWebhookSubscription(
userId: string,
webhookData: any,
requestId: string
) {
): Promise<string | undefined> {
try {
const { path, providerConfig } = webhookData
const { baseId, tableId, includeCellValuesInFieldIds } = providerConfig || {}
@@ -474,7 +505,9 @@ async function createAirtableWebhookSubscription(
logger.warn(`[${requestId}] Missing baseId or tableId for Airtable webhook creation.`, {
webhookId: webhookData.id,
})
return // Cannot proceed without base/table IDs
throw new Error(
'Base ID and Table ID are required to create Airtable webhook. Please provide valid Airtable base and table IDs.'
)
}
const accessToken = await getOAuthToken(userId, 'airtable')
@@ -532,32 +565,24 @@ async function createAirtableWebhookSubscription(
`[${requestId}] Failed to create webhook in Airtable for webhook ${webhookData.id}. Status: ${airtableResponse.status}`,
{ type: errorType, message: errorMessage, response: responseBody }
)
} else {
logger.info(
`[${requestId}] Successfully created webhook in Airtable for webhook ${webhookData.id}.`,
{
airtableWebhookId: responseBody.id,
}
)
// Store the airtableWebhookId (responseBody.id) within the providerConfig
try {
const currentConfig = (webhookData.providerConfig as Record<string, any>) || {}
const updatedConfig = {
...currentConfig,
externalId: responseBody.id, // Add/update the externalId
}
await db
.update(webhook)
.set({ providerConfig: updatedConfig, updatedAt: new Date() })
.where(eq(webhook.id, webhookData.id))
} catch (dbError: any) {
logger.error(
`[${requestId}] Failed to store externalId in providerConfig for webhook ${webhookData.id}.`,
dbError
)
// Even if saving fails, the webhook exists in Airtable. Log and continue.
let userFriendlyMessage = 'Failed to create webhook subscription in Airtable'
if (airtableResponse.status === 404) {
userFriendlyMessage =
'Airtable base or table not found. Please verify that the Base ID and Table ID are correct and that you have access to them.'
} else if (errorMessage && errorMessage !== 'Unknown Airtable API error') {
userFriendlyMessage = `Airtable error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
logger.info(
`[${requestId}] Successfully created webhook in Airtable for webhook ${webhookData.id}.`,
{
airtableWebhookId: responseBody.id,
}
)
return responseBody.id
} catch (error: any) {
logger.error(
`[${requestId}] Exception during Airtable webhook creation for webhook ${webhookData.id}.`,
@@ -566,6 +591,8 @@ async function createAirtableWebhookSubscription(
stack: error.stack,
}
)
// Re-throw the error so it can be caught by the outer try-catch
throw error
}
}
// Helper function to create the webhook subscription in Webflow
@@ -574,7 +601,7 @@ async function createWebflowWebhookSubscription(
userId: string,
webhookData: any,
requestId: string
) {
): Promise<string | undefined> {
try {
const { path, providerConfig } = webhookData
const { siteId, triggerId, collectionId, formId } = providerConfig || {}
@@ -672,24 +699,7 @@ async function createWebflowWebhookSubscription(
}
)
// Store the Webflow webhook ID in the providerConfig
try {
const currentConfig = (webhookData.providerConfig as Record<string, any>) || {}
const updatedConfig = {
...currentConfig,
externalId: responseBody.id || responseBody._id,
}
await db
.update(webhook)
.set({ providerConfig: updatedConfig, updatedAt: new Date() })
.where(eq(webhook.id, webhookData.id))
} catch (dbError: any) {
logger.error(
`[${requestId}] Failed to store externalId in providerConfig for webhook ${webhookData.id}.`,
dbError
)
// Even if saving fails, the webhook exists in Webflow. Log and continue.
}
return responseBody.id || responseBody._id
} catch (error: any) {
logger.error(
`[${requestId}] Exception during Webflow webhook creation for webhook ${webhookData.id}.`,

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { templates, workflow } from '@sim/db/schema'
import { templates, webhook, workflow } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
@@ -252,6 +252,48 @@ export async function DELETE(
}
}
// Clean up external webhooks before deleting workflow
try {
const { cleanupExternalWebhook } = await import('@/lib/webhooks/webhook-helpers')
const webhooksToCleanup = await db
.select({
webhook: webhook,
workflow: {
id: workflow.id,
userId: workflow.userId,
workspaceId: workflow.workspaceId,
},
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(eq(webhook.workflowId, workflowId))
if (webhooksToCleanup.length > 0) {
logger.info(
`[${requestId}] Found ${webhooksToCleanup.length} webhook(s) to cleanup for workflow ${workflowId}`
)
// Clean up each webhook (don't fail if cleanup fails)
for (const webhookData of webhooksToCleanup) {
try {
await cleanupExternalWebhook(webhookData.webhook, webhookData.workflow, requestId)
} catch (cleanupError) {
logger.warn(
`[${requestId}] Failed to cleanup external webhook ${webhookData.webhook.id} during workflow deletion`,
cleanupError
)
// Continue with deletion even if cleanup fails
}
}
}
} catch (webhookCleanupError) {
logger.warn(
`[${requestId}] Error during webhook cleanup for workflow deletion (continuing with deletion)`,
webhookCleanupError
)
// Continue with workflow deletion even if webhook cleanup fails
}
await db.delete(workflow).where(eq(workflow.id, workflowId))
const elapsed = Date.now() - startTime

View File

@@ -1,6 +1,6 @@
import type { ReactElement } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Wand2 } from 'lucide-react'
import { Check, Copy, Wand2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { highlight, languages } from 'prismjs'
import 'prismjs/components/prism-javascript'
@@ -37,6 +37,11 @@ interface CodeProps {
isPreview?: boolean
previewValue?: string | null
disabled?: boolean
readOnly?: boolean
collapsible?: boolean
defaultCollapsed?: boolean
defaultValue?: string | number | boolean | Record<string, unknown> | Array<unknown>
showCopyButton?: boolean
onValidationChange?: (isValid: boolean) => void
wandConfig: {
enabled: boolean
@@ -76,6 +81,11 @@ export function Code({
isPreview = false,
previewValue,
disabled = false,
readOnly = false,
collapsible,
defaultCollapsed = false,
defaultValue,
showCopyButton = false,
onValidationChange,
wandConfig,
}: CodeProps) {
@@ -101,20 +111,26 @@ export function Code({
const [cursorPosition, setCursorPosition] = useState(0)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
const [visualLineHeights, setVisualLineHeights] = useState<number[]>([])
const [copied, setCopied] = useState(false)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const collapsedStateKey = `${subBlockId}_collapsed`
const isCollapsed =
(useSubBlockStore((state) => state.getValue(blockId, collapsedStateKey)) as boolean) ?? false
const storeCollapsedValue = useSubBlockStore((state) =>
state.getValue(blockId, collapsedStateKey)
) as boolean | undefined
const isCollapsed = storeCollapsedValue !== undefined ? storeCollapsedValue : defaultCollapsed
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const setCollapsedValue = (blockId: string, subblockId: string, value: any) => {
collaborativeSetSubblockValue(blockId, subblockId, value)
}
const showCollapseButton =
(subBlockId === 'responseFormat' || subBlockId === 'code') && code.split('\n').length > 5
const shouldShowCollapseButton = useMemo(() => {
if (collapsible === false) return false
if (collapsible === true) return true
return (subBlockId === 'responseFormat' || subBlockId === 'code') && code.split('\n').length > 5
}, [collapsible, subBlockId, code])
const isValidJson = useMemo(() => {
if (subBlockId !== 'responseFormat' || !code.trim()) {
@@ -211,7 +227,19 @@ IMPORTANT FORMATTING RULES:
const emitTagSelection = useTagSelection(blockId, subBlockId)
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
const getDefaultValueString = () => {
if (defaultValue === undefined || defaultValue === null) return ''
if (typeof defaultValue === 'string') return defaultValue
return JSON.stringify(defaultValue, null, 2)
}
const value = isPreview
? previewValue
: propValue !== undefined
? propValue
: readOnly && defaultValue !== undefined
? getDefaultValueString()
: storeValue
useEffect(() => {
handleStreamStartRef.current = () => {
@@ -301,8 +329,36 @@ IMPORTANT FORMATTING RULES:
}
}, [code])
useEffect(() => {
if (!editorRef.current) return
const setReadOnly = () => {
const textarea = editorRef.current?.querySelector('textarea')
if (textarea) {
textarea.readOnly = readOnly
}
}
setReadOnly()
const timeoutId = setTimeout(setReadOnly, 0)
const observer = new MutationObserver(setReadOnly)
if (editorRef.current) {
observer.observe(editorRef.current, {
childList: true,
subtree: true,
})
}
return () => {
clearTimeout(timeoutId)
observer.disconnect()
}
}, [readOnly])
const handleDrop = (e: React.DragEvent) => {
if (isPreview) return
if (isPreview || readOnly) return
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'))
@@ -334,8 +390,17 @@ IMPORTANT FORMATTING RULES:
}
}
const handleCopy = () => {
const textToCopy = code
if (textToCopy) {
navigator.clipboard.writeText(textToCopy)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
const handleTagSelect = (newValue: string) => {
if (!isPreview) {
if (!isPreview && !readOnly) {
setCode(newValue)
emitTagSelection(newValue)
}
@@ -348,7 +413,7 @@ IMPORTANT FORMATTING RULES:
}
const handleEnvVarSelect = (newValue: string) => {
if (!isPreview) {
if (!isPreview && !readOnly) {
setCode(newValue)
emitTagSelection(newValue)
}
@@ -439,7 +504,7 @@ IMPORTANT FORMATTING RULES:
onDrop={handleDrop}
>
<div className='absolute top-2 right-3 z-10 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
{wandConfig?.enabled && !isCollapsed && !isAiStreaming && !isPreview && (
{wandConfig?.enabled && !isCollapsed && !isAiStreaming && !isPreview && !readOnly && (
<Button
variant='ghost'
size='icon'
@@ -452,7 +517,26 @@ IMPORTANT FORMATTING RULES:
</Button>
)}
{showCollapseButton && !isAiStreaming && !isPreview && (
{showCopyButton && code && (
<Button
type='button'
variant='ghost'
size='sm'
onClick={handleCopy}
disabled={!code}
className={cn(
'h-8 w-8 p-0',
'text-muted-foreground/60 transition-all duration-200',
'hover:scale-105 hover:bg-muted/50 hover:text-foreground',
'active:scale-95'
)}
aria-label='Copy code'
>
{copied ? <Check className='h-3.5 w-3.5' /> : <Copy className='h-3.5 w-3.5' />}
</Button>
)}
{shouldShowCollapseButton && !isAiStreaming && (
<Button
variant='ghost'
size='sm'
@@ -489,7 +573,7 @@ IMPORTANT FORMATTING RULES:
<Editor
value={code}
onValueChange={(newCode) => {
if (!isCollapsed && !isAiStreaming && !isPreview && !disabled) {
if (!isCollapsed && !isAiStreaming && !isPreview && !disabled && !readOnly) {
setCode(newCode)
setStoreValue(newCode)
@@ -524,14 +608,12 @@ IMPORTANT FORMATTING RULES:
[]
let processedCode = codeToHighlight
// Replace environment variables with placeholders
processedCode = processedCode.replace(/\{\{([^}]+)\}\}/g, (match) => {
const placeholder = `__ENV_VAR_${placeholders.length}__`
placeholders.push({ placeholder, original: match, type: 'env' })
return placeholder
})
// Replace variable references with placeholders
processedCode = processedCode.replace(/<([^>]+)>/g, (match) => {
if (shouldHighlightReference(match)) {
const placeholder = `__VAR_REF_${placeholders.length}__`
@@ -541,11 +623,9 @@ IMPORTANT FORMATTING RULES:
return match
})
// Apply Prism syntax highlighting
const lang = effectiveLanguage === 'python' ? 'python' : 'javascript'
let highlightedCode = highlight(processedCode, languages[lang], lang)
// Restore and highlight the placeholders
placeholders.forEach(({ placeholder, original, type }) => {
if (type === 'env') {
highlightedCode = highlightedCode.replace(
@@ -553,7 +633,6 @@ IMPORTANT FORMATTING RULES:
`<span class="text-blue-500">${original}</span>`
)
} else if (type === 'var') {
// Escape the < and > for display
const escaped = original.replace(/</g, '&lt;').replace(/>/g, '&gt;')
highlightedCode = highlightedCode.replace(
placeholder,
@@ -575,7 +654,8 @@ IMPORTANT FORMATTING RULES:
className={cn(
'code-editor-area caret-primary dark:caret-white',
'bg-transparent focus:outline-none',
(isCollapsed || isAiStreaming) && 'cursor-not-allowed opacity-50'
(isCollapsed || isAiStreaming) && 'cursor-default opacity-50',
readOnly && !isCollapsed && 'cursor-text opacity-100'
)}
textareaClassName={cn(
'focus:outline-none focus:ring-0 border-none bg-transparent resize-none',
@@ -583,7 +663,7 @@ IMPORTANT FORMATTING RULES:
)}
/>
{showEnvVars && !isCollapsed && !isAiStreaming && (
{showEnvVars && !isCollapsed && !isAiStreaming && !readOnly && (
<EnvVarDropdown
visible={showEnvVars}
onSelect={handleEnvVarSelect}
@@ -598,7 +678,7 @@ IMPORTANT FORMATTING RULES:
/>
)}
{showTags && !isCollapsed && !isAiStreaming && (
{showTags && !isCollapsed && !isAiStreaming && !readOnly && (
<TagDropdown
visible={showTags}
onSelect={handleTagSelect}

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown, Loader2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
@@ -17,12 +18,16 @@ interface DropdownProps {
defaultValue?: string
blockId: string
subBlockId: string
value?: string
value?: string | string[]
isPreview?: boolean
previewValue?: string | null
previewValue?: string | string[] | null
disabled?: boolean
placeholder?: string
config?: import('@/blocks/types').SubBlockConfig
multiSelect?: boolean
fetchOptions?: (
blockId: string,
subBlockId: string
) => Promise<Array<{ label: string; id: string }>>
}
export function Dropdown({
@@ -35,22 +40,28 @@ export function Dropdown({
previewValue,
disabled,
placeholder = 'Select an option...',
config,
multiSelect = false,
fetchOptions,
}: DropdownProps) {
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlockId)
const [storeValue, setStoreValue] = useSubBlockValue<string | string[]>(blockId, subBlockId) as [
string | string[] | null | undefined,
(value: string | string[]) => void,
]
const [storeInitialized, setStoreInitialized] = useState(false)
const [open, setOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const [fetchedOptions, setFetchedOptions] = useState<Array<{ label: string; id: string }>>([])
const [isLoadingOptions, setIsLoadingOptions] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const previousModeRef = useRef<string | null>(null)
// For response dataMode conversion - get builderData and data sub-blocks
const [builderData, setBuilderData] = useSubBlockValue<any[]>(blockId, 'builderData')
const [data, setData] = useSubBlockValue<string>(blockId, 'data')
// Keep refs with latest values to avoid stale closures
const builderDataRef = useRef(builderData)
const dataRef = useRef(data)
@@ -59,14 +70,56 @@ export function Dropdown({
dataRef.current = data
}, [builderData, data])
// Use preview value when in preview mode, otherwise use store value or prop value
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
// Evaluate options if it's a function
const singleValue = multiSelect ? null : (value as string | null | undefined)
const multiValues = multiSelect ? (value as string[] | null | undefined) || [] : null
const fetchOptionsIfNeeded = useCallback(async () => {
if (!fetchOptions || isPreview || disabled) return
setIsLoadingOptions(true)
setFetchError(null)
try {
const options = await fetchOptions(blockId, subBlockId)
setFetchedOptions(options)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options'
setFetchError(errorMessage)
setFetchedOptions([])
} finally {
setIsLoadingOptions(false)
}
}, [fetchOptions, blockId, subBlockId, isPreview, disabled])
const evaluatedOptions = useMemo(() => {
return typeof options === 'function' ? options() : options
}, [options])
const normalizedFetchedOptions = useMemo(() => {
return fetchedOptions.map((opt) => ({ label: opt.label, id: opt.id }))
}, [fetchedOptions])
const availableOptions = useMemo(() => {
if (fetchOptions && normalizedFetchedOptions.length > 0) {
return normalizedFetchedOptions
}
return evaluatedOptions
}, [fetchOptions, normalizedFetchedOptions, evaluatedOptions])
const normalizedOptions = useMemo(() => {
return availableOptions.map((opt) => {
if (typeof opt === 'string') {
return { id: opt, label: opt }
}
return { id: opt.id, label: opt.label }
})
}, [availableOptions])
const optionMap = useMemo(() => {
return new Map(normalizedOptions.map((opt) => [opt.id, opt.label]))
}, [normalizedOptions])
const getOptionValue = (
option:
| string
@@ -83,47 +136,39 @@ export function Dropdown({
return typeof option === 'string' ? option : option.label
}
// Get the default option value (first option or provided defaultValue)
const defaultOptionValue = useMemo(() => {
if (multiSelect) return undefined
if (defaultValue !== undefined) {
return defaultValue
}
if (evaluatedOptions.length > 0) {
return getOptionValue(evaluatedOptions[0])
if (availableOptions.length > 0) {
const firstOption = availableOptions[0]
return typeof firstOption === 'string' ? firstOption : firstOption.id
}
return undefined
}, [defaultValue, evaluatedOptions, getOptionValue])
}, [defaultValue, availableOptions, multiSelect])
// Mark store as initialized on first render
useEffect(() => {
setStoreInitialized(true)
}, [])
// Only set default value once the store is confirmed to be initialized
// and we know the actual value is null/undefined (not just loading)
useEffect(() => {
if (
storeInitialized &&
(value === null || value === undefined) &&
defaultOptionValue !== undefined
) {
if (multiSelect || !storeInitialized || defaultOptionValue === undefined) {
return
}
if (storeValue === null || storeValue === undefined || storeValue === '') {
setStoreValue(defaultOptionValue)
}
}, [storeInitialized, value, defaultOptionValue, setStoreValue])
}, [storeInitialized, storeValue, defaultOptionValue, setStoreValue, multiSelect])
// Helper function to normalize variable references in JSON strings
const normalizeVariableReferences = (jsonString: string): string => {
// Replace unquoted variable references with quoted ones
// Pattern: <variable.name> -> "<variable.name>"
return jsonString.replace(/([^"]<[^>]+>)/g, '"$1"')
}
// Helper function to convert JSON string to builder data format
const convertJsonToBuilderData = (jsonString: string): any[] => {
try {
// Always normalize variable references first
const normalizedJson = normalizeVariableReferences(jsonString)
const parsed = JSON.parse(normalizedJson)
@@ -149,7 +194,6 @@ export function Dropdown({
}
}
// Helper function to infer field type from value
const inferType = (value: any): 'string' | 'number' | 'boolean' | 'object' | 'array' => {
if (typeof value === 'boolean') return 'boolean'
if (typeof value === 'number') return 'number'
@@ -158,16 +202,13 @@ export function Dropdown({
return 'string'
}
// Handle data conversion when dataMode changes
useEffect(() => {
if (subBlockId !== 'dataMode' || isPreview || disabled) return
if (multiSelect || subBlockId !== 'dataMode' || isPreview || disabled) return
const currentMode = storeValue
const currentMode = storeValue as string
const previousMode = previousModeRef.current
// Only convert if the mode actually changed
if (previousMode !== null && previousMode !== currentMode) {
// Builder to Editor mode (structured → json)
if (currentMode === 'json' && previousMode === 'structured') {
const currentBuilderData = builderDataRef.current
if (
@@ -178,9 +219,7 @@ export function Dropdown({
const jsonString = ResponseBlockHandler.convertBuilderDataToJsonString(currentBuilderData)
setData(jsonString)
}
}
// Editor to Builder mode (json → structured)
else if (currentMode === 'structured' && previousMode === 'json') {
} else if (currentMode === 'structured' && previousMode === 'json') {
const currentData = dataRef.current
if (currentData && typeof currentData === 'string' && currentData.trim().length > 0) {
const builderArray = convertJsonToBuilderData(currentData)
@@ -189,27 +228,39 @@ export function Dropdown({
}
}
// Update the previous mode ref
previousModeRef.current = currentMode
}, [storeValue, subBlockId, isPreview, disabled, setData, setBuilderData])
}, [storeValue, subBlockId, isPreview, disabled, setData, setBuilderData, multiSelect])
// Event handlers
const handleSelect = (selectedValue: string) => {
if (!isPreview && !disabled) {
setStoreValue(selectedValue)
if (multiSelect) {
const currentValues = multiValues || []
const newValues = currentValues.includes(selectedValue)
? currentValues.filter((v) => v !== selectedValue)
: [...currentValues, selectedValue]
setStoreValue(newValues)
} else {
setStoreValue(selectedValue)
setOpen(false)
setHighlightedIndex(-1)
inputRef.current?.blur()
}
} else if (!multiSelect) {
setOpen(false)
setHighlightedIndex(-1)
inputRef.current?.blur()
}
setOpen(false)
setHighlightedIndex(-1)
inputRef.current?.blur()
}
const handleDropdownClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled) {
setOpen(!open)
if (!open) {
const willOpen = !open
setOpen(willOpen)
if (willOpen) {
inputRef.current?.focus()
fetchOptionsIfNeeded()
}
}
}
@@ -217,10 +268,10 @@ export function Dropdown({
const handleFocus = () => {
setOpen(true)
setHighlightedIndex(-1)
fetchOptionsIfNeeded()
}
const handleBlur = () => {
// Delay closing to allow dropdown selection
setTimeout(() => {
const activeElement = document.activeElement
if (!activeElement || !activeElement.closest('.absolute.top-full')) {
@@ -242,38 +293,37 @@ export function Dropdown({
if (!open) {
setOpen(true)
setHighlightedIndex(0)
fetchOptionsIfNeeded()
} else {
setHighlightedIndex((prev) => (prev < evaluatedOptions.length - 1 ? prev + 1 : 0))
setHighlightedIndex((prev) => (prev < availableOptions.length - 1 ? prev + 1 : 0))
}
}
if (e.key === 'ArrowUp') {
e.preventDefault()
if (open) {
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : evaluatedOptions.length - 1))
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : availableOptions.length - 1))
}
}
if (e.key === 'Enter' && open && highlightedIndex >= 0) {
e.preventDefault()
const selectedOption = evaluatedOptions[highlightedIndex]
const selectedOption = availableOptions[highlightedIndex]
if (selectedOption) {
handleSelect(getOptionValue(selectedOption))
}
}
}
// Effects
useEffect(() => {
setHighlightedIndex((prev) => {
if (prev >= 0 && prev < evaluatedOptions.length) {
if (prev >= 0 && prev < availableOptions.length) {
return prev
}
return -1
})
}, [evaluatedOptions])
}, [availableOptions])
// Scroll highlighted option into view
useEffect(() => {
if (highlightedIndex >= 0 && dropdownRef.current) {
const highlightedElement = dropdownRef.current.querySelector(
@@ -309,16 +359,49 @@ export function Dropdown({
}
}, [open])
// Display value
const displayValue = value?.toString() ?? ''
const selectedOption = evaluatedOptions.find((opt) => getOptionValue(opt) === value)
const displayValue = singleValue?.toString() ?? ''
const selectedOption = availableOptions.find((opt) => {
const optValue = typeof opt === 'string' ? opt : opt.id
return optValue === singleValue
})
const selectedLabel = selectedOption ? getOptionLabel(selectedOption) : displayValue
const SelectedIcon =
selectedOption && typeof selectedOption === 'object' && 'icon' in selectedOption
? (selectedOption.icon as React.ComponentType<{ className?: string }>)
: null
// Render component
const multiSelectDisplay =
multiValues && multiValues.length > 0 ? (
<div className='flex flex-wrap items-center gap-1'>
{(() => {
const optionsNotLoaded = fetchOptions && fetchedOptions.length === 0
if (optionsNotLoaded) {
return (
<Badge variant='secondary' className='text-xs'>
{multiValues.length} selected
</Badge>
)
}
return (
<>
{multiValues.slice(0, 2).map((selectedValue: string) => (
<Badge key={selectedValue} variant='secondary' className='text-xs'>
{optionMap.get(selectedValue) || selectedValue}
</Badge>
))}
{multiValues.length > 2 && (
<Badge variant='secondary' className='text-xs'>
+{multiValues.length - 2} more
</Badge>
)}
</>
)
})()}
</div>
) : null
return (
<div className='relative w-full'>
<div className='relative'>
@@ -326,10 +409,11 @@ export function Dropdown({
ref={inputRef}
className={cn(
'w-full cursor-pointer overflow-hidden pr-10 text-foreground',
SelectedIcon ? 'pl-8' : ''
SelectedIcon ? 'pl-8' : '',
multiSelect && multiSelectDisplay ? 'py-1.5' : ''
)}
placeholder={placeholder}
value={selectedLabel || ''}
placeholder={multiSelect && multiSelectDisplay ? '' : placeholder}
value={multiSelect ? '' : selectedLabel || ''}
readOnly
onFocus={handleFocus}
onBlur={handleBlur}
@@ -337,6 +421,12 @@ export function Dropdown({
disabled={disabled}
autoComplete='off'
/>
{/* Multi-select badges overlay */}
{multiSelect && multiSelectDisplay && (
<div className='pointer-events-none absolute top-0 bottom-0 left-0 flex items-center overflow-hidden bg-transparent pr-10 pl-3'>
{multiSelectDisplay}
</div>
)}
{/* Icon overlay */}
{SelectedIcon && (
<div className='pointer-events-none absolute top-0 bottom-0 left-0 flex items-center bg-transparent pl-3 text-sm'>
@@ -366,26 +456,34 @@ export function Dropdown({
className='allow-scroll max-h-48 overflow-y-auto p-1'
style={{ scrollbarWidth: 'thin' }}
>
{evaluatedOptions.length === 0 ? (
{isLoadingOptions ? (
<div className='flex items-center justify-center py-6'>
<Loader2 className='h-4 w-4 animate-spin text-muted-foreground' />
<span className='ml-2 text-muted-foreground text-sm'>Loading options...</span>
</div>
) : fetchError ? (
<div className='px-2 py-6 text-center text-destructive text-sm'>{fetchError}</div>
) : availableOptions.length === 0 ? (
<div className='py-6 text-center text-muted-foreground text-sm'>
No options available.
</div>
) : (
evaluatedOptions.map((option, index) => {
availableOptions.map((option, index) => {
const optionValue = getOptionValue(option)
const optionLabel = getOptionLabel(option)
const OptionIcon =
typeof option === 'object' && 'icon' in option
? (option.icon as React.ComponentType<{ className?: string }>)
: null
const isSelected = value === optionValue
const isSelected = multiSelect
? multiValues?.includes(optionValue)
: singleValue === optionValue
const isHighlighted = index === highlightedIndex
return (
<div
key={optionValue}
data-option-index={index}
onClick={() => handleSelect(optionValue)}
onMouseDown={(e) => {
e.preventDefault()
handleSelect(optionValue)

View File

@@ -25,8 +25,9 @@ export { SliderInput } from './slider-input'
export { InputFormat } from './starter/input-format'
export { Switch } from './switch'
export { Table } from './table'
export { Text } from './text'
export { TimeInput } from './time-input'
export { ToolInput } from './tool-input/tool-input'
export { TriggerConfig } from './trigger-config/trigger-config'
export { TriggerSave } from './trigger-save/trigger-save'
export { VariablesInput } from './variables-input/variables-input'
export { WebhookConfig } from './webhook/webhook'

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Wand2 } from 'lucide-react'
import { Check, Copy, Wand2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useReactFlow } from 'reactflow'
import { Button } from '@/components/ui/button'
@@ -15,6 +15,7 @@ import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
import type { SubBlockConfig } from '@/blocks/types'
import { useTagSelection } from '@/hooks/use-tag-selection'
import { useWebhookManagement } from '@/hooks/use-webhook-management'
const logger = createLogger('ShortInput')
@@ -30,6 +31,9 @@ interface ShortInputProps {
isPreview?: boolean
previewValue?: string | null
disabled?: boolean
readOnly?: boolean
showCopyButton?: boolean
useWebhookUrl?: boolean
}
export function ShortInput({
@@ -44,33 +48,39 @@ export function ShortInput({
isPreview = false,
previewValue,
disabled = false,
readOnly = false,
showCopyButton = false,
useWebhookUrl = false,
}: ShortInputProps) {
// Local state for immediate UI updates during streaming
const [localContent, setLocalContent] = useState<string>('')
const [isFocused, setIsFocused] = useState(false)
const [showEnvVars, setShowEnvVars] = useState(false)
const [showTags, setShowTags] = useState(false)
const [copied, setCopied] = useState(false)
const webhookManagement = useWebhookUrl
? useWebhookManagement({
blockId,
triggerId: undefined,
isPreview,
})
: null
// Wand functionality (only if wandConfig is enabled)
const wandHook = config.wandConfig?.enabled
? useWand({
wandConfig: config.wandConfig,
currentValue: localContent,
onStreamStart: () => {
// Clear the content when streaming starts
setLocalContent('')
},
onStreamChunk: (chunk) => {
// Update local content with each chunk as it arrives
setLocalContent((current) => current + chunk)
},
onGeneratedContent: (content) => {
// Final content update
setLocalContent(content)
},
})
: null
// State management - useSubBlockValue with explicit streaming control
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId, false, {
isStreaming: wandHook?.isStreaming || false,
onStreamingEnd: () => {
@@ -89,16 +99,15 @@ export function ShortInput({
const params = useParams()
const workspaceId = params.workspaceId as string
// Get ReactFlow instance for zoom control
const reactFlowInstance = useReactFlow()
// Use preview value when in preview mode, otherwise use store value or prop value
const baseValue = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
// During streaming, use local content; otherwise use base value
const value = wandHook?.isStreaming ? localContent : baseValue
const effectiveValue =
useWebhookUrl && webhookManagement?.webhookUrl ? webhookManagement.webhookUrl : baseValue
const value = wandHook?.isStreaming ? localContent : effectiveValue
// Sync local content with base value when not streaming
useEffect(() => {
if (!wandHook?.isStreaming) {
const baseValueString = baseValue?.toString() ?? ''
@@ -108,7 +117,6 @@ export function ShortInput({
}
}, [baseValue, wandHook?.isStreaming])
// Update store value during streaming (but won't persist until streaming ends)
useEffect(() => {
if (wandHook?.isStreaming && localContent !== '') {
if (!isPreview && !disabled) {
@@ -117,12 +125,10 @@ export function ShortInput({
}
}, [localContent, wandHook?.isStreaming, isPreview, disabled, setStoreValue])
// Check if this input is API key related
const isApiKeyField = useMemo(() => {
const normalizedId = config?.id?.replace(/\s+/g, '').toLowerCase() || ''
const normalizedTitle = config?.title?.replace(/\s+/g, '').toLowerCase() || ''
// Check for common API key naming patterns
const apiKeyPatterns = [
'apikey',
'api_key',
@@ -146,10 +152,17 @@ export function ShortInput({
)
}, [config?.id, config?.title])
// Handle input changes
const handleCopy = () => {
const textToCopy = useWebhookUrl ? webhookManagement?.webhookUrl : value?.toString()
if (textToCopy) {
navigator.clipboard.writeText(textToCopy)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Don't allow changes if disabled
if (disabled) {
if (disabled || readOnly) {
e.preventDefault()
return
}
@@ -160,52 +173,39 @@ export function ShortInput({
if (onChange) {
onChange(newValue)
} else if (!isPreview) {
// Only update store when not in preview mode
setStoreValue(newValue)
}
setCursorPosition(newCursorPosition)
// Check for environment variables trigger
const envVarTrigger = checkEnvVarTrigger(newValue, newCursorPosition)
// For API key fields, always show dropdown when typing (without requiring {{ trigger)
if (isApiKeyField && isFocused) {
// Only show dropdown if there's text to filter by or the field is empty
const shouldShowDropdown = newValue.trim() !== '' || newValue === ''
setShowEnvVars(shouldShowDropdown)
// Use the entire input value as search term for API key fields,
// but if {{ is detected, use the standard search term extraction
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : newValue)
} else {
// Normal behavior for non-API key fields
setShowEnvVars(envVarTrigger.show)
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
}
// Check for tag trigger
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
setShowTags(tagTrigger.show)
}
// Sync scroll position between input and overlay
const handleScroll = (e: React.UIEvent<HTMLInputElement>) => {
if (overlayRef.current) {
overlayRef.current.scrollLeft = e.currentTarget.scrollLeft
}
}
// Remove the auto-scroll effect that forces cursor position and replace with natural scrolling
useEffect(() => {
if (inputRef.current && overlayRef.current) {
overlayRef.current.scrollLeft = inputRef.current.scrollLeft
}
}, [value])
// Handle paste events to ensure long values are handled correctly
const handlePaste = (_e: React.ClipboardEvent<HTMLInputElement>) => {
// Let the paste happen normally
// Then ensure scroll positions are synced after the content is updated
setTimeout(() => {
if (inputRef.current && overlayRef.current) {
overlayRef.current.scrollLeft = inputRef.current.scrollLeft
@@ -213,37 +213,27 @@ export function ShortInput({
}, 0)
}
// Handle wheel events to control ReactFlow zoom
const handleWheel = (e: React.WheelEvent<HTMLInputElement>) => {
// Only handle zoom when Ctrl/Cmd key is pressed
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
e.stopPropagation()
// Get current zoom level and viewport
const currentZoom = reactFlowInstance.getZoom()
const { x: viewportX, y: viewportY } = reactFlowInstance.getViewport()
// Calculate zoom factor based on wheel delta
// Use a smaller factor for smoother zooming that matches ReactFlow's native behavior
const delta = e.deltaY > 0 ? 1 : -1
// Using 0.98 instead of 0.95 makes the zoom much slower and more gradual
const zoomFactor = 0.96 ** delta
// Calculate new zoom level with min/max constraints
const newZoom = Math.min(Math.max(currentZoom * zoomFactor, 0.1), 1)
// Get the position of the cursor in the page
const { x: pointerX, y: pointerY } = reactFlowInstance.screenToFlowPosition({
x: e.clientX,
y: e.clientY,
})
// Calculate the new viewport position to keep the cursor position fixed
const newViewportX = viewportX + (pointerX * currentZoom - pointerX * newZoom)
const newViewportY = viewportY + (pointerY * currentZoom - pointerY * newZoom)
// Set the new viewport with the calculated position and zoom
reactFlowInstance.setViewport(
{
x: newViewportX,
@@ -256,12 +246,9 @@ export function ShortInput({
return false
}
// For regular scrolling (without Ctrl/Cmd), let the default behavior happen
// Don't interfere with normal scrolling
return true
}
// Drag and Drop handlers
const handleDragOver = (e: React.DragEvent<HTMLInputElement>) => {
if (config?.connectionDroppable === false) return
e.preventDefault()
@@ -275,19 +262,14 @@ export function ShortInput({
const data = JSON.parse(e.dataTransfer.getData('application/json'))
if (data.type !== 'connectionBlock') return
// Get current cursor position or append to end
const dropPosition = inputRef.current?.selectionStart ?? value?.toString().length ?? 0
// Insert '<' at drop position to trigger the dropdown
const currentValue = value?.toString() ?? ''
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
// Focus the input first
inputRef.current?.focus()
// Update all state in a single batch
Promise.resolve().then(() => {
// Update value through onChange if provided, otherwise use store
if (onChange) {
onChange(newValue)
} else if (!isPreview) {
@@ -297,12 +279,10 @@ export function ShortInput({
setCursorPosition(dropPosition + 1)
setShowTags(true)
// Pass the source block ID from the dropped connection
if (data.connectionData?.sourceBlockId) {
setActiveSourceBlockId(data.connectionData.sourceBlockId)
}
// Set cursor position after state updates
setTimeout(() => {
if (inputRef.current) {
inputRef.current.selectionStart = dropPosition + 1
@@ -315,7 +295,6 @@ export function ShortInput({
}
}
// Handle key combinations
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
setShowEnvVars(false)
@@ -323,7 +302,6 @@ export function ShortInput({
return
}
// For API key fields, show env vars when clearing with keyboard shortcuts
if (
isApiKeyField &&
(e.key === 'Delete' || e.key === 'Backspace') &&
@@ -334,13 +312,10 @@ export function ShortInput({
}
}
// Value display logic
const displayValue =
password && !isFocused ? '•'.repeat(value?.toString().length ?? 0) : (value?.toString() ?? '')
// Explicitly mark environment variable references with '{{' and '}}' when inserting
const handleEnvVarSelect = (newValue: string) => {
// For API keys, ensure we're using the full value with {{ }} format
if (isApiKeyField && !newValue.startsWith('{{')) {
newValue = `{{${newValue}}}`
}
@@ -378,21 +353,21 @@ export function ShortInput({
'allow-scroll w-full overflow-auto text-transparent caret-foreground [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground/50 [&::-webkit-scrollbar]:hidden',
isConnecting &&
config?.connectionDroppable !== false &&
'ring-2 ring-blue-500 ring-offset-2 focus-visible:ring-blue-500'
'ring-2 ring-blue-500 ring-offset-2 focus-visible:ring-blue-500',
showCopyButton && 'pr-14'
)}
placeholder={placeholder ?? ''}
type='text'
value={displayValue}
onChange={handleChange}
readOnly={readOnly}
onFocus={() => {
setIsFocused(true)
// If this is an API key field, automatically show env vars dropdown
if (isApiKeyField) {
setShowEnvVars(true)
setSearchTerm('')
// Set cursor position to the end of the input
const inputLength = value?.toString().length ?? 0
setCursorPosition(inputLength)
} else {
@@ -417,11 +392,15 @@ export function ShortInput({
/>
<div
ref={overlayRef}
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-3 text-sm [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
className={cn(
'pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent text-sm [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
'pl-3',
showCopyButton ? 'pr-14' : 'pr-3'
)}
style={{ overflowX: 'auto', scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
<div
className='w-full whitespace-pre'
className={cn('whitespace-pre', showCopyButton ? 'mr-12' : '')}
style={{ scrollbarWidth: 'none', minWidth: 'fit-content' }}
>
{password && !isFocused
@@ -433,6 +412,27 @@ export function ShortInput({
</div>
</div>
{/* Copy Button */}
{showCopyButton && value && (
<div className='pointer-events-none absolute top-0 right-0 bottom-0 z-10 flex w-14 items-center justify-end pr-2 opacity-0 transition-opacity group-hover:opacity-100'>
<Button
type='button'
variant='ghost'
size='icon'
onClick={handleCopy}
disabled={!value}
className='pointer-events-auto h-6 w-6 p-0'
aria-label='Copy value'
>
{copied ? (
<Check className='h-3.5 w-3.5 text-green-500' />
) : (
<Copy className='h-3.5 w-3.5 text-muted-foreground' />
)}
</Button>
</div>
)}
{/* Wand Button */}
{wandHook && !isPreview && !wandHook.isStreaming && (
<div className='-translate-y-1/2 absolute top-1/2 right-3 z-10 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100'>

View File

@@ -0,0 +1,33 @@
interface TextProps {
blockId: string
subBlockId: string
content: string
className?: string
}
export function Text({ blockId, subBlockId, content, className }: TextProps) {
const containsHtml = /<[^>]+>/.test(content)
if (containsHtml) {
return (
<div
id={`${blockId}-${subBlockId}`}
className={`rounded-md border bg-card p-4 shadow-sm ${className || ''}`}
>
<div
className='prose prose-sm dark:prose-invert max-w-none text-sm [&_a]:text-blue-600 [&_a]:underline [&_a]:hover:text-blue-700 [&_a]:dark:text-blue-400 [&_a]:dark:hover:text-blue-300 [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-xs [&_strong]:font-semibold [&_ul]:ml-5 [&_ul]:list-disc'
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
)
}
return (
<div
id={`${blockId}-${subBlockId}`}
className={`whitespace-pre-wrap rounded-md border bg-card p-4 text-muted-foreground text-sm shadow-sm ${className || ''}`}
>
{content}
</div>
)
}

View File

@@ -1,447 +0,0 @@
import { useState } from 'react'
import { Check, ChevronDown, Copy, Eye, EyeOff, Info } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { formatDisplayText } from '@/components/ui/formatted-text'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { TriggerConfig } from '@/triggers/types'
import { CredentialSelector } from '../../credential-selector/credential-selector'
interface TriggerConfigSectionProps {
blockId: string
triggerDef: TriggerConfig
config: Record<string, any>
onChange: (fieldId: string, value: any) => void
webhookUrl: string
dynamicOptions?: Record<string, Array<{ id: string; name: string }> | string[]>
loadingFields?: Record<string, boolean>
}
export function TriggerConfigSection({
blockId,
triggerDef,
config,
onChange,
webhookUrl,
dynamicOptions = {},
loadingFields = {},
}: TriggerConfigSectionProps) {
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({})
const [copied, setCopied] = useState<string | null>(null)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const copyToClipboard = (text: string, type: string) => {
navigator.clipboard.writeText(text)
setCopied(type)
setTimeout(() => setCopied(null), 2000)
}
const toggleSecretVisibility = (fieldId: string) => {
setShowSecrets((prev) => ({
...prev,
[fieldId]: !prev[fieldId],
}))
}
const renderField = (fieldId: string, fieldDef: any) => {
const value = config[fieldId] ?? fieldDef.defaultValue ?? ''
const isSecret = fieldDef.isSecret
const showSecret = showSecrets[fieldId]
switch (fieldDef.type) {
case 'boolean':
return (
<div className='flex items-center space-x-2'>
<Switch
id={fieldId}
checked={value}
onCheckedChange={(checked) => onChange(fieldId, checked)}
/>
<Label htmlFor={fieldId}>{fieldDef.label}</Label>
</div>
)
case 'select': {
const rawOptions = dynamicOptions?.[fieldId] || fieldDef.options || []
const isLoading = loadingFields[fieldId] || false
const availableOptions = Array.isArray(rawOptions)
? rawOptions.map((option: any) => {
if (typeof option === 'string') {
return { id: option, name: option }
}
return option
})
: []
return (
<div className='space-y-2'>
<Label htmlFor={fieldId} className='font-medium text-sm'>
{fieldDef.label}
{fieldDef.required && <span className='ml-1 text-red-500'>*</span>}
</Label>
{fieldDef.description && (
<p className='text-muted-foreground text-sm'>{fieldDef.description}</p>
)}
<Select
value={value}
onValueChange={(value) => onChange(fieldId, value)}
disabled={isLoading}
>
<SelectTrigger id={fieldId} className='h-10'>
<SelectValue placeholder={isLoading ? 'Loading...' : fieldDef.placeholder} />
</SelectTrigger>
<SelectContent>
{availableOptions.map((option: any) => (
<SelectItem key={option.id} value={option.id}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
case 'multiselect': {
const selectedValues = Array.isArray(value) ? value : []
const rawOptions = dynamicOptions[fieldId] || fieldDef.options || []
// Handle both string[] and {id, name}[] formats
const availableOptions = rawOptions.map((option: any) => {
if (typeof option === 'string') {
return { id: option, name: option }
}
return option
})
// Create a map for quick lookup of display names
const optionMap = new Map(availableOptions.map((opt: any) => [opt.id, opt.name]))
return (
<div className='space-y-2'>
<Label htmlFor={fieldId}>
{fieldDef.label}
{fieldDef.required && <span className='ml-1 text-red-500'>*</span>}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
className='h-9 w-full justify-between rounded-[8px] text-left font-normal'
>
<div className='flex w-full items-center justify-between'>
{selectedValues.length > 0 ? (
<div className='flex flex-wrap gap-1'>
{selectedValues.slice(0, 2).map((selectedValue: string) => (
<Badge key={selectedValue} variant='secondary' className='text-xs'>
{optionMap.get(selectedValue) || selectedValue}
</Badge>
))}
{selectedValues.length > 2 && (
<Badge variant='secondary' className='text-xs'>
+{selectedValues.length - 2} more
</Badge>
)}
</div>
) : (
<span className='text-muted-foreground'>{fieldDef.placeholder}</span>
)}
<ChevronDown className='h-4 w-4 opacity-50' />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className='w-[400px] p-0' align='start'>
<Command className='outline-none focus:outline-none'>
<CommandInput
placeholder={`Search ${fieldDef.label.toLowerCase()}...`}
className='text-foreground placeholder:text-muted-foreground'
/>
<CommandList
className='max-h-[200px] overflow-y-auto outline-none focus:outline-none'
onWheel={(e) => e.stopPropagation()}
>
<CommandEmpty>
{availableOptions.length === 0
? 'No options available. Please select credentials first.'
: 'No options found.'}
</CommandEmpty>
<CommandGroup>
{availableOptions.map((option: any) => (
<CommandItem
key={option.id}
value={option.id}
onSelect={() => {
const newValues = selectedValues.includes(option.id)
? selectedValues.filter((v: string) => v !== option.id)
: [...selectedValues, option.id]
onChange(fieldId, newValues)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedValues.includes(option.id) ? 'opacity-100' : 'opacity-0'
)}
/>
{option.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{fieldDef.description && (
<p className='text-muted-foreground text-sm'>{fieldDef.description}</p>
)}
</div>
)
}
case 'number':
return (
<div className='space-y-2'>
<Label htmlFor={fieldId}>
{fieldDef.label}
{fieldDef.required && <span className='ml-1 text-red-500'>*</span>}
</Label>
<Input
id={fieldId}
type='number'
placeholder={fieldDef.placeholder}
value={value}
onChange={(e) => onChange(fieldId, Number(e.target.value))}
className='h-9 rounded-[8px]'
/>
{fieldDef.description && (
<p className='text-muted-foreground text-sm'>{fieldDef.description}</p>
)}
</div>
)
case 'credential':
return (
<div className='space-y-2'>
<Label htmlFor={fieldId}>
{fieldDef.label}
{fieldDef.required && <span className='ml-1 text-red-500'>*</span>}
</Label>
<CredentialSelector
blockId={blockId}
subBlock={{
id: fieldId,
type: 'oauth-input' as const,
placeholder: fieldDef.placeholder || `Select ${fieldDef.provider} credential`,
provider: fieldDef.provider as any,
requiredScopes: fieldDef.requiredScopes || [],
}}
previewValue={value}
/>
{fieldDef.description && (
<p className='text-muted-foreground text-sm'>{fieldDef.description}</p>
)}
</div>
)
default: // string
return (
<div className='mb-4 space-y-1'>
<div className='flex items-center gap-2'>
<Label htmlFor={fieldId} className='font-medium text-sm'>
{fieldDef.label}
{fieldDef.required && <span className='ml-1 text-red-500'>*</span>}
</Label>
{fieldDef.description && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-1 text-gray-500'
aria-label={`Learn more about ${fieldDef.label}`}
>
<Info className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent
side='right'
align='center'
className='z-[100] max-w-[300px] p-3'
role='tooltip'
>
<p className='text-sm'>{fieldDef.description}</p>
</TooltipContent>
</Tooltip>
)}
</div>
<div className='relative'>
<Input
id={fieldId}
type={isSecret && !showSecret ? 'password' : 'text'}
placeholder={fieldDef.placeholder}
value={value}
onChange={(e) => onChange(fieldId, e.target.value)}
className={cn(
'h-9 rounded-[8px]',
isSecret ? 'pr-32' : '',
'focus-visible:ring-2 focus-visible:ring-primary/20',
!isSecret && 'text-transparent caret-foreground'
)}
/>
{!isSecret && (
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
<div className='whitespace-pre'>
{formatDisplayText(value?.toString() || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
)}
{isSecret && (
<div className='absolute top-0.5 right-0.5 flex h-8 items-center gap-1 pr-1'>
<Button
type='button'
variant='ghost'
size='sm'
className={cn(
'group h-7 w-7 rounded-md p-0',
'text-muted-foreground/60 transition-all duration-200',
'hover:bg-muted/50 hover:text-foreground',
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
onClick={() => toggleSecretVisibility(fieldId)}
aria-label={showSecret ? 'Hide secret' : 'Show secret'}
>
{showSecret ? (
<EyeOff className='h-3.5 w-3.5 ' />
) : (
<Eye className='h-3.5 w-3.5 ' />
)}
<span className='sr-only'>{showSecret ? 'Hide secret' : 'Show secret'}</span>
</Button>
<Button
type='button'
variant='ghost'
size='sm'
className={cn(
'group h-7 w-7 rounded-md p-0',
'text-muted-foreground/60 transition-all duration-200',
'hover:bg-muted/50 hover:text-foreground',
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
onClick={() => copyToClipboard(value, fieldId)}
disabled={!value}
>
{copied === fieldId ? (
<Check className='h-3.5 w-3.5 text-foreground' />
) : (
<Copy className='h-3.5 w-3.5 ' />
)}
</Button>
</div>
)}
</div>
</div>
)
}
}
// Show webhook URL only for manual webhooks (have webhook config but no OAuth auto-registration)
// Auto-registered webhooks (like Webflow, Airtable) have requiresCredentials and register via API
// Polling triggers (like Gmail) don't have webhook property at all
const shouldShowWebhookUrl = webhookUrl && triggerDef.webhook && !triggerDef.requiresCredentials
return (
<div className='space-y-4 rounded-md border border-border bg-card p-4 shadow-sm'>
{shouldShowWebhookUrl && (
<div className='mb-4 space-y-1'>
<div className='flex items-center gap-2'>
<Label className='font-medium text-sm'>Webhook URL</Label>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-1 text-gray-500'
aria-label='Learn more about Webhook URL'
>
<Info className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent
side='right'
align='center'
className='z-[100] max-w-[300px] p-3'
role='tooltip'
>
<p className='text-sm'>This is the URL that will receive webhook requests</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className='relative'>
<Input
value={webhookUrl}
readOnly
className={cn(
'h-9 cursor-text rounded-[8px] pr-10 font-mono text-xs',
'focus-visible:ring-2 focus-visible:ring-primary/20'
)}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<div className='absolute top-0.5 right-0.5 flex h-8 items-center gap-1 pr-1'>
<Button
type='button'
variant='ghost'
size='sm'
className={cn(
'group h-7 w-7 rounded-md p-0',
'text-muted-foreground/60 transition-all duration-200',
'hover:scale-105 hover:bg-muted/50 hover:text-foreground',
'active:scale-95',
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
onClick={() => copyToClipboard(webhookUrl, 'url')}
>
{copied === 'url' ? (
<Check className='h-3.5 w-3.5 text-foreground' />
) : (
<Copy className='h-3.5 w-3.5 ' />
)}
</Button>
</div>
</div>
</div>
)}
{Object.entries(triggerDef.configFields).map(([fieldId, fieldDef]) => (
<div key={fieldId}>{renderField(fieldId, fieldDef)}</div>
))}
</div>
)
}

View File

@@ -1,128 +0,0 @@
import { useState } from 'react'
import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react'
import { Button, Notice } from '@/components/ui'
import { cn } from '@/lib/utils'
import { JSONView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components'
import type { TriggerConfig } from '@/triggers/types'
interface TriggerInstructionsProps {
instructions: string[]
webhookUrl: string
samplePayload: any
triggerDef: TriggerConfig
config?: Record<string, any>
}
export function TriggerInstructions({
instructions,
webhookUrl,
samplePayload,
triggerDef,
config = {},
}: TriggerInstructionsProps) {
const [copied, setCopied] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const token = (config as any)?.token as string | undefined
const secretHeaderName = (config as any)?.secretHeaderName as string | undefined
const formId = (config as any)?.formId || '<YOUR_FORM_ID>'
const headerLine = secretHeaderName
? `{ '${secretHeaderName}': TOKEN }`
: "{ Authorization: 'Bearer ' + TOKEN }"
const googleFormsSnippet = token
? `const WEBHOOK_URL = '${webhookUrl || '<WEBHOOK URL>'}';\nconst TOKEN = '${token}'; // from Sim Trigger Configuration\nconst FORM_ID = '${formId}'; // optional but recommended\n\nfunction onFormSubmit(e) {\n var answers = {};\n var formResponse = e && e.response;\n if (formResponse && typeof formResponse.getItemResponses === 'function') {\n var itemResponses = formResponse.getItemResponses() || [];\n for (var i = 0; i < itemResponses.length; i++) {\n var ir = itemResponses[i];\n var question = ir.getItem().getTitle();\n var value = ir.getResponse();\n if (Array.isArray(value)) {\n value = value.length === 1 ? value[0] : value;\n }\n answers[question] = value;\n }\n } else if (e && e.namedValues) {\n var namedValues = e.namedValues || {};\n for (var k in namedValues) {\n var v = namedValues[k];\n answers[k] = Array.isArray(v) ? (v.length === 1 ? v[0] : v) : v;\n }\n }\n\n var payload = {\n provider: 'googleforms',\n formId: FORM_ID || undefined,\n responseId: Utilities.getUuid(),\n createTime: new Date().toISOString(),\n lastSubmittedTime: new Date().toISOString(),\n answers: answers,\n raw: e || {}\n };\n\n UrlFetchApp.fetch(WEBHOOK_URL, {\n method: 'post',\n contentType: 'application/json',\n payload: JSON.stringify(payload),\n headers: ${headerLine},\n muteHttpExceptions: true\n });\n}`
: `const WEBHOOK_URL = '${webhookUrl || '<WEBHOOK URL>'}';\nconst FORM_ID = '${formId}'; // optional but recommended\n\nfunction onFormSubmit(e) {\n var answers = {};\n var formResponse = e && e.response;\n if (formResponse && typeof formResponse.getItemResponses === 'function') {\n var itemResponses = formResponse.getItemResponses() || [];\n for (var i = 0; i < itemResponses.length; i++) {\n var ir = itemResponses[i];\n var question = ir.getItem().getTitle();\n var value = ir.getResponse();\n if (Array.isArray(value)) {\n value = value.length === 1 ? value[0] : value;\n }\n answers[question] = value;\n }\n } else if (e && e.namedValues) {\n var namedValues = e.namedValues || {};\n for (var k in namedValues) {\n var v = namedValues[k];\n answers[k] = Array.isArray(v) ? (v.length === 1 ? v[0] : v) : v;\n }\n }\n\n var payload = {\n provider: 'googleforms',\n formId: FORM_ID || undefined,\n responseId: Utilities.getUuid(),\n createTime: new Date().toISOString(),\n lastSubmittedTime: new Date().toISOString(),\n answers: answers,\n raw: e || {}\n };\n\n UrlFetchApp.fetch(WEBHOOK_URL, {\n method: 'post',\n contentType: 'application/json',\n payload: JSON.stringify(payload),\n muteHttpExceptions: true\n });\n}`
const copySnippet = async () => {
try {
await navigator.clipboard.writeText(googleFormsSnippet)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {}
}
return (
<div className='space-y-4'>
<div className={cn('mt-4 rounded-md border border-border bg-card/50 p-4 shadow-sm')}>
<h4 className='mb-3 font-medium text-base'>Setup Instructions</h4>
<div className='space-y-1 text-muted-foreground text-sm [&_a]:text-muted-foreground [&_a]:underline [&_a]:hover:text-muted-foreground/80 [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs'>
<ol className='list-inside list-decimal space-y-2'>
{instructions.map((instruction, index) => (
<li key={index} dangerouslySetInnerHTML={{ __html: instruction }} />
))}
</ol>
</div>
{triggerDef.provider === 'google_forms' && (
<div className='mt-4'>
<div className='relative overflow-hidden rounded-lg border border-border bg-card shadow-sm'>
<div
className='relative flex cursor-pointer items-center border-border/60 border-b bg-muted/30 px-4 py-3 transition-colors hover:bg-muted/40'
onClick={() => setIsExpanded(!isExpanded)}
>
<div className='flex items-center gap-2'>
{isExpanded ? (
<ChevronDown className='h-4 w-4 text-muted-foreground' />
) : (
<ChevronRight className='h-4 w-4 text-muted-foreground' />
)}
{triggerDef.icon && (
<triggerDef.icon className='h-4 w-4 text-[#611f69] dark:text-[#e01e5a]' />
)}
<h5 className='font-medium text-sm'>Apps Script snippet</h5>
</div>
{isExpanded && (
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
copySnippet()
}}
aria-label='Copy snippet'
className={cn(
'group -translate-y-1/2 absolute top-1/2 right-3 h-6 w-6 rounded-md p-0',
'text-muted-foreground/60 transition-all duration-200',
'hover:bg-muted/50 hover:text-foreground',
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
>
{copied ? (
<Check className='h-3 w-3 text-foreground' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
)}
</div>
{isExpanded && (
<div className='overflow-auto p-4'>
<pre className='whitespace-pre-wrap font-mono text-foreground text-xs leading-5'>
{googleFormsSnippet}
</pre>
</div>
)}
</div>
</div>
)}
</div>
<Notice
variant='default'
className='border-slate-200 bg-white dark:border-border dark:bg-background'
icon={
triggerDef.icon ? (
<triggerDef.icon className='mt-0.5 mr-3.5 h-5 w-5 flex-shrink-0 text-[#611f69] dark:text-[#e01e5a]' />
) : null
}
title={`${triggerDef.provider.charAt(0).toUpperCase() + triggerDef.provider.slice(1).replace(/_/g, ' ')} Event Payload Example`}
>
Your workflow will receive a payload similar to this when a subscribed event occurs.
<div className='overflow-wrap-anywhere mt-2 whitespace-normal break-normal font-mono text-sm'>
<JSONView data={samplePayload} />
</div>
</Notice>
</div>
)
}

View File

@@ -1,759 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, Copy, Info, RotateCcw, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console/logger'
import { getBaseUrl } from '@/lib/urls/utils'
import { cn } from '@/lib/utils'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getTrigger } from '@/triggers'
import type { TriggerConfig } from '@/triggers/types'
import { CredentialSelector } from '../../credential-selector/credential-selector'
import { TriggerConfigSection } from './trigger-config-section'
import { TriggerInstructions } from './trigger-instructions'
const logger = createLogger('TriggerModal')
interface TriggerModalProps {
isOpen: boolean
onClose: () => void
triggerPath: string
triggerDef: TriggerConfig
triggerConfig: Record<string, any>
onSave?: (path: string, config: Record<string, any>) => Promise<boolean>
onDelete?: () => Promise<boolean>
triggerId?: string
blockId: string
availableTriggers?: string[]
selectedTriggerId?: string | null
onTriggerChange?: (triggerId: string) => void
}
export function TriggerModal({
isOpen,
onClose,
triggerPath,
triggerDef: propTriggerDef,
triggerConfig: initialConfig,
onSave,
onDelete,
triggerId,
blockId,
availableTriggers = [],
selectedTriggerId,
onTriggerChange,
}: TriggerModalProps) {
// Use selectedTriggerId to get the current trigger definition dynamically
const triggerDef = selectedTriggerId
? getTrigger(selectedTriggerId) || propTriggerDef
: propTriggerDef
const [config, setConfig] = useState<Record<string, any>>(initialConfig)
const [isSaving, setIsSaving] = useState(false)
// Snapshot initial values at open for stable dirty-checking across collaborators
const initialConfigRef = useRef<Record<string, any>>(initialConfig)
const initialCredentialRef = useRef<string | null>(null)
// Capture initial credential on first detect
useEffect(() => {
if (initialCredentialRef.current !== null) return
const subBlockStore = useSubBlockStore.getState()
const cred = (subBlockStore.getValue(blockId, 'triggerCredentials') as string | null) || null
initialCredentialRef.current = cred
}, [blockId])
// Track if config has changed from initial snapshot
const hasConfigChanged = useMemo(() => {
return JSON.stringify(config) !== JSON.stringify(initialConfigRef.current)
}, [config])
// Track if credential has changed from initial snapshot (computed later once selectedCredentialId is declared)
let hasCredentialChanged = false
const [isDeleting, setIsDeleting] = useState(false)
const [webhookUrl, setWebhookUrl] = useState('')
const [generatedPath, setGeneratedPath] = useState('')
const [hasCredentials, setHasCredentials] = useState(false)
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(null)
hasCredentialChanged = selectedCredentialId !== initialCredentialRef.current
const [dynamicOptions, setDynamicOptions] = useState<
Record<string, Array<{ id: string; name: string }>>
>({})
const [loadingFields, setLoadingFields] = useState<Record<string, boolean>>({})
const lastCredentialIdRef = useRef<string | null>(null)
const [testUrl, setTestUrl] = useState<string | null>(null)
const [testUrlExpiresAt, setTestUrlExpiresAt] = useState<string | null>(null)
const [isGeneratingTestUrl, setIsGeneratingTestUrl] = useState(false)
const [copiedTestUrl, setCopiedTestUrl] = useState(false)
// Reset provider-dependent config fields when credentials change
const resetFieldsForCredentialChange = () => {
setConfig((prev) => {
const next = { ...prev }
if (triggerDef.provider === 'gmail') {
if (Array.isArray(next.labelIds)) next.labelIds = []
} else if (triggerDef.provider === 'outlook') {
if (Array.isArray(next.folderIds)) next.folderIds = []
} else if (triggerDef.provider === 'airtable') {
if (typeof next.baseId === 'string') next.baseId = ''
if (typeof next.tableId === 'string') next.tableId = ''
} else if (triggerDef.provider === 'webflow') {
if (typeof next.siteId === 'string') next.siteId = ''
if (typeof next.collectionId === 'string') next.collectionId = ''
}
return next
})
}
// Initialize config with default values from trigger definition
useEffect(() => {
const defaultConfig: Record<string, any> = {}
// Apply default values from trigger definition
Object.entries(triggerDef.configFields).forEach(([fieldId, field]) => {
if (field.defaultValue !== undefined && !(fieldId in initialConfig)) {
defaultConfig[fieldId] = field.defaultValue
}
})
// Merge with initial config, prioritizing initial config values
const mergedConfig = { ...defaultConfig, ...initialConfig }
// Only update if there are actually default values to apply
if (Object.keys(defaultConfig).length > 0) {
setConfig(mergedConfig)
// Reset dirty snapshot when defaults are applied to avoid false-disabled Save
initialConfigRef.current = mergedConfig
}
}, [triggerDef.configFields, initialConfig])
// Monitor credential selection across collaborators; clear options on change/clear
useEffect(() => {
if (triggerDef.requiresCredentials && triggerDef.credentialProvider) {
const checkCredentials = () => {
const subBlockStore = useSubBlockStore.getState()
const credentialValue = subBlockStore.getValue(blockId, 'triggerCredentials') as
| string
| null
const currentCredentialId = credentialValue || null
const hasCredential = Boolean(currentCredentialId)
setHasCredentials(hasCredential)
// If credential was cleared by another user, reset local state and dynamic options
if (!hasCredential) {
if (selectedCredentialId !== null) {
setSelectedCredentialId(null)
}
// Clear provider-specific dynamic options
setDynamicOptions({})
// Per requirements: only clear dependent selections on actual credential CHANGE,
// not when it becomes empty. So we do NOT reset fields here.
lastCredentialIdRef.current = null
return
}
// If credential changed, clear options immediately and load for new cred
const previousCredentialId = lastCredentialIdRef.current
// First detection (prev null → current non-null): do not clear selections
if (previousCredentialId === null) {
setSelectedCredentialId(currentCredentialId)
lastCredentialIdRef.current = currentCredentialId
if (typeof currentCredentialId === 'string') {
if (triggerDef.provider === 'gmail') {
void loadGmailLabels(currentCredentialId)
} else if (triggerDef.provider === 'outlook') {
void loadOutlookFolders(currentCredentialId)
} else if (triggerDef.provider === 'webflow') {
void loadWebflowSites()
}
}
return
}
// Real change (prev non-null → different non-null): clear dependent selections
if (
typeof currentCredentialId === 'string' &&
currentCredentialId !== previousCredentialId
) {
setSelectedCredentialId(currentCredentialId)
lastCredentialIdRef.current = currentCredentialId
// Clear stale options before loading new ones
setDynamicOptions({})
// Clear any selected values that depend on the credential
resetFieldsForCredentialChange()
if (triggerDef.provider === 'gmail') {
void loadGmailLabels(currentCredentialId)
} else if (triggerDef.provider === 'outlook') {
void loadOutlookFolders(currentCredentialId)
} else if (triggerDef.provider === 'webflow') {
void loadWebflowSites()
}
}
}
checkCredentials()
const unsubscribe = useSubBlockStore.subscribe(checkCredentials)
return unsubscribe
}
setHasCredentials(true)
}, [
blockId,
triggerDef.requiresCredentials,
triggerDef.credentialProvider,
selectedCredentialId,
triggerDef.provider,
])
// Load Gmail labels for the selected credential
const loadGmailLabels = async (credentialId: string) => {
try {
const response = await fetch(`/api/tools/gmail/labels?credentialId=${credentialId}`)
if (response.ok) {
const data = await response.json()
if (data.labels && Array.isArray(data.labels)) {
const labelOptions = data.labels.map((label: any) => ({
id: label.id,
name: label.name,
}))
setDynamicOptions((prev) => ({
...prev,
labelIds: labelOptions,
}))
}
} else {
logger.error('Failed to load Gmail labels:', response.statusText)
}
} catch (error) {
logger.error('Error loading Gmail labels:', error)
}
}
// Load Outlook folders for the selected credential
const loadOutlookFolders = async (credentialId: string) => {
try {
const response = await fetch(`/api/tools/outlook/folders?credentialId=${credentialId}`)
if (response.ok) {
const data = await response.json()
if (data.folders && Array.isArray(data.folders)) {
const folderOptions = data.folders.map((folder: any) => ({
id: folder.id,
name: folder.name,
}))
setDynamicOptions((prev) => ({
...prev,
folderIds: folderOptions,
}))
}
} else {
logger.error('Failed to load Outlook folders:', response.statusText)
}
} catch (error) {
logger.error('Error loading Outlook folders:', error)
}
}
const loadWebflowSites = async () => {
setLoadingFields((prev) => ({ ...prev, siteId: true }))
try {
const response = await fetch('/api/tools/webflow/sites')
if (response.ok) {
const data = await response.json()
if (data.sites && Array.isArray(data.sites)) {
setDynamicOptions((prev) => ({
...prev,
siteId: data.sites,
}))
}
} else {
logger.error('Failed to load Webflow sites:', response.statusText)
}
} catch (error) {
logger.error('Error loading Webflow sites:', error)
} finally {
setLoadingFields((prev) => ({ ...prev, siteId: false }))
}
}
const loadWebflowCollections = async (siteId: string) => {
setLoadingFields((prev) => ({ ...prev, collectionId: true }))
try {
const response = await fetch(`/api/tools/webflow/collections?siteId=${siteId}`)
if (response.ok) {
const data = await response.json()
if (data.collections && Array.isArray(data.collections)) {
setDynamicOptions((prev) => ({
...prev,
collectionId: data.collections,
}))
}
} else {
logger.error('Failed to load Webflow collections:', response.statusText)
}
} catch (error) {
logger.error('Error loading Webflow collections:', error)
} finally {
setLoadingFields((prev) => ({ ...prev, collectionId: false }))
}
}
useEffect(() => {
if (triggerDef.provider === 'webflow' && config.siteId) {
void loadWebflowCollections(config.siteId)
}
}, [config.siteId, triggerDef.provider])
useEffect(() => {
if (triggerDef.requiresCredentials && !triggerDef.webhook) {
setWebhookUrl('')
setGeneratedPath('')
return
}
let finalPath = triggerPath
if (!finalPath && !generatedPath) {
const newPath = crypto.randomUUID()
setGeneratedPath(newPath)
finalPath = newPath
} else if (generatedPath && !triggerPath) {
finalPath = generatedPath
}
if (finalPath) {
setWebhookUrl(`${getBaseUrl()}/api/webhooks/trigger/${finalPath}`)
}
}, [
triggerPath,
generatedPath,
triggerDef.provider,
triggerDef.requiresCredentials,
triggerDef.webhook,
])
const handleConfigChange = (fieldId: string, value: any) => {
setConfig((prev) => ({
...prev,
[fieldId]: value,
}))
}
const handleCopyTestUrl = () => {
if (testUrl) {
navigator.clipboard.writeText(testUrl)
setCopiedTestUrl(true)
setTimeout(() => setCopiedTestUrl(false), 2000)
}
}
const generateTestUrl = async () => {
try {
if (!triggerId) {
logger.warn('Cannot generate test URL until trigger is saved')
return
}
setIsGeneratingTestUrl(true)
const res = await fetch(`/api/webhooks/${triggerId}/test-url`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err?.error || 'Failed to generate test URL')
}
const json = await res.json()
setTestUrl(json.url)
setTestUrlExpiresAt(json.expiresAt)
setConfig((prev) => ({
...prev,
testUrl: json.url,
testUrlExpiresAt: json.expiresAt,
}))
} catch (e) {
logger.error('Failed to generate test webhook URL', { error: e })
} finally {
setIsGeneratingTestUrl(false)
}
}
// Generate test URL only once when needed (skip if one is already provided in initialConfig)
useEffect(() => {
const initialTestUrl = (initialConfig as any)?.testUrl as string | undefined
if (isOpen && triggerDef.webhook && !testUrl && !isGeneratingTestUrl && !initialTestUrl) {
generateTestUrl()
}
}, [isOpen, triggerDef.webhook, testUrl, isGeneratingTestUrl, initialConfig])
// Clear test URL when triggerId changes (after save)
useEffect(() => {
if (triggerId !== initialConfigRef.current?.triggerId) {
setTestUrl(null)
setTestUrlExpiresAt(null)
}
}, [triggerId])
// Initialize saved test URL from initial config if present
useEffect(() => {
const url = (initialConfig as any)?.testUrl as string | undefined
const expires = (initialConfig as any)?.testUrlExpiresAt as string | undefined
if (url) setTestUrl(url)
if (expires) setTestUrlExpiresAt(expires)
}, [initialConfig])
const handleSave = async () => {
if (!onSave) return
setIsSaving(true)
try {
// Use the existing trigger path or the generated one
const path = triggerPath || generatedPath
// For credential-based triggers that don't use webhooks (like Gmail), path is optional
const requiresPath = triggerDef.webhook !== undefined
if (requiresPath && !path) {
logger.error('No webhook path available for saving trigger')
return
}
const success = await onSave(path || '', {
...config,
...(testUrl ? { testUrl } : {}),
...(testUrlExpiresAt ? { testUrlExpiresAt } : {}),
})
if (success) {
onClose()
}
} catch (error) {
logger.error('Error saving trigger:', error)
} finally {
setIsSaving(false)
}
}
const handleDelete = async () => {
if (!onDelete) return
setIsDeleting(true)
try {
const success = await onDelete()
if (success) {
onClose()
}
} catch (error) {
logger.error('Error deleting trigger:', error)
} finally {
setIsDeleting(false)
}
}
const isConfigValid = () => {
// Check if credentials are required and available
if (triggerDef.requiresCredentials && !hasCredentials) {
return false
}
// Check required fields (skip credential fields - they're stored separately in subblock store)
for (const [fieldId, fieldDef] of Object.entries(triggerDef.configFields)) {
if (fieldDef.required && fieldDef.type !== 'credential' && !config[fieldId]) {
return false
}
}
return true
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className='flex max-h-[90vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[800px]'
hideCloseButton
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader className='border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<DialogTitle className='font-medium text-lg'>
{triggerDef.name} Configuration
</DialogTitle>
{triggerId && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant='outline'
className='flex items-center gap-1 border-green-200 bg-green-50 font-normal text-green-600 text-xs hover:bg-green-50 dark:bg-green-900/20 dark:text-green-400'
>
<div className='relative mr-0.5 flex items-center justify-center'>
<div className='absolute h-3 w-3 rounded-full bg-green-500/20' />
<div className='relative h-2 w-2 rounded-full bg-green-500' />
</div>
Active Trigger
</Badge>
</TooltipTrigger>
<TooltipContent side='bottom' className='max-w-[300px] p-4'>
<p className='text-sm'>{triggerDef.name}</p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
</DialogHeader>
<div className='flex-1 overflow-y-auto px-6 py-6'>
<div className='space-y-6'>
{/* Trigger Type Selector - only show if multiple triggers available */}
{availableTriggers && availableTriggers.length > 1 && onTriggerChange && (
<div className='space-y-2 rounded-md border border-border bg-card p-4 shadow-sm'>
<Label htmlFor='trigger-type-select' className='font-medium text-sm'>
Trigger Type
</Label>
<p className='text-muted-foreground text-sm'>
Choose how this workflow should be triggered
</p>
<Select
value={selectedTriggerId || availableTriggers[0]}
onValueChange={(value) => {
if (onTriggerChange && value !== selectedTriggerId) {
onTriggerChange(value)
}
}}
disabled={!!triggerId}
>
<SelectTrigger id='trigger-type-select' className='h-10'>
<SelectValue placeholder='Select trigger type' />
</SelectTrigger>
<SelectContent>
{availableTriggers.map((triggerId) => {
const trigger = getTrigger(triggerId)
return (
<SelectItem key={triggerId} value={triggerId}>
<div className='flex items-center gap-2'>
{trigger?.icon && <trigger.icon className='h-4 w-4' />}
<span>{trigger?.name || triggerId}</span>
</div>
</SelectItem>
)
})}
</SelectContent>
</Select>
{triggerId && (
<p className='text-muted-foreground text-xs'>
Delete the trigger to change the trigger type
</p>
)}
</div>
)}
{triggerDef.requiresCredentials && triggerDef.credentialProvider && (
<div className='space-y-2 rounded-md border border-border bg-card p-4 shadow-sm'>
<h3 className='font-medium text-sm'>Credentials</h3>
<p className='text-muted-foreground text-sm'>
This trigger requires {triggerDef.credentialProvider.replace('-', ' ')}{' '}
credentials to access your account.
</p>
<CredentialSelector
blockId={blockId}
subBlock={{
id: 'triggerCredentials',
type: 'oauth-input' as const,
placeholder: `Select ${triggerDef.credentialProvider.replace('-', ' ')} credential`,
provider: triggerDef.credentialProvider as any,
requiredScopes: [],
}}
previewValue={null}
/>
</div>
)}
<TriggerConfigSection
blockId={blockId}
triggerDef={triggerDef}
config={config}
onChange={handleConfigChange}
webhookUrl={webhookUrl}
dynamicOptions={dynamicOptions}
loadingFields={loadingFields}
/>
{triggerDef.webhook && (
<div className='space-y-4 rounded-md border border-border bg-card p-4 shadow-sm'>
<TooltipProvider delayDuration={0}>
<div className='space-y-1'>
<div className='flex items-center gap-2'>
<Label className='font-medium text-sm'>Test Webhook URL</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-1 text-gray-500'
aria-label='Learn more about Test Webhook URL'
>
<Info className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent
side='right'
align='center'
className='z-[100] max-w-[300px] p-3'
role='tooltip'
>
<p className='text-sm'>
Temporary URL for testing canvas state instead of deployed version.
Expires after 24 hours. You must save the trigger before generating a
test URL.
</p>
</TooltipContent>
</Tooltip>
</div>
{testUrl ? (
<>
<div className='relative'>
<Input
value={testUrl}
readOnly
className={cn(
'h-9 cursor-text rounded-[8px] pr-20 font-mono text-xs',
'focus-visible:ring-2 focus-visible:ring-primary/20'
)}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<div className='absolute top-0.5 right-0.5 flex h-8 items-center gap-1 pr-1'>
<Button
type='button'
variant='ghost'
size='sm'
onClick={generateTestUrl}
disabled={isGeneratingTestUrl || !triggerId}
className={cn(
'group h-7 w-7 rounded-md p-0',
'text-muted-foreground/60 transition-all duration-200',
'hover:scale-105 hover:bg-muted/50 hover:text-foreground',
'active:scale-95',
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
>
<RotateCcw
className={cn('h-3.5 w-3.5', isGeneratingTestUrl && 'animate-spin')}
/>
</Button>
<Button
type='button'
variant='ghost'
size='sm'
className={cn(
'group h-7 w-7 rounded-md p-0',
'text-muted-foreground/60 transition-all duration-200',
'hover:scale-105 hover:bg-muted/50 hover:text-foreground',
'active:scale-95',
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
onClick={handleCopyTestUrl}
>
{copiedTestUrl ? (
<Check className='h-3.5 w-3.5' />
) : (
<Copy className='h-3.5 w-3.5' />
)}
</Button>
</div>
</div>
{testUrlExpiresAt && (
<p className='text-muted-foreground text-xs'>
Expires: {new Date(testUrlExpiresAt).toLocaleString()}
</p>
)}
</>
) : isGeneratingTestUrl ? (
<div className='text-muted-foreground text-sm'>Generating test URL...</div>
) : null}
</div>
</TooltipProvider>
</div>
)}
<TriggerInstructions
instructions={triggerDef.instructions}
webhookUrl={webhookUrl}
samplePayload={triggerDef.samplePayload}
triggerDef={triggerDef}
/>
</div>
</div>
<DialogFooter className='border-t px-6 py-4'>
<div className='flex w-full justify-between'>
<div>
{triggerId && (
<Button
type='button'
variant='destructive'
onClick={handleDelete}
disabled={isDeleting || isSaving}
size='default'
className='h-9 rounded-[8px]'
>
{isDeleting ? (
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
) : (
<Trash2 className='mr-2 h-4 w-4' />
)}
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
)}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
onClick={onClose}
size='default'
className='h-9 rounded-[8px]'
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={
isSaving ||
!isConfigValid() ||
(!(hasConfigChanged || hasCredentialChanged) && !!triggerId)
}
className={cn(
'w-[140px] rounded-[8px]',
isConfigValid() && (hasConfigChanged || hasCredentialChanged || !triggerId)
? 'bg-primary hover:bg-primary/90'
: ''
)}
size='sm'
>
{isSaving && (
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
)}
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,424 +0,0 @@
import { useEffect, useState } from 'react'
import { ExternalLink } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { createLogger } from '@/lib/logs/console/logger'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getTrigger } from '@/triggers'
import { TriggerModal } from './components/trigger-modal'
const logger = createLogger('TriggerConfig')
interface TriggerConfigProps {
blockId: string
isConnecting: boolean
isPreview?: boolean
value?: {
triggerId?: string
triggerPath?: string
triggerConfig?: Record<string, any>
}
disabled?: boolean
availableTriggers?: string[]
}
export function TriggerConfig({
blockId,
isConnecting,
isPreview = false,
value: propValue,
disabled = false,
availableTriggers = [],
}: TriggerConfigProps) {
const [isModalOpen, setIsModalOpen] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [triggerId, setTriggerId] = useState<string | null>(null)
const params = useParams()
const workflowId = params.workflowId as string
const [isLoading, setIsLoading] = useState(false)
// Get trigger configuration from the block state
const [storeTriggerPath, setTriggerPath] = useSubBlockValue(blockId, 'triggerPath')
const [storeTriggerConfig, setTriggerConfig] = useSubBlockValue(blockId, 'triggerConfig')
const [storeTriggerId, setStoredTriggerId] = useSubBlockValue(blockId, 'triggerId')
// Use prop values when available (preview mode), otherwise use store values
const selectedTriggerId = propValue?.triggerId ?? storeTriggerId ?? (availableTriggers[0] || null)
const triggerPath = propValue?.triggerPath ?? storeTriggerPath
const triggerConfig = propValue?.triggerConfig ?? storeTriggerConfig
// Consolidate trigger ID logic
const effectiveTriggerId = selectedTriggerId || availableTriggers[0]
const triggerDef = effectiveTriggerId ? getTrigger(effectiveTriggerId) : null
// Set the trigger ID to the first available one if none is set
useEffect(() => {
if (!selectedTriggerId && availableTriggers[0] && !isPreview) {
setStoredTriggerId(availableTriggers[0])
}
}, [availableTriggers, selectedTriggerId, setStoredTriggerId, isPreview])
// Store the actual trigger from the database
const [actualTriggerId, setActualTriggerId] = useState<string | null>(null)
useEffect(() => {
if (isModalOpen || isSaving || isDeleting) return
if (isPreview || !effectiveTriggerId) {
setIsLoading(false)
return
}
;(async () => {
setIsLoading(true)
try {
const response = await fetch(`/api/webhooks?workflowId=${workflowId}&blockId=${blockId}`)
if (response.ok) {
const data = await response.json()
if (data.webhooks && data.webhooks.length > 0) {
const webhook = data.webhooks[0].webhook
setTriggerId(webhook.id)
setActualTriggerId(webhook.provider)
if (webhook.path && webhook.path !== triggerPath) {
setTriggerPath(webhook.path)
}
if (webhook.providerConfig) {
setTriggerConfig(webhook.providerConfig)
}
} else {
setTriggerId(null)
setActualTriggerId(null)
if (triggerPath) {
setTriggerPath('')
logger.info('Cleared stale trigger path on page refresh - no webhook in database', {
blockId,
clearedPath: triggerPath,
})
}
}
}
} catch (error) {
logger.error('Error checking webhook:', { error })
} finally {
setIsLoading(false)
}
})()
}, [
isPreview,
effectiveTriggerId,
workflowId,
blockId,
storeTriggerId,
storeTriggerPath,
storeTriggerConfig,
isModalOpen,
isSaving,
isDeleting,
triggerPath,
])
const handleOpenModal = () => {
if (isPreview || disabled) return
setIsModalOpen(true)
setError(null)
}
const handleCloseModal = () => {
setIsModalOpen(false)
}
const handleSaveTrigger = async (path: string, config: Record<string, any>) => {
if (isPreview || disabled || !effectiveTriggerId) return false
try {
setIsSaving(true)
setError(null)
// Get trigger definition to check if it requires webhooks
const triggerDef = getTrigger(effectiveTriggerId)
if (!triggerDef) {
throw new Error('Trigger definition not found')
}
if (path && path !== triggerPath) {
setTriggerPath(path)
}
setTriggerConfig(config)
setStoredTriggerId(effectiveTriggerId)
const webhookProvider = triggerDef.provider
const selectedCredentialId =
(useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as string | null) ||
null
// For credential-based triggers (like Gmail), create webhook entry for polling service but no webhook URL
if (triggerDef.requiresCredentials && !triggerDef.webhook) {
// Gmail polling service requires a webhook database entry to find the configuration
const response = await fetch('/api/webhooks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workflowId,
blockId,
path: '', // Empty path - API will generate dummy path for Gmail
provider: webhookProvider,
providerConfig: {
...config,
...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
triggerId: effectiveTriggerId, // Include trigger ID to determine subscription vs polling
},
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(
typeof errorData.error === 'object'
? errorData.error.message || JSON.stringify(errorData.error)
: errorData.error || 'Failed to save credential-based trigger'
)
}
const data = await response.json()
const savedWebhookId = data.webhook.id
setTriggerId(savedWebhookId)
logger.info('Credential-based trigger saved successfully', {
webhookId: savedWebhookId,
triggerDefId: effectiveTriggerId,
provider: webhookProvider,
blockId,
})
// Update the actual trigger after saving
setActualTriggerId(webhookProvider)
return true
}
// Save as webhook using existing webhook API (for webhook-based triggers)
const webhookConfig = {
...config,
...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
triggerId: effectiveTriggerId,
}
logger.info('Saving webhook-based trigger', {
triggerId: effectiveTriggerId,
provider: webhookProvider,
hasCredential: !!selectedCredentialId,
credentialId: selectedCredentialId,
webhookConfig,
})
const response = await fetch('/api/webhooks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workflowId,
blockId,
path,
provider: webhookProvider,
providerConfig: webhookConfig,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(
typeof errorData.error === 'object'
? errorData.error.message || JSON.stringify(errorData.error)
: errorData.error || 'Failed to save trigger'
)
}
const data = await response.json()
const savedWebhookId = data.webhook.id
setTriggerId(savedWebhookId)
// Update the actual trigger after saving
setActualTriggerId(webhookProvider)
return true
} catch (error: any) {
logger.error('Error saving trigger:', { error })
setError(error.message || 'Failed to save trigger configuration')
return false
} finally {
setIsSaving(false)
}
}
const handleDeleteTrigger = async () => {
if (isPreview || disabled || !triggerId) return false
try {
setIsDeleting(true)
setError(null)
// Delete webhook using existing webhook API (works for both webhook and credential-based triggers)
const response = await fetch(`/api/webhooks/${triggerId}`, {
method: 'DELETE',
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to delete trigger')
}
// Remove trigger-specific fields from the block state
const store = useSubBlockStore.getState()
const workflowValues = store.workflowValues[workflowId] || {}
const blockValues = { ...workflowValues[blockId] }
// Remove trigger-related fields
blockValues.triggerId = undefined
blockValues.triggerConfig = undefined
blockValues.triggerPath = undefined
// Update the store with the cleaned block values
useSubBlockStore.setState({
workflowValues: {
...workflowValues,
[workflowId]: {
...workflowValues,
[blockId]: blockValues,
},
},
})
// Clear component state
setTriggerId(null)
setActualTriggerId(null)
// Also clear store values using the setters to ensure UI updates
setTriggerPath('')
setTriggerConfig({})
setStoredTriggerId('')
logger.info('Trigger deleted successfully', {
blockId,
triggerType:
triggerDef?.requiresCredentials && !triggerDef.webhook
? 'credential-based'
: 'webhook-based',
hadWebhookId: Boolean(triggerId),
})
handleCloseModal()
return true
} catch (error: any) {
logger.error('Error deleting trigger:', { error })
setError(error.message || 'Failed to delete trigger')
return false
} finally {
setIsDeleting(false)
}
}
// Check if the trigger is connected
// Both webhook and credential-based triggers now have webhook database entries
const isTriggerConnected = Boolean(triggerId && actualTriggerId)
// Debug logging to help with troubleshooting
useEffect(() => {
logger.info('Trigger connection status:', {
triggerId,
actualTriggerId,
triggerPath,
isTriggerConnected,
effectiveTriggerId,
triggerConfig,
triggerConfigKeys: triggerConfig ? Object.keys(triggerConfig) : [],
isCredentialBased: triggerDef?.requiresCredentials && !triggerDef.webhook,
storeValues: {
storeTriggerId,
storeTriggerPath,
storeTriggerConfig,
},
})
}, [
triggerId,
actualTriggerId,
triggerPath,
isTriggerConnected,
effectiveTriggerId,
triggerConfig,
triggerDef,
storeTriggerId,
storeTriggerPath,
storeTriggerConfig,
])
return (
<div className='w-full'>
{error && <div className='mb-2 text-red-500 text-sm dark:text-red-400'>{error}</div>}
{isTriggerConnected ? (
<div className='flex flex-col space-y-2'>
<div
className='flex h-10 cursor-pointer items-center justify-center rounded border border-border bg-background px-3 py-2 transition-colors duration-200 hover:bg-accent hover:text-accent-foreground'
onClick={handleOpenModal}
>
<div className='flex items-center gap-2'>
<div className='flex items-center'>
{triggerDef?.icon && (
<triggerDef.icon className='mr-2 h-4 w-4 text-[#611f69] dark:text-[#e01e5a]' />
)}
<span className='font-normal text-sm'>{triggerDef?.name || 'Active Trigger'}</span>
</div>
</div>
</div>
</div>
) : (
<Button
variant='outline'
size='sm'
className='flex h-10 w-full items-center bg-background font-normal text-sm'
onClick={handleOpenModal}
disabled={
isConnecting || isSaving || isDeleting || isPreview || disabled || !effectiveTriggerId
}
>
{isLoading ? (
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
) : (
<ExternalLink className='mr-2 h-4 w-4' />
)}
Configure Trigger
</Button>
)}
{isModalOpen && triggerDef && (
<TriggerModal
isOpen={isModalOpen}
onClose={handleCloseModal}
triggerPath={triggerPath || ''}
triggerDef={triggerDef}
triggerConfig={triggerConfig || {}}
onSave={handleSaveTrigger}
onDelete={handleDeleteTrigger}
triggerId={triggerId || undefined}
blockId={blockId}
availableTriggers={availableTriggers}
selectedTriggerId={selectedTriggerId}
onTriggerChange={(newTriggerId) => {
setStoredTriggerId(newTriggerId)
// Clear config when changing trigger type
setTriggerConfig({})
}}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,485 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AlertCircle, Check, Copy, Save, Trash2 } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useTriggerConfigAggregation } 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/consts'
const logger = createLogger('TriggerSave')
interface TriggerSaveProps {
blockId: string
subBlockId: string
triggerId?: string
isPreview?: boolean
disabled?: boolean
}
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
export function TriggerSave({
blockId,
subBlockId,
triggerId,
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 [testUrl, setTestUrl] = useState<string | null>(null)
const [testUrlExpiresAt, setTestUrlExpiresAt] = useState<string | null>(null)
const [isGeneratingTestUrl, setIsGeneratingTestUrl] = useState(false)
const [copied, setCopied] = useState<string | null>(null)
const effectiveTriggerId = useMemo(() => {
if (triggerId && isTriggerValid(triggerId)) {
return triggerId
}
const selectedTriggerId = useSubBlockStore.getState().getValue(blockId, 'selectedTriggerId')
if (typeof selectedTriggerId === 'string' && isTriggerValid(selectedTriggerId)) {
return selectedTriggerId
}
return triggerId
}, [blockId, triggerId])
const { webhookId, saveConfig, deleteConfig, isLoading } = useWebhookManagement({
blockId,
triggerId: effectiveTriggerId,
isPreview,
})
const triggerConfig = useSubBlockStore((state) => state.getValue(blockId, 'triggerConfig'))
const triggerCredentials = useSubBlockStore((state) =>
state.getValue(blockId, 'triggerCredentials')
)
const triggerDef =
effectiveTriggerId && isTriggerValid(effectiveTriggerId) ? getTrigger(effectiveTriggerId) : null
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 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]
)
const requiredSubBlockIds = useMemo(() => {
if (!triggerDef) return []
return triggerDef.subBlocks
.filter((sb) => sb.required && sb.mode === 'trigger' && !SYSTEM_SUBBLOCK_IDS.includes(sb.id))
.map((sb) => sb.id)
}, [triggerDef])
const otherRequiredValues = useMemo(() => {
if (!triggerDef) return {}
const values: Record<string, any> = {}
requiredSubBlockIds
.filter((id) => id !== 'triggerCredentials')
.forEach((subBlockId) => {
const value = useSubBlockStore.getState().getValue(blockId, subBlockId)
if (value !== null && value !== undefined && value !== '') {
values[subBlockId] = value
}
})
return values
}, [blockId, triggerDef, requiredSubBlockIds])
const requiredSubBlockValues = useMemo(() => {
return {
triggerCredentials,
...otherRequiredValues,
}
}, [triggerCredentials, otherRequiredValues])
const previousValuesRef = useRef<Record<string, any>>({})
const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (saveStatus !== 'error' || !triggerDef) {
previousValuesRef.current = requiredSubBlockValues
return
}
const hasChanges = Object.keys(requiredSubBlockValues).some(
(key) =>
previousValuesRef.current[key] !== (requiredSubBlockValues 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 configToValidate =
aggregatedConfig ?? useSubBlockStore.getState().getValue(blockId, 'triggerConfig')
const validation = validateRequiredFields(configToValidate)
if (validation.valid) {
setErrorMessage(null)
setSaveStatus('idle')
logger.debug('Error cleared after validation passed', {
blockId,
triggerId: effectiveTriggerId,
})
} else {
const newErrorMessage = `Missing required fields: ${validation.missingFields.join(', ')}`
setErrorMessage((prev) => {
if (prev !== newErrorMessage) {
logger.debug('Error message updated', {
blockId,
triggerId: effectiveTriggerId,
missingFields: validation.missingFields,
})
return newErrorMessage
}
return prev
})
}
previousValuesRef.current = requiredSubBlockValues
}, 300)
return () => {
if (validationTimeoutRef.current) {
clearTimeout(validationTimeoutRef.current)
}
}
}, [
blockId,
effectiveTriggerId,
triggerDef,
requiredSubBlockValues,
saveStatus,
validateRequiredFields,
])
const handleSave = async () => {
if (isPreview || disabled) return
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 configToValidate = aggregatedConfig ?? triggerConfig
const validation = validateRequiredFields(configToValidate)
if (!validation.valid) {
setErrorMessage(`Missing required fields: ${validation.missingFields.join(', ')}`)
setSaveStatus('error')
return
}
const success = await saveConfig()
if (success) {
setSaveStatus('saved')
setErrorMessage(null)
setTimeout(() => {
setSaveStatus('idle')
}, 2000)
logger.info('Trigger configuration saved successfully', {
blockId,
triggerId: effectiveTriggerId,
hasWebhookId: !!webhookId,
})
} else {
setSaveStatus('error')
setErrorMessage('Failed to save trigger configuration. Please try again.')
logger.error('Failed to save trigger configuration')
}
} catch (error: any) {
setSaveStatus('error')
setErrorMessage(error.message || 'An error occurred while saving.')
logger.error('Error saving trigger configuration', { error })
}
}
const generateTestUrl = async () => {
if (!webhookId) return
try {
setIsGeneratingTestUrl(true)
const res = await fetch(`/api/webhooks/${webhookId}/test-url`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err?.error || 'Failed to generate test URL')
}
const json = await res.json()
setTestUrl(json.url)
setTestUrlExpiresAt(json.expiresAt)
} catch (e) {
logger.error('Failed to generate test webhook URL', { error: e })
setErrorMessage(
e instanceof Error ? e.message : 'Failed to generate test URL. Please try again.'
)
} finally {
setIsGeneratingTestUrl(false)
}
}
const copyToClipboard = (text: string, type: string): void => {
navigator.clipboard.writeText(text)
setCopied(type)
setTimeout(() => setCopied(null), 2000)
}
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)
setTestUrl(null)
setTestUrlExpiresAt(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
return (
<div id={`${blockId}-${subBlockId}`}>
<div className='flex gap-2'>
<Button
onClick={handleSave}
disabled={disabled || isProcessing}
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' && (
<>
<Check className='mr-2 h-4 w-4' />
Saved
</>
)}
{saveStatus === 'error' && (
<>
<AlertCircle className='mr-2 h-4 w-4' />
Error
</>
)}
{saveStatus === 'idle' && (
<>
<Save className='mr-2 h-4 w-4' />
{webhookId ? 'Update Configuration' : 'Save Configuration'}
</>
)}
</Button>
{webhookId && (
<Button
onClick={handleDeleteClick}
disabled={disabled || isProcessing}
variant='outline'
className='h-9 rounded-[8px] px-3 text-destructive hover:bg-destructive/10'
>
{deleteStatus === 'deleting' ? (
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
) : (
<Trash2 className='h-4 w-4' />
)}
</Button>
)}
</div>
{errorMessage && (
<Alert variant='destructive' className='mt-2'>
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
{webhookId && hasWebhookUrlDisplay && (
<div className='mt-2 space-y-1'>
<div className='flex items-center justify-between'>
<span className='font-medium text-sm'>Test Webhook URL</span>
<Button
variant='outline'
size='sm'
onClick={generateTestUrl}
disabled={isGeneratingTestUrl || isProcessing}
className='h-8 rounded-[8px]'
>
{isGeneratingTestUrl ? (
<>
<div className='mr-2 h-3 w-3 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
Generating
</>
) : testUrl ? (
'Regenerate'
) : (
'Generate'
)}
</Button>
</div>
{testUrl ? (
<div className='flex items-center gap-2'>
<Input
readOnly
value={testUrl}
className='h-9 flex-1 rounded-[8px] font-mono text-xs'
onClick={(e: React.MouseEvent<HTMLInputElement>) =>
(e.target as HTMLInputElement).select()
}
/>
<Button
type='button'
size='icon'
variant='outline'
className='h-9 w-9 rounded-[8px]'
onClick={() => copyToClipboard(testUrl, 'testUrl')}
>
{copied === 'testUrl' ? (
<Check className='h-4 w-4 text-green-500' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</div>
) : (
<p className='text-muted-foreground text-xs'>
Generate a temporary URL that executes this webhook against the live (un-deployed)
workflow state.
</p>
)}
{testUrlExpiresAt && (
<p className='text-muted-foreground text-xs'>
Expires at {new Date(testUrlExpiresAt).toLocaleString()}
</p>
)}
</div>
)}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Trigger Configuration</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this trigger configuration? This will remove the
webhook and stop all incoming triggers. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -32,9 +32,10 @@ import {
SliderInput,
Switch,
Table,
Text,
TimeInput,
ToolInput,
TriggerConfig,
TriggerSave,
VariablesInput,
WebhookConfig,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components'
@@ -100,6 +101,9 @@ export const SubBlock = memo(
subBlockId={config.id}
placeholder={config.placeholder}
password={config.password}
readOnly={config.readOnly}
showCopyButton={config.showCopyButton}
useWebhookUrl={config.useWebhookUrl}
isConnecting={isConnecting}
config={config}
isPreview={isPreview}
@@ -133,7 +137,8 @@ export const SubBlock = memo(
isPreview={isPreview}
previewValue={previewValue}
disabled={isDisabled}
config={config}
multiSelect={config.multiSelect}
fetchOptions={config.fetchOptions}
/>
</div>
)
@@ -190,9 +195,17 @@ export const SubBlock = memo(
placeholder={config.placeholder}
language={config.language}
generationType={config.generationType}
value={
typeof config.value === 'function' ? config.value(subBlockValues || {}) : undefined
}
isPreview={isPreview}
previewValue={previewValue}
disabled={isDisabled}
readOnly={config.readOnly}
collapsible={config.collapsible}
defaultCollapsed={config.defaultCollapsed}
defaultValue={config.defaultValue}
showCopyButton={config.showCopyButton}
onValidationChange={handleValidationChange}
wandConfig={
config.wandConfig || {
@@ -333,27 +346,6 @@ export const SubBlock = memo(
/>
)
}
case 'trigger-config': {
// For trigger config, we need to construct the value from multiple subblock values
const triggerValue =
isPreview && subBlockValues
? {
triggerId: subBlockValues.triggerId?.value,
triggerPath: subBlockValues.triggerPath?.value,
triggerConfig: subBlockValues.triggerConfig?.value,
}
: previewValue
return (
<TriggerConfig
blockId={blockId}
isConnecting={isConnecting}
isPreview={isPreview}
value={triggerValue}
disabled={isDisabled}
availableTriggers={config.availableTriggers}
/>
)
}
case 'schedule-config':
return (
<ScheduleConfig
@@ -540,6 +532,28 @@ export const SubBlock = memo(
isConnecting={isConnecting}
/>
)
case 'text':
return (
<Text
blockId={blockId}
subBlockId={config.id}
content={
typeof config.value === 'function'
? config.value(subBlockValues || {})
: (config.defaultValue as string) || ''
}
/>
)
case 'trigger-save':
return (
<TriggerSave
blockId={blockId}
subBlockId={config.id}
triggerId={config.triggerId}
isPreview={isPreview}
disabled={disabled}
/>
)
default:
return <div>Unknown input type: {config.type}</div>
}

View File

@@ -12,13 +12,12 @@ import { parseCronToHumanReadable } from '@/lib/schedules/utils'
import { cn, validateName } from '@/lib/utils'
import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useExecutionStore } from '@/stores/execution/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { useCurrentWorkflow } from '../../hooks'
import { ActionBar } from './components/action-bar/action-bar'
@@ -231,8 +230,7 @@ export const WorkflowBlock = memo(
// Check if this is a starter block or trigger block
const isStarterBlock = type === 'starter'
const isTriggerBlock = config.category === 'triggers'
const isWebhookTriggerBlock = type === 'webhook'
const isWebhookTriggerBlock = type === 'webhook' || type === 'generic_webhook'
const reactivateSchedule = async (scheduleId: string) => {
try {
@@ -466,10 +464,13 @@ export const WorkflowBlock = memo(
// In diff mode, use the diff workflow's subblock values
stateToUse = currentBlock.subBlocks || {}
} else {
// In normal mode, use merged state
const blocks = useWorkflowStore.getState().blocks
const mergedState = mergeSubblockState(blocks, activeWorkflowId || undefined, id)[id]
stateToUse = mergedState?.subBlocks || {}
stateToUse = Object.entries(blockSubBlockValues).reduce(
(acc, [key, value]) => {
acc[key] = { value }
return acc
},
{} as Record<string, any>
)
}
const effectiveAdvanced = displayAdvancedMode
@@ -485,23 +486,31 @@ export const WorkflowBlock = memo(
return false
}
// Special handling for trigger mode
if (block.type === ('trigger-config' as SubBlockType)) {
// Show trigger-config blocks when in trigger mode OR for pure trigger blocks
const isPureTriggerBlock = config?.triggers?.enabled && config.category === 'triggers'
return effectiveTrigger || isPureTriggerBlock
// Determine if this is a pure trigger block (category: 'triggers')
const isPureTriggerBlock = config?.triggers?.enabled && config.category === 'triggers'
// When in trigger mode, filter out non-trigger subblocks
if (effectiveTrigger) {
// For pure trigger blocks (category: 'triggers'), allow subblocks with mode='trigger' or no mode
// For tool blocks with trigger capability, only allow subblocks with mode='trigger'
const isValidTriggerSubblock = isPureTriggerBlock
? block.mode === 'trigger' || !block.mode
: block.mode === 'trigger'
if (!isValidTriggerSubblock) {
return false
}
// Continue to condition check below - don't return here!
} else {
// When NOT in trigger mode, hide trigger-specific subblocks
if (block.mode === 'trigger') {
return false
}
}
if (effectiveTrigger && block.type !== ('trigger-config' as SubBlockType)) {
// In trigger mode, hide all non-trigger-config blocks
return false
}
// Filter by mode if specified
if (block.mode) {
if (block.mode === 'basic' && effectiveAdvanced) return false
if (block.mode === 'advanced' && !effectiveAdvanced) return false
}
// Handle basic/advanced modes
if (block.mode === 'basic' && effectiveAdvanced) return false
if (block.mode === 'advanced' && !effectiveAdvanced) return false
// If there's no condition, the block should be shown
if (!block.condition) return true

View File

@@ -19,6 +19,7 @@ import { Dialog, DialogOverlay, DialogPortal, DialogTitle } from '@/components/u
import { Input } from '@/components/ui/input'
import { useBrandConfig } from '@/lib/branding/branding'
import { cn } from '@/lib/utils'
import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/trigger-utils'
import { getKeyboardShortcutText } from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts'
import { getAllBlocks } from '@/blocks'
import { type NavigationSection, useSearchNavigation } from './hooks/use-search-navigation'
@@ -54,6 +55,7 @@ interface BlockItem {
icon: React.ComponentType<any>
bgColor: string
type: string
config?: any // Store block config to check trigger capability
}
interface ToolItem {
@@ -147,16 +149,13 @@ export function SearchModal({
return [...regularBlocks, ...specialBlocks].sort((a, b) => a.name.localeCompare(b.name))
}, [isOnWorkflowPage])
// Get all available triggers - only when on workflow page
const triggers = useMemo(() => {
if (!isOnWorkflowPage) return []
const allBlocks = getAllBlocks()
return allBlocks
.filter(
(block) =>
block.type !== 'starter' && !block.hideFromToolbar && block.category === 'triggers'
)
const triggerBlocks = getTriggersForSidebar()
return triggerBlocks
.filter((block) => block.type !== 'webhook') // Exclude old webhook block - use generic_webhook instead
.map(
(block): BlockItem => ({
id: block.type,
@@ -166,6 +165,7 @@ export function SearchModal({
icon: block.icon,
bgColor: block.bgColor || '#6B7280',
type: block.type,
config: block, // Store config to check trigger capability
})
)
.sort((a, b) => a.name.localeCompare(b.name))
@@ -371,11 +371,12 @@ export function SearchModal({
// Handle block/tool click (same as toolbar interaction)
const handleBlockClick = useCallback(
(blockType: string) => {
(blockType: string, enableTriggerMode?: boolean) => {
// Dispatch a custom event to be caught by the workflow component
const event = new CustomEvent('add-block-from-toolbar', {
detail: {
type: blockType,
enableTriggerMode: enableTriggerMode || false,
},
})
window.dispatchEvent(event)
@@ -475,7 +476,9 @@ export function SearchModal({
const { section, item } = current
if (section.id === 'blocks' || section.id === 'triggers' || section.id === 'tools') {
handleBlockClick(item.type)
const enableTriggerMode =
section.id === 'triggers' && item.config ? hasTriggerCapability(item.config) : false
handleBlockClick(item.type, enableTriggerMode)
} else if (section.id === 'list') {
switch (item.type) {
case 'workspace':
@@ -652,7 +655,12 @@ export function SearchModal({
{filteredTriggers.map((trigger, index) => (
<button
key={trigger.id}
onClick={() => handleBlockClick(trigger.type)}
onClick={() =>
handleBlockClick(
trigger.type,
trigger.config ? hasTriggerCapability(trigger.config) : false
)
}
data-nav-item={`triggers-${index}`}
className={`flex h-auto w-[180px] flex-shrink-0 cursor-pointer flex-col items-start gap-2 rounded-[8px] border p-3 transition-all duration-200 ${
isItemSelected('triggers', index)

View File

@@ -22,7 +22,7 @@ import { Executor } from '@/executor'
import type { ExecutionResult } from '@/executor/types'
import { Serializer } from '@/serializer'
import { mergeSubblockState } from '@/stores/workflows/server-utils'
import { getTrigger } from '@/triggers'
import { getTrigger, isTriggerValid } from '@/triggers'
const logger = createLogger('TriggerWebhookExecution')
@@ -380,10 +380,10 @@ async function executeWebhookJobInternal(
const triggerBlock = blocks[payload.blockId]
const triggerId = triggerBlock?.subBlocks?.triggerId?.value
if (triggerId && typeof triggerId === 'string') {
if (triggerId && typeof triggerId === 'string' && isTriggerValid(triggerId)) {
const triggerConfig = getTrigger(triggerId)
if (triggerConfig?.outputs) {
if (triggerConfig.outputs) {
logger.debug(`[${requestId}] Processing trigger ${triggerId} file outputs`)
const processedInput = await processTriggerFileOutputs(input, triggerConfig.outputs, {
workspaceId: workspaceId || '',

View File

@@ -2,6 +2,7 @@ import { AirtableIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { AirtableResponse } from '@/tools/airtable/types'
import { getTrigger } from '@/triggers'
export const AirtableBlock: BlockConfig<AirtableResponse> = {
type: 'airtable',
@@ -100,15 +101,7 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
condition: { field: 'operation', value: 'update' },
required: true,
},
// TRIGGER MODE: Trigger configuration (only shown when trigger mode is active)
{
id: 'triggerConfig',
title: 'Trigger Configuration',
type: 'trigger-config',
layout: 'full',
triggerProvider: 'airtable',
availableTriggers: ['airtable_webhook'],
},
...getTrigger('airtable_webhook').subBlocks,
],
tools: {
access: [

View File

@@ -2,6 +2,7 @@ import type { SVGProps } from 'react'
import { createElement } from 'react'
import { Webhook } from 'lucide-react'
import type { BlockConfig } from '@/blocks/types'
import { getTrigger } from '@/triggers'
const WebhookIcon = (props: SVGProps<SVGSVGElement>) => createElement(Webhook, props)
@@ -18,26 +19,7 @@ export const GenericWebhookBlock: BlockConfig = {
- Continuing example above, the body can be accessed in downstream block using dot notation. E.g. <webhook1.message> and <webhook1.data.key>
- Only use when there's no existing integration for the service with triggerAllowed flag set to true.
`,
subBlocks: [
// Generic webhook configuration - always visible
{
id: 'triggerConfig',
title: 'Webhook Configuration',
type: 'trigger-config',
layout: 'full',
triggerProvider: 'generic',
availableTriggers: ['generic_webhook'],
},
// Optional input format for structured data including files
{
id: 'inputFormat',
title: 'Input Format',
type: 'input-format',
layout: 'full',
description:
'Define the expected JSON input schema for this webhook (optional). Use type "files" for file uploads.',
},
],
subBlocks: [...getTrigger('generic_webhook').subBlocks],
tools: {
access: [], // No external tools needed for triggers

View File

@@ -2,6 +2,7 @@ import { GithubIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { GitHubResponse } from '@/tools/github/types'
import { getTrigger } from '@/triggers'
export const GitHubBlock: BlockConfig<GitHubResponse> = {
type: 'github',
@@ -89,15 +90,7 @@ export const GitHubBlock: BlockConfig<GitHubResponse> = {
password: true,
required: true,
},
// TRIGGER MODE: Trigger configuration (only shown when trigger mode is active)
{
id: 'triggerConfig',
title: 'Trigger Configuration',
type: 'trigger-config',
layout: 'full',
triggerProvider: 'github',
availableTriggers: ['github_webhook'],
},
...getTrigger('github_webhook').subBlocks,
{
id: 'commentType',
title: 'Comment Type',

View File

@@ -2,6 +2,7 @@ import { GmailIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { GmailToolResponse } from '@/tools/gmail/types'
import { getTrigger } from '@/triggers'
export const GmailBlock: BlockConfig<GmailToolResponse> = {
type: 'gmail',
@@ -197,15 +198,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
placeholder: 'Maximum number of results (default: 10)',
condition: { field: 'operation', value: ['search_gmail', 'read_gmail'] },
},
// TRIGGER MODE: Trigger configuration (only shown when trigger mode is active)
{
id: 'triggerConfig',
title: 'Trigger Configuration',
type: 'trigger-config',
layout: 'full',
triggerProvider: 'gmail',
availableTriggers: ['gmail_poller'],
},
...getTrigger('gmail_poller').subBlocks,
],
tools: {
access: ['gmail_send', 'gmail_draft', 'gmail_read', 'gmail_search'],

View File

@@ -1,5 +1,6 @@
import { GoogleFormsIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { getTrigger } from '@/triggers'
export const GoogleFormsBlock: BlockConfig = {
type: 'google_forms',
@@ -46,15 +47,7 @@ export const GoogleFormsBlock: BlockConfig = {
layout: 'full',
placeholder: 'Max responses to retrieve (default 5000)',
},
// Trigger configuration (shown when block is in trigger mode)
{
id: 'triggerConfig',
title: 'Trigger Configuration',
type: 'trigger-config',
layout: 'full',
triggerProvider: 'google_forms',
availableTriggers: ['google_forms_webhook'],
},
...getTrigger('google_forms_webhook').subBlocks,
],
tools: {
access: ['google_forms_get_responses'],

View File

@@ -2,6 +2,7 @@ import { MicrosoftTeamsIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { MicrosoftTeamsResponse } from '@/tools/microsoft_teams/types'
import { getTrigger } from '@/triggers'
export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
type: 'microsoft_teams',
@@ -164,14 +165,8 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
mode: 'advanced',
required: false,
},
{
id: 'triggerConfig',
title: 'Trigger Configuration',
type: 'trigger-config',
layout: 'full',
triggerProvider: 'microsoftteams',
availableTriggers: ['microsoftteams_webhook', 'microsoftteams_chat_subscription'],
},
...getTrigger('microsoftteams_webhook').subBlocks,
...getTrigger('microsoftteams_chat_subscription').subBlocks,
],
tools: {
access: [

View File

@@ -2,6 +2,7 @@ import { OutlookIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { OutlookResponse } from '@/tools/outlook/types'
import { getTrigger } from '@/triggers'
export const OutlookBlock: BlockConfig<OutlookResponse> = {
type: 'outlook',
@@ -205,15 +206,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
layout: 'full',
condition: { field: 'operation', value: 'read_outlook' },
},
// TRIGGER MODE: Trigger configuration (only shown when trigger mode is active)
{
id: 'triggerConfig',
title: 'Trigger Configuration',
type: 'trigger-config',
layout: 'full',
triggerProvider: 'outlook',
availableTriggers: ['outlook_poller'],
},
...getTrigger('outlook_poller').subBlocks,
],
tools: {
access: ['outlook_send', 'outlook_draft', 'outlook_read', 'outlook_forward'],

View File

@@ -2,6 +2,7 @@ import { SlackIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { SlackResponse } from '@/tools/slack/types'
import { getTrigger } from '@/triggers'
export const SlackBlock: BlockConfig<SlackResponse> = {
type: 'slack',
@@ -182,15 +183,7 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
value: 'read',
},
},
// TRIGGER MODE: Trigger configuration (only shown when trigger mode is active)
{
id: 'triggerConfig',
title: 'Trigger Configuration',
type: 'trigger-config',
layout: 'full',
triggerProvider: 'slack',
availableTriggers: ['slack_webhook'],
},
...getTrigger('slack_webhook').subBlocks,
],
tools: {
access: ['slack_message', 'slack_canvas', 'slack_message_reader'],

View File

@@ -2,6 +2,7 @@ import { TelegramIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { TelegramResponse } from '@/tools/telegram/types'
import { getTrigger } from '@/triggers'
export const TelegramBlock: BlockConfig<TelegramResponse> = {
type: 'telegram',
@@ -163,15 +164,7 @@ export const TelegramBlock: BlockConfig<TelegramResponse> = {
required: true,
condition: { field: 'operation', value: 'telegram_delete_message' },
},
// TRIGGER MODE: Trigger configuration (only shown when trigger mode is active)
{
id: 'triggerConfig',
title: 'Trigger Configuration',
type: 'trigger-config',
layout: 'full',
triggerProvider: 'telegram',
availableTriggers: ['telegram_webhook'],
},
...getTrigger('telegram_webhook').subBlocks,
],
tools: {
access: [

View File

@@ -2,6 +2,7 @@ import { WebflowIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { WebflowResponse } from '@/tools/webflow/types'
import { getTrigger } from '@/triggers'
export const WebflowBlock: BlockConfig<WebflowResponse> = {
type: 'webflow',
@@ -85,19 +86,9 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
condition: { field: 'operation', value: ['create', 'update'] },
required: true,
},
{
id: 'triggerConfig',
title: 'Trigger Configuration',
type: 'trigger-config',
layout: 'full',
triggerProvider: 'webflow',
availableTriggers: [
'webflow_collection_item_created',
'webflow_collection_item_changed',
'webflow_collection_item_deleted',
'webflow_form_submission',
],
},
...getTrigger('webflow_collection_item_created').subBlocks,
...getTrigger('webflow_collection_item_changed').subBlocks,
...getTrigger('webflow_collection_item_deleted').subBlocks,
],
tools: {
access: [

View File

@@ -2,6 +2,7 @@ import { WhatsAppIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { WhatsAppResponse } from '@/tools/whatsapp/types'
import { getTrigger } from '@/triggers'
export const WhatsAppBlock: BlockConfig<WhatsAppResponse> = {
type: 'whatsapp',
@@ -48,14 +49,7 @@ export const WhatsAppBlock: BlockConfig<WhatsAppResponse> = {
password: true,
required: true,
},
{
id: 'triggerConfig',
title: 'Trigger Configuration',
type: 'trigger-config',
layout: 'full',
triggerProvider: 'whatsapp',
availableTriggers: ['whatsapp_webhook'],
},
...getTrigger('whatsapp_webhook').subBlocks,
],
tools: {
access: ['whatsapp_send_message'],

View File

@@ -53,7 +53,6 @@ export type SubBlockType =
| 'time-input' // Time input
| 'oauth-input' // OAuth credential selector
| 'webhook-config' // Webhook configuration
| 'trigger-config' // Trigger configuration
| 'schedule-config' // Schedule status and information
| 'file-selector' // File selector for Google Drive, etc.
| 'project-selector' // Project selector for Jira, Discord, etc.
@@ -68,9 +67,11 @@ export type SubBlockType =
| 'mcp-dynamic-args' // MCP dynamic arguments based on tool schema
| 'input-format' // Input structure format
| 'response-format' // Response structure format
| 'trigger-save' // Trigger save button with validation
| 'file-upload' // File uploader
| 'input-mapping' // Map parent variables to child workflow input schema
| 'variables-input' // Variable assignments for updating workflow variables
| 'text' // Read-only text display
export type SubBlockLayout = 'full' | 'half'
@@ -120,7 +121,7 @@ export interface SubBlockConfig {
title?: string
type: SubBlockType
layout?: SubBlockLayout
mode?: 'basic' | 'advanced' | 'both' // Default is 'both' if not specified
mode?: 'basic' | 'advanced' | 'both' | 'trigger' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode
canonicalParamId?: string
required?: boolean
defaultValue?: string | number | boolean | Record<string, unknown> | Array<unknown>
@@ -142,6 +143,8 @@ export interface SubBlockConfig {
columns?: string[]
placeholder?: string
password?: boolean
readOnly?: boolean
showCopyButton?: boolean
connectionDroppable?: boolean
hidden?: boolean
description?: string
@@ -174,6 +177,8 @@ export interface SubBlockConfig {
// Props specific to 'code' sub-block type
language?: 'javascript' | 'json'
generationType?: GenerationType
collapsible?: boolean // Whether the code block can be collapsed
defaultCollapsed?: boolean // Whether the code block is collapsed by default
// OAuth specific properties
provider?: string
serviceId?: string
@@ -199,12 +204,18 @@ export interface SubBlockConfig {
placeholder?: string // Custom placeholder for the prompt input
maintainHistory?: boolean // Whether to maintain conversation history
}
// Trigger-specific configuration
availableTriggers?: string[] // List of trigger IDs available for this subblock
triggerProvider?: string // Which provider's triggers to show
// Declarative dependency hints for cross-field clearing or invalidation
// Example: dependsOn: ['credential'] means this field should be cleared when credential changes
dependsOn?: string[]
// Copyable-text specific: Use webhook URL from webhook management hook
useWebhookUrl?: boolean
// Trigger-save specific: The trigger ID for validation and saving
triggerId?: string
// Dropdown specific: Function to fetch options dynamically (for multi-select or single-select)
fetchOptions?: (
blockId: string,
subBlockId: string
) => Promise<Array<{ label: string; id: string }>>
}
export interface BlockConfig<T extends ToolResponse = ToolResponse> {

View File

@@ -53,7 +53,7 @@ export function Notice({ children, variant = 'info', className, icon, title }: N
return (
<div className={cn('flex rounded-md border p-3', styles.container, className)}>
<div className='flex items-start'>
{icon || styles.icon}
{icon !== null && (icon || styles.icon)}
<div className='flex-1'>
{title && <div className={cn('mb-1', styles.title)}>{title}</div>}
<div className={cn('text-sm', styles.text)}>{children}</div>

View File

@@ -1199,7 +1199,7 @@ export function useCollaborativeWorkflow() {
horizontalHandles: sourceBlock.horizontalHandles ?? true,
isWide: sourceBlock.isWide ?? false,
advancedMode: sourceBlock.advancedMode ?? false,
triggerMode: false, // Always duplicate as normal mode to avoid webhook conflicts
triggerMode: sourceBlock.triggerMode ?? false,
height: sourceBlock.height || 0,
}
@@ -1216,7 +1216,7 @@ export function useCollaborativeWorkflow() {
horizontalHandles: sourceBlock.horizontalHandles,
isWide: sourceBlock.isWide,
advancedMode: sourceBlock.advancedMode,
triggerMode: false, // Always duplicate as normal mode
triggerMode: sourceBlock.triggerMode ?? false,
height: sourceBlock.height,
}
)
@@ -1235,7 +1235,7 @@ export function useCollaborativeWorkflow() {
horizontalHandles: sourceBlock.horizontalHandles,
isWide: sourceBlock.isWide,
advancedMode: sourceBlock.advancedMode,
triggerMode: false, // Always duplicate as normal mode
triggerMode: sourceBlock.triggerMode ?? false,
height: sourceBlock.height,
}
)

View File

@@ -0,0 +1,161 @@
import { createLogger } from '@/lib/logs/console/logger'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getTrigger, isTriggerValid } from '@/triggers'
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/consts'
const logger = createLogger('useTriggerConfigAggregation')
/**
* Maps old trigger config field names to new subblock IDs for backward compatibility.
* This handles field name changes during the migration from modal-based configuration
* to individual subblock fields.
*
* @param oldFieldName - The field name from the old triggerConfig object
* @returns The corresponding new subblock ID, or the original field name if no mapping exists
*
* @example
* mapOldFieldNameToNewSubBlockId('credentialId') // Returns 'triggerCredentials'
* mapOldFieldNameToNewSubBlockId('labelIds') // Returns 'labelIds' (no mapping needed)
*/
function mapOldFieldNameToNewSubBlockId(oldFieldName: string): string {
const fieldMapping: Record<string, string> = {
credentialId: 'triggerCredentials',
includeCellValuesInFieldIds: 'includeCellValues',
}
return fieldMapping[oldFieldName] || oldFieldName
}
/**
* Aggregates individual trigger field subblocks into a triggerConfig object.
* This is called on-demand when saving, not continuously.
*
* @param blockId - The block ID that has the trigger fields
* @param triggerId - The trigger ID to get the config fields from
* @returns The aggregated config object, or null if no valid config
*/
export function useTriggerConfigAggregation(
blockId: string,
triggerId: string | undefined
): Record<string, any> | null {
if (!triggerId || !blockId) {
return null
}
if (!isTriggerValid(triggerId)) {
logger.warn(`Trigger definition not found for ID: ${triggerId}`)
return null
}
const triggerDef = getTrigger(triggerId)
const subBlockStore = useSubBlockStore.getState()
const aggregatedConfig: Record<string, any> = {}
let hasAnyValue = false
triggerDef.subBlocks
.filter((sb) => sb.mode === 'trigger' && !SYSTEM_SUBBLOCK_IDS.includes(sb.id))
.forEach((subBlock) => {
const fieldValue = subBlockStore.getValue(blockId, subBlock.id)
let valueToUse = fieldValue
if (
(fieldValue === null || fieldValue === undefined || fieldValue === '') &&
subBlock.required &&
subBlock.defaultValue !== undefined
) {
valueToUse = subBlock.defaultValue
}
if (valueToUse !== null && valueToUse !== undefined && valueToUse !== '') {
aggregatedConfig[subBlock.id] = valueToUse
hasAnyValue = true
}
})
if (!hasAnyValue) {
return null
}
logger.debug('Aggregated trigger config fields', {
blockId,
triggerId,
aggregatedConfig,
})
return aggregatedConfig
}
/**
* Populates individual trigger field subblocks from a triggerConfig object.
* Used for backward compatibility when loading existing workflows.
*
* @param blockId - The block ID to populate fields for
* @param triggerConfig - The trigger config object to extract fields from
* @param triggerId - The trigger ID to get the field definitions
*/
export function populateTriggerFieldsFromConfig(
blockId: string,
triggerConfig: Record<string, any> | null | undefined,
triggerId: string | undefined
) {
if (!triggerConfig || !triggerId || !blockId) {
return
}
if (Object.keys(triggerConfig).length === 0) {
return
}
if (!isTriggerValid(triggerId)) {
return
}
const triggerDef = getTrigger(triggerId)
const subBlockStore = useSubBlockStore.getState()
triggerDef.subBlocks
.filter((sb) => sb.mode === 'trigger' && !SYSTEM_SUBBLOCK_IDS.includes(sb.id))
.forEach((subBlock) => {
let configValue: any
if (subBlock.id in triggerConfig) {
configValue = triggerConfig[subBlock.id]
} else {
for (const [oldFieldName, value] of Object.entries(triggerConfig)) {
const mappedFieldName = mapOldFieldNameToNewSubBlockId(oldFieldName)
if (mappedFieldName === subBlock.id) {
configValue = value
break
}
}
}
if (configValue !== undefined) {
const currentValue = subBlockStore.getValue(blockId, subBlock.id)
let normalizedValue = configValue
if (subBlock.id === 'labelIds' || subBlock.id === 'folderIds') {
if (typeof configValue === 'string' && configValue.trim() !== '') {
try {
normalizedValue = JSON.parse(configValue)
} catch {
normalizedValue = [configValue]
}
} else if (
!Array.isArray(configValue) &&
configValue !== null &&
configValue !== undefined
) {
normalizedValue = [configValue]
}
}
if (currentValue === null || currentValue === undefined || currentValue === '') {
subBlockStore.setValue(blockId, subBlock.id, normalizedValue)
}
}
})
}

View File

@@ -0,0 +1,395 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
import { getBaseUrl } from '@/lib/urls/utils'
import { getBlock } from '@/blocks'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { getTrigger, isTriggerValid } from '@/triggers'
import { populateTriggerFieldsFromConfig } from './use-trigger-config-aggregation'
const logger = createLogger('useWebhookManagement')
interface UseWebhookManagementProps {
blockId: string
triggerId?: string
isPreview?: boolean
}
interface WebhookManagementState {
webhookUrl: string
webhookPath: string
webhookId: string | null
isLoading: boolean
isSaving: boolean
saveConfig: () => Promise<boolean>
deleteConfig: () => Promise<boolean>
}
/**
* 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,
triggerId,
isPreview = false,
}: UseWebhookManagementProps): WebhookManagementState {
const params = useParams()
const workflowId = params.workflowId as string
const triggerDef = triggerId && isTriggerValid(triggerId) ? getTrigger(triggerId) : null
const webhookId = useSubBlockStore(
useCallback((state) => state.getValue(blockId, 'webhookId') as string | null, [blockId])
)
const webhookPath = useSubBlockStore(
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) {
const baseUrl = getBaseUrl()
return `${baseUrl}/api/webhooks/trigger/${blockId}`
}
const baseUrl = getBaseUrl()
return `${baseUrl}/api/webhooks/trigger/${webhookPath}`
}, [webhookPath, blockId])
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
if (triggerId && !isPreview) {
const storedTriggerId = useSubBlockStore.getState().getValue(blockId, 'triggerId')
if (storedTriggerId !== triggerId) {
useSubBlockStore.getState().setValue(blockId, 'triggerId', triggerId)
}
}
}, [triggerId, blockId, isPreview])
useEffect(() => {
if (isPreview) {
return
}
const store = useSubBlockStore.getState()
const currentlyLoading = store.loadingWebhooks.has(blockId)
const alreadyChecked = store.checkedWebhooks.has(blockId)
const currentWebhookId = store.getValue(blockId, 'webhookId')
if (currentlyLoading) {
return
}
if (alreadyChecked && currentWebhookId) {
return
}
if (alreadyChecked && !currentWebhookId) {
useSubBlockStore.setState((state) => {
const newSet = new Set(state.checkedWebhooks)
newSet.delete(blockId)
return { checkedWebhooks: newSet }
})
}
let isMounted = true
const loadWebhookOrGenerateUrl = async () => {
const currentStore = useSubBlockStore.getState()
if (currentStore.loadingWebhooks.has(blockId)) {
return
}
useSubBlockStore.setState((state) => ({
loadingWebhooks: new Set([...state.loadingWebhooks, blockId]),
}))
try {
const response = await fetch(`/api/webhooks?workflowId=${workflowId}&blockId=${blockId}`)
const stillMounted = isMounted
if (response.ok) {
const data = await response.json()
if (data.webhooks && data.webhooks.length > 0) {
const webhook = data.webhooks[0].webhook
useSubBlockStore.getState().setValue(blockId, 'webhookId', webhook.id)
logger.info('Webhook loaded from API', {
blockId,
webhookId: webhook.id,
hasProviderConfig: !!webhook.providerConfig,
wasMounted: stillMounted,
})
if (webhook.path) {
const currentPath = useSubBlockStore.getState().getValue(blockId, 'triggerPath')
if (webhook.path !== currentPath) {
useSubBlockStore.getState().setValue(blockId, 'triggerPath', webhook.path)
}
}
if (webhook.providerConfig) {
let effectiveTriggerId: string | undefined = triggerId
if (!effectiveTriggerId) {
const storedTriggerId = useSubBlockStore.getState().getValue(blockId, 'triggerId')
effectiveTriggerId =
(typeof storedTriggerId === 'string' ? storedTriggerId : undefined) || undefined
}
if (!effectiveTriggerId && webhook.providerConfig.triggerId) {
effectiveTriggerId =
typeof webhook.providerConfig.triggerId === 'string'
? webhook.providerConfig.triggerId
: undefined
}
if (!effectiveTriggerId) {
const workflowState = useWorkflowStore.getState()
const block = workflowState.blocks?.[blockId]
if (block) {
const blockConfig = getBlock(block.type)
if (blockConfig) {
if (blockConfig.category === 'triggers') {
effectiveTriggerId = block.type
} else if (block.triggerMode && blockConfig.triggers?.enabled) {
const selectedTriggerIdValue = block.subBlocks?.selectedTriggerId?.value
const triggerIdValue = block.subBlocks?.triggerId?.value
effectiveTriggerId =
(typeof selectedTriggerIdValue === 'string' &&
isTriggerValid(selectedTriggerIdValue)
? selectedTriggerIdValue
: undefined) ||
(typeof triggerIdValue === 'string' && isTriggerValid(triggerIdValue)
? triggerIdValue
: undefined) ||
blockConfig.triggers?.available?.[0]
}
}
}
}
const currentConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig')
if (JSON.stringify(webhook.providerConfig) !== JSON.stringify(currentConfig)) {
useSubBlockStore
.getState()
.setValue(blockId, 'triggerConfig', webhook.providerConfig)
if (effectiveTriggerId) {
populateTriggerFieldsFromConfig(
blockId,
webhook.providerConfig,
effectiveTriggerId
)
} else {
logger.warn('Cannot migrate - triggerId not available', {
blockId,
propTriggerId: triggerId,
providerConfigTriggerId: webhook.providerConfig.triggerId,
})
}
}
}
} else {
useSubBlockStore.getState().setValue(blockId, 'webhookId', null)
}
useSubBlockStore.setState((state) => ({
checkedWebhooks: new Set([...state.checkedWebhooks, blockId]),
}))
} else {
logger.warn('API response not OK', {
blockId,
workflowId,
status: response.status,
statusText: response.statusText,
})
}
} catch (error) {
logger.error('Error loading webhook:', { error, blockId, workflowId })
} finally {
useSubBlockStore.setState((state) => {
const newSet = new Set(state.loadingWebhooks)
newSet.delete(blockId)
return { loadingWebhooks: newSet }
})
}
}
loadWebhookOrGenerateUrl()
return () => {
isMounted = false
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPreview, triggerId, workflowId, blockId])
const saveConfig = async (): Promise<boolean> => {
if (isPreview || !triggerDef) {
return false
}
let effectiveTriggerId: string | undefined = triggerId
if (!effectiveTriggerId) {
const selectedTriggerId = useSubBlockStore.getState().getValue(blockId, 'selectedTriggerId')
if (typeof selectedTriggerId === 'string' && isTriggerValid(selectedTriggerId)) {
effectiveTriggerId = selectedTriggerId
}
}
if (!effectiveTriggerId) {
const storedTriggerId = useSubBlockStore.getState().getValue(blockId, 'triggerId')
effectiveTriggerId =
typeof storedTriggerId === 'string' && isTriggerValid(storedTriggerId)
? storedTriggerId
: triggerId
}
try {
setIsSaving(true)
if (!webhookId) {
const path = blockId
const selectedCredentialId =
(useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as string | null) ||
null
const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig')
const webhookConfig = {
...(triggerConfig || {}),
...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
triggerId: effectiveTriggerId,
}
const response = await fetch('/api/webhooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflowId,
blockId,
path,
provider: triggerDef.provider,
providerConfig: webhookConfig,
}),
})
if (!response.ok) {
let errorMessage = 'Failed to create webhook'
try {
const errorData = await response.json()
errorMessage = errorData.details || errorData.error || errorMessage
} catch {
// If response is not JSON, use default message
}
logger.error('Failed to create webhook', { errorMessage })
throw new Error(errorMessage)
}
const data = await response.json()
const savedWebhookId = data.webhook.id
useSubBlockStore.getState().setValue(blockId, 'triggerPath', path)
useSubBlockStore.getState().setValue(blockId, 'triggerId', effectiveTriggerId)
useSubBlockStore.getState().setValue(blockId, 'webhookId', savedWebhookId)
useSubBlockStore.setState((state) => ({
checkedWebhooks: new Set([...state.checkedWebhooks, blockId]),
}))
logger.info('Trigger webhook created successfully', {
webhookId: savedWebhookId,
triggerId: effectiveTriggerId,
provider: triggerDef.provider,
blockId,
})
return true
}
const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig')
const triggerCredentials = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials')
const selectedCredentialId = triggerCredentials as string | null
const response = await fetch(`/api/webhooks/${webhookId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerConfig: {
...triggerConfig,
...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
triggerId: effectiveTriggerId,
},
}),
})
if (!response.ok) {
let errorMessage = 'Failed to save trigger configuration'
try {
const errorData = await response.json()
errorMessage = errorData.details || errorData.error || errorMessage
} catch {
// If response is not JSON, use default message
}
logger.error('Failed to save trigger config', { errorMessage })
throw new Error(errorMessage)
}
logger.info('Trigger config saved successfully')
return true
} catch (error) {
logger.error('Error saving trigger config:', error)
throw error
} finally {
setIsSaving(false)
}
}
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,
webhookId,
isLoading,
isSaving,
saveConfig,
deleteConfig,
}
}

View File

@@ -10,7 +10,8 @@ import { registry as blockRegistry } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { tools as toolsRegistry } from '@/tools/registry'
import { TRIGGER_REGISTRY } from '@/triggers'
import { getTrigger, isTriggerValid } from '@/triggers'
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/consts'
export interface CopilotSubblockMetadata {
id: string
@@ -162,17 +163,59 @@ export const getBlocksMetadataServerTool: BaseServerTool<
const triggers: CopilotTriggerMetadata[] = []
const availableTriggerIds = blockConfig.triggers?.available || []
for (const tid of availableTriggerIds) {
const trig = TRIGGER_REGISTRY[tid]
if (!isTriggerValid(tid)) {
logger.debug('Invalid trigger ID found in block config', { blockId, triggerId: tid })
continue
}
const trig = getTrigger(tid)
const configFields: Record<string, any> = {}
for (const subBlock of trig.subBlocks) {
if (subBlock.mode === 'trigger' && !SYSTEM_SUBBLOCK_IDS.includes(subBlock.id)) {
const fieldDef: any = {
type: subBlock.type,
required: subBlock.required || false,
}
if (subBlock.title) fieldDef.title = subBlock.title
if (subBlock.description) fieldDef.description = subBlock.description
if (subBlock.placeholder) fieldDef.placeholder = subBlock.placeholder
if (subBlock.defaultValue !== undefined) fieldDef.default = subBlock.defaultValue
if (subBlock.options && Array.isArray(subBlock.options)) {
fieldDef.options = subBlock.options.map((opt: any) => ({
id: opt.id,
label: opt.label || opt.id,
}))
}
if (subBlock.condition) {
const cond =
typeof subBlock.condition === 'function'
? subBlock.condition()
: subBlock.condition
if (cond) {
fieldDef.condition = cond
}
}
configFields[subBlock.id] = fieldDef
}
}
triggers.push({
id: tid,
outputs: trig?.outputs || {},
configFields: trig?.configFields || {},
outputs: trig.outputs || {},
configFields,
})
}
const blockInputs = computeBlockLevelInputs(blockConfig)
const { commonParameters, operationParameters } = splitParametersByOperation(
Array.isArray(blockConfig.subBlocks) ? blockConfig.subBlocks : [],
Array.isArray(blockConfig.subBlocks)
? blockConfig.subBlocks.filter((sb) => sb.mode !== 'trigger')
: [],
blockInputs
)
@@ -239,7 +282,6 @@ export const getBlocksMetadataServerTool: BaseServerTool<
}
}
// Transform metadata to cleaner format
const transformedResult: Record<string, any> = {}
for (const [blockId, metadata] of Object.entries(result)) {
transformedResult[blockId] = transformBlockMetadata(metadata)
@@ -256,16 +298,13 @@ function transformBlockMetadata(metadata: CopilotBlockMetadata): any {
description: metadata.description,
}
// Add best practices if available
if (metadata.bestPractices) {
transformed.bestPractices = metadata.bestPractices
}
// Add auth type and required credentials if available
if (metadata.authType) {
transformed.authType = metadata.authType
// Add credential requirements based on auth type
if (metadata.authType === 'OAuth') {
transformed.requiredCredentials = {
type: 'oauth',
@@ -285,13 +324,11 @@ function transformBlockMetadata(metadata: CopilotBlockMetadata): any {
}
}
// Process inputs
const inputs = extractInputs(metadata)
if (inputs.required.length > 0 || inputs.optional.length > 0) {
transformed.inputs = inputs
}
// Add operations if available
const hasOperations = metadata.operations && Object.keys(metadata.operations).length > 0
if (hasOperations && metadata.operations) {
const blockLevelInputs = new Set(Object.keys(metadata.inputDefinitions || {}))
@@ -309,8 +346,6 @@ function transformBlockMetadata(metadata: CopilotBlockMetadata): any {
)
}
// Process outputs - only show at block level if there are NO operations
// For blocks with operations, outputs are shown per-operation to avoid ambiguity
if (!hasOperations) {
const outputs = extractOutputs(metadata)
if (outputs.length > 0) {
@@ -318,19 +353,14 @@ function transformBlockMetadata(metadata: CopilotBlockMetadata): any {
}
}
// Don't include availableTools - it's internal implementation detail
// For agent block, tools.access contains LLM provider APIs (not useful)
// For other blocks, it's redundant with operations
// Add triggers if present
if (metadata.triggers && metadata.triggers.length > 0) {
transformed.triggers = metadata.triggers.map((t) => ({
id: t.id,
outputs: formatOutputsFromDefinition(t.outputs || {}),
configFields: t.configFields || {},
}))
}
// Add YAML documentation if available
if (metadata.yamlDocumentation) {
transformed.yamlDocumentation = metadata.yamlDocumentation
}
@@ -346,9 +376,12 @@ function extractInputs(metadata: CopilotBlockMetadata): {
const optional: any[] = []
const inputDefs = metadata.inputDefinitions || {}
// Process inputSchema to get UI-level input information
for (const schema of metadata.inputSchema || []) {
// Skip credential inputs (handled by requiredCredentials)
// Skip trigger subBlocks - they're handled separately in triggers.configFields
if (schema.mode === 'trigger') {
continue
}
if (
schema.type === 'oauth-credential' ||
schema.type === 'credential-input' ||
@@ -357,14 +390,12 @@ function extractInputs(metadata: CopilotBlockMetadata): {
continue
}
// Skip trigger config (only relevant when setting up triggers)
if (schema.id === 'triggerConfig' || schema.type === 'trigger-config') {
continue
}
const inputDef = inputDefs[schema.id] || inputDefs[schema.canonicalParamId || '']
// For operation field, provide a clearer description
let description = schema.description || inputDef?.description || schema.title
if (schema.id === 'operation') {
description = 'Operation to perform'
@@ -376,8 +407,6 @@ function extractInputs(metadata: CopilotBlockMetadata): {
description,
}
// Add options for dropdown/combobox types
// For operation field, use IDs instead of labels for clarity
if (schema.options && schema.options.length > 0) {
if (schema.id === 'operation') {
input.options = schema.options.map((opt) => opt.id)
@@ -386,19 +415,16 @@ function extractInputs(metadata: CopilotBlockMetadata): {
}
}
// Add enum from input definitions
if (inputDef?.enum && Array.isArray(inputDef.enum)) {
input.options = inputDef.enum
}
// Add default value if present
if (schema.defaultValue !== undefined) {
input.default = schema.defaultValue
} else if (inputDef?.default !== undefined) {
input.default = inputDef.default
}
// Add constraints for numbers
if (schema.type === 'slider' || schema.type === 'number-input') {
if (schema.min !== undefined) input.min = schema.min
if (schema.max !== undefined) input.max = schema.max
@@ -407,14 +433,11 @@ function extractInputs(metadata: CopilotBlockMetadata): {
if (inputDef.maximum !== undefined) input.max = inputDef.maximum
}
// Add example if we can infer one
const example = generateInputExample(schema, inputDef)
if (example !== undefined) {
input.example = example
}
// Determine if required
// For blocks with operations, the operation field is always required
const isOperationField =
schema.id === 'operation' &&
metadata.operations &&
@@ -443,12 +466,10 @@ function extractOperationInputs(
const inputs = opData.inputs || {}
for (const [key, inputDef] of Object.entries(inputs)) {
// Skip inputs that are already defined at block level (avoid duplication)
if (blockLevelInputs.has(key)) {
continue
}
// Skip credential-related inputs (these are inherited from block-level auth)
const lowerKey = key.toLowerCase()
if (
lowerKey.includes('token') ||
@@ -489,12 +510,10 @@ function extractOperationInputs(
function extractOutputs(metadata: CopilotBlockMetadata): any[] {
const outputs: any[] = []
// Use block's defined outputs if available
if (metadata.outputs && Object.keys(metadata.outputs).length > 0) {
return formatOutputsFromDefinition(metadata.outputs)
}
// If block has operations, use the first operation's outputs as representative
if (metadata.operations && Object.keys(metadata.operations).length > 0) {
const firstOp = Object.values(metadata.operations)[0]
return formatOutputsFromDefinition(firstOp.outputs || {})
@@ -542,17 +561,14 @@ function mapSchemaTypeToSimpleType(schemaType: string, schema: CopilotSubblockMe
const mappedType = typeMap[schemaType] || schemaType
// Override with multiSelect
if (schema.multiSelect) return 'array'
return mappedType
}
function generateInputExample(schema: CopilotSubblockMetadata, inputDef?: any): any {
// Return explicit example if available
if (inputDef?.example !== undefined) return inputDef.example
// Generate based on type
switch (schema.type) {
case 'short-input':
case 'long-input':
@@ -579,15 +595,12 @@ function generateInputExample(schema: CopilotSubblockMetadata, inputDef?: any):
}
function processSubBlock(sb: any): CopilotSubblockMetadata {
// Start with required fields
const processed: CopilotSubblockMetadata = {
id: sb.id,
type: sb.type,
}
// Process all optional fields - only add if they exist and are not null/undefined
const optionalFields = {
// Basic properties
title: sb.title,
required: sb.required,
description: sb.description,
@@ -674,7 +687,6 @@ function resolveSubblockOptions(
sb: any
): { id: string; label?: string; hasIcon?: boolean }[] | undefined {
try {
// Resolve options if it's a function
const rawOptions = typeof sb.options === 'function' ? sb.options() : sb.options
if (!Array.isArray(rawOptions)) return undefined
@@ -682,7 +694,6 @@ function resolveSubblockOptions(
.map((opt: any) => {
if (!opt) return undefined
// Handle both string and object options
const id = typeof opt === 'object' ? opt.id : opt
if (id === undefined || id === null) return undefined
@@ -690,12 +701,10 @@ function resolveSubblockOptions(
id: String(id),
}
// Add label if present
if (typeof opt === 'object' && typeof opt.label === 'string') {
result.label = opt.label
}
// Check for icon presence
if (typeof opt === 'object' && opt.icon) {
result.hasIcon = true
}
@@ -778,9 +787,10 @@ function splitParametersByOperation(
function computeBlockLevelInputs(blockConfig: BlockConfig): Record<string, any> {
const inputs = blockConfig.inputs || {}
const subBlocks: any[] = Array.isArray(blockConfig.subBlocks) ? blockConfig.subBlocks : []
const subBlocks: any[] = Array.isArray(blockConfig.subBlocks)
? blockConfig.subBlocks.filter((sb) => sb.mode !== 'trigger')
: []
// Build quick lookup of subBlocks by id and canonicalParamId
const byParamKey: Record<string, any[]> = {}
for (const sb of subBlocks) {
if (sb.id) {
@@ -796,7 +806,6 @@ function computeBlockLevelInputs(blockConfig: BlockConfig): Record<string, any>
const blockInputs: Record<string, any> = {}
for (const key of Object.keys(inputs)) {
const sbs = byParamKey[key] || []
// If any related subBlock is gated by operation, treat as operation-level and exclude
const isOperationGated = sbs.some((sb) => {
const cond = normalizeCondition(sb.condition)
return cond && cond.field === 'operation' && !cond.not && cond.value !== undefined
@@ -813,11 +822,12 @@ function computeOperationLevelInputs(
blockConfig: BlockConfig
): Record<string, Record<string, any>> {
const inputs = blockConfig.inputs || {}
const subBlocks = Array.isArray(blockConfig.subBlocks) ? blockConfig.subBlocks : []
const subBlocks = Array.isArray(blockConfig.subBlocks)
? blockConfig.subBlocks.filter((sb) => sb.mode !== 'trigger')
: []
const opInputs: Record<string, Record<string, any>> = {}
// Map subblocks to inputs keys via id or canonicalParamId and collect by operation
for (const sb of subBlocks) {
const cond = normalizeCondition(sb.condition)
if (!cond || cond.field !== 'operation' || cond.not) continue
@@ -842,13 +852,11 @@ function resolveOperationIds(
blockConfig: BlockConfig,
operationParameters: Record<string, CopilotSubblockMetadata[]>
): string[] {
// Prefer explicit operation subblock options if present
const opBlock = (blockConfig.subBlocks || []).find((sb) => sb.id === 'operation')
if (opBlock && Array.isArray(opBlock.options)) {
const ids = opBlock.options.map((o) => o.id).filter(Boolean)
if (ids.length > 0) return ids
}
// Fallback: keys from operationParameters
return Object.keys(operationParameters)
}

View File

@@ -4,7 +4,6 @@ import { createLogger } from '@/lib/logs/console/logger'
import { registry as blockRegistry } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
// Define input and result schemas
export const GetTriggerBlocksInput = z.object({})
export const GetTriggerBlocksResult = z.object({
triggerBlockIds: z.array(z.string()),
@@ -22,24 +21,17 @@ export const getTriggerBlocksServerTool: BaseServerTool<
const triggerBlockIds: string[] = []
Object.entries(blockRegistry).forEach(([blockType, blockConfig]: [string, BlockConfig]) => {
// Skip hidden blocks
if (blockConfig.hideFromToolbar) return
// Check if it's a trigger block (category: 'triggers')
if (blockConfig.category === 'triggers') {
triggerBlockIds.push(blockType)
}
// Check if it's a tool with trigger capability (triggerAllowed: true)
else if ('triggerAllowed' in blockConfig && blockConfig.triggerAllowed === true) {
} else if ('triggerAllowed' in blockConfig && blockConfig.triggerAllowed === true) {
triggerBlockIds.push(blockType)
}
// Check if it has a trigger-config subblock
else if (blockConfig.subBlocks?.some((subBlock) => subBlock.type === 'trigger-config')) {
} else if (blockConfig.subBlocks?.some((subBlock) => subBlock.mode === 'trigger')) {
triggerBlockIds.push(blockType)
}
})
// Sort alphabetically for consistency
triggerBlockIds.sort()
logger.debug(`Found ${triggerBlockIds.length} trigger blocks`)

View File

@@ -1,96 +1,102 @@
import { db } from '@sim/db'
import { webhook as webhookTable } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { getBaseUrl } from '@/lib/urls/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const teamsLogger = createLogger('TeamsSubscription')
const telegramLogger = createLogger('TelegramWebhook')
const airtableLogger = createLogger('AirtableWebhook')
function getProviderConfig(webhook: any): Record<string, any> {
return (webhook.providerConfig as Record<string, any>) || {}
}
function getNotificationUrl(webhook: any): string {
return `${getBaseUrl()}/api/webhooks/trigger/${webhook.path}`
}
/**
* Create a Microsoft Teams chat subscription
* Returns true if successful, false otherwise
* Throws errors with friendly messages if subscription creation fails
*/
export async function createTeamsSubscription(
request: NextRequest,
webhook: any,
workflow: any,
requestId: string
): Promise<boolean> {
try {
const config = (webhook.providerConfig as Record<string, any>) || {}
): Promise<void> {
const config = getProviderConfig(webhook)
// Only handle Teams chat subscriptions
if (config.triggerId !== 'microsoftteams_chat_subscription') {
return true // Not a Teams subscription, no action needed
}
if (config.triggerId !== 'microsoftteams_chat_subscription') {
return
}
const credentialId = config.credentialId as string | undefined
const chatId = config.chatId as string | undefined
const credentialId = config.credentialId as string | undefined
const chatId = config.chatId as string | undefined
if (!credentialId) {
teamsLogger.warn(
`[${requestId}] Missing credentialId for Teams chat subscription ${webhook.id}`
if (!credentialId) {
teamsLogger.warn(
`[${requestId}] Missing credentialId for Teams chat subscription ${webhook.id}`
)
throw new Error(
'Microsoft Teams credentials are required. Please connect your Microsoft account in the trigger configuration.'
)
}
if (!chatId) {
teamsLogger.warn(`[${requestId}] Missing chatId for Teams chat subscription ${webhook.id}`)
throw new Error(
'Chat ID is required to create a Teams subscription. Please provide a valid chat ID.'
)
}
const accessToken = await refreshAccessTokenIfNeeded(credentialId, workflow.userId, requestId)
if (!accessToken) {
teamsLogger.error(
`[${requestId}] Failed to get access token for Teams subscription ${webhook.id}`
)
throw new Error(
'Failed to authenticate with Microsoft Teams. Please reconnect your Microsoft account and try again.'
)
}
const existingSubscriptionId = config.externalSubscriptionId as string | undefined
if (existingSubscriptionId) {
try {
const checkRes = await fetch(
`https://graph.microsoft.com/v1.0/subscriptions/${existingSubscriptionId}`,
{ method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } }
)
return false
}
if (!chatId) {
teamsLogger.warn(`[${requestId}] Missing chatId for Teams chat subscription ${webhook.id}`)
return false
}
// Get access token
const accessToken = await refreshAccessTokenIfNeeded(credentialId, workflow.userId, requestId)
if (!accessToken) {
teamsLogger.error(
`[${requestId}] Failed to get access token for Teams subscription ${webhook.id}`
)
return false
}
// Check if subscription already exists
const existingSubscriptionId = config.externalSubscriptionId as string | undefined
if (existingSubscriptionId) {
try {
const checkRes = await fetch(
`https://graph.microsoft.com/v1.0/subscriptions/${existingSubscriptionId}`,
{ method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } }
if (checkRes.ok) {
teamsLogger.info(
`[${requestId}] Teams subscription ${existingSubscriptionId} already exists for webhook ${webhook.id}`
)
if (checkRes.ok) {
teamsLogger.info(
`[${requestId}] Teams subscription ${existingSubscriptionId} already exists for webhook ${webhook.id}`
)
return true
}
} catch {
teamsLogger.debug(`[${requestId}] Existing subscription check failed, will create new one`)
return
}
} catch {
teamsLogger.debug(`[${requestId}] Existing subscription check failed, will create new one`)
}
}
// Build notification URL
// Always use NEXT_PUBLIC_APP_URL to ensure Microsoft Graph can reach the public endpoint
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${webhook.path}`
// Always use NEXT_PUBLIC_APP_URL to ensure Microsoft Graph can reach the public endpoint
const notificationUrl = getNotificationUrl(webhook)
const resource = `/chats/${chatId}/messages`
// Subscribe to the specified chat
const resource = `/chats/${chatId}/messages`
// Max lifetime: 4230 minutes (~3 days) - Microsoft Graph API limit
const maxLifetimeMinutes = 4230
const expirationDateTime = new Date(Date.now() + maxLifetimeMinutes * 60 * 1000).toISOString()
// Create subscription with max lifetime (4230 minutes = ~3 days)
const maxLifetimeMinutes = 4230
const expirationDateTime = new Date(Date.now() + maxLifetimeMinutes * 60 * 1000).toISOString()
const body = {
changeType: 'created,updated',
notificationUrl,
lifecycleNotificationUrl: notificationUrl,
resource,
includeResourceData: false,
expirationDateTime,
clientState: webhook.id,
}
const body = {
changeType: 'created,updated',
notificationUrl,
lifecycleNotificationUrl: notificationUrl,
resource,
includeResourceData: false,
expirationDateTime,
clientState: webhook.id,
}
try {
const res = await fetch('https://graph.microsoft.com/v1.0/subscriptions', {
method: 'POST',
headers: {
@@ -102,6 +108,8 @@ export async function createTeamsSubscription(
const payload = await res.json()
if (!res.ok) {
const errorMessage =
payload.error?.message || payload.error?.code || 'Unknown Microsoft Graph API error'
teamsLogger.error(
`[${requestId}] Failed to create Teams subscription for webhook ${webhook.id}`,
{
@@ -109,37 +117,49 @@ export async function createTeamsSubscription(
error: payload.error,
}
)
return false
}
// Update webhook config with subscription details
const updatedConfig = {
...config,
externalSubscriptionId: payload.id,
subscriptionExpiration: payload.expirationDateTime,
}
let userFriendlyMessage = 'Failed to create Teams subscription'
if (res.status === 401 || res.status === 403) {
userFriendlyMessage =
'Authentication failed. Please reconnect your Microsoft Teams account and ensure you have the necessary permissions.'
} else if (res.status === 404) {
userFriendlyMessage =
'Chat not found. Please verify that the Chat ID is correct and that you have access to the specified chat.'
} else if (errorMessage && errorMessage !== 'Unknown Microsoft Graph API error') {
userFriendlyMessage = `Teams error: ${errorMessage}`
}
await db
.update(webhookTable)
.set({ providerConfig: updatedConfig, updatedAt: new Date() })
.where(eq(webhookTable.id, webhook.id))
throw new Error(userFriendlyMessage)
}
teamsLogger.info(
`[${requestId}] Successfully created Teams subscription ${payload.id} for webhook ${webhook.id}`
)
return true
} catch (error) {
} catch (error: any) {
if (
error instanceof Error &&
(error.message.includes('credentials') ||
error.message.includes('Chat ID') ||
error.message.includes('authenticate'))
) {
throw error
}
teamsLogger.error(
`[${requestId}] Error creating Teams subscription for webhook ${webhook.id}`,
error
)
return false
throw new Error(
error instanceof Error
? error.message
: 'Failed to create Teams subscription. Please try again.'
)
}
}
/**
* Delete a Microsoft Teams chat subscription
* Always returns true (don't fail webhook deletion if cleanup fails)
* Don't fail webhook deletion if cleanup fails
*/
export async function deleteTeamsSubscription(
webhook: any,
@@ -147,11 +167,10 @@ export async function deleteTeamsSubscription(
requestId: string
): Promise<void> {
try {
const config = (webhook.providerConfig as Record<string, any>) || {}
const config = getProviderConfig(webhook)
// Only handle Teams chat subscriptions
if (config.triggerId !== 'microsoftteams_chat_subscription') {
return // Not a Teams subscription, no action needed
return
}
const externalSubscriptionId = config.externalSubscriptionId as string | undefined
@@ -164,13 +183,12 @@ export async function deleteTeamsSubscription(
return
}
// Get access token
const accessToken = await refreshAccessTokenIfNeeded(credentialId, workflow.userId, requestId)
if (!accessToken) {
teamsLogger.warn(
`[${requestId}] Could not get access token to delete Teams subscription for webhook ${webhook.id}`
)
return // Don't fail deletion
return
}
const res = await fetch(
@@ -196,31 +214,32 @@ export async function deleteTeamsSubscription(
`[${requestId}] Error deleting Teams subscription for webhook ${webhook.id}`,
error
)
// Don't fail webhook deletion
}
}
/**
* Create a Telegram bot webhook
* Returns true if successful, false otherwise
* Throws errors with friendly messages if webhook creation fails
*/
export async function createTelegramWebhook(
request: NextRequest,
webhook: any,
requestId: string
): Promise<boolean> {
): Promise<void> {
const config = getProviderConfig(webhook)
const botToken = config.botToken as string | undefined
if (!botToken) {
telegramLogger.warn(`[${requestId}] Missing botToken for Telegram webhook ${webhook.id}`)
throw new Error(
'Bot token is required to create a Telegram webhook. Please provide a valid Telegram bot token.'
)
}
const notificationUrl = getNotificationUrl(webhook)
const telegramApiUrl = `https://api.telegram.org/bot${botToken}/setWebhook`
try {
const config = (webhook.providerConfig as Record<string, any>) || {}
const botToken = config.botToken as string | undefined
if (!botToken) {
telegramLogger.warn(`[${requestId}] Missing botToken for Telegram webhook ${webhook.id}`)
return false
}
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${webhook.path}`
const telegramApiUrl = `https://api.telegram.org/bot${botToken}/setWebhook`
const telegramResponse = await fetch(telegramApiUrl, {
method: 'POST',
headers: {
@@ -236,29 +255,48 @@ export async function createTelegramWebhook(
responseBody.description ||
`Failed to create Telegram webhook. Status: ${telegramResponse.status}`
telegramLogger.error(`[${requestId}] ${errorMessage}`, { response: responseBody })
return false
let userFriendlyMessage = 'Failed to create Telegram webhook'
if (telegramResponse.status === 401) {
userFriendlyMessage =
'Invalid bot token. Please verify that the bot token is correct and try again.'
} else if (responseBody.description) {
userFriendlyMessage = `Telegram error: ${responseBody.description}`
}
throw new Error(userFriendlyMessage)
}
telegramLogger.info(
`[${requestId}] Successfully created Telegram webhook for webhook ${webhook.id}`
)
return true
} catch (error) {
} catch (error: any) {
if (
error instanceof Error &&
(error.message.includes('Bot token') || error.message.includes('Telegram error'))
) {
throw error
}
telegramLogger.error(
`[${requestId}] Error creating Telegram webhook for webhook ${webhook.id}`,
error
)
return false
throw new Error(
error instanceof Error
? error.message
: 'Failed to create Telegram webhook. Please try again.'
)
}
}
/**
* Delete a Telegram bot webhook
* Always returns void (don't fail webhook deletion if cleanup fails)
* Don't fail webhook deletion if cleanup fails
*/
export async function deleteTelegramWebhook(webhook: any, requestId: string): Promise<void> {
try {
const config = (webhook.providerConfig as Record<string, any>) || {}
const config = getProviderConfig(webhook)
const botToken = config.botToken as string | undefined
if (!botToken) {
@@ -290,6 +328,152 @@ export async function deleteTelegramWebhook(webhook: any, requestId: string): Pr
`[${requestId}] Error deleting Telegram webhook for webhook ${webhook.id}`,
error
)
// Don't fail webhook deletion
}
}
/**
* Delete an Airtable webhook
* Don't fail webhook deletion if cleanup fails
*/
export async function deleteAirtableWebhook(
webhook: any,
workflow: any,
requestId: string
): Promise<void> {
try {
const config = getProviderConfig(webhook)
const { baseId, externalId } = config as {
baseId?: string
externalId?: string
}
if (!baseId) {
airtableLogger.warn(`[${requestId}] Missing baseId for Airtable webhook deletion`, {
webhookId: webhook.id,
})
return
}
const userIdForToken = workflow.userId
const accessToken = await getOAuthToken(userIdForToken, 'airtable')
if (!accessToken) {
airtableLogger.warn(
`[${requestId}] Could not retrieve Airtable access token for user ${userIdForToken}. Cannot delete webhook in Airtable.`,
{ webhookId: webhook.id }
)
return
}
let resolvedExternalId: string | undefined = externalId
if (!resolvedExternalId) {
try {
const expectedNotificationUrl = getNotificationUrl(webhook)
const listUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks`
const listResp = await fetch(listUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
const listBody = await listResp.json().catch(() => null)
if (listResp.ok && listBody && Array.isArray(listBody.webhooks)) {
const match = listBody.webhooks.find((w: any) => {
const url: string | undefined = w?.notificationUrl
if (!url) return false
return (
url === expectedNotificationUrl ||
url.endsWith(`/api/webhooks/trigger/${webhook.path}`)
)
})
if (match?.id) {
resolvedExternalId = match.id as string
airtableLogger.info(`[${requestId}] Resolved Airtable externalId by listing webhooks`, {
baseId,
externalId: resolvedExternalId,
})
} else {
airtableLogger.warn(`[${requestId}] Could not resolve Airtable externalId from list`, {
baseId,
expectedNotificationUrl,
})
}
} else {
airtableLogger.warn(
`[${requestId}] Failed to list Airtable webhooks to resolve externalId`,
{
baseId,
status: listResp.status,
body: listBody,
}
)
}
} catch (e: any) {
airtableLogger.warn(`[${requestId}] Error attempting to resolve Airtable externalId`, {
error: e?.message,
})
}
}
if (!resolvedExternalId) {
airtableLogger.info(
`[${requestId}] Airtable externalId not found; skipping remote deletion`,
{ baseId }
)
return
}
const airtableDeleteUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks/${resolvedExternalId}`
const airtableResponse = await fetch(airtableDeleteUrl, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!airtableResponse.ok) {
let responseBody: any = null
try {
responseBody = await airtableResponse.json()
} catch {
// Ignore parse errors
}
airtableLogger.warn(
`[${requestId}] Failed to delete Airtable webhook in Airtable. Status: ${airtableResponse.status}`,
{ baseId, externalId: resolvedExternalId, response: responseBody }
)
} else {
airtableLogger.info(`[${requestId}] Successfully deleted Airtable webhook in Airtable`, {
baseId,
externalId: resolvedExternalId,
})
}
} catch (error: any) {
airtableLogger.error(`[${requestId}] Error deleting Airtable webhook`, {
webhookId: webhook.id,
error: error.message,
stack: error.stack,
})
}
}
/**
* Clean up external webhook subscriptions for a webhook
* Handles Airtable, Teams, and Telegram cleanup
* Don't fail deletion if cleanup fails
*/
export async function cleanupExternalWebhook(
webhook: any,
workflow: any,
requestId: string
): Promise<void> {
if (webhook.provider === 'airtable') {
await deleteAirtableWebhook(webhook, workflow, requestId)
} else if (webhook.provider === 'microsoftteams') {
await deleteTeamsSubscription(webhook, workflow, requestId)
} else if (webhook.provider === 'telegram') {
await deleteTelegramWebhook(webhook, requestId)
}
}

View File

@@ -1,6 +1,6 @@
import { getBlock } from '@/blocks'
import type { BlockConfig } from '@/blocks/types'
import { getTrigger } from '@/triggers'
import { getTrigger, isTriggerValid } from '@/triggers'
/**
* Get the effective outputs for a block, including dynamic outputs from inputFormat
@@ -16,10 +16,19 @@ export function getBlockOutputs(
// If block is in trigger mode, use trigger outputs instead of block outputs
if (triggerMode && blockConfig.triggers?.enabled) {
const triggerId = subBlocks?.triggerId?.value || blockConfig.triggers?.available?.[0]
if (triggerId) {
const selectedTriggerIdValue = subBlocks?.selectedTriggerId?.value
const triggerIdValue = subBlocks?.triggerId?.value
const triggerId =
(typeof selectedTriggerIdValue === 'string' && isTriggerValid(selectedTriggerIdValue)
? selectedTriggerIdValue
: undefined) ||
(typeof triggerIdValue === 'string' && isTriggerValid(triggerIdValue)
? triggerIdValue
: undefined) ||
blockConfig.triggers?.available?.[0]
if (triggerId && isTriggerValid(triggerId)) {
const trigger = getTrigger(triggerId)
if (trigger?.outputs) {
if (trigger.outputs) {
return trigger.outputs
}
}

View File

@@ -58,10 +58,10 @@ export function getAllTriggerBlocks(): TriggerInfo[] {
}
/**
* Check if a block has trigger capability (contains a trigger-config subblock)
* Check if a block has trigger capability (contains trigger mode subblocks)
*/
export function hasTriggerCapability(block: BlockConfig): boolean {
return block.subBlocks.some((subBlock) => subBlock.type === 'trigger-config')
return block.subBlocks.some((subBlock) => subBlock.mode === 'trigger')
}
/**

View File

@@ -1,10 +1,11 @@
import * as schema from '@sim/db'
import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@sim/db'
import { and, eq, or, sql } from 'drizzle-orm'
import { webhook, workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@sim/db'
import { and, eq, inArray, or, sql } from 'drizzle-orm'
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { cleanupExternalWebhook } from '@/lib/webhooks/webhook-helpers'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
const logger = createLogger('SocketDatabase')
@@ -21,10 +22,8 @@ const socketDb = drizzle(
{ schema }
)
// Use dedicated connection for socket operations, fallback to shared db for compatibility
const db = socketDb
// Constants
const DEFAULT_LOOP_ITERATIONS = 5
/**
@@ -55,18 +54,15 @@ async function insertAutoConnectEdge(
)
}
// Enum for subflow types
enum SubflowType {
LOOP = 'loop',
PARALLEL = 'parallel',
}
// Helper function to check if a block type is a subflow type
function isSubflowBlockType(blockType: string): blockType is SubflowType {
return Object.values(SubflowType).includes(blockType as SubflowType)
}
// Helper function to update subflow node lists when child blocks are added/removed
export async function updateSubflowNodeList(dbOrTx: any, workflowId: string, parentId: string) {
try {
// Get all child blocks of this parent
@@ -110,7 +106,6 @@ export async function updateSubflowNodeList(dbOrTx: any, workflowId: string, par
}
}
// Get workflow state
export async function getWorkflowState(workflowId: string) {
try {
const workflowData = await db
@@ -123,16 +118,12 @@ export async function getWorkflowState(workflowId: string) {
throw new Error(`Workflow ${workflowId} not found`)
}
// Load from normalized tables first (same logic as REST API)
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (normalizedData) {
// Use normalized data as source of truth
const finalState = {
// Default values for expected properties
deploymentStatuses: {},
hasActiveWebhook: false,
// Data from normalized tables
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
@@ -148,7 +139,6 @@ export async function getWorkflowState(workflowId: string) {
lastModified: Date.now(),
}
}
// Fallback to JSON blob
return {
...workflowData[0],
lastModified: Date.now(),
@@ -159,15 +149,12 @@ export async function getWorkflowState(workflowId: string) {
}
}
// Persist workflow operation
export async function persistWorkflowOperation(workflowId: string, operation: any) {
const startTime = Date.now()
try {
const { operation: op, target, payload, timestamp, userId } = operation
// Log high-frequency operations for monitoring
if (op === 'update-position' && Math.random() < 0.01) {
// Log 1% of position updates
logger.debug('Socket DB operation sample:', {
operation: op,
target,
@@ -176,35 +163,31 @@ export async function persistWorkflowOperation(workflowId: string, operation: an
}
await db.transaction(async (tx) => {
// Update the workflow's last modified timestamp first
await tx
.update(workflow)
.set({ updatedAt: new Date(timestamp) })
.where(eq(workflow.id, workflowId))
// Handle different operation types within the transaction
switch (target) {
case 'block':
await handleBlockOperationTx(tx, workflowId, op, payload, userId)
await handleBlockOperationTx(tx, workflowId, op, payload)
break
case 'edge':
await handleEdgeOperationTx(tx, workflowId, op, payload, userId)
await handleEdgeOperationTx(tx, workflowId, op, payload)
break
case 'subflow':
await handleSubflowOperationTx(tx, workflowId, op, payload, userId)
await handleSubflowOperationTx(tx, workflowId, op, payload)
break
case 'variable':
await handleVariableOperationTx(tx, workflowId, op, payload, userId)
await handleVariableOperationTx(tx, workflowId, op, payload)
break
default:
throw new Error(`Unknown operation target: ${target}`)
}
})
// Log slow operations for monitoring
const duration = Date.now() - startTime
if (duration > 100) {
// Log operations taking more than 100ms
logger.warn('Slow socket DB operation:', {
operation: operation.operation,
target: operation.target,
@@ -222,13 +205,11 @@ export async function persistWorkflowOperation(workflowId: string, operation: an
}
}
// Block operations
async function handleBlockOperationTx(
tx: any,
workflowId: string,
operation: string,
payload: any,
userId: string
payload: any
) {
switch (operation) {
case 'add': {
@@ -237,9 +218,7 @@ async function handleBlockOperationTx(
throw new Error('Missing required fields for add block operation')
}
// Note: single-API-trigger enforcement is handled client-side to avoid disconnects
logger.debug(`[SERVER] Adding block: ${payload.type} (${payload.id})`, {
logger.debug(`Adding block: ${payload.type} (${payload.id})`, {
isSubflowType: isSubflowBlockType(payload.type),
})
@@ -247,7 +226,7 @@ async function handleBlockOperationTx(
const parentId = payload.parentId || payload.data?.parentId || null
const extent = payload.extent || payload.data?.extent || null
logger.debug(`[SERVER] Block parent info:`, {
logger.debug(`Block parent info:`, {
blockId: payload.id,
hasParent: !!parentId,
parentId,
@@ -281,10 +260,9 @@ async function handleBlockOperationTx(
await tx.insert(workflowBlocks).values(insertData)
// Handle auto-connect edge if present
await insertAutoConnectEdge(tx, workflowId, payload.autoConnectEdge, logger)
} catch (insertError) {
logger.error(`[SERVER] ❌ Failed to insert block ${payload.id}:`, insertError)
logger.error(`❌ Failed to insert block ${payload.id}:`, insertError)
throw insertError
}
@@ -309,10 +287,7 @@ async function handleBlockOperationTx(
distribution: payload.data?.collection || '',
}
logger.debug(
`[SERVER] Auto-creating ${payload.type} subflow ${payload.id}:`,
subflowConfig
)
logger.debug(`Auto-creating ${payload.type} subflow ${payload.id}:`, subflowConfig)
await tx.insert(workflowSubflows).values({
id: payload.id,
@@ -321,10 +296,7 @@ async function handleBlockOperationTx(
config: subflowConfig,
})
} catch (subflowError) {
logger.error(
`[SERVER] ❌ Failed to create ${payload.type} subflow ${payload.id}:`,
subflowError
)
logger.error(`❌ Failed to create ${payload.type} subflow ${payload.id}:`, subflowError)
throw subflowError
}
}
@@ -368,6 +340,9 @@ async function handleBlockOperationTx(
throw new Error('Missing block ID for remove operation')
}
// Collect all block IDs that will be deleted (including child blocks)
const blocksToDelete = new Set<string>([payload.id])
// Check if this is a subflow block that needs cascade deletion
const blockToRemove = await tx
.select({
@@ -391,12 +366,15 @@ async function handleBlockOperationTx(
)
logger.debug(
`[SERVER] Starting cascade deletion for subflow block ${payload.id} (type: ${blockToRemove[0].type})`
`Starting cascade deletion for subflow block ${payload.id} (type: ${blockToRemove[0].type})`
)
logger.debug(
`[SERVER] Found ${childBlocks.length} child blocks to delete: [${childBlocks.map((b: any) => `${b.id} (${b.type})`).join(', ')}]`
`Found ${childBlocks.length} child blocks to delete: [${childBlocks.map((b: any) => `${b.id} (${b.type})`).join(', ')}]`
)
// Add child blocks to deletion set
childBlocks.forEach((child: { id: string; type: string }) => blocksToDelete.add(child.id))
// Remove edges connected to child blocks
for (const childBlock of childBlocks) {
await tx
@@ -430,6 +408,56 @@ async function handleBlockOperationTx(
)
}
// Clean up external webhooks before deleting blocks
try {
const blockIdsArray = Array.from(blocksToDelete)
const webhooksToCleanup = await tx
.select({
webhook: webhook,
workflow: {
id: workflow.id,
userId: workflow.userId,
workspaceId: workflow.workspaceId,
},
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(and(eq(webhook.workflowId, workflowId), inArray(webhook.blockId, blockIdsArray)))
if (webhooksToCleanup.length > 0) {
logger.debug(
`Found ${webhooksToCleanup.length} webhook(s) to cleanup for blocks: ${blockIdsArray.join(', ')}`
)
const requestId = `socket-${workflowId}-${Date.now()}-${Math.random().toString(36).substring(7)}`
// Clean up each webhook (don't fail if cleanup fails)
for (const webhookData of webhooksToCleanup) {
try {
await cleanupExternalWebhook(webhookData.webhook, webhookData.workflow, requestId)
} catch (cleanupError) {
logger.error(`Failed to cleanup external webhook during block deletion`, {
webhookId: webhookData.webhook.id,
workflowId: webhookData.workflow.id,
userId: webhookData.workflow.userId,
workspaceId: webhookData.workflow.workspaceId,
provider: webhookData.webhook.provider,
blockId: webhookData.webhook.blockId,
error: cleanupError,
})
// Continue with deletion even if cleanup fails
}
}
}
} catch (webhookCleanupError) {
logger.error(`Error during webhook cleanup for block deletion (continuing with deletion)`, {
workflowId,
blockIds: Array.from(blocksToDelete),
error: webhookCleanupError,
})
// Continue with block deletion even if webhook cleanup fails
}
// Remove any edges connected to this block
await tx
.delete(workflowEdges)
@@ -670,13 +698,10 @@ async function handleBlockOperationTx(
throw new Error('Missing required fields for duplicate block operation')
}
logger.debug(
`[SERVER] Duplicating block: ${payload.type} (${payload.sourceId} -> ${payload.id})`,
{
isSubflowType: isSubflowBlockType(payload.type),
payload,
}
)
logger.debug(`Duplicating block: ${payload.type} (${payload.sourceId} -> ${payload.id})`, {
isSubflowType: isSubflowBlockType(payload.type),
payload,
})
// Extract parentId and extent from payload
const parentId = payload.parentId || null
@@ -710,7 +735,7 @@ async function handleBlockOperationTx(
// Handle auto-connect edge if present
await insertAutoConnectEdge(tx, workflowId, payload.autoConnectEdge, logger)
} catch (insertError) {
logger.error(`[SERVER] ❌ Failed to insert duplicated block ${payload.id}:`, insertError)
logger.error(`❌ Failed to insert duplicated block ${payload.id}:`, insertError)
throw insertError
}
@@ -736,7 +761,7 @@ async function handleBlockOperationTx(
}
logger.debug(
`[SERVER] Auto-creating ${payload.type} subflow for duplicated block ${payload.id}:`,
`Auto-creating ${payload.type} subflow for duplicated block ${payload.id}:`,
subflowConfig
)
@@ -748,7 +773,7 @@ async function handleBlockOperationTx(
})
} catch (subflowError) {
logger.error(
`[SERVER] ❌ Failed to create ${payload.type} subflow for duplicated block ${payload.id}:`,
`❌ Failed to create ${payload.type} subflow for duplicated block ${payload.id}:`,
subflowError
)
throw subflowError
@@ -774,13 +799,7 @@ async function handleBlockOperationTx(
}
// Edge operations
async function handleEdgeOperationTx(
tx: any,
workflowId: string,
operation: string,
payload: any,
userId: string
) {
async function handleEdgeOperationTx(tx: any, workflowId: string, operation: string, payload: any) {
switch (operation) {
case 'add': {
// Validate required fields
@@ -825,13 +844,11 @@ async function handleEdgeOperationTx(
}
}
// Subflow operations
async function handleSubflowOperationTx(
tx: any,
workflowId: string,
operation: string,
payload: any,
userId: string
payload: any
) {
switch (operation) {
case 'update': {
@@ -839,7 +856,7 @@ async function handleSubflowOperationTx(
throw new Error('Missing required fields for update subflow operation')
}
logger.debug(`[SERVER] Updating subflow ${payload.id} with config:`, payload.config)
logger.debug(`Updating subflow ${payload.id} with config:`, payload.config)
// Update the subflow configuration
const updateResult = await tx
@@ -857,7 +874,7 @@ async function handleSubflowOperationTx(
throw new Error(`Subflow ${payload.id} not found in workflow ${workflowId}`)
}
logger.debug(`[SERVER] Successfully updated subflow ${payload.id} in database`)
logger.debug(`Successfully updated subflow ${payload.id} in database`)
// Also update the corresponding block's data to keep UI in sync
if (payload.type === 'loop' && payload.config.iterations !== undefined) {
@@ -934,8 +951,7 @@ async function handleVariableOperationTx(
tx: any,
workflowId: string,
operation: string,
payload: any,
userId: string
payload: any
) {
// Get current workflow variables
const workflowData = await tx

View File

@@ -1,8 +1,14 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { createLogger } from '@/lib/logs/console/logger'
import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import { populateTriggerFieldsFromConfig } from '@/hooks/use-trigger-config-aggregation'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { SubBlockStore } from '@/stores/workflows/subblock/types'
import { isTriggerValid } from '@/triggers'
const logger = createLogger('SubBlockStore')
/**
* SubBlockState stores values for all subblocks in workflows
@@ -19,39 +25,36 @@ import type { SubBlockStore } from '@/stores/workflows/subblock/types'
export const useSubBlockStore = create<SubBlockStore>()(
devtools((set, get) => ({
workflowValues: {},
loadingWebhooks: new Set<string>(),
checkedWebhooks: new Set<string>(),
setValue: (blockId: string, subBlockId: string, value: any) => {
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) return
// Validate and fix table data if needed
let validatedValue = value
if (Array.isArray(value)) {
// Check if this looks like table data (array of objects with cells)
const isTableData =
value.length > 0 &&
value.some((item) => item && typeof item === 'object' && 'cells' in item)
if (isTableData) {
console.log('Validating table data for subblock:', { blockId, subBlockId })
logger.debug('Validating table data for subblock', { blockId, subBlockId })
validatedValue = value.map((row: any) => {
// Ensure each row has proper structure
if (!row || typeof row !== 'object') {
console.warn('Fixing malformed table row:', row)
logger.warn('Fixing malformed table row', { blockId, subBlockId, row })
return {
id: crypto.randomUUID(),
cells: { Key: '', Value: '' },
}
}
// Ensure row has an id
if (!row.id) {
row.id = crypto.randomUUID()
}
// Ensure row has cells object
if (!row.cells || typeof row.cells !== 'object') {
console.warn('Fixing malformed table row cells:', row)
logger.warn('Fixing malformed table row cells', { blockId, subBlockId, row })
row.cells = { Key: '', Value: '' }
}
@@ -72,9 +75,6 @@ export const useSubBlockStore = create<SubBlockStore>()(
},
},
}))
// Trigger debounced sync to DB
get().syncWithDB()
},
getValue: (blockId: string, subBlockId: string) => {
@@ -94,13 +94,11 @@ export const useSubBlockStore = create<SubBlockStore>()(
[activeWorkflowId]: {},
},
}))
// Note: Socket.IO handles real-time sync automatically
},
initializeFromWorkflow: (workflowId: string, blocks: Record<string, any>) => {
// Initialize from blocks
const values: Record<string, Record<string, any>> = {}
Object.entries(blocks).forEach(([blockId, block]) => {
values[blockId] = {}
Object.entries(block.subBlocks || {}).forEach(([subBlockId, subBlock]) => {
@@ -114,11 +112,55 @@ export const useSubBlockStore = create<SubBlockStore>()(
[workflowId]: values,
},
}))
},
// Removed syncWithDB - Socket.IO handles real-time sync automatically
syncWithDB: () => {
// No-op: Socket.IO handles real-time sync
const originalActiveWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
useWorkflowRegistry.setState({ activeWorkflowId: workflowId })
Object.entries(blocks).forEach(([blockId, block]) => {
const blockConfig = getBlock(block.type)
if (!blockConfig) return
const isTriggerBlock = blockConfig.category === 'triggers' || block.triggerMode === true
if (!isTriggerBlock) return
let triggerId: string | undefined
if (blockConfig.category === 'triggers') {
triggerId = block.type
} else if (block.triggerMode === true && blockConfig.triggers?.enabled) {
const selectedTriggerIdValue = block.subBlocks?.selectedTriggerId?.value
const triggerIdValue = block.subBlocks?.triggerId?.value
triggerId =
(typeof selectedTriggerIdValue === 'string' && isTriggerValid(selectedTriggerIdValue)
? selectedTriggerIdValue
: undefined) ||
(typeof triggerIdValue === 'string' && isTriggerValid(triggerIdValue)
? triggerIdValue
: undefined) ||
blockConfig.triggers?.available?.[0]
}
if (!triggerId || !isTriggerValid(triggerId)) {
return
}
const triggerConfigSubBlock = block.subBlocks?.triggerConfig
if (triggerConfigSubBlock?.value && typeof triggerConfigSubBlock.value === 'object') {
populateTriggerFieldsFromConfig(blockId, triggerConfigSubBlock.value, triggerId)
const currentChecked = get().checkedWebhooks
if (currentChecked.has(blockId)) {
set((state) => {
const newSet = new Set(state.checkedWebhooks)
newSet.delete(blockId)
return { checkedWebhooks: newSet }
})
}
}
})
if (originalActiveWorkflowId !== workflowId) {
useWorkflowRegistry.setState({ activeWorkflowId: originalActiveWorkflowId })
}
},
}))
)

View File

@@ -1,5 +1,7 @@
export interface SubBlockState {
workflowValues: Record<string, Record<string, Record<string, any>>> // Store values per workflow ID
loadingWebhooks: Set<string> // Track which blockIds are currently loading webhooks
checkedWebhooks: Set<string> // Track which blockIds have been checked for webhooks
}
export interface SubBlockStore extends SubBlockState {
@@ -7,6 +9,4 @@ export interface SubBlockStore extends SubBlockState {
getValue: (blockId: string, subBlockId: string) => any
clear: () => void
initializeFromWorkflow: (workflowId: string, blocks: Record<string, any>) => void
// Add debounced sync function
syncWithDB: () => void
}

View File

@@ -1,5 +1,5 @@
import { AirtableIcon } from '@/components/icons'
import type { TriggerConfig } from '../types'
import type { TriggerConfig } from '@/triggers/types'
export const airtableWebhookTrigger: TriggerConfig = {
id: 'airtable_webhook',
@@ -10,32 +10,122 @@ export const airtableWebhookTrigger: TriggerConfig = {
version: '1.0.0',
icon: AirtableIcon,
// Airtable requires OAuth credentials to create webhooks
requiresCredentials: true,
credentialProvider: 'airtable',
configFields: {
baseId: {
type: 'string',
label: 'Base ID',
subBlocks: [
{
id: 'triggerCredentials',
title: 'Credentials',
type: 'oauth-input',
description: 'This trigger requires airtable credentials to access your account.',
provider: 'airtable',
requiredScopes: [],
required: true,
mode: 'trigger',
},
{
id: 'baseId',
title: 'Base ID',
type: 'short-input',
placeholder: 'appXXXXXXXXXXXXXX',
description: 'The ID of the Airtable Base this webhook will monitor.',
required: true,
mode: 'trigger',
},
tableId: {
type: 'string',
label: 'Table ID',
{
id: 'tableId',
title: 'Table ID',
type: 'short-input',
placeholder: 'tblXXXXXXXXXXXXXX',
description: 'The ID of the table within the Base that the webhook will monitor.',
required: true,
mode: 'trigger',
},
includeCellValues: {
type: 'boolean',
label: 'Include Full Record Data',
{
id: 'includeCellValues',
title: 'Include Full Record Data',
type: 'switch',
description: 'Enable to receive the complete record data in the payload, not just changes.',
defaultValue: false,
mode: 'trigger',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
type: 'text',
defaultValue: [
'Connect your Airtable account using the "Select Airtable credential" button above.',
'Ensure you have provided the correct Base ID and Table ID above.',
'You can find your Base ID in the Airtable URL: https://airtable.com/[baseId]/...',
'You can find your Table ID by clicking on the table name and looking in the URL.',
'The webhook will trigger whenever records are created, updated, or deleted in the specified table.',
'Make sure your Airtable account has appropriate permissions for the specified base.',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'airtable_webhook',
},
{
id: 'samplePayload',
title: 'Event Payload Example',
type: 'code',
language: 'json',
defaultValue: JSON.stringify(
{
webhook: {
id: 'achAbCdEfGhIjKlMn',
},
timestamp: '2023-01-01T00:00:00.000Z',
base: {
id: 'appXXXXXXXXXXXXXX',
},
table: {
id: 'tblXXXXXXXXXXXXXX',
},
changedTablesById: {
tblXXXXXXXXXXXXXX: {
changedRecordsById: {
recXXXXXXXXXXXXXX: {
current: {
id: 'recXXXXXXXXXXXXXX',
createdTime: '2023-01-01T00:00:00.000Z',
fields: {
Name: 'Sample Record',
Status: 'Active',
},
},
previous: {
id: 'recXXXXXXXXXXXXXX',
createdTime: '2023-01-01T00:00:00.000Z',
fields: {
Name: 'Sample Record',
Status: 'Inactive',
},
},
},
},
createdRecordsById: {},
destroyedRecordIds: [],
},
},
},
null,
2
),
readOnly: true,
collapsible: true,
defaultCollapsed: true,
mode: 'trigger',
},
],
outputs: {
payloads: {
@@ -78,54 +168,6 @@ export const airtableWebhookTrigger: TriggerConfig = {
},
},
instructions: [
'Connect your Airtable account using the "Select Airtable credential" button above.',
'Ensure you have provided the correct Base ID and Table ID above.',
'You can find your Base ID in the Airtable URL: https://airtable.com/[baseId]/...',
'You can find your Table ID by clicking on the table name and looking in the URL.',
'The webhook will trigger whenever records are created, updated, or deleted in the specified table.',
'Make sure your Airtable account has appropriate permissions for the specified base.',
],
samplePayload: {
webhook: {
id: 'achAbCdEfGhIjKlMn',
},
timestamp: '2023-01-01T00:00:00.000Z',
base: {
id: 'appXXXXXXXXXXXXXX',
},
table: {
id: 'tblXXXXXXXXXXXXXX',
},
changedTablesById: {
tblXXXXXXXXXXXXXX: {
changedRecordsById: {
recXXXXXXXXXXXXXX: {
current: {
id: 'recXXXXXXXXXXXXXX',
createdTime: '2023-01-01T00:00:00.000Z',
fields: {
Name: 'Sample Record',
Status: 'Active',
},
},
previous: {
id: 'recXXXXXXXXXXXXXX',
createdTime: '2023-01-01T00:00:00.000Z',
fields: {
Name: 'Sample Record',
Status: 'Inactive',
},
},
},
},
createdRecordsById: {},
destroyedRecordIds: [],
},
},
},
webhook: {
method: 'POST',
headers: {

View File

@@ -0,0 +1,16 @@
/**
* System subblock IDs that are part of the trigger UI infrastructure
* and should NOT be aggregated into triggerConfig or validated as user fields.
*
* These subblocks provide UI/UX functionality but aren't configuration data.
*/
export const SYSTEM_SUBBLOCK_IDS: string[] = [
'triggerCredentials', // OAuth credentials subblock
'triggerInstructions', // Setup instructions text
'webhookUrlDisplay', // Webhook URL display
'triggerSave', // Save configuration button
'samplePayload', // Example payload display
'setupScript', // Setup script code (e.g., Apps Script)
'triggerId', // Stored trigger ID
'selectedTriggerId', // Selected trigger from dropdown (multi-trigger blocks)
]

View File

@@ -1,5 +1,5 @@
import { WebhookIcon } from '@/components/icons'
import type { TriggerConfig } from '../types'
import type { TriggerConfig } from '@/triggers/types'
export const genericWebhookTrigger: TriggerConfig = {
id: 'generic_webhook',
@@ -9,54 +9,109 @@ export const genericWebhookTrigger: TriggerConfig = {
version: '1.0.0',
icon: WebhookIcon,
configFields: {
requireAuth: {
type: 'boolean',
label: 'Require Authentication',
subBlocks: [
{
id: 'webhookUrlDisplay',
title: 'Webhook URL',
type: 'short-input',
readOnly: true,
showCopyButton: true,
useWebhookUrl: true,
placeholder: 'Webhook URL will be generated',
mode: 'trigger',
},
{
id: 'requireAuth',
title: 'Require Authentication',
type: 'switch',
description: 'Require authentication for all webhook requests',
defaultValue: false,
mode: 'trigger',
},
token: {
type: 'string',
label: 'Authentication Token',
{
id: 'token',
title: 'Authentication Token',
type: 'short-input',
placeholder: 'Enter an auth token',
description: 'Token used to authenticate webhook requests via Bearer token or custom header',
password: true,
required: false,
isSecret: true,
mode: 'trigger',
},
secretHeaderName: {
type: 'string',
label: 'Secret Header Name (Optional)',
{
id: 'secretHeaderName',
title: 'Secret Header Name (Optional)',
type: 'short-input',
placeholder: 'X-Secret-Key',
description:
'Custom HTTP header name for the auth token. If blank, uses "Authorization: Bearer TOKEN"',
required: false,
mode: 'trigger',
},
{
id: 'inputFormat',
title: 'Input Format',
type: 'input-format',
layout: 'full',
description:
'Define the expected JSON input schema for this webhook (optional). Use type "files" for file uploads.',
mode: 'trigger',
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
type: 'text',
defaultValue: [
'Copy the webhook URL and use it in your external service or API.',
'Configure your service to send webhooks to this URL.',
'The webhook will receive any HTTP method (GET, POST, PUT, DELETE, etc.).',
'All request data (headers, body, query parameters) will be available in your workflow.',
'If authentication is enabled, include the token in requests using either the custom header or "Authorization: Bearer TOKEN".',
'Common fields like "event", "id", and "data" will be automatically extracted from the payload when available.',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'generic_webhook',
},
{
id: 'samplePayload',
title: 'Event Payload Example',
type: 'code',
language: 'json',
defaultValue: JSON.stringify(
{
event: 'user.created',
id: 'evt_1234567890',
data: {
user: {
id: 'user_123',
email: 'user@example.com',
name: 'John Doe',
},
},
timestamp: '2023-01-01T12:00:00Z',
},
null,
2
),
readOnly: true,
collapsible: true,
defaultCollapsed: true,
mode: 'trigger',
},
},
outputs: {},
instructions: [
'Copy the webhook URL provided above and use it in your external service or API.',
'Configure your service to send webhooks to this URL.',
'The webhook will receive any HTTP method (GET, POST, PUT, DELETE, etc.).',
'All request data (headers, body, query parameters) will be available in your workflow.',
'If authentication is enabled, include the token in requests using either the custom header or "Authorization: Bearer TOKEN".',
'Common fields like "event", "id", and "data" will be automatically extracted from the payload when available.',
],
samplePayload: {
event: 'user.created',
id: 'evt_1234567890',
data: {
user: {
id: 'user_123',
email: 'user@example.com',
name: 'John Doe',
},
},
timestamp: '2023-01-01T12:00:00Z',
},
outputs: {},
webhook: {
method: 'POST',

View File

@@ -1,5 +1,5 @@
import { GithubIcon } from '@/components/icons'
import type { TriggerConfig } from '../types'
import type { TriggerConfig } from '@/triggers/types'
export const githubWebhookTrigger: TriggerConfig = {
id: 'github_webhook',
@@ -9,35 +9,134 @@ export const githubWebhookTrigger: TriggerConfig = {
version: '1.0.0',
icon: GithubIcon,
configFields: {
contentType: {
type: 'select',
label: 'Content Type',
options: ['application/json', 'application/x-www-form-urlencoded'],
subBlocks: [
{
id: 'webhookUrlDisplay',
title: 'Webhook URL',
type: 'short-input',
readOnly: true,
showCopyButton: true,
useWebhookUrl: true,
placeholder: 'Webhook URL will be generated',
mode: 'trigger',
},
{
id: 'contentType',
title: 'Content Type',
type: 'dropdown',
options: [
{ label: 'application/json', id: 'application/json' },
{ label: 'application/x-www-form-urlencoded', id: 'application/x-www-form-urlencoded' },
],
defaultValue: 'application/json',
description: 'Format GitHub will use when sending the webhook payload.',
required: true,
mode: 'trigger',
},
webhookSecret: {
type: 'string',
label: 'Webhook Secret (Recommended)',
{
id: 'webhookSecret',
title: 'Webhook Secret (Recommended)',
type: 'short-input',
placeholder: 'Generate or enter a strong secret',
description: 'Validates that webhook deliveries originate from GitHub.',
password: true,
required: false,
isSecret: true,
mode: 'trigger',
},
sslVerification: {
type: 'select',
label: 'SSL Verification',
options: ['enabled', 'disabled'],
{
id: 'sslVerification',
title: 'SSL Verification',
type: 'dropdown',
options: [
{ label: 'Enabled', id: 'enabled' },
{ label: 'Disabled', id: 'disabled' },
],
defaultValue: 'enabled',
description: 'GitHub verifies SSL certificates when delivering webhooks.',
required: true,
mode: 'trigger',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
type: 'text',
defaultValue: [
'Go to your GitHub Repository > Settings > Webhooks.',
'Click "Add webhook".',
'Paste the <strong>Webhook URL</strong> above into the "Payload URL" field.',
'Select your chosen Content Type from the dropdown.',
'Enter the <strong>Webhook Secret</strong> into the "Secret" field if you\'ve configured one.',
'Set SSL verification according to your selection.',
'Choose which events should trigger this webhook.',
'Ensure "Active" is checked and click "Add webhook".',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'github_webhook',
},
{
id: 'samplePayload',
title: 'Event Payload Example',
type: 'code',
language: 'json',
defaultValue: JSON.stringify(
{
action: 'opened',
number: 1,
pull_request: {
id: 1,
number: 1,
state: 'open',
title: 'Update README',
user: {
login: 'octocat',
id: 1,
},
body: 'This is a pretty simple change that we need to pull into main.',
head: {
ref: 'feature-branch',
sha: 'abc123',
},
base: {
ref: 'main',
sha: 'def456',
},
},
repository: {
id: 35129377,
name: 'public-repo',
full_name: 'baxterthehacker/public-repo',
owner: {
login: 'baxterthehacker',
id: 6752317,
},
},
sender: {
login: 'baxterthehacker',
id: 6752317,
},
},
null,
2
),
readOnly: true,
collapsible: true,
defaultCollapsed: true,
mode: 'trigger',
},
],
outputs: {
// GitHub webhook payload structure - now at root for direct access
ref: {
type: 'string',
description: 'Git reference (e.g., refs/heads/fix/telegram-wh)',
@@ -460,54 +559,6 @@ export const githubWebhookTrigger: TriggerConfig = {
},
},
instructions: [
'Go to your GitHub Repository > Settings > Webhooks.',
'Click "Add webhook".',
'Paste the <strong>Webhook URL</strong> (from above) into the "Payload URL" field.',
'Select your chosen Content Type from the dropdown above.',
'Enter the <strong>Webhook Secret</strong> (from above) into the "Secret" field if you\'ve configured one.',
'Set SSL verification according to your selection above.',
'Choose which events should trigger this webhook.',
'Ensure "Active" is checked and click "Add webhook".',
],
samplePayload: {
action: 'opened',
number: 1,
pull_request: {
id: 1,
number: 1,
state: 'open',
title: 'Update README',
user: {
login: 'octocat',
id: 1,
},
body: 'This is a pretty simple change that we need to pull into main.',
head: {
ref: 'feature-branch',
sha: 'abc123',
},
base: {
ref: 'main',
sha: 'def456',
},
},
repository: {
id: 35129377,
name: 'public-repo',
full_name: 'baxterthehacker/public-repo',
owner: {
login: 'baxterthehacker',
id: 6752317,
},
},
sender: {
login: 'baxterthehacker',
id: 6752317,
},
},
webhook: {
method: 'POST',
headers: {

View File

@@ -1,4 +1,5 @@
import { GmailIcon } from '@/components/icons'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { TriggerConfig } from '@/triggers/types'
export const gmailPollingTrigger: TriggerConfig = {
@@ -9,51 +10,152 @@ export const gmailPollingTrigger: TriggerConfig = {
version: '1.0.0',
icon: GmailIcon,
// Gmail requires OAuth credentials to work
requiresCredentials: true,
credentialProvider: 'google-email',
configFields: {
labelIds: {
type: 'multiselect',
label: 'Gmail Labels to Monitor',
subBlocks: [
{
id: 'triggerCredentials',
title: 'Credentials',
type: 'oauth-input',
description: 'This trigger requires google email credentials to access your account.',
provider: 'google-email',
requiredScopes: [],
required: true,
mode: 'trigger',
},
{
id: 'labelIds',
title: 'Gmail Labels to Monitor',
type: 'dropdown',
multiSelect: true,
placeholder: 'Select Gmail labels to monitor for new emails',
description: 'Choose which Gmail labels to monitor. Leave empty to monitor all emails.',
required: false,
options: [], // Will be populated dynamically from user's Gmail labels
fetchOptions: async (blockId: string, subBlockId: string) => {
const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as
| string
| null
if (!credentialId) {
return []
}
try {
const response = await fetch(`/api/tools/gmail/labels?credentialId=${credentialId}`)
if (!response.ok) {
throw new Error('Failed to fetch Gmail labels')
}
const data = await response.json()
if (data.labels && Array.isArray(data.labels)) {
return data.labels.map((label: { id: string; name: string }) => ({
id: label.id,
label: label.name,
}))
}
return []
} catch (error) {
console.error('Error fetching Gmail labels:', error)
return []
}
},
mode: 'trigger',
},
labelFilterBehavior: {
type: 'select',
label: 'Label Filter Behavior',
options: ['INCLUDE', 'EXCLUDE'],
{
id: 'labelFilterBehavior',
title: 'Label Filter Behavior',
type: 'dropdown',
options: [
{ label: 'INCLUDE', id: 'INCLUDE' },
{ label: 'EXCLUDE', id: 'EXCLUDE' },
],
defaultValue: 'INCLUDE',
description:
'Include only emails with selected labels, or exclude emails with selected labels',
required: true,
mode: 'trigger',
},
searchQuery: {
type: 'string',
label: 'Gmail Search Query',
{
id: 'searchQuery',
title: 'Gmail Search Query',
type: 'short-input',
placeholder: 'subject:report OR from:important@example.com',
description:
'Optional Gmail search query to filter emails. Use the same format as Gmail search box (e.g., "subject:invoice", "from:boss@company.com", "has:attachment"). Leave empty to search all emails.',
required: false,
mode: 'trigger',
},
markAsRead: {
type: 'boolean',
label: 'Mark as Read',
{
id: 'markAsRead',
title: 'Mark as Read',
type: 'switch',
defaultValue: false,
description: 'Automatically mark emails as read after processing',
required: false,
mode: 'trigger',
},
includeAttachments: {
type: 'boolean',
label: 'Include Attachments',
{
id: 'includeAttachments',
title: 'Include Attachments',
type: 'switch',
defaultValue: false,
description: 'Download and include email attachments in the trigger payload',
required: false,
mode: 'trigger',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
type: 'text',
defaultValue: [
'Connect your Gmail account using OAuth credentials',
'Configure which Gmail labels to monitor (optional)',
'The system will automatically check for new emails and trigger your workflow',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'gmail_poller',
},
{
id: 'samplePayload',
title: 'Event Payload Example',
type: 'code',
language: 'json',
defaultValue: JSON.stringify(
{
email: {
id: '18e0ffabd5b5a0f4',
threadId: '18e0ffabd5b5a0f4',
subject: 'Monthly Report - April 2025',
from: 'sender@example.com',
to: 'recipient@example.com',
cc: 'team@example.com',
date: '2025-05-10T10:15:23.000Z',
bodyText:
'Hello,\n\nPlease find attached the monthly report for April 2025.\n\nBest regards,\nSender',
bodyHtml:
'<div><p>Hello,</p><p>Please find attached the monthly report for April 2025.</p><p>Best regards,<br>Sender</p></div>',
labels: ['INBOX', 'IMPORTANT'],
hasAttachments: true,
attachments: [],
},
timestamp: '2025-05-10T10:15:30.123Z',
},
null,
2
),
readOnly: true,
collapsible: true,
defaultCollapsed: true,
mode: 'trigger',
},
],
outputs: {
email: {
@@ -111,30 +213,4 @@ export const gmailPollingTrigger: TriggerConfig = {
description: 'Event timestamp',
},
},
instructions: [
'Connect your Gmail account using OAuth credentials',
'Configure which Gmail labels to monitor (optional)',
'The system will automatically check for new emails and trigger your workflow',
],
samplePayload: {
email: {
id: '18e0ffabd5b5a0f4',
threadId: '18e0ffabd5b5a0f4',
subject: 'Monthly Report - April 2025',
from: 'sender@example.com',
to: 'recipient@example.com',
cc: 'team@example.com',
date: '2025-05-10T10:15:23.000Z',
bodyText:
'Hello,\n\nPlease find attached the monthly report for April 2025.\n\nBest regards,\nSender',
bodyHtml:
'<div><p>Hello,</p><p>Please find attached the monthly report for April 2025.</p><p>Best regards,<br>Sender</p></div>',
labels: ['INBOX', 'IMPORTANT'],
hasAttachments: true,
attachments: [],
},
timestamp: '2025-05-10T10:15:30.123Z',
},
}

View File

@@ -0,0 +1 @@
export { googleFormsWebhookTrigger } from './webhook'

View File

@@ -1,5 +1,5 @@
import { GoogleFormsIcon } from '@/components/icons'
import type { TriggerConfig } from '../types'
import type { TriggerConfig } from '@/triggers/types'
export const googleFormsWebhookTrigger: TriggerConfig = {
id: 'google_forms_webhook',
@@ -9,41 +9,178 @@ export const googleFormsWebhookTrigger: TriggerConfig = {
version: '1.0.0',
icon: GoogleFormsIcon,
configFields: {
token: {
type: 'string',
label: 'Shared Secret',
subBlocks: [
{
id: 'webhookUrlDisplay',
title: 'Webhook URL',
type: 'short-input',
readOnly: true,
showCopyButton: true,
useWebhookUrl: true,
placeholder: 'Webhook URL will be generated',
mode: 'trigger',
},
{
id: 'token',
title: 'Shared Secret',
type: 'short-input',
placeholder: 'Enter a secret used by your Apps Script forwarder',
description:
'We validate requests using this secret. Send it as Authorization: Bearer <token> or a custom header.',
password: true,
required: true,
isSecret: true,
mode: 'trigger',
},
secretHeaderName: {
type: 'string',
label: 'Custom Secret Header (optional)',
{
id: 'secretHeaderName',
title: 'Custom Secret Header (optional)',
type: 'short-input',
placeholder: 'X-GForms-Secret',
description:
'If set, the webhook will validate this header equals your Shared Secret instead of Authorization.',
required: false,
mode: 'trigger',
},
formId: {
type: 'string',
label: 'Form ID (optional)',
{
id: 'formId',
title: 'Form ID (optional)',
type: 'short-input',
placeholder: '1FAIpQLSd... (Google Form ID)',
description:
'Optional, for clarity and matching in workflows. Not required for webhook to work.',
required: false,
mode: 'trigger',
},
includeRawPayload: {
type: 'boolean',
label: 'Include Raw Payload',
{
id: 'includeRawPayload',
title: 'Include Raw Payload',
type: 'switch',
description: 'Include the original payload from Apps Script in the workflow input.',
defaultValue: true,
mode: 'trigger',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
type: 'text',
defaultValue: [
'Open your Google Form → More (⋮) → Script editor.',
'Paste the Apps Script snippet from below into <code>Code.gs</code> → Save.',
'Triggers (clock icon) → Add Trigger → Function: <code>onFormSubmit</code> → Event source: <code>From form</code> → Event type: <code>On form submit</code> → Save.',
'Authorize when prompted. Submit a test response and verify the run in Sim → Logs.',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
},
{
id: 'setupScript',
title: 'Apps Script Code',
type: 'code',
language: 'javascript',
value: (params: Record<string, any>) => {
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++) {
const itemResponse = itemResponses[i];
const question = itemResponse.getItem().getTitle();
const answer = itemResponse.getResponse();
answers[question] = answer;
}
// Build payload
const payload = {
provider: "google_forms",
formId: form.getId(),
responseId: formResponse.getId(),
createTime: formResponse.getTimestamp().toISOString(),
lastSubmittedTime: formResponse.getTimestamp().toISOString(),
answers: answers
};
// Send to webhook
const options = {
method: "post",
contentType: "application/json",
headers: {
"Authorization": "Bearer " + SHARED_SECRET
},
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(WEBHOOK_URL, options);
if (response.getResponseCode() !== 200) {
Logger.log("Webhook failed: " + response.getContentText());
} else {
Logger.log("Successfully sent form response to webhook");
}
} catch (error) {
Logger.log("Error in onFormSubmit: " + error.toString());
}
}`
const webhookUrl = params.webhookUrlDisplay || ''
const token = params.token || ''
return script
.replace(/\{\{WEBHOOK_URL\}\}/g, webhookUrl)
.replace(/\{\{SHARED_SECRET\}\}/g, token)
},
collapsible: true,
defaultCollapsed: true,
showCopyButton: true,
description: 'Copy this code and paste it into your Google Forms Apps Script editor',
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'google_forms_webhook',
},
{
id: 'samplePayload',
title: 'Event Payload Example',
type: 'code',
language: 'json',
defaultValue: JSON.stringify(
{
provider: 'google_forms',
formId: '1FAIpQLSdEXAMPLE',
responseId: 'R_12345',
createTime: '2025-01-01T12:00:00.000Z',
lastSubmittedTime: '2025-01-01T12:00:00.000Z',
answers: {
'What is your name?': 'Ada Lovelace',
Languages: ['TypeScript', 'Python'],
'Subscribed?': true,
},
raw: { any: 'original payload from Apps Script if included' },
},
null,
2
),
readOnly: true,
collapsible: true,
defaultCollapsed: true,
mode: 'trigger',
},
],
outputs: {
// Expose flattened fields at the root; nested google_forms exists at runtime for back-compat
responseId: { type: 'string', description: 'Unique response identifier (if available)' },
createTime: { type: 'string', description: 'Response creation timestamp' },
lastSubmittedTime: { type: 'string', description: 'Last submitted timestamp' },
@@ -52,27 +189,6 @@ export const googleFormsWebhookTrigger: TriggerConfig = {
raw: { type: 'object', description: 'Original payload (when enabled)' },
},
instructions: [
'Open your Google Form → More (⋮) → Script editor.',
'Paste the Apps Script snippet from below into <code>Code.gs</code> → Save.',
'Triggers (clock icon) → Add Trigger → Function: <code>onFormSubmit</code> → Event source: <code>From form</code> → Event type: <code>On form submit</code> → Save.',
'Authorize when prompted. Submit a test response and verify the run in Sim → Logs.',
],
samplePayload: {
provider: 'google_forms',
formId: '1FAIpQLSdEXAMPLE',
responseId: 'R_12345',
createTime: '2025-01-01T12:00:00.000Z',
lastSubmittedTime: '2025-01-01T12:00:00.000Z',
answers: {
'What is your name?': 'Ada Lovelace',
Languages: ['TypeScript', 'Python'],
'Subscribed?': true,
},
raw: { any: 'original payload from Apps Script if included' },
},
webhook: {
method: 'POST',
headers: {

View File

@@ -1,50 +1,12 @@
// Import trigger definitions
import { TRIGGER_REGISTRY } from '@/triggers/registry'
import type { TriggerConfig } from '@/triggers/types'
import { airtableWebhookTrigger } from './airtable'
import { genericWebhookTrigger } from './generic'
import { githubWebhookTrigger } from './github'
import { gmailPollingTrigger } from './gmail'
import { googleFormsWebhookTrigger } from './googleforms/webhook'
import {
microsoftTeamsChatSubscriptionTrigger,
microsoftTeamsWebhookTrigger,
} from './microsoftteams'
import { outlookPollingTrigger } from './outlook'
import { slackWebhookTrigger } from './slack'
import { stripeWebhookTrigger } from './stripe/webhook'
import { telegramWebhookTrigger } from './telegram'
import type { TriggerConfig, TriggerRegistry } from './types'
import {
webflowCollectionItemChangedTrigger,
webflowCollectionItemCreatedTrigger,
webflowCollectionItemDeletedTrigger,
webflowFormSubmissionTrigger,
} from './webflow'
import { whatsappWebhookTrigger } from './whatsapp'
// Central registry of all available triggers
export const TRIGGER_REGISTRY: TriggerRegistry = {
slack_webhook: slackWebhookTrigger,
airtable_webhook: airtableWebhookTrigger,
generic_webhook: genericWebhookTrigger,
github_webhook: githubWebhookTrigger,
gmail_poller: gmailPollingTrigger,
microsoftteams_webhook: microsoftTeamsWebhookTrigger,
microsoftteams_chat_subscription: microsoftTeamsChatSubscriptionTrigger,
outlook_poller: outlookPollingTrigger,
stripe_webhook: stripeWebhookTrigger,
telegram_webhook: telegramWebhookTrigger,
whatsapp_webhook: whatsappWebhookTrigger,
google_forms_webhook: googleFormsWebhookTrigger,
webflow_collection_item_created: webflowCollectionItemCreatedTrigger,
webflow_collection_item_changed: webflowCollectionItemChangedTrigger,
webflow_collection_item_deleted: webflowCollectionItemDeletedTrigger,
webflow_form_submission: webflowFormSubmissionTrigger,
}
// Utility functions for working with triggers
export function getTrigger(triggerId: string): TriggerConfig | undefined {
return TRIGGER_REGISTRY[triggerId]
export function getTrigger(triggerId: string): TriggerConfig {
const trigger = TRIGGER_REGISTRY[triggerId]
if (!trigger) {
throw new Error(`Trigger not found: ${triggerId}`)
}
return trigger
}
export function getTriggersByProvider(provider: string): TriggerConfig[] {
@@ -63,5 +25,4 @@ export function isTriggerValid(triggerId: string): boolean {
return triggerId in TRIGGER_REGISTRY
}
// Export types for use elsewhere
export type { TriggerConfig, TriggerRegistry } from './types'
export type { TriggerConfig, TriggerRegistry } from '@/triggers/types'

View File

@@ -10,36 +10,107 @@ export const microsoftTeamsChatSubscriptionTrigger: TriggerConfig = {
version: '1.0.0',
icon: MicrosoftTeamsIcon,
// Credentials are handled by requiresCredentials below, not in configFields
configFields: {
chatId: {
type: 'string',
label: 'Chat ID',
subBlocks: [
{
id: 'triggerCredentials',
title: 'Credentials',
type: 'oauth-input',
description: 'This trigger requires microsoft teams credentials to access your account.',
provider: 'microsoft-teams',
requiredScopes: [],
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'microsoftteams_chat_subscription',
},
},
{
id: 'chatId',
title: 'Chat ID',
type: 'short-input',
placeholder: 'Enter chat ID',
description: 'The ID of the Teams chat to monitor',
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'microsoftteams_chat_subscription',
},
},
includeAttachments: {
type: 'boolean',
label: 'Include Attachments',
{
id: 'includeAttachments',
title: 'Include Attachments',
type: 'switch',
defaultValue: true,
description: 'Fetch hosted contents and upload to storage',
required: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'microsoftteams_chat_subscription',
},
},
},
// Require Microsoft Teams OAuth credentials
requiresCredentials: true,
credentialProvider: 'microsoft-teams',
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
{
id: 'triggerInstructions',
title: 'Setup Instructions',
type: 'text',
defaultValue: [
'Connect your Microsoft Teams account and grant the required permissions.',
'Enter the Chat ID of the Teams chat you want to monitor.',
'We will create a Microsoft Graph change notification subscription that delivers chat message events to your Sim webhook URL.',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'microsoftteams_chat_subscription',
},
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'microsoftteams_chat_subscription',
condition: {
field: 'selectedTriggerId',
value: 'microsoftteams_chat_subscription',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',
type: 'code',
language: 'json',
defaultValue: JSON.stringify(
{
message_id: '1708709741557',
chat_id: '19:abcxyz@unq.gbl.spaces',
from_name: 'Adele Vance',
text: 'Hello from Teams!',
created_at: '2025-01-01T10:00:00Z',
attachments: [],
},
null,
2
),
readOnly: true,
collapsible: true,
defaultCollapsed: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'microsoftteams_chat_subscription',
},
},
],
outputs: {
// Core message fields
message_id: { type: 'string', description: 'Message ID' },
chat_id: { type: 'string', description: 'Chat ID' },
from_name: { type: 'string', description: 'Sender display name' },
@@ -48,18 +119,10 @@ export const microsoftTeamsChatSubscriptionTrigger: TriggerConfig = {
attachments: { type: 'file[]', description: 'Uploaded attachments as files' },
},
instructions: [
'Connect your Microsoft Teams account and grant the required permissions.',
'Enter the Chat ID of the Teams chat you want to monitor.',
'We will create a Microsoft Graph change notification subscription that delivers chat message events to your Sim webhook URL.',
],
samplePayload: {
message_id: '1708709741557',
chat_id: '19:abcxyz@unq.gbl.spaces',
from_name: 'Adele Vance',
text: 'Hello from Teams!',
created_at: '2025-01-01T10:00:00Z',
attachments: [],
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}

View File

@@ -9,20 +9,120 @@ export const microsoftTeamsWebhookTrigger: TriggerConfig = {
version: '1.0.0',
icon: MicrosoftTeamsIcon,
configFields: {
hmacSecret: {
type: 'string',
label: 'HMAC Secret',
subBlocks: [
{
id: 'selectedTriggerId',
title: 'Trigger Type',
type: 'dropdown',
mode: 'trigger',
options: [
{ label: 'Microsoft Teams Channel', id: 'microsoftteams_webhook' },
{ label: 'Microsoft Teams Chat', id: 'microsoftteams_chat_subscription' },
],
value: () => 'microsoftteams_webhook',
required: true,
},
{
id: 'webhookUrlDisplay',
title: 'Webhook URL',
type: 'short-input',
readOnly: true,
showCopyButton: true,
useWebhookUrl: true,
placeholder: 'Webhook URL will be generated',
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'microsoftteams_webhook',
},
},
{
id: 'hmacSecret',
title: 'HMAC Secret',
type: 'short-input',
placeholder: 'Enter HMAC secret from Teams',
description:
'The security token provided by Teams when creating an outgoing webhook. Used to verify request authenticity.',
password: true,
required: true,
isSecret: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'microsoftteams_webhook',
},
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
type: 'text',
defaultValue: [
'Open Microsoft Teams and go to the team where you want to add the webhook.',
'Click the three dots (•••) next to the team name and select "Manage team".',
'Go to the "Apps" tab and click "Create an outgoing webhook".',
'Provide a name, description, and optionally a profile picture.',
'Set the callback URL to your Sim webhook URL above.',
'Copy the HMAC security token and paste it into the "HMAC Secret" field.',
'Click "Create" to finish setup.',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'microsoftteams_webhook',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'microsoftteams_webhook',
condition: {
field: 'selectedTriggerId',
value: 'microsoftteams_webhook',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',
type: 'code',
language: 'json',
defaultValue: JSON.stringify(
{
type: 'message',
id: '1234567890',
timestamp: '2023-01-01T00:00:00.000Z',
localTimestamp: '2023-01-01T00:00:00.000Z',
serviceUrl: 'https://smba.trafficmanager.net/amer/',
channelId: 'msteams',
from: {
id: '29:1234567890abcdef',
name: 'John Doe',
},
conversation: {
id: '19:meeting_abcdef@thread.v2',
},
text: 'Hello Sim Bot!',
},
null,
2
),
readOnly: true,
collapsible: true,
defaultCollapsed: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'microsoftteams_webhook',
},
},
],
outputs: {
// Top-level valid payloads only
from: {
id: { type: 'string', description: 'Sender ID' },
name: { type: 'string', description: 'Sender name' },
@@ -63,33 +163,6 @@ export const microsoftTeamsWebhookTrigger: TriggerConfig = {
},
},
instructions: [
'Open Microsoft Teams and go to the team where you want to add the webhook.',
'Click the three dots (•••) next to the team name and select "Manage team".',
'Go to the "Apps" tab and click "Create an outgoing webhook".',
'Provide a name, description, and optionally a profile picture.',
'Set the callback URL to your Sim webhook URL (shown above).',
'Copy the HMAC security token and paste it into the "HMAC Secret" field above.',
'Click "Create" to finish setup.',
],
samplePayload: {
type: 'message',
id: '1234567890',
timestamp: '2023-01-01T00:00:00.000Z',
localTimestamp: '2023-01-01T00:00:00.000Z',
serviceUrl: 'https://smba.trafficmanager.net/amer/',
channelId: 'msteams',
from: {
id: '29:1234567890abcdef',
name: 'John Doe',
},
conversation: {
id: '19:meeting_abcdef@thread.v2',
},
text: 'Hello Sim Bot!',
},
webhook: {
method: 'POST',
headers: {

View File

@@ -1,4 +1,5 @@
import { OutlookIcon } from '@/components/icons'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { TriggerConfig } from '@/triggers/types'
export const outlookPollingTrigger: TriggerConfig = {
@@ -9,43 +10,145 @@ export const outlookPollingTrigger: TriggerConfig = {
version: '1.0.0',
icon: OutlookIcon,
// Outlook requires OAuth credentials to work
requiresCredentials: true,
credentialProvider: 'outlook',
configFields: {
folderIds: {
type: 'multiselect',
label: 'Outlook Folders to Monitor',
subBlocks: [
{
id: 'triggerCredentials',
title: 'Credentials',
type: 'oauth-input',
description: 'This trigger requires outlook credentials to access your account.',
provider: 'outlook',
requiredScopes: [],
required: true,
mode: 'trigger',
},
{
id: 'folderIds',
title: 'Outlook Folders to Monitor',
type: 'dropdown',
multiSelect: true,
placeholder: 'Select Outlook folders to monitor for new emails',
description: 'Choose which Outlook folders to monitor. Leave empty to monitor all emails.',
required: false,
options: [], // Will be populated dynamically from user's Outlook folders
options: [], // Will be populated dynamically
fetchOptions: async (blockId: string, subBlockId: string) => {
const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as
| string
| null
if (!credentialId) {
return []
}
try {
const response = await fetch(`/api/tools/outlook/folders?credentialId=${credentialId}`)
if (!response.ok) {
throw new Error('Failed to fetch Outlook folders')
}
const data = await response.json()
if (data.folders && Array.isArray(data.folders)) {
return data.folders.map((folder: { id: string; name: string }) => ({
id: folder.id,
label: folder.name,
}))
}
return []
} catch (error) {
console.error('Error fetching Outlook folders:', error)
return []
}
},
mode: 'trigger',
},
folderFilterBehavior: {
type: 'select',
label: 'Folder Filter Behavior',
options: ['INCLUDE', 'EXCLUDE'],
{
id: 'folderFilterBehavior',
title: 'Folder Filter Behavior',
type: 'dropdown',
options: [
{ label: 'INCLUDE', id: 'INCLUDE' },
{ label: 'EXCLUDE', id: 'EXCLUDE' },
],
defaultValue: 'INCLUDE',
description:
'Include only emails from selected folders, or exclude emails from selected folders',
required: true,
mode: 'trigger',
},
markAsRead: {
type: 'boolean',
label: 'Mark as Read',
{
id: 'markAsRead',
title: 'Mark as Read',
type: 'switch',
defaultValue: false,
description: 'Automatically mark emails as read after processing',
required: false,
mode: 'trigger',
},
includeAttachments: {
type: 'boolean',
label: 'Include Attachments',
{
id: 'includeAttachments',
title: 'Include Attachments',
type: 'switch',
defaultValue: false,
description: 'Download and include email attachments in the trigger payload',
required: false,
mode: 'trigger',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
type: 'text',
defaultValue: [
'Connect your Microsoft account using OAuth credentials',
'Configure which Outlook folders to monitor (optional)',
'The system will automatically check for new emails and trigger your workflow',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'outlook_poller',
},
{
id: 'samplePayload',
title: 'Event Payload Example',
type: 'code',
language: 'json',
defaultValue: JSON.stringify(
{
email: {
id: 'AAMkADg1OWUyZjg4LWJkNGYtNDFhYy04OGVjLWVkM2VhY2YzYTcwZgBGAAAAAACE3bU',
conversationId: 'AAQkADg1OWUyZjg4LWJkNGYtNDFhYy04OGVjLWVkM2VhY2YzYTcwZgAQAErzGBJV',
subject: 'Quarterly Business Review - Q1 2025',
from: 'manager@company.com',
to: 'team@company.com',
cc: 'stakeholders@company.com',
date: '2025-05-10T14:30:00Z',
bodyText:
'Hi Team,\n\nPlease find attached the Q1 2025 business review document. We need to discuss the results in our next meeting.\n\nBest regards,\nManager',
bodyHtml:
'<div><p>Hi Team,</p><p>Please find attached the Q1 2025 business review document. We need to discuss the results in our next meeting.</p><p>Best regards,<br>Manager</p></div>',
hasAttachments: true,
attachments: [],
isRead: false,
folderId: 'AQMkADg1OWUyZjg4LWJkNGYtNDFhYy04OGVjAC4AAAJzE3bU',
messageId: 'AAMkADg1OWUyZjg4LWJkNGYtNDFhYy04OGVjLWVkM2VhY2YzYTcwZgBGAAAAAACE3bU',
threadId: 'AAQkADg1OWUyZjg4LWJkNGYtNDFhYy04OGVjLWVkM2VhY2YzYTcwZgAQAErzGBJV',
},
timestamp: '2025-05-10T14:30:15.123Z',
},
null,
2
),
readOnly: true,
collapsible: true,
defaultCollapsed: true,
mode: 'trigger',
},
],
outputs: {
email: {
@@ -115,33 +218,4 @@ export const outlookPollingTrigger: TriggerConfig = {
description: 'Event timestamp',
},
},
instructions: [
'Connect your Microsoft account using OAuth credentials',
'Configure which Outlook folders to monitor (optional)',
'The system will automatically check for new emails and trigger your workflow',
],
samplePayload: {
email: {
id: 'AAMkADg1OWUyZjg4LWJkNGYtNDFhYy04OGVjLWVkM2VhY2YzYTcwZgBGAAAAAACE3bU',
conversationId: 'AAQkADg1OWUyZjg4LWJkNGYtNDFhYy04OGVjLWVkM2VhY2YzYTcwZgAQAErzGBJV',
subject: 'Quarterly Business Review - Q1 2025',
from: 'manager@company.com',
to: 'team@company.com',
cc: 'stakeholders@company.com',
date: '2025-05-10T14:30:00Z',
bodyText:
'Hi Team,\n\nPlease find attached the Q1 2025 business review document. We need to discuss the results in our next meeting.\n\nBest regards,\nManager',
bodyHtml:
'<div><p>Hi Team,</p><p>Please find attached the Q1 2025 business review document. We need to discuss the results in our next meeting.</p><p>Best regards,<br>Manager</p></div>',
hasAttachments: true,
attachments: [],
isRead: false,
folderId: 'AQMkADg1OWUyZjg4LWJkNGYtNDFhYy04OGVjAC4AAAJzE3bU',
messageId: 'AAMkADg1OWUyZjg4LWJkNGYtNDFhYy04OGVjLWVkM2VhY2YzYTcwZgBGAAAAAACE3bU',
threadId: 'AAQkADg1OWUyZjg4LWJkNGYtNDFhYy04OGVjLWVkM2VhY2YzYTcwZgAQAErzGBJV',
},
timestamp: '2025-05-10T14:30:15.123Z',
},
}

View File

@@ -0,0 +1,40 @@
import { airtableWebhookTrigger } from '@/triggers/airtable'
import { genericWebhookTrigger } from '@/triggers/generic'
import { githubWebhookTrigger } from '@/triggers/github'
import { gmailPollingTrigger } from '@/triggers/gmail'
import { googleFormsWebhookTrigger } from '@/triggers/googleforms'
import {
microsoftTeamsChatSubscriptionTrigger,
microsoftTeamsWebhookTrigger,
} from '@/triggers/microsoftteams'
import { outlookPollingTrigger } from '@/triggers/outlook'
import { slackWebhookTrigger } from '@/triggers/slack'
import { stripeWebhookTrigger } from '@/triggers/stripe'
import { telegramWebhookTrigger } from '@/triggers/telegram'
import type { TriggerRegistry } from '@/triggers/types'
import {
webflowCollectionItemChangedTrigger,
webflowCollectionItemCreatedTrigger,
webflowCollectionItemDeletedTrigger,
webflowFormSubmissionTrigger,
} from '@/triggers/webflow'
import { whatsappWebhookTrigger } from '@/triggers/whatsapp'
export const TRIGGER_REGISTRY: TriggerRegistry = {
slack_webhook: slackWebhookTrigger,
airtable_webhook: airtableWebhookTrigger,
generic_webhook: genericWebhookTrigger,
github_webhook: githubWebhookTrigger,
gmail_poller: gmailPollingTrigger,
microsoftteams_webhook: microsoftTeamsWebhookTrigger,
microsoftteams_chat_subscription: microsoftTeamsChatSubscriptionTrigger,
outlook_poller: outlookPollingTrigger,
stripe_webhook: stripeWebhookTrigger,
telegram_webhook: telegramWebhookTrigger,
whatsapp_webhook: whatsappWebhookTrigger,
google_forms_webhook: googleFormsWebhookTrigger,
webflow_collection_item_created: webflowCollectionItemCreatedTrigger,
webflow_collection_item_changed: webflowCollectionItemChangedTrigger,
webflow_collection_item_deleted: webflowCollectionItemDeletedTrigger,
webflow_form_submission: webflowFormSubmissionTrigger,
}

View File

@@ -1,5 +1,5 @@
import { SlackIcon } from '@/components/icons'
import type { TriggerConfig } from '../types'
import type { TriggerConfig } from '@/triggers/types'
export const slackWebhookTrigger: TriggerConfig = {
id: 'slack_webhook',
@@ -9,16 +9,83 @@ export const slackWebhookTrigger: TriggerConfig = {
version: '1.0.0',
icon: SlackIcon,
configFields: {
signingSecret: {
type: 'string',
label: 'Signing Secret',
subBlocks: [
{
id: 'webhookUrlDisplay',
title: 'Webhook URL',
type: 'short-input',
readOnly: true,
showCopyButton: true,
useWebhookUrl: true,
placeholder: 'Webhook URL will be generated',
mode: 'trigger',
},
{
id: 'signingSecret',
title: 'Signing Secret',
type: 'short-input',
placeholder: 'Enter your Slack app signing secret',
description: 'The signing secret from your Slack app to validate request authenticity.',
password: true,
required: true,
isSecret: true,
mode: 'trigger',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
type: 'text',
defaultValue: [
'Go to <a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer" class="text-muted-foreground underline transition-colors hover:text-muted-foreground/80">Slack Apps page</a>',
'If you don\'t have an app:<br><ul class="mt-1 ml-5 list-disc"><li>Create an app from scratch</li><li>Give it a name and select your workspace</li></ul>',
'Go to "Basic Information", find the "Signing Secret", and paste it in the field above.',
'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.',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'slack_webhook',
},
{
id: 'samplePayload',
title: 'Event Payload Example',
type: 'code',
language: 'json',
defaultValue: JSON.stringify(
{
type: 'event_callback',
event: {
type: 'app_mention',
channel: 'C0123456789',
user: 'U0123456789',
text: '<@U0BOTUSER123> Hello from Slack!',
ts: '1234567890.123456',
channel_type: 'channel',
},
team_id: 'T0123456789',
event_id: 'Ev0123456789',
event_time: 1234567890,
},
null,
2
),
readOnly: true,
collapsible: true,
defaultCollapsed: true,
mode: 'trigger',
},
],
outputs: {
event: {
@@ -61,31 +128,6 @@ export const slackWebhookTrigger: TriggerConfig = {
},
},
instructions: [
'Go to <a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer" class="text-muted-foreground underline transition-colors hover:text-muted-foreground/80">Slack Apps page</a>',
'If you don\'t have an app:<br><ul class="mt-1 ml-5 list-disc"><li>Create an app from scratch</li><li>Give it a name and select your workspace</li></ul>',
'Go to "Basic Information", find the "Signing Secret", and paste it in the field above.',
'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 (from 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.',
],
samplePayload: {
type: 'event_callback',
event: {
type: 'app_mention',
channel: 'C0123456789',
user: 'U0123456789',
text: '<@U0BOTUSER123> Hello from Slack!',
ts: '1234567890.123456',
channel_type: 'channel',
},
team_id: 'T0123456789',
event_id: 'Ev0123456789',
event_time: 1234567890,
},
webhook: {
method: 'POST',
headers: {

View File

@@ -1,5 +1,5 @@
import { ShieldCheck } from 'lucide-react'
import type { TriggerConfig } from '../types'
import type { TriggerConfig } from '@/triggers/types'
export const stripeWebhookTrigger: TriggerConfig = {
id: 'stripe_webhook',
@@ -9,9 +9,84 @@ export const stripeWebhookTrigger: TriggerConfig = {
version: '1.0.0',
icon: ShieldCheck,
configFields: {
// Stripe webhooks don't require configuration fields - events are selected in Stripe dashboard
},
subBlocks: [
{
id: 'webhookUrlDisplay',
title: 'Webhook URL',
type: 'short-input',
readOnly: true,
showCopyButton: true,
useWebhookUrl: true,
placeholder: 'Webhook URL will be generated',
mode: 'trigger',
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
type: 'text',
defaultValue: [
'Go to your Stripe Dashboard at https://dashboard.stripe.com/',
'Navigate to Developers > Webhooks',
'Click "Add endpoint"',
'Paste the Webhook URL above into the "Endpoint URL" field',
'Select the events you want to listen to (e.g., charge.succeeded)',
'Click "Add endpoint"',
'Stripe will send a test event to verify your webhook endpoint',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'stripe_webhook',
},
{
id: 'samplePayload',
title: 'Event Payload Example',
type: 'code',
language: 'json',
defaultValue: JSON.stringify(
{
id: 'evt_1234567890',
type: 'charge.succeeded',
created: 1641234567,
data: {
object: {
id: 'ch_1234567890',
object: 'charge',
amount: 2500,
currency: 'usd',
description: 'Sample charge',
paid: true,
status: 'succeeded',
customer: 'cus_1234567890',
receipt_email: 'customer@example.com',
},
},
object: 'event',
livemode: false,
api_version: '2020-08-27',
request: {
id: 'req_1234567890',
idempotency_key: null,
},
},
null,
2
),
readOnly: true,
collapsible: true,
defaultCollapsed: true,
mode: 'trigger',
},
],
outputs: {
id: {
@@ -48,42 +123,6 @@ export const stripeWebhookTrigger: TriggerConfig = {
},
},
instructions: [
'Go to your Stripe Dashboard at https://dashboard.stripe.com/',
'Navigate to Developers > Webhooks',
'Click "Add endpoint"',
'Paste the Webhook URL (from above) into the "Endpoint URL" field',
'Select the events you want to listen to (e.g., charge.succeeded)',
'Click "Add endpoint"',
'Stripe will send a test event to verify your webhook endpoint',
],
samplePayload: {
id: 'evt_1234567890',
type: 'charge.succeeded',
created: 1641234567,
data: {
object: {
id: 'ch_1234567890',
object: 'charge',
amount: 2500,
currency: 'usd',
description: 'Sample charge',
paid: true,
status: 'succeeded',
customer: 'cus_1234567890',
receipt_email: 'customer@example.com',
},
},
object: 'event',
livemode: false,
api_version: '2020-08-27',
request: {
id: 'req_1234567890',
idempotency_key: null,
},
},
webhook: {
method: 'POST',
headers: {

View File

@@ -1,5 +1,5 @@
import { TelegramIcon } from '@/components/icons'
import type { TriggerConfig } from '../types'
import type { TriggerConfig } from '@/triggers/types'
export const telegramWebhookTrigger: TriggerConfig = {
id: 'telegram_webhook',
@@ -9,20 +9,97 @@ export const telegramWebhookTrigger: TriggerConfig = {
version: '1.0.0',
icon: TelegramIcon,
configFields: {
botToken: {
type: 'string',
label: 'Bot Token',
subBlocks: [
{
id: 'webhookUrlDisplay',
title: 'Webhook URL',
type: 'short-input',
readOnly: true,
showCopyButton: true,
useWebhookUrl: true,
placeholder: 'Webhook URL will be generated',
mode: 'trigger',
},
{
id: 'botToken',
title: 'Bot Token',
type: 'short-input',
placeholder: '123456789:ABCdefGHIjklMNOpqrsTUVwxyz',
description: 'Your Telegram Bot Token from BotFather',
password: true,
required: true,
isSecret: true,
mode: 'trigger',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
type: 'text',
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.',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'telegram_webhook',
},
{
id: 'samplePayload',
title: 'Event Payload Example',
type: 'code',
language: 'json',
defaultValue: JSON.stringify(
{
update_id: 123456789,
message: {
message_id: 123,
from: {
id: 987654321,
is_bot: false,
first_name: 'John',
last_name: 'Doe',
username: 'johndoe',
language_code: 'en',
},
chat: {
id: 987654321,
first_name: 'John',
last_name: 'Doe',
username: 'johndoe',
type: 'private',
},
date: 1234567890,
text: 'Hello from Telegram!',
entities: [
{
offset: 0,
length: 5,
type: 'bold',
},
],
},
},
null,
2
),
readOnly: true,
collapsible: true,
defaultCollapsed: true,
mode: 'trigger',
},
],
outputs: {
// Matches the formatted payload built in `formatWebhookInput` for provider "telegram"
// Supports tags like <telegram.message.text> and deep paths like <telegram.message.raw.chat.id>
message: {
id: {
type: 'number',
@@ -91,43 +168,6 @@ export const telegramWebhookTrigger: TriggerConfig = {
},
},
instructions: [
'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.',
],
samplePayload: {
update_id: 123456789,
message: {
message_id: 123,
from: {
id: 987654321,
is_bot: false,
first_name: 'John',
last_name: 'Doe',
username: 'johndoe',
language_code: 'en',
},
chat: {
id: 987654321,
first_name: 'John',
last_name: 'Doe',
username: 'johndoe',
type: 'private',
},
date: 1234567890,
text: 'Hello from Telegram!',
entities: [
{
offset: 0,
length: 5,
type: 'bold',
},
],
},
},
webhook: {
method: 'POST',
headers: {

View File

@@ -1,24 +1,3 @@
export type TriggerFieldType =
| 'string'
| 'boolean'
| 'select'
| 'number'
| 'multiselect'
| 'credential'
export interface TriggerConfigField {
type: TriggerFieldType
label: string
placeholder?: string
options?: string[]
defaultValue?: string | boolean | number | string[]
description?: string
required?: boolean
isSecret?: boolean
provider?: string // OAuth provider for credential type fields
requiredScopes?: string[] // Required OAuth scopes for credential type fields
}
export interface TriggerOutput {
type?: string
description?: string
@@ -35,27 +14,17 @@ export interface TriggerConfig {
// Optional icon component for UI display
icon?: React.ComponentType<{ className?: string }>
// Configuration fields that users need to fill
configFields: Record<string, TriggerConfigField>
// Subblocks define the UI and configuration (same as blocks)
subBlocks: import('@/blocks/types').SubBlockConfig[]
// Define the structure of data this trigger outputs to workflows
outputs: Record<string, TriggerOutput>
// Setup instructions for users
instructions: string[]
// Example payload for documentation
samplePayload: any
// Webhook configuration (for most triggers)
webhook?: {
method?: 'POST' | 'GET' | 'PUT' | 'DELETE'
headers?: Record<string, string>
}
// For triggers that require OAuth credentials (like Gmail)
requiresCredentials?: boolean
credentialProvider?: string // 'google-email', 'microsoft', etc.
}
export interface TriggerRegistry {

View File

@@ -10,27 +10,122 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = {
version: '1.0.0',
icon: WebflowIcon,
requiresCredentials: true,
credentialProvider: 'webflow',
configFields: {
siteId: {
type: 'select',
label: 'Site',
subBlocks: [
{
id: 'triggerCredentials',
title: 'Credentials',
type: 'oauth-input',
description: 'This trigger requires webflow credentials to access your account.',
provider: 'webflow',
requiredScopes: [],
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_changed',
},
},
{
id: 'siteId',
title: 'Site',
type: 'dropdown',
placeholder: 'Select a site',
description: 'The Webflow site to monitor',
required: true,
options: [],
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_changed',
},
},
collectionId: {
type: 'select',
label: 'Collection',
{
id: 'collectionId',
title: 'Collection',
type: 'dropdown',
placeholder: 'Select a collection (optional)',
description: 'Optionally filter to monitor only a specific collection',
required: false,
options: [],
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_changed',
},
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
type: 'text',
defaultValue: [
'Connect your Webflow account using the "Select Webflow credential" button above.',
'Enter your Webflow Site ID (found in the site URL or site settings).',
'Optionally enter a Collection ID to monitor only specific collections.',
'If no Collection ID is provided, the trigger will fire for items changed in any collection on the site.',
'The webhook will trigger whenever an existing item is updated in the specified collection(s).',
'Make sure your Webflow account has appropriate permissions for the specified site.',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_changed',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'webflow_collection_item_changed',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_changed',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',
type: 'code',
language: 'json',
defaultValue: JSON.stringify(
{
siteId: '68f9666057aa8abaa9b0b668',
workspaceId: '68f96081e7018465432953b5',
collectionId: '68f9666257aa8abaa9b0b6d6',
payload: {
id: '68fa8445de250e147cd95cfd',
cmsLocaleId: '68f9666257aa8abaa9b0b6c9',
lastPublished: '2024-01-15T14:45:00.000Z',
lastUpdated: '2024-01-15T14:45:00.000Z',
createdOn: '2024-01-15T10:30:00.000Z',
isArchived: false,
isDraft: false,
fieldData: {
name: 'Updated Blog Post',
slug: 'updated-blog-post',
'post-summary': 'This blog post has been updated',
featured: true,
},
},
},
null,
2
),
readOnly: true,
collapsible: true,
defaultCollapsed: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_changed',
},
},
],
outputs: {
siteId: {
@@ -57,36 +152,6 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = {
},
},
instructions: [
'Connect your Webflow account using the "Select Webflow credential" button above.',
'Enter your Webflow Site ID (found in the site URL or site settings).',
'Optionally enter a Collection ID to monitor only specific collections.',
'If no Collection ID is provided, the trigger will fire for items changed in any collection on the site.',
'The webhook will trigger whenever an existing item is updated in the specified collection(s).',
'Make sure your Webflow account has appropriate permissions for the specified site.',
],
samplePayload: {
siteId: '68f9666057aa8abaa9b0b668',
workspaceId: '68f96081e7018465432953b5',
collectionId: '68f9666257aa8abaa9b0b6d6',
payload: {
id: '68fa8445de250e147cd95cfd',
cmsLocaleId: '68f9666257aa8abaa9b0b6c9',
lastPublished: '2024-01-15T14:45:00.000Z',
lastUpdated: '2024-01-15T14:45:00.000Z',
createdOn: '2024-01-15T10:30:00.000Z',
isArchived: false,
isDraft: false,
fieldData: {
name: 'Updated Blog Post',
slug: 'updated-blog-post',
'post-summary': 'This blog post has been updated',
featured: true,
},
},
},
webhook: {
method: 'POST',
headers: {

View File

@@ -10,28 +10,135 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = {
version: '1.0.0',
icon: WebflowIcon,
// Webflow requires OAuth credentials to create webhooks
requiresCredentials: true,
credentialProvider: 'webflow',
configFields: {
siteId: {
type: 'select',
label: 'Site',
subBlocks: [
{
id: 'selectedTriggerId',
title: 'Trigger Type',
type: 'dropdown',
mode: 'trigger',
options: [
{ label: 'Collection Item Created', id: 'webflow_collection_item_created' },
{ label: 'Collection Item Changed', id: 'webflow_collection_item_changed' },
{ label: 'Collection Item Deleted', id: 'webflow_collection_item_deleted' },
],
value: () => 'webflow_collection_item_created',
required: true,
},
{
id: 'triggerCredentials',
title: 'Credentials',
type: 'oauth-input',
description: 'This trigger requires webflow credentials to access your account.',
provider: 'webflow',
requiredScopes: [],
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_created',
},
},
{
id: 'siteId',
title: 'Site',
type: 'dropdown',
placeholder: 'Select a site',
description: 'The Webflow site to monitor',
required: true,
options: [], // Will be populated dynamically from API
options: [],
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_created',
},
},
collectionId: {
type: 'select',
label: 'Collection',
{
id: 'collectionId',
title: 'Collection',
type: 'dropdown',
placeholder: 'Select a collection (optional)',
description: 'Optionally filter to monitor only a specific collection',
required: false,
options: [], // Will be populated dynamically based on selected site
options: [],
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_created',
},
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
type: 'text',
defaultValue: [
'Connect your Webflow account using the "Select Webflow credential" button above.',
'Enter your Webflow Site ID (found in the site URL or site settings).',
'Optionally enter a Collection ID to monitor only specific collections.',
'If no Collection ID is provided, the trigger will fire for items created in any collection on the site.',
'The webhook will trigger whenever a new item is created in the specified collection(s).',
'Make sure your Webflow account has appropriate permissions for the specified site.',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_created',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'webflow_collection_item_created',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_created',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',
type: 'code',
language: 'json',
defaultValue: JSON.stringify(
{
siteId: '68f9666057aa8abaa9b0b668',
workspaceId: '68f96081e7018465432953b5',
collectionId: '68f9666257aa8abaa9b0b6d6',
payload: {
id: '68fa8445de250e147cd95cfd',
cmsLocaleId: '68f9666257aa8abaa9b0b6c9',
lastPublished: '2024-01-15T10:30:00.000Z',
lastUpdated: '2024-01-15T10:30:00.000Z',
createdOn: '2024-01-15T10:30:00.000Z',
isArchived: false,
isDraft: false,
fieldData: {
name: 'Sample Blog Post',
slug: 'sample-blog-post',
'post-summary': 'This is a sample blog post created in the collection',
featured: false,
},
},
},
null,
2
),
readOnly: true,
collapsible: true,
defaultCollapsed: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_created',
},
},
],
outputs: {
siteId: {
@@ -58,36 +165,6 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = {
},
},
instructions: [
'Connect your Webflow account using the "Select Webflow credential" button above.',
'Enter your Webflow Site ID (found in the site URL or site settings).',
'Optionally enter a Collection ID to monitor only specific collections.',
'If no Collection ID is provided, the trigger will fire for items created in any collection on the site.',
'The webhook will trigger whenever a new item is created in the specified collection(s).',
'Make sure your Webflow account has appropriate permissions for the specified site.',
],
samplePayload: {
siteId: '68f9666057aa8abaa9b0b668',
workspaceId: '68f96081e7018465432953b5',
collectionId: '68f9666257aa8abaa9b0b6d6',
payload: {
id: '68fa8445de250e147cd95cfd',
cmsLocaleId: '68f9666257aa8abaa9b0b6c9',
lastPublished: '2024-01-15T10:30:00.000Z',
lastUpdated: '2024-01-15T10:30:00.000Z',
createdOn: '2024-01-15T10:30:00.000Z',
isArchived: false,
isDraft: false,
fieldData: {
name: 'Sample Blog Post',
slug: 'sample-blog-post',
'post-summary': 'This is a sample blog post created in the collection',
featured: false,
},
},
},
webhook: {
method: 'POST',
headers: {

View File

@@ -10,27 +10,112 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = {
version: '1.0.0',
icon: WebflowIcon,
requiresCredentials: true,
credentialProvider: 'webflow',
configFields: {
siteId: {
type: 'select',
label: 'Site',
subBlocks: [
{
id: 'triggerCredentials',
title: 'Credentials',
type: 'oauth-input',
description: 'This trigger requires webflow credentials to access your account.',
provider: 'webflow',
requiredScopes: [],
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_deleted',
},
},
{
id: 'siteId',
title: 'Site',
type: 'dropdown',
placeholder: 'Select a site',
description: 'The Webflow site to monitor',
required: true,
options: [],
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_deleted',
},
},
collectionId: {
type: 'select',
label: 'Collection',
{
id: 'collectionId',
title: 'Collection',
type: 'dropdown',
placeholder: 'Select a collection (optional)',
description: 'Optionally filter to monitor only a specific collection',
required: false,
options: [],
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_deleted',
},
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
type: 'text',
defaultValue: [
'Connect your Webflow account using the "Select Webflow credential" button above.',
'Enter your Webflow Site ID (found in the site URL or site settings).',
'Optionally enter a Collection ID to monitor only specific collections.',
'If no Collection ID is provided, the trigger will fire for items deleted in any collection on the site.',
'The webhook will trigger whenever an item is deleted from the specified collection(s).',
'Note: Once an item is deleted, only minimal information (ID, collection, site) is available.',
'Make sure your Webflow account has appropriate permissions for the specified site.',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_deleted',
},
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'webflow_collection_item_deleted',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_deleted',
},
},
{
id: 'samplePayload',
title: 'Event Payload Example',
type: 'code',
language: 'json',
defaultValue: JSON.stringify(
{
siteId: '68f9666057aa8abaa9b0b668',
workspaceId: '68f96081e7018465432953b5',
collectionId: '68f9666257aa8abaa9b0b6d6',
payload: {
id: '68fa8445de250e147cd95cfd',
deletedOn: '2024-01-15T16:20:00.000Z',
},
},
null,
2
),
readOnly: true,
collapsible: true,
defaultCollapsed: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'webflow_collection_item_deleted',
},
},
],
outputs: {
siteId: {
@@ -51,26 +136,6 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = {
},
},
instructions: [
'Connect your Webflow account using the "Select Webflow credential" button above.',
'Enter your Webflow Site ID (found in the site URL or site settings).',
'Optionally enter a Collection ID to monitor only specific collections.',
'If no Collection ID is provided, the trigger will fire for items deleted in any collection on the site.',
'The webhook will trigger whenever an item is deleted from the specified collection(s).',
'Note: Once an item is deleted, only minimal information (ID, collection, site) is available.',
'Make sure your Webflow account has appropriate permissions for the specified site.',
],
samplePayload: {
siteId: '68f9666057aa8abaa9b0b668',
workspaceId: '68f96081e7018465432953b5',
collectionId: '68f9666257aa8abaa9b0b6d6',
payload: {
id: '68fa8445de250e147cd95cfd',
deletedOn: '2024-01-15T16:20:00.000Z',
},
},
webhook: {
method: 'POST',
headers: {

View File

@@ -10,26 +10,99 @@ export const webflowFormSubmissionTrigger: TriggerConfig = {
version: '1.0.0',
icon: WebflowIcon,
requiresCredentials: true,
credentialProvider: 'webflow',
configFields: {
siteId: {
type: 'select',
label: 'Site',
subBlocks: [
{
id: 'triggerCredentials',
title: 'Credentials',
type: 'oauth-input',
description: 'This trigger requires webflow credentials to access your account.',
provider: 'webflow',
requiredScopes: [],
required: true,
mode: 'trigger',
},
{
id: 'siteId',
title: 'Site',
type: 'dropdown',
placeholder: 'Select a site',
description: 'The Webflow site to monitor',
required: true,
options: [],
mode: 'trigger',
},
formId: {
type: 'string',
label: 'Form ID',
{
id: 'formId',
title: 'Form ID',
type: 'short-input',
placeholder: 'form-123abc (optional)',
description: 'The ID of the specific form to monitor (optional - leave empty for all forms)',
required: false,
mode: 'trigger',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
type: 'text',
defaultValue: [
'Connect your Webflow account using the "Select Webflow credential" button above.',
'Enter your Webflow Site ID (found in the site URL or site settings).',
'Optionally enter a Form ID to monitor only a specific form.',
'If no Form ID is provided, the trigger will fire for any form submission on the site.',
'The webhook will trigger whenever a form is submitted on the specified site.',
'Form data will be included in the payload with all submitted field values.',
'Make sure your Webflow account has appropriate permissions for the specified site.',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'webflow_form_submission',
},
{
id: 'samplePayload',
title: 'Event Payload Example',
type: 'code',
language: 'json',
defaultValue: JSON.stringify(
{
siteId: '68f9666057aa8abaa9b0b668',
workspaceId: '68f96081e7018465432953b5',
name: 'Contact Form',
id: '68fa8445de250e147cd95cfd',
submittedAt: '2024-01-15T12:00:00.000Z',
data: {
name: 'John Doe',
email: 'john@example.com',
message: 'I would like more information about your services.',
'consent-checkbox': 'true',
},
schema: {
fields: [
{ name: 'name', type: 'text' },
{ name: 'email', type: 'email' },
{ name: 'message', type: 'textarea' },
],
},
formElementId: '68f9666257aa8abaa9b0b6e2',
},
null,
2
),
readOnly: true,
collapsible: true,
defaultCollapsed: true,
mode: 'trigger',
},
],
outputs: {
siteId: {
@@ -66,38 +139,6 @@ export const webflowFormSubmissionTrigger: TriggerConfig = {
},
},
instructions: [
'Connect your Webflow account using the "Select Webflow credential" button above.',
'Enter your Webflow Site ID (found in the site URL or site settings).',
'Optionally enter a Form ID to monitor only a specific form.',
'If no Form ID is provided, the trigger will fire for any form submission on the site.',
'The webhook will trigger whenever a form is submitted on the specified site.',
'Form data will be included in the payload with all submitted field values.',
'Make sure your Webflow account has appropriate permissions for the specified site.',
],
samplePayload: {
siteId: '68f9666057aa8abaa9b0b668',
workspaceId: '68f96081e7018465432953b5',
name: 'Contact Form',
id: '68fa8445de250e147cd95cfd',
submittedAt: '2024-01-15T12:00:00.000Z',
data: {
name: 'John Doe',
email: 'john@example.com',
message: 'I would like more information about your services.',
'consent-checkbox': 'true',
},
schema: {
fields: [
{ name: 'name', type: 'text' },
{ name: 'email', type: 'email' },
{ name: 'message', type: 'textarea' },
],
},
formElementId: '68f9666257aa8abaa9b0b6e2',
},
webhook: {
method: 'POST',
headers: {

View File

@@ -9,17 +9,110 @@ export const whatsappWebhookTrigger: TriggerConfig = {
version: '1.0.0',
icon: WhatsAppIcon,
configFields: {
verificationToken: {
type: 'string',
label: 'Verification Token',
subBlocks: [
{
id: 'webhookUrlDisplay',
title: 'Webhook URL',
type: 'short-input',
readOnly: true,
showCopyButton: true,
useWebhookUrl: true,
placeholder: 'Webhook URL will be generated',
mode: 'trigger',
},
{
id: 'verificationToken',
title: 'Verification Token',
type: 'short-input',
placeholder: 'Generate or enter a verification token',
description:
"Enter any secure token here. You'll need to provide the same token in your WhatsApp Business Platform dashboard.",
password: true,
required: true,
isSecret: true,
mode: 'trigger',
},
},
{
id: 'triggerInstructions',
title: 'Setup Instructions',
type: 'text',
defaultValue: [
'Go to your <a href="https://developers.facebook.com/apps/" target="_blank" rel="noopener noreferrer" class="text-muted-foreground underline transition-colors hover:text-muted-foreground/80">Meta for Developers Apps</a> page and navigate to the "Build with us" --> "App Events" section.',
'If you don\'t have an app:<br><ul class="mt-1 ml-5 list-disc"><li>Create an app from scratch</li><li>Give it a name and select your workspace</li></ul>',
'Select your App, then navigate to WhatsApp > Configuration.',
'Find the Webhooks section and click "Edit".',
'Paste the <strong>Webhook URL</strong> above into the "Callback URL" field.',
'Paste the <strong>Verification Token</strong> into the "Verify token" field.',
'Click "Verify and save".',
'Click "Manage" next to Webhook fields and subscribe to `messages`.',
]
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
mode: 'trigger',
triggerId: 'whatsapp_webhook',
},
{
id: 'samplePayload',
title: 'Event Payload Example',
type: 'code',
language: 'json',
defaultValue: JSON.stringify(
{
object: 'whatsapp_business_account',
entry: [
{
id: '1234567890123456',
changes: [
{
value: {
messaging_product: 'whatsapp',
metadata: {
display_phone_number: '15551234567',
phone_number_id: '1234567890123456',
},
contacts: [
{
profile: {
name: 'John Doe',
},
wa_id: '15555551234',
},
],
messages: [
{
from: '15555551234',
id: 'wamid.HBgNMTU1NTU1NTEyMzQVAgASGBQzQTdBNjg4QjU2NjZCMzY4ODE2AA==',
timestamp: '1234567890',
text: {
body: 'Hello from WhatsApp!',
},
type: 'text',
},
],
},
field: 'messages',
},
],
},
],
},
null,
2
),
readOnly: true,
collapsible: true,
defaultCollapsed: true,
mode: 'trigger',
},
],
outputs: {
messageId: {
@@ -48,57 +141,6 @@ export const whatsappWebhookTrigger: TriggerConfig = {
},
},
instructions: [
'Go to your <a href="https://developers.facebook.com/apps/" target="_blank" rel="noopener noreferrer" class="text-muted-foreground underline transition-colors hover:text-muted-foreground/80">Meta for Developers Apps</a> page and navigate to the "Build with us" --> "App Events" section.',
'If you don\'t have an app:<br><ul class="mt-1 ml-5 list-disc"><li>Create an app from scratch</li><li>Give it a name and select your workspace</li></ul>',
'Select your App, then navigate to WhatsApp > Configuration.',
'Find the Webhooks section and click "Edit".',
'Paste the <strong>Webhook URL</strong> (from above) into the "Callback URL" field.',
'Paste the <strong>Verification Token</strong> (from above) into the "Verify token" field.',
'Click "Verify and save".',
'Click "Manage" next to Webhook fields and subscribe to `messages`.',
],
samplePayload: {
object: 'whatsapp_business_account',
entry: [
{
id: '1234567890123456',
changes: [
{
value: {
messaging_product: 'whatsapp',
metadata: {
display_phone_number: '15551234567',
phone_number_id: '1234567890123456',
},
contacts: [
{
profile: {
name: 'John Doe',
},
wa_id: '15555551234',
},
],
messages: [
{
from: '15555551234',
id: 'wamid.HBgNMTU1NTU1NTEyMzQVAgASGBQzQTdBNjg4QjU2NjZCMzY4ODE2AA==',
timestamp: '1234567890',
text: {
body: 'Hello from WhatsApp!',
},
type: 'text',
},
],
},
field: 'messages',
},
],
},
],
},
webhook: {
method: 'POST',
headers: {