mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -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}`)
|
||||
|
||||
@@ -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}.`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, '<').replace(/>/g, '>')
|
||||
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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
161
apps/sim/hooks/use-trigger-config-aggregation.ts
Normal file
161
apps/sim/hooks/use-trigger-config-aggregation.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
395
apps/sim/hooks/use-webhook-management.ts
Normal file
395
apps/sim/hooks/use-webhook-management.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
16
apps/sim/triggers/consts.ts
Normal file
16
apps/sim/triggers/consts.ts
Normal 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)
|
||||
]
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
1
apps/sim/triggers/googleforms/index.ts
Normal file
1
apps/sim/triggers/googleforms/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { googleFormsWebhookTrigger } from './webhook'
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
40
apps/sim/triggers/registry.ts
Normal file
40
apps/sim/triggers/registry.ts
Normal 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,
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user