mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
v0.3.32: loop block max increase, url-encoded API calls, subflow logs, new supabase tools
This commit is contained in:
@@ -142,7 +142,7 @@ Get a single row from a Supabase table based on filter criteria
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `results` | object | The row data if found, null if not found |
|
||||
| `results` | array | Array containing the row data if found, empty array if not found |
|
||||
|
||||
### `supabase_update`
|
||||
|
||||
@@ -185,6 +185,26 @@ Delete rows from a Supabase table based on filter criteria
|
||||
| `message` | string | Operation status message |
|
||||
| `results` | array | Array of deleted records |
|
||||
|
||||
### `supabase_upsert`
|
||||
|
||||
Insert or update data in a Supabase table (upsert operation)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) |
|
||||
| `table` | string | Yes | The name of the Supabase table to upsert data into |
|
||||
| `data` | any | Yes | The data to upsert \(insert or update\) |
|
||||
| `apiKey` | string | Yes | Your Supabase service role secret key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `results` | array | Array of upserted records |
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function GET(request: NextRequest) {
|
||||
// Fetch the file from Google Drive API
|
||||
logger.info(`[${requestId}] Fetching file ${fileId} from Google Drive API`)
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks`,
|
||||
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks,shortcutDetails&supportsAllDrives=true`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
@@ -77,6 +77,34 @@ export async function GET(request: NextRequest) {
|
||||
'application/vnd.google-apps.presentation': 'application/pdf', // Google Slides to PDF
|
||||
}
|
||||
|
||||
// Resolve shortcuts transparently for UI stability
|
||||
if (
|
||||
file.mimeType === 'application/vnd.google-apps.shortcut' &&
|
||||
file.shortcutDetails?.targetId
|
||||
) {
|
||||
const targetId = file.shortcutDetails.targetId
|
||||
const shortcutResp = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files/${targetId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks&supportsAllDrives=true`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
}
|
||||
)
|
||||
if (shortcutResp.ok) {
|
||||
const targetFile = await shortcutResp.json()
|
||||
file.id = targetFile.id
|
||||
file.name = targetFile.name
|
||||
file.mimeType = targetFile.mimeType
|
||||
file.iconLink = targetFile.iconLink
|
||||
file.webViewLink = targetFile.webViewLink
|
||||
file.thumbnailLink = targetFile.thumbnailLink
|
||||
file.createdTime = targetFile.createdTime
|
||||
file.modifiedTime = targetFile.modifiedTime
|
||||
file.size = targetFile.size
|
||||
file.owners = targetFile.owners
|
||||
file.exportLinks = targetFile.exportLinks
|
||||
}
|
||||
}
|
||||
|
||||
// If the file is a Google Docs, Sheets, or Slides file, we need to provide the export link
|
||||
if (file.mimeType.startsWith('application/vnd.google-apps.')) {
|
||||
const format = exportFormats[file.mimeType] || 'application/pdf'
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { account } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -32,64 +30,48 @@ export async function GET(request: NextRequest) {
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const mimeType = searchParams.get('mimeType')
|
||||
const query = searchParams.get('query') || ''
|
||||
const folderId = searchParams.get('folderId') || searchParams.get('parentId') || ''
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
|
||||
if (!credentialId) {
|
||||
logger.warn(`[${requestId}] Missing credential ID`)
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get the credential from the database
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`, { credentialId })
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
|
||||
// Check if the credential belongs to the user
|
||||
if (credential.userId !== session.user.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
|
||||
credentialUserId: credential.userId,
|
||||
requestUserId: session.user.id,
|
||||
})
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
// Authorize use of the credential (supports collaborator credentials via workflow)
|
||||
const authz = await authorizeCredentialUse(request, { credentialId: credentialId!, workflowId })
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
logger.warn(`[${requestId}] Unauthorized credential access attempt`, authz)
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Refresh access token if needed using the utility function
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId!,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Build the query parameters for Google Drive API
|
||||
let queryParams = 'trashed=false'
|
||||
|
||||
// Add mimeType filter if provided
|
||||
// Build Drive 'q' expression safely
|
||||
const qParts: string[] = ['trashed = false']
|
||||
if (folderId) {
|
||||
qParts.push(`'${folderId.replace(/'/g, "\\'")}' in parents`)
|
||||
}
|
||||
if (mimeType) {
|
||||
// For Google Drive API, we need to use 'q' parameter for mimeType filtering
|
||||
// Instead of using the mimeType parameter directly, we'll add it to the query
|
||||
if (queryParams.includes('q=')) {
|
||||
queryParams += ` and mimeType='${mimeType}'`
|
||||
} else {
|
||||
queryParams += `&q=mimeType='${mimeType}'`
|
||||
}
|
||||
qParts.push(`mimeType = '${mimeType.replace(/'/g, "\\'")}'`)
|
||||
}
|
||||
|
||||
// Add search query if provided
|
||||
if (query) {
|
||||
if (queryParams.includes('q=')) {
|
||||
queryParams += ` and name contains '${query}'`
|
||||
} else {
|
||||
queryParams += `&q=name contains '${query}'`
|
||||
}
|
||||
qParts.push(`name contains '${query.replace(/'/g, "\\'")}'`)
|
||||
}
|
||||
const q = encodeURIComponent(qParts.join(' and '))
|
||||
|
||||
// Fetch files from Google Drive API
|
||||
// Fetch files from Google Drive API with shared drives support
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files?${queryParams}&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners)`,
|
||||
`https://www.googleapis.com/drive/v3/files?q=${q}&supportsAllDrives=true&includeItemsFromAllDrives=true&spaces=drive&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,parents)`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
|
||||
@@ -329,7 +329,7 @@ export async function POST(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`)
|
||||
try {
|
||||
const { configureGmailPolling } = await import('@/lib/webhooks/utils')
|
||||
// Use workflow owner for OAuth lookups to support collaborator-saved credentials
|
||||
// Pass workflow owner for backward-compat fallback (utils prefers credentialId if present)
|
||||
const success = await configureGmailPolling(workflowRecord.userId, savedWebhook, requestId)
|
||||
|
||||
if (!success) {
|
||||
@@ -364,7 +364,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
try {
|
||||
const { configureOutlookPolling } = await import('@/lib/webhooks/utils')
|
||||
// Use workflow owner for OAuth lookups to support collaborator-saved credentials
|
||||
// Pass workflow owner for backward-compat fallback (utils prefers credentialId if present)
|
||||
const success = await configureOutlookPolling(
|
||||
workflowRecord.userId,
|
||||
savedWebhook,
|
||||
|
||||
@@ -82,14 +82,21 @@ function transformBlockData(data: any, blockType: string, isInput: boolean) {
|
||||
interface CollapsibleInputOutputProps {
|
||||
span: TraceSpan
|
||||
spanId: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
function CollapsibleInputOutput({ span, spanId }: CollapsibleInputOutputProps) {
|
||||
function CollapsibleInputOutput({ span, spanId, depth }: CollapsibleInputOutputProps) {
|
||||
const [inputExpanded, setInputExpanded] = useState(false)
|
||||
const [outputExpanded, setOutputExpanded] = useState(false)
|
||||
|
||||
// Calculate the left margin based on depth to match the parent span's indentation
|
||||
const leftMargin = depth * 16 + 8 + 24 // Base depth indentation + icon width + extra padding
|
||||
|
||||
return (
|
||||
<div className='mt-2 mr-4 mb-4 ml-8 space-y-3 overflow-hidden'>
|
||||
<div
|
||||
className='mt-2 mr-4 mb-4 space-y-3 overflow-hidden'
|
||||
style={{ marginLeft: `${leftMargin}px` }}
|
||||
>
|
||||
{/* Input Data - Collapsible */}
|
||||
{span.input && (
|
||||
<div>
|
||||
@@ -162,26 +169,30 @@ function BlockDataDisplay({
|
||||
if (value === undefined) return <span className='text-muted-foreground italic'>undefined</span>
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return <span className='break-all text-green-700 dark:text-green-400'>"{value}"</span>
|
||||
return <span className='break-all text-emerald-700 dark:text-emerald-400'>"{value}"</span>
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return <span className='text-blue-700 dark:text-blue-400'>{value}</span>
|
||||
return <span className='font-mono text-blue-700 dark:text-blue-400'>{value}</span>
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return <span className='text-purple-700 dark:text-purple-400'>{value.toString()}</span>
|
||||
return (
|
||||
<span className='font-mono text-amber-700 dark:text-amber-400'>{value.toString()}</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return <span className='text-muted-foreground'>[]</span>
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='space-y-0.5'>
|
||||
<span className='text-muted-foreground'>[</span>
|
||||
<div className='ml-4 space-y-1'>
|
||||
<div className='ml-2 space-y-0.5'>
|
||||
{value.map((item, index) => (
|
||||
<div key={index} className='flex min-w-0 gap-2'>
|
||||
<span className='flex-shrink-0 text-muted-foreground text-xs'>{index}:</span>
|
||||
<div key={index} className='flex min-w-0 gap-1.5'>
|
||||
<span className='flex-shrink-0 font-mono text-slate-600 text-xs dark:text-slate-400'>
|
||||
{index}:
|
||||
</span>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(item)}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -196,10 +207,10 @@ function BlockDataDisplay({
|
||||
if (entries.length === 0) return <span className='text-muted-foreground'>{'{}'}</span>
|
||||
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='space-y-0.5'>
|
||||
{entries.map(([objKey, objValue]) => (
|
||||
<div key={objKey} className='flex min-w-0 gap-2'>
|
||||
<span className='flex-shrink-0 font-medium text-orange-700 dark:text-orange-400'>
|
||||
<div key={objKey} className='flex min-w-0 gap-1.5'>
|
||||
<span className='flex-shrink-0 font-medium text-indigo-700 dark:text-indigo-400'>
|
||||
{objKey}:
|
||||
</span>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(objValue, objKey)}</div>
|
||||
@@ -227,12 +238,12 @@ function BlockDataDisplay({
|
||||
{transformedData &&
|
||||
Object.keys(transformedData).filter((key) => key !== 'error' && key !== 'success')
|
||||
.length > 0 && (
|
||||
<div className='space-y-1'>
|
||||
<div className='space-y-0.5'>
|
||||
{Object.entries(transformedData)
|
||||
.filter(([key]) => key !== 'error' && key !== 'success')
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className='flex gap-2'>
|
||||
<span className='font-medium text-orange-700 dark:text-orange-400'>{key}:</span>
|
||||
<div key={key} className='flex gap-1.5'>
|
||||
<span className='font-medium text-indigo-700 dark:text-indigo-400'>{key}:</span>
|
||||
{renderValue(value, key)}
|
||||
</div>
|
||||
))}
|
||||
@@ -592,7 +603,9 @@ function TraceSpanItem({
|
||||
{expanded && (
|
||||
<div>
|
||||
{/* Block Input/Output Data - Collapsible */}
|
||||
{(span.input || span.output) && <CollapsibleInputOutput span={span} spanId={spanId} />}
|
||||
{(span.input || span.output) && (
|
||||
<CollapsibleInputOutput span={span} spanId={spanId} depth={depth} />
|
||||
)}
|
||||
|
||||
{/* Children and tool calls */}
|
||||
{/* Render child spans */}
|
||||
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
type SlackChannelInfo,
|
||||
SlackChannelSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
interface ChannelSelectorInputProps {
|
||||
blockId: string
|
||||
@@ -29,8 +29,6 @@ export function ChannelSelectorInput({
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
}: ChannelSelectorInputProps) {
|
||||
const { getValue } = useSubBlockStore()
|
||||
|
||||
// Use the proper hook to get the current value and setter (same as file-selector)
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
// Reactive upstream fields
|
||||
@@ -43,6 +41,8 @@ export function ChannelSelectorInput({
|
||||
// Get provider-specific values
|
||||
const provider = subBlock.provider || 'slack'
|
||||
const isSlack = provider === 'slack'
|
||||
// Central dependsOn gating
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
|
||||
// Get the credential for the provider - use provided credential or fall back to reactive values
|
||||
let credential: string
|
||||
@@ -89,15 +89,10 @@ export function ChannelSelectorInput({
|
||||
}}
|
||||
credential={credential}
|
||||
label={subBlock.placeholder || 'Select Slack channel'}
|
||||
disabled={disabled || !credential}
|
||||
disabled={finalDisabled}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!credential && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select a Slack account or enter a bot token first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
@@ -26,7 +26,6 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
const logger = createLogger('CredentialSelector')
|
||||
|
||||
@@ -217,17 +216,6 @@ export function CredentialSelector({
|
||||
setSelectedId(credentialId)
|
||||
if (!isPreview) {
|
||||
setStoreValue(credentialId)
|
||||
// If credential changed, clear other sub-block fields for a clean state
|
||||
if (previousId && previousId !== credentialId) {
|
||||
const wfId = (activeWorkflowId as string) || ''
|
||||
const workflowValues = useSubBlockStore.getState().workflowValues[wfId] || {}
|
||||
const blockValues = workflowValues[blockId] || {}
|
||||
Object.keys(blockValues).forEach((key) => {
|
||||
if (key !== subBlock.id) {
|
||||
collaborativeSetSubblockValue(blockId, key, '')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
|
||||
@@ -65,6 +66,9 @@ export function DocumentSelector({
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
const isDisabled = finalDisabled
|
||||
|
||||
// Fetch documents for the selected knowledge base
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
if (!knowledgeBaseId) {
|
||||
@@ -103,6 +107,7 @@ export function DocumentSelector({
|
||||
// Handle dropdown open/close - fetch documents when opening
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (isPreview) return
|
||||
if (isDisabled) return
|
||||
|
||||
setOpen(isOpen)
|
||||
|
||||
@@ -124,13 +129,14 @@ export function DocumentSelector({
|
||||
|
||||
// Sync selected document with value prop
|
||||
useEffect(() => {
|
||||
if (isDisabled) return
|
||||
if (value && documents.length > 0) {
|
||||
const docInfo = documents.find((doc) => doc.id === value)
|
||||
setSelectedDocument(docInfo || null)
|
||||
} else {
|
||||
setSelectedDocument(null)
|
||||
}
|
||||
}, [value, documents])
|
||||
}, [value, documents, isDisabled])
|
||||
|
||||
// Reset documents when knowledge base changes
|
||||
useEffect(() => {
|
||||
@@ -141,10 +147,10 @@ export function DocumentSelector({
|
||||
|
||||
// Fetch documents when knowledge base is available
|
||||
useEffect(() => {
|
||||
if (knowledgeBaseId && !isPreview) {
|
||||
if (knowledgeBaseId && !isPreview && !isDisabled) {
|
||||
fetchDocuments()
|
||||
}
|
||||
}, [knowledgeBaseId, isPreview, fetchDocuments])
|
||||
}, [knowledgeBaseId, isPreview, isDisabled, fetchDocuments])
|
||||
|
||||
const formatDocumentName = (document: DocumentData) => {
|
||||
return document.filename
|
||||
@@ -166,9 +172,6 @@ export function DocumentSelector({
|
||||
|
||||
const label = subBlock.placeholder || 'Select document'
|
||||
|
||||
// Show disabled state if no knowledge base is selected
|
||||
const isDisabled = disabled || isPreview || !knowledgeBaseId
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
|
||||
@@ -376,6 +376,14 @@ export function ConfluenceFileSelector({
|
||||
}
|
||||
}, [value])
|
||||
|
||||
// Clear preview when value is cleared (e.g., collaborator cleared or domain change cascade)
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSelectedFile(null)
|
||||
onFileInfoChange?.(null)
|
||||
}
|
||||
}, [value, onFileInfoChange])
|
||||
|
||||
// Handle file selection
|
||||
const handleSelectFile = (file: ConfluenceFileInfo) => {
|
||||
setSelectedFileId(file.id)
|
||||
@@ -547,7 +555,7 @@ export function ConfluenceFileSelector({
|
||||
</Popover>
|
||||
|
||||
{/* File preview */}
|
||||
{showPreview && selectedFile && (
|
||||
{showPreview && selectedFile && selectedFileId && selectedFile.id === selectedFileId && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
|
||||
@@ -414,6 +414,13 @@ export function GoogleDrivePicker({
|
||||
return <FileIcon className={`${iconSize} text-muted-foreground`} />
|
||||
}
|
||||
|
||||
const canShowPreview = !!(
|
||||
showPreview &&
|
||||
selectedFile &&
|
||||
selectedFileId &&
|
||||
selectedFile.id === selectedFileId
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
@@ -440,7 +447,7 @@ export function GoogleDrivePicker({
|
||||
}}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{selectedFile ? (
|
||||
{canShowPreview ? (
|
||||
<>
|
||||
{getFileIcon(selectedFile, 'sm')}
|
||||
<span className='truncate font-normal'>{selectedFile.name}</span>
|
||||
@@ -460,7 +467,7 @@ export function GoogleDrivePicker({
|
||||
</Button>
|
||||
|
||||
{/* File preview */}
|
||||
{showPreview && selectedFile && (
|
||||
{canShowPreview && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
|
||||
@@ -727,6 +727,13 @@ export function MicrosoftFileSelector({
|
||||
})
|
||||
: availableFiles
|
||||
|
||||
const canShowPreview = !!(
|
||||
showPreview &&
|
||||
selectedFile &&
|
||||
selectedFileId &&
|
||||
selectedFile.id === selectedFileId
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
@@ -750,7 +757,7 @@ export function MicrosoftFileSelector({
|
||||
}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{selectedFile ? (
|
||||
{canShowPreview ? (
|
||||
<>
|
||||
{getFileIcon(selectedFile, 'sm')}
|
||||
<span className='truncate font-normal'>{selectedFile.name}</span>
|
||||
@@ -925,7 +932,7 @@ export function MicrosoftFileSelector({
|
||||
</Popover>
|
||||
|
||||
{/* File preview */}
|
||||
{showPreview && selectedFile && (
|
||||
{canShowPreview && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import {
|
||||
type ConfluenceFileInfo,
|
||||
ConfluenceFileSelector,
|
||||
type DiscordChannelInfo,
|
||||
DiscordChannelSelector,
|
||||
type FileInfo,
|
||||
type GoogleCalendarInfo,
|
||||
GoogleCalendarSelector,
|
||||
GoogleDrivePicker,
|
||||
type JiraIssueInfo,
|
||||
JiraIssueSelector,
|
||||
type MicrosoftFileInfo,
|
||||
MicrosoftFileSelector,
|
||||
type TeamsMessageInfo,
|
||||
TeamsMessageSelector,
|
||||
WealthboxFileSelector,
|
||||
type WealthboxItemInfo,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
interface FileSelectorInputProps {
|
||||
blockId: string
|
||||
@@ -46,11 +37,12 @@ export function FileSelectorInput({
|
||||
previewValue,
|
||||
previewContextValues,
|
||||
}: FileSelectorInputProps) {
|
||||
const { getValue } = useSubBlockStore()
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const params = useParams()
|
||||
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
|
||||
// Central dependsOn gating for this selector instance
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
|
||||
// Helper to coerce various preview value shapes into a string ID
|
||||
const coerceToIdString = (val: unknown): string => {
|
||||
@@ -72,18 +64,13 @@ export function FileSelectorInput({
|
||||
// Use the proper hook to get the current value and setter
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
|
||||
const [selectedFileId, setSelectedFileId] = useState<string>('')
|
||||
const [_fileInfo, setFileInfo] = useState<FileInfo | ConfluenceFileInfo | null>(null)
|
||||
const [selectedIssueId, setSelectedIssueId] = useState<string>('')
|
||||
const [_issueInfo, setIssueInfo] = useState<JiraIssueInfo | null>(null)
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
|
||||
const [channelInfo, setChannelInfo] = useState<DiscordChannelInfo | null>(null)
|
||||
const [selectedMessageId, setSelectedMessageId] = useState<string>('')
|
||||
const [messageInfo, setMessageInfo] = useState<TeamsMessageInfo | null>(null)
|
||||
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('')
|
||||
const [calendarInfo, setCalendarInfo] = useState<GoogleCalendarInfo | null>(null)
|
||||
const [selectedWealthboxItemId, setSelectedWealthboxItemId] = useState<string>('')
|
||||
const [wealthboxItemInfo, setWealthboxItemInfo] = useState<WealthboxItemInfo | null>(null)
|
||||
const [domainValue] = useSubBlockValue(blockId, 'domain')
|
||||
const [projectIdValue] = useSubBlockValue(blockId, 'projectId')
|
||||
const [planIdValue] = useSubBlockValue(blockId, 'planId')
|
||||
const [teamIdValue] = useSubBlockValue(blockId, 'teamId')
|
||||
const [operationValue] = useSubBlockValue(blockId, 'operation')
|
||||
const [serverIdValue] = useSubBlockValue(blockId, 'serverId')
|
||||
const [botTokenValue] = useSubBlockValue(blockId, 'botToken')
|
||||
|
||||
// Determine if the persisted credential belongs to the current viewer
|
||||
const { isForeignCredential } = useForeignCredential(
|
||||
@@ -104,128 +91,29 @@ export function FileSelectorInput({
|
||||
const isWealthbox = provider === 'wealthbox'
|
||||
const isMicrosoftSharePoint = provider === 'microsoft' && subBlock.serviceId === 'sharepoint'
|
||||
const isMicrosoftPlanner = provider === 'microsoft-planner'
|
||||
|
||||
// For Confluence and Jira, we need the domain and credentials
|
||||
const domain =
|
||||
isConfluence || isJira
|
||||
? (isPreview && previewContextValues?.domain?.value) ||
|
||||
(getValue(blockId, 'domain') as string) ||
|
||||
''
|
||||
? (isPreview && previewContextValues?.domain?.value) || (domainValue as string) || ''
|
||||
: ''
|
||||
const jiraCredential = isJira
|
||||
? (isPreview && previewContextValues?.credential?.value) ||
|
||||
(getValue(blockId, 'credential') as string) ||
|
||||
(connectedCredential as string) ||
|
||||
''
|
||||
: ''
|
||||
|
||||
// For Discord, we need the bot token and server ID
|
||||
const botToken = isDiscord
|
||||
? (isPreview && previewContextValues?.botToken?.value) ||
|
||||
(getValue(blockId, 'botToken') as string) ||
|
||||
''
|
||||
? (isPreview && previewContextValues?.botToken?.value) || (botTokenValue as string) || ''
|
||||
: ''
|
||||
const serverId = isDiscord
|
||||
? (isPreview && previewContextValues?.serverId?.value) ||
|
||||
(getValue(blockId, 'serverId') as string) ||
|
||||
''
|
||||
? (isPreview && previewContextValues?.serverId?.value) || (serverIdValue as string) || ''
|
||||
: ''
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
// Keep local selection in sync with store value (and preview)
|
||||
useEffect(() => {
|
||||
const raw = isPreview && previewValue !== undefined ? previewValue : storeValue
|
||||
const effective = coerceToIdString(raw)
|
||||
if (effective) {
|
||||
if (isJira) {
|
||||
setSelectedIssueId(effective)
|
||||
} else if (isDiscord) {
|
||||
setSelectedChannelId(effective)
|
||||
} else if (isMicrosoftTeams) {
|
||||
setSelectedMessageId(effective)
|
||||
} else if (isGoogleCalendar) {
|
||||
setSelectedCalendarId(effective)
|
||||
} else if (isWealthbox) {
|
||||
setSelectedWealthboxItemId(effective)
|
||||
} else if (isMicrosoftSharePoint) {
|
||||
setSelectedFileId(effective)
|
||||
} else {
|
||||
setSelectedFileId(effective)
|
||||
}
|
||||
} else {
|
||||
// Clear when value becomes empty
|
||||
if (isJira) {
|
||||
setSelectedIssueId('')
|
||||
} else if (isDiscord) {
|
||||
setSelectedChannelId('')
|
||||
} else if (isMicrosoftTeams) {
|
||||
setSelectedMessageId('')
|
||||
} else if (isGoogleCalendar) {
|
||||
setSelectedCalendarId('')
|
||||
} else if (isWealthbox) {
|
||||
setSelectedWealthboxItemId('')
|
||||
} else if (isMicrosoftSharePoint) {
|
||||
setSelectedFileId('')
|
||||
} else {
|
||||
setSelectedFileId('')
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isPreview,
|
||||
previewValue,
|
||||
storeValue,
|
||||
isJira,
|
||||
isDiscord,
|
||||
isMicrosoftTeams,
|
||||
isGoogleCalendar,
|
||||
isWealthbox,
|
||||
isMicrosoftSharePoint,
|
||||
])
|
||||
|
||||
// Handle file selection
|
||||
const handleFileChange = (fileId: string, info?: any) => {
|
||||
setSelectedFileId(fileId)
|
||||
setFileInfo(info || null)
|
||||
setStoreValue(fileId)
|
||||
}
|
||||
|
||||
// Handle issue selection
|
||||
const handleIssueChange = (issueKey: string, info?: JiraIssueInfo) => {
|
||||
setSelectedIssueId(issueKey)
|
||||
setIssueInfo(info || null)
|
||||
setStoreValue(issueKey)
|
||||
|
||||
// Clear the fields when a new issue is selected
|
||||
if (isJira) {
|
||||
collaborativeSetSubblockValue(blockId, 'summary', '')
|
||||
collaborativeSetSubblockValue(blockId, 'description', '')
|
||||
if (!issueKey) {
|
||||
// Also clear the manual issue key when cleared
|
||||
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle channel selection
|
||||
const handleChannelChange = (channelId: string, info?: DiscordChannelInfo) => {
|
||||
setSelectedChannelId(channelId)
|
||||
setChannelInfo(info || null)
|
||||
setStoreValue(channelId)
|
||||
}
|
||||
|
||||
// Handle calendar selection
|
||||
const handleCalendarChange = (calendarId: string, info?: GoogleCalendarInfo) => {
|
||||
setSelectedCalendarId(calendarId)
|
||||
setCalendarInfo(info || null)
|
||||
setStoreValue(calendarId)
|
||||
}
|
||||
|
||||
// Handle Wealthbox item selection
|
||||
const handleWealthboxItemChange = (itemId: string, info?: WealthboxItemInfo) => {
|
||||
setSelectedWealthboxItemId(itemId)
|
||||
setWealthboxItemInfo(info || null)
|
||||
setStoreValue(itemId)
|
||||
}
|
||||
|
||||
// For Google Drive
|
||||
const clientId = getEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID') || ''
|
||||
const apiKey = getEnv('NEXT_PUBLIC_GOOGLE_API_KEY') || ''
|
||||
@@ -245,25 +133,17 @@ export function FileSelectorInput({
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val, info) => {
|
||||
setSelectedCalendarId(val)
|
||||
setCalendarInfo(info || null)
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
label={subBlock.placeholder || 'Select Google Calendar'}
|
||||
disabled={disabled || !credential}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onCalendarInfoChange={setCalendarInfo}
|
||||
credentialId={credential}
|
||||
workflowId={workflowIdFromUrl}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!credential && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select Google Calendar credentials first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
@@ -277,21 +157,18 @@ export function FileSelectorInput({
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<DiscordChannelSelector
|
||||
value={selectedChannelId}
|
||||
onChange={handleChannelChange}
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(channelId) => setStoreValue(channelId)}
|
||||
botToken={botToken}
|
||||
serverId={serverId}
|
||||
label={subBlock.placeholder || 'Select Discord channel'}
|
||||
disabled={disabled || !botToken || !serverId}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{(!botToken || !serverId) && (
|
||||
<TooltipContent side='top'>
|
||||
<p>{!botToken ? 'Please enter a Bot Token first' : 'Please select a Server first'}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
@@ -311,9 +188,7 @@ export function FileSelectorInput({
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val, info) => {
|
||||
setSelectedFileId(val)
|
||||
setFileInfo(info || null)
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
domain={domain}
|
||||
@@ -321,20 +196,14 @@ export function FileSelectorInput({
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Confluence page'}
|
||||
disabled={disabled || !domain}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo as (info: ConfluenceFileInfo | null) => void}
|
||||
credentialId={credential}
|
||||
workflowId={workflowIdFromUrl}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!domain && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please enter a Confluence domain first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
@@ -353,168 +222,139 @@ export function FileSelectorInput({
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val, info) => {
|
||||
setSelectedIssueId(val)
|
||||
setIssueInfo(info || null)
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
onChange={(issueKey) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, issueKey)
|
||||
// Clear related fields when a new issue is selected
|
||||
collaborativeSetSubblockValue(blockId, 'summary', '')
|
||||
collaborativeSetSubblockValue(blockId, 'description', '')
|
||||
if (!issueKey) {
|
||||
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
|
||||
}
|
||||
}}
|
||||
domain={domain}
|
||||
provider='jira'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Jira issue'}
|
||||
disabled={
|
||||
disabled || !domain || !credential || !(getValue(blockId, 'projectId') as string)
|
||||
}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onIssueInfoChange={setIssueInfo as (info: JiraIssueInfo | null) => void}
|
||||
credentialId={credential}
|
||||
projectId={(getValue(blockId, 'projectId') as string) || ''}
|
||||
projectId={(projectIdValue as string) || ''}
|
||||
isForeignCredential={isForeignCredential}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!domain ? (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please enter a Jira domain first</p>
|
||||
</TooltipContent>
|
||||
) : !credential ? (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select Jira credentials first</p>
|
||||
</TooltipContent>
|
||||
) : !(getValue(blockId, 'projectId') as string) ? (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select a Jira project first</p>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMicrosoftExcel) {
|
||||
// Get credential reactively
|
||||
const credential = (connectedCredential as string) || ''
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(selectedFileId)}
|
||||
onChange={handleFileChange}
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft-excel'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Microsoft Excel file'}
|
||||
disabled={disabled || !credential}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialId={credential}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!credential && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select Microsoft Excel credentials first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle Microsoft Word selector
|
||||
// Microsoft Word selector
|
||||
if (isMicrosoftWord) {
|
||||
// Get credential reactively
|
||||
const credential = (connectedCredential as string) || ''
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(selectedFileId)}
|
||||
onChange={handleFileChange}
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft-word'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Microsoft Word document'}
|
||||
disabled={disabled || !credential}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!credential && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select Microsoft Word credentials first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle Microsoft OneDrive selector
|
||||
// Microsoft OneDrive selector
|
||||
if (isMicrosoftOneDrive) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(selectedFileId)}
|
||||
onChange={handleFileChange}
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select OneDrive folder'}
|
||||
disabled={disabled || !credential}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialId={credential}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!credential && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select Microsoft credentials first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle Microsoft SharePoint selector
|
||||
// Microsoft SharePoint selector
|
||||
if (isMicrosoftSharePoint) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(selectedFileId)}
|
||||
onChange={handleFileChange}
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select SharePoint site'}
|
||||
disabled={disabled || !credential}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialId={credential}
|
||||
isForeignCredential={isForeignCredential}
|
||||
@@ -531,26 +371,26 @@ export function FileSelectorInput({
|
||||
)
|
||||
}
|
||||
|
||||
// Handle Microsoft Planner task selector
|
||||
// Microsoft Planner task selector
|
||||
if (isMicrosoftPlanner) {
|
||||
const credential = (connectedCredential as string) || ''
|
||||
const planId = (getValue(blockId, 'planId') as string) || ''
|
||||
|
||||
const planId = (planIdValue as string) || ''
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={selectedFileId}
|
||||
onChange={handleFileChange}
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft-planner'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId='microsoft-planner'
|
||||
label={subBlock.placeholder || 'Select task'}
|
||||
disabled={disabled || !credential || !planId}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
|
||||
planId={planId}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialId={credential}
|
||||
@@ -572,32 +412,22 @@ export function FileSelectorInput({
|
||||
)
|
||||
}
|
||||
|
||||
// Handle Microsoft Teams selector
|
||||
// Microsoft Teams selector
|
||||
if (isMicrosoftTeams) {
|
||||
// Get credential using the same pattern as other tools
|
||||
const credential = (connectedCredential as string) || ''
|
||||
|
||||
// Determine the selector type based on the subBlock ID
|
||||
// Determine the selector type based on the subBlock ID / operation
|
||||
let selectionType: 'team' | 'channel' | 'chat' = 'team'
|
||||
|
||||
if (subBlock.id === 'teamId') {
|
||||
selectionType = 'team'
|
||||
} else if (subBlock.id === 'channelId') {
|
||||
selectionType = 'channel'
|
||||
} else if (subBlock.id === 'chatId') {
|
||||
selectionType = 'chat'
|
||||
} else {
|
||||
// Fallback: look at the operation to determine the selection type
|
||||
const operation = (getValue(blockId, 'operation') as string) || ''
|
||||
if (operation.includes('chat')) {
|
||||
selectionType = 'chat'
|
||||
} else if (operation.includes('channel')) {
|
||||
selectionType = 'channel'
|
||||
}
|
||||
if (subBlock.id === 'teamId') selectionType = 'team'
|
||||
else if (subBlock.id === 'channelId') selectionType = 'channel'
|
||||
else if (subBlock.id === 'chatId') selectionType = 'chat'
|
||||
else {
|
||||
const operation = (operationValue as string) || ''
|
||||
if (operation.includes('chat')) selectionType = 'chat'
|
||||
else if (operation.includes('channel')) selectionType = 'channel'
|
||||
}
|
||||
|
||||
// Get the teamId from workflow parameters for channel selector
|
||||
const selectedTeamId = (getValue(blockId, 'teamId') as string) || ''
|
||||
const selectedTeamId = (teamIdValue as string) || ''
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
@@ -610,10 +440,8 @@ export function FileSelectorInput({
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(value, info) => {
|
||||
setSelectedMessageId(value)
|
||||
setMessageInfo(info || null)
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, value)
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
provider='microsoft-teams'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
@@ -621,7 +449,6 @@ export function FileSelectorInput({
|
||||
label={subBlock.placeholder || 'Select Teams message location'}
|
||||
disabled={disabled || !credential}
|
||||
showPreview={true}
|
||||
onMessageInfoChange={setMessageInfo}
|
||||
credential={credential}
|
||||
selectionType={selectionType}
|
||||
initialTeamId={selectedTeamId}
|
||||
@@ -640,15 +467,11 @@ export function FileSelectorInput({
|
||||
)
|
||||
}
|
||||
|
||||
// Render Wealthbox selector
|
||||
// Wealthbox selector
|
||||
if (isWealthbox) {
|
||||
// Get credential reactively
|
||||
const credential = (connectedCredential as string) || ''
|
||||
|
||||
// Only handle contacts now - both notes and tasks use short-input
|
||||
if (subBlock.id === 'contactId') {
|
||||
const itemType = 'contact'
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -660,9 +483,7 @@ export function FileSelectorInput({
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val, info) => {
|
||||
setSelectedWealthboxItemId(val)
|
||||
setWealthboxItemInfo(info || null)
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
provider='wealthbox'
|
||||
@@ -671,7 +492,6 @@ export function FileSelectorInput({
|
||||
label={subBlock.placeholder || `Select ${itemType}`}
|
||||
disabled={disabled || !credential}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setWealthboxItemInfo}
|
||||
credentialId={credential}
|
||||
itemType={itemType}
|
||||
/>
|
||||
@@ -686,7 +506,7 @@ export function FileSelectorInput({
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
// If it's noteId or taskId, we should not render the file selector since they now use short-input
|
||||
// noteId or taskId now use short-input
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -705,9 +525,7 @@ export function FileSelectorInput({
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(val, info) => {
|
||||
setSelectedFileId(val)
|
||||
setFileInfo(info || null)
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
provider={provider}
|
||||
@@ -717,7 +535,6 @@ export function FileSelectorInput({
|
||||
serviceId={subBlock.serviceId}
|
||||
mimeTypeFilter={subBlock.mimeType}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo}
|
||||
clientId={clientId}
|
||||
apiKey={apiKey}
|
||||
credentialId={credential}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type FolderInfo,
|
||||
FolderSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
@@ -37,8 +38,13 @@ export function FolderSelectorInput({
|
||||
(connectedCredential as string) || ''
|
||||
)
|
||||
|
||||
// Central dependsOn gating
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
|
||||
// Get the current value from the store or prop value if in preview mode
|
||||
useEffect(() => {
|
||||
// When gated/disabled, do not set defaults or write to store
|
||||
if (finalDisabled) return
|
||||
if (isPreview && previewValue !== undefined) {
|
||||
setSelectedFolderId(previewValue)
|
||||
return
|
||||
@@ -54,7 +60,15 @@ export function FolderSelectorInput({
|
||||
if (!isPreview) {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, defaultValue)
|
||||
}
|
||||
}, [blockId, subBlock.id, storeValue, collaborativeSetSubblockValue, isPreview, previewValue])
|
||||
}, [
|
||||
blockId,
|
||||
subBlock.id,
|
||||
storeValue,
|
||||
collaborativeSetSubblockValue,
|
||||
isPreview,
|
||||
previewValue,
|
||||
finalDisabled,
|
||||
])
|
||||
|
||||
// Handle folder selection
|
||||
const handleFolderChange = (folderId: string, info?: FolderInfo) => {
|
||||
@@ -72,7 +86,7 @@ export function FolderSelectorInput({
|
||||
provider={subBlock.provider || 'google-email'}
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
label={subBlock.placeholder || 'Select folder'}
|
||||
disabled={disabled}
|
||||
disabled={finalDisabled}
|
||||
serviceId={subBlock.serviceId}
|
||||
onFolderInfoChange={setFolderInfo}
|
||||
credentialId={(connectedCredential as string) || ''}
|
||||
|
||||
@@ -279,29 +279,33 @@ export function FolderSelector({
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
useEffect(() => {
|
||||
if (disabled) return
|
||||
if (!initialFetchRef.current) {
|
||||
fetchCredentials()
|
||||
initialFetchRef.current = true
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
}, [fetchCredentials, disabled])
|
||||
|
||||
// Fetch folders when credential is selected
|
||||
useEffect(() => {
|
||||
if (disabled) return
|
||||
if (selectedCredentialId) {
|
||||
fetchFolders()
|
||||
}
|
||||
}, [selectedCredentialId, fetchFolders])
|
||||
}, [selectedCredentialId, fetchFolders, disabled])
|
||||
|
||||
// Keep internal selectedFolderId in sync with the value prop
|
||||
useEffect(() => {
|
||||
if (disabled) return
|
||||
const currentValue = isPreview ? previewValue : value
|
||||
if (currentValue !== selectedFolderId) {
|
||||
setSelectedFolderId(currentValue || '')
|
||||
}
|
||||
}, [value, isPreview, previewValue])
|
||||
}, [value, isPreview, previewValue, disabled])
|
||||
|
||||
// Fetch the selected folder metadata once credentials are ready or value changes
|
||||
useEffect(() => {
|
||||
if (disabled) return
|
||||
const currentValue = isPreview ? (previewValue as string) : (value as string)
|
||||
if (
|
||||
currentValue &&
|
||||
@@ -310,7 +314,15 @@ export function FolderSelector({
|
||||
) {
|
||||
fetchFolderById(currentValue)
|
||||
}
|
||||
}, [value, selectedCredentialId, selectedFolder, fetchFolderById, isPreview, previewValue])
|
||||
}, [
|
||||
value,
|
||||
selectedCredentialId,
|
||||
selectedFolder,
|
||||
fetchFolderById,
|
||||
isPreview,
|
||||
previewValue,
|
||||
disabled,
|
||||
])
|
||||
|
||||
// Handle folder selection
|
||||
const handleSelectFolder = (folder: FolderInfo) => {
|
||||
|
||||
@@ -353,6 +353,14 @@ export function JiraProjectSelector({
|
||||
}
|
||||
}, [value])
|
||||
|
||||
// Clear local preview when value is cleared remotely or via collaborator
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSelectedProject(null)
|
||||
onProjectInfoChange?.(null)
|
||||
}
|
||||
}, [value, onProjectInfoChange])
|
||||
|
||||
// Handle open change
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
@@ -387,6 +395,8 @@ export function JiraProjectSelector({
|
||||
onProjectInfoChange?.(null)
|
||||
}
|
||||
|
||||
const canShowPreview = !!(showPreview && selectedProject && value && selectedProject.id === value)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
@@ -399,7 +409,7 @@ export function JiraProjectSelector({
|
||||
className='w-full justify-between'
|
||||
disabled={disabled || !domain || !selectedCredentialId || isForeignCredential}
|
||||
>
|
||||
{selectedProject ? (
|
||||
{canShowPreview ? (
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{selectedProject.name}</span>
|
||||
@@ -546,7 +556,7 @@ export function JiraProjectSelector({
|
||||
</Popover>
|
||||
|
||||
{/* Project preview */}
|
||||
{showPreview && selectedProject && (
|
||||
{canShowPreview && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type LinearTeamInfo,
|
||||
LinearTeamSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
@@ -51,16 +52,11 @@ export function ProjectSelectorInput({
|
||||
subBlock.provider || subBlock.serviceId || 'jira',
|
||||
(connectedCredential as string) || ''
|
||||
)
|
||||
// Local setters for related Jira fields to ensure immediate UI clearing
|
||||
const [_issueKeyValue, setIssueKeyValue] = useSubBlockValue<string>(blockId, 'issueKey')
|
||||
const [_manualIssueKeyValue, setManualIssueKeyValue] = useSubBlockValue<string>(
|
||||
blockId,
|
||||
'manualIssueKey'
|
||||
)
|
||||
// Reactive dependencies from store for Linear
|
||||
const [linearCredential] = useSubBlockValue(blockId, 'credential')
|
||||
const [linearTeamId] = useSubBlockValue(blockId, 'teamId')
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
|
||||
// Get provider-specific values
|
||||
const provider = subBlock.provider || 'jira'
|
||||
@@ -95,27 +91,6 @@ export function ProjectSelectorInput({
|
||||
setProjectInfo(info || null)
|
||||
setStoreValue(projectId)
|
||||
|
||||
// Clear the issue-related fields when a new project is selected
|
||||
if (provider === 'jira') {
|
||||
collaborativeSetSubblockValue(blockId, 'summary', '')
|
||||
collaborativeSetSubblockValue(blockId, 'description', '')
|
||||
// Clear both the basic and advanced issue key fields to ensure UI resets
|
||||
collaborativeSetSubblockValue(blockId, 'issueKey', '')
|
||||
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
|
||||
// Also clear locally for immediate UI feedback on this client
|
||||
setIssueKeyValue('')
|
||||
setManualIssueKeyValue('')
|
||||
} else if (provider === 'discord') {
|
||||
collaborativeSetSubblockValue(blockId, 'channelId', '')
|
||||
} else if (provider === 'linear') {
|
||||
if (subBlock.id === 'teamId') {
|
||||
collaborativeSetSubblockValue(blockId, 'teamId', projectId)
|
||||
collaborativeSetSubblockValue(blockId, 'projectId', '')
|
||||
} else if (subBlock.id === 'projectId') {
|
||||
collaborativeSetSubblockValue(blockId, 'projectId', projectId)
|
||||
}
|
||||
}
|
||||
|
||||
onProjectSelect?.(projectId)
|
||||
}
|
||||
|
||||
@@ -163,7 +138,7 @@ export function ProjectSelectorInput({
|
||||
}}
|
||||
credential={(linearCredential as string) || ''}
|
||||
label={subBlock.placeholder || 'Select Linear team'}
|
||||
disabled={disabled || !(linearCredential as string)}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
/>
|
||||
@@ -171,7 +146,7 @@ export function ProjectSelectorInput({
|
||||
(() => {
|
||||
const credential = (linearCredential as string) || ''
|
||||
const teamId = (linearTeamId as string) || ''
|
||||
const isDisabled = disabled || !credential || !teamId
|
||||
const isDisabled = finalDisabled
|
||||
return (
|
||||
<LinearProjectSelector
|
||||
value={selectedProjectId}
|
||||
@@ -213,7 +188,7 @@ export function ProjectSelectorInput({
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Jira project'}
|
||||
disabled={disabled || !domain || !(jiraCredential as string)}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onProjectInfoChange={setProjectInfo}
|
||||
credentialId={(jiraCredential as string) || ''}
|
||||
@@ -222,15 +197,6 @@ export function ProjectSelectorInput({
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!domain ? (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please enter a Jira domain first</p>
|
||||
</TooltipContent>
|
||||
) : !(jiraCredential as string) ? (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select a Jira account first</p>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -46,18 +46,51 @@ export function TriggerModal({
|
||||
const [config, setConfig] = useState<Record<string, any>>(initialConfig)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Track if config has changed from initial values
|
||||
// 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(initialConfig)
|
||||
}, [config, initialConfig])
|
||||
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 lastCredentialIdRef = useRef<string | null>(null)
|
||||
|
||||
// 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 = ''
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize config with default values from trigger definition
|
||||
useEffect(() => {
|
||||
@@ -79,35 +112,71 @@ export function TriggerModal({
|
||||
}
|
||||
}, [triggerDef.configFields, initialConfig])
|
||||
|
||||
// Monitor credential selection
|
||||
// Monitor credential selection across collaborators; clear options on change/clear
|
||||
useEffect(() => {
|
||||
if (triggerDef.requiresCredentials && triggerDef.credentialProvider) {
|
||||
// Check if credentials are selected by monitoring the sub-block store
|
||||
const checkCredentials = () => {
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
const credentialValue = subBlockStore.getValue(blockId, 'triggerCredentials')
|
||||
const hasCredential = Boolean(credentialValue)
|
||||
const credentialValue = subBlockStore.getValue(blockId, 'triggerCredentials') as
|
||||
| string
|
||||
| null
|
||||
const currentCredentialId = credentialValue || null
|
||||
const hasCredential = Boolean(currentCredentialId)
|
||||
setHasCredentials(hasCredential)
|
||||
|
||||
// If credential changed and it's a Gmail trigger, load labels
|
||||
if (hasCredential && credentialValue !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialValue)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
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') {
|
||||
loadGmailLabels(credentialValue)
|
||||
void loadGmailLabels(currentCredentialId)
|
||||
} else if (triggerDef.provider === 'outlook') {
|
||||
loadOutlookFolders(credentialValue)
|
||||
void loadOutlookFolders(currentCredentialId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkCredentials()
|
||||
|
||||
// Set up a subscription to monitor changes
|
||||
const unsubscribe = useSubBlockStore.subscribe(checkCredentials)
|
||||
|
||||
return unsubscribe
|
||||
}
|
||||
// If credentials aren't required, set to true
|
||||
setHasCredentials(true)
|
||||
}, [
|
||||
blockId,
|
||||
@@ -367,10 +436,14 @@ export function TriggerModal({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !isConfigValid() || (!hasConfigChanged && !!triggerId)}
|
||||
disabled={
|
||||
isSaving ||
|
||||
!isConfigValid() ||
|
||||
(!(hasConfigChanged || hasCredentialChanged) && !!triggerId)
|
||||
}
|
||||
className={cn(
|
||||
'h-10',
|
||||
isConfigValid() && (hasConfigChanged || !triggerId)
|
||||
isConfigValid() && (hasConfigChanged || hasCredentialChanged || !triggerId)
|
||||
? 'bg-primary hover:bg-primary/90'
|
||||
: '',
|
||||
isSaving &&
|
||||
|
||||
@@ -172,6 +172,11 @@ export function TriggerConfig({
|
||||
// Map trigger ID to webhook provider name
|
||||
const webhookProvider = effectiveTriggerId.replace(/_webhook|_poller$/, '') // e.g., 'slack_webhook' -> 'slack', 'gmail_poller' -> 'gmail'
|
||||
|
||||
// Include selected credential from the modal (if any)
|
||||
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
|
||||
@@ -185,7 +190,10 @@ export function TriggerConfig({
|
||||
blockId,
|
||||
path: '', // Empty path - API will generate dummy path for Gmail
|
||||
provider: webhookProvider,
|
||||
providerConfig: config,
|
||||
providerConfig: {
|
||||
...config,
|
||||
...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -225,7 +233,10 @@ export function TriggerConfig({
|
||||
blockId,
|
||||
path,
|
||||
provider: webhookProvider,
|
||||
providerConfig: config,
|
||||
providerConfig: {
|
||||
...config,
|
||||
...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
/**
|
||||
* Centralized dependsOn gating for sub-block components.
|
||||
* - Computes dependency values from the active workflow/block
|
||||
* - Returns a stable disabled flag to pass to inputs and to guard effects
|
||||
*/
|
||||
export function useDependsOnGate(
|
||||
blockId: string,
|
||||
subBlock: SubBlockConfig,
|
||||
opts?: { disabled?: boolean; isPreview?: boolean }
|
||||
) {
|
||||
const disabledProp = opts?.disabled ?? false
|
||||
const isPreview = opts?.isPreview ?? false
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
|
||||
// Use only explicit dependsOn from block config. No inference.
|
||||
const dependsOn: string[] = (subBlock.dependsOn as string[] | undefined) || []
|
||||
|
||||
const dependencyValues = useSubBlockStore((state) => {
|
||||
if (dependsOn.length === 0) return [] as any[]
|
||||
if (!activeWorkflowId) return dependsOn.map(() => null)
|
||||
const workflowValues = state.workflowValues[activeWorkflowId] || {}
|
||||
const blockValues = (workflowValues as any)[blockId] || {}
|
||||
return dependsOn.map((depKey) => (blockValues as any)[depKey] ?? null)
|
||||
}) as any[]
|
||||
|
||||
const depsSatisfied = useMemo(() => {
|
||||
if (dependsOn.length === 0) return true
|
||||
return dependencyValues.every((v) =>
|
||||
typeof v === 'string' ? v.trim().length > 0 : v !== null && v !== undefined && v !== ''
|
||||
)
|
||||
}, [dependencyValues, dependsOn])
|
||||
|
||||
// Block everything except the credential field itself until dependencies are set
|
||||
const blocked =
|
||||
!isPreview && dependsOn.length > 0 && !depsSatisfied && subBlock.type !== 'oauth-input'
|
||||
|
||||
const finalDisabled = disabledProp || isPreview || blocked
|
||||
|
||||
return {
|
||||
dependsOn,
|
||||
dependencyValues,
|
||||
depsSatisfied,
|
||||
blocked,
|
||||
finalDisabled,
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,10 @@ export function useSubBlockValue<T = any>(
|
||||
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
|
||||
// Subscribe to active workflow id to avoid races where the workflow id is set after mount.
|
||||
// This ensures our selector recomputes when the active workflow changes.
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
|
||||
const blockType = useWorkflowStore(
|
||||
useCallback((state) => state.blocks?.[blockId]?.type, [blockId])
|
||||
)
|
||||
@@ -56,9 +60,16 @@ export function useSubBlockValue<T = any>(
|
||||
const streamingValueRef = useRef<T | null>(null)
|
||||
const wasStreamingRef = useRef<boolean>(false)
|
||||
|
||||
// Get value from subblock store - always call this hook unconditionally
|
||||
// Get value from subblock store, keyed by active workflow id
|
||||
// We intentionally depend on activeWorkflowId so this recomputes when it changes.
|
||||
const storeValue = useSubBlockStore(
|
||||
useCallback((state) => state.getValue(blockId, subBlockId), [blockId, subBlockId])
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!activeWorkflowId) return null
|
||||
return state.workflowValues[activeWorkflowId]?.[blockId]?.[subBlockId] ?? null
|
||||
},
|
||||
[activeWorkflowId, blockId, subBlockId]
|
||||
)
|
||||
)
|
||||
|
||||
// Check if we're in diff mode and get diff value if available
|
||||
@@ -123,12 +134,10 @@ export function useSubBlockValue<T = any>(
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[useWorkflowRegistry.getState().activeWorkflowId || '']: {
|
||||
...state.workflowValues[useWorkflowRegistry.getState().activeWorkflowId || ''],
|
||||
[activeWorkflowId || '']: {
|
||||
...state.workflowValues[activeWorkflowId || ''],
|
||||
[blockId]: {
|
||||
...state.workflowValues[useWorkflowRegistry.getState().activeWorkflowId || '']?.[
|
||||
blockId
|
||||
],
|
||||
...state.workflowValues[activeWorkflowId || '']?.[blockId],
|
||||
[subBlockId]: newValue,
|
||||
},
|
||||
},
|
||||
@@ -190,6 +199,7 @@ export function useSubBlockValue<T = any>(
|
||||
isStreaming,
|
||||
emitValue,
|
||||
isShowingDiff,
|
||||
activeWorkflowId,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -807,7 +807,7 @@ export function useWorkflowExecution() {
|
||||
|
||||
// Continue execution until there are no more pending blocks
|
||||
let iterationCount = 0
|
||||
const maxIterations = 100 // Safety to prevent infinite loops
|
||||
const maxIterations = 500 // Safety to prevent infinite loops
|
||||
|
||||
while (currentPendingBlocks.length > 0 && iterationCount < maxIterations) {
|
||||
logger.info(
|
||||
|
||||
@@ -720,21 +720,47 @@ export function Sidebar() {
|
||||
`[data-workflow-id="${workflowId}"]`
|
||||
) as HTMLElement
|
||||
if (activeWorkflow) {
|
||||
activeWorkflow.scrollIntoView({
|
||||
block: 'start',
|
||||
})
|
||||
// Check if this is a newly created workflow (created within the last 5 seconds)
|
||||
const currentWorkflow = workflows[workflowId]
|
||||
const isNewlyCreated =
|
||||
currentWorkflow &&
|
||||
currentWorkflow.lastModified instanceof Date &&
|
||||
Date.now() - currentWorkflow.lastModified.getTime() < 5000 // 5 seconds
|
||||
|
||||
// Adjust scroll position to eliminate the small gap at the top
|
||||
const scrollViewport = scrollContainer.querySelector(
|
||||
'[data-radix-scroll-area-viewport]'
|
||||
) as HTMLElement
|
||||
if (scrollViewport && scrollViewport.scrollTop > 0) {
|
||||
scrollViewport.scrollTop = Math.max(0, scrollViewport.scrollTop - 8)
|
||||
if (isNewlyCreated) {
|
||||
// For newly created workflows, use the original behavior - scroll to top
|
||||
activeWorkflow.scrollIntoView({
|
||||
block: 'start',
|
||||
})
|
||||
|
||||
// Adjust scroll position to eliminate the small gap at the top
|
||||
const scrollViewport = scrollContainer.querySelector(
|
||||
'[data-radix-scroll-area-viewport]'
|
||||
) as HTMLElement
|
||||
if (scrollViewport && scrollViewport.scrollTop > 0) {
|
||||
scrollViewport.scrollTop = Math.max(0, scrollViewport.scrollTop - 8)
|
||||
}
|
||||
} else {
|
||||
// For existing workflows, check if already visible and scroll minimally
|
||||
const containerRect = scrollContainer.getBoundingClientRect()
|
||||
const workflowRect = activeWorkflow.getBoundingClientRect()
|
||||
|
||||
// Only scroll if the workflow is not fully visible
|
||||
const isFullyVisible =
|
||||
workflowRect.top >= containerRect.top && workflowRect.bottom <= containerRect.bottom
|
||||
|
||||
if (!isFullyVisible) {
|
||||
// Use 'nearest' to scroll minimally - only bring into view, don't force to top
|
||||
activeWorkflow.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [workflowId, isLoading])
|
||||
}, [workflowId, isLoading, workflows])
|
||||
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
|
||||
@@ -45,6 +45,7 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter your base ID (e.g., appXXXXXXXXXXXXXX)',
|
||||
dependsOn: ['credential'],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
@@ -53,6 +54,7 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter table ID (e.g., tblXXXXXXXXXXXXXX)',
|
||||
dependsOn: ['credential', 'baseId'],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -58,6 +58,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
||||
provider: 'confluence',
|
||||
serviceId: 'confluence',
|
||||
placeholder: 'Select Confluence page',
|
||||
dependsOn: ['credential', 'domain'],
|
||||
mode: 'basic',
|
||||
},
|
||||
// Manual page ID input (advanced mode)
|
||||
|
||||
@@ -43,6 +43,7 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
|
||||
provider: 'discord',
|
||||
serviceId: 'discord',
|
||||
placeholder: 'Select Discord server',
|
||||
dependsOn: ['botToken'],
|
||||
mode: 'basic',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
@@ -71,6 +72,7 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
|
||||
provider: 'discord',
|
||||
serviceId: 'discord',
|
||||
placeholder: 'Select Discord channel',
|
||||
dependsOn: ['botToken', 'serverId'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: ['discord_send_message', 'discord_get_messages'] },
|
||||
},
|
||||
|
||||
@@ -106,6 +106,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
|
||||
'https://www.googleapis.com/auth/gmail.labels',
|
||||
],
|
||||
placeholder: 'Select Gmail label/folder',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: 'read_gmail' },
|
||||
},
|
||||
|
||||
@@ -48,6 +48,7 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
|
||||
serviceId: 'google-calendar',
|
||||
requiredScopes: ['https://www.googleapis.com/auth/calendar'],
|
||||
placeholder: 'Select calendar',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
},
|
||||
// Manual calendar ID input (advanced mode)
|
||||
|
||||
@@ -49,6 +49,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
|
||||
requiredScopes: [],
|
||||
mimeType: 'application/vnd.google-apps.document',
|
||||
placeholder: 'Select a document',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: ['read', 'write'] },
|
||||
},
|
||||
@@ -59,6 +60,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter document ID',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: ['read', 'write'] },
|
||||
},
|
||||
@@ -83,6 +85,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
|
||||
requiredScopes: [],
|
||||
mimeType: 'application/vnd.google-apps.folder',
|
||||
placeholder: 'Select a parent folder',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: 'create' },
|
||||
},
|
||||
@@ -93,6 +96,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter parent folder ID (leave empty for root folder)',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'create' },
|
||||
},
|
||||
|
||||
@@ -82,6 +82,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
|
||||
mimeType: 'application/vnd.google-apps.folder',
|
||||
placeholder: 'Select a parent folder',
|
||||
mode: 'basic',
|
||||
dependsOn: ['credential'],
|
||||
condition: { field: 'operation', value: 'upload' },
|
||||
},
|
||||
{
|
||||
@@ -155,6 +156,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
|
||||
mimeType: 'application/vnd.google-apps.folder',
|
||||
placeholder: 'Select a parent folder',
|
||||
mode: 'basic',
|
||||
dependsOn: ['credential'],
|
||||
condition: { field: 'operation', value: 'create_folder' },
|
||||
},
|
||||
// Manual Folder ID input (advanced mode)
|
||||
@@ -179,6 +181,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
|
||||
mimeType: 'application/vnd.google-apps.folder',
|
||||
placeholder: 'Select a folder to list files from',
|
||||
mode: 'basic',
|
||||
dependsOn: ['credential'],
|
||||
condition: { field: 'operation', value: 'list' },
|
||||
},
|
||||
// Manual Folder ID input (advanced mode)
|
||||
|
||||
@@ -50,6 +50,7 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
|
||||
requiredScopes: [],
|
||||
mimeType: 'application/vnd.google-apps.spreadsheet',
|
||||
placeholder: 'Select a spreadsheet',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
},
|
||||
// Manual Spreadsheet ID (advanced mode)
|
||||
@@ -59,6 +60,7 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'ID of the spreadsheet (from URL)',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'advanced',
|
||||
},
|
||||
// Range
|
||||
|
||||
@@ -62,6 +62,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
provider: 'jira',
|
||||
serviceId: 'jira',
|
||||
placeholder: 'Select Jira project',
|
||||
dependsOn: ['credential', 'domain'],
|
||||
mode: 'basic',
|
||||
},
|
||||
// Manual project ID input (advanced mode)
|
||||
@@ -71,6 +72,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter Jira project ID',
|
||||
dependsOn: ['credential', 'domain'],
|
||||
mode: 'advanced',
|
||||
},
|
||||
// Issue selector (basic mode)
|
||||
@@ -82,6 +84,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
provider: 'jira',
|
||||
serviceId: 'jira',
|
||||
placeholder: 'Select Jira issue',
|
||||
dependsOn: ['credential', 'domain', 'projectId'],
|
||||
condition: { field: 'operation', value: ['read', 'update'] },
|
||||
mode: 'basic',
|
||||
},
|
||||
@@ -92,6 +95,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter Jira issue key',
|
||||
dependsOn: ['credential', 'domain', 'projectId'],
|
||||
condition: { field: 'operation', value: ['read', 'update'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
|
||||
@@ -66,6 +66,7 @@ export const KnowledgeBlock: BlockConfig = {
|
||||
type: 'document-selector',
|
||||
layout: 'full',
|
||||
placeholder: 'Select document',
|
||||
dependsOn: ['knowledgeBaseId'],
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'upload_chunk' },
|
||||
},
|
||||
|
||||
@@ -42,6 +42,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
|
||||
provider: 'linear',
|
||||
serviceId: 'linear',
|
||||
placeholder: 'Select a team',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
},
|
||||
{
|
||||
@@ -52,6 +53,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
|
||||
provider: 'linear',
|
||||
serviceId: 'linear',
|
||||
placeholder: 'Select a project',
|
||||
dependsOn: ['credential', 'teamId'],
|
||||
mode: 'basic',
|
||||
},
|
||||
// Manual team ID input (advanced mode)
|
||||
|
||||
@@ -46,6 +46,7 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
|
||||
requiredScopes: [],
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
placeholder: 'Select a spreadsheet',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
},
|
||||
{
|
||||
@@ -54,6 +55,7 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter spreadsheet ID',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -61,6 +61,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
|
||||
layout: 'full',
|
||||
placeholder: 'Enter the plan ID',
|
||||
condition: { field: 'operation', value: ['create_task', 'read_task'] },
|
||||
dependsOn: ['credential'],
|
||||
},
|
||||
{
|
||||
id: 'taskId',
|
||||
@@ -70,6 +71,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
|
||||
placeholder: 'Select a task',
|
||||
provider: 'microsoft-planner',
|
||||
condition: { field: 'operation', value: ['read_task'] },
|
||||
dependsOn: ['credential', 'planId'],
|
||||
mode: 'basic',
|
||||
},
|
||||
|
||||
@@ -81,6 +83,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
|
||||
layout: 'full',
|
||||
placeholder: 'Enter the task ID',
|
||||
condition: { field: 'operation', value: ['read_task'] },
|
||||
dependsOn: ['credential', 'planId'],
|
||||
mode: 'advanced',
|
||||
},
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
|
||||
serviceId: 'microsoft-teams',
|
||||
requiredScopes: [],
|
||||
placeholder: 'Select a team',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: ['read_channel', 'write_channel'] },
|
||||
},
|
||||
@@ -82,6 +83,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
|
||||
serviceId: 'microsoft-teams',
|
||||
requiredScopes: [],
|
||||
placeholder: 'Select a chat',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: ['read_chat', 'write_chat'] },
|
||||
},
|
||||
@@ -103,6 +105,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
|
||||
serviceId: 'microsoft-teams',
|
||||
requiredScopes: [],
|
||||
placeholder: 'Select a channel',
|
||||
dependsOn: ['credential', 'teamId'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: ['read_channel', 'write_channel'] },
|
||||
},
|
||||
|
||||
@@ -78,6 +78,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
|
||||
],
|
||||
mimeType: 'application/vnd.microsoft.graph.folder',
|
||||
placeholder: 'Select a parent folder',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: 'upload' },
|
||||
},
|
||||
@@ -87,6 +88,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter parent folder ID (leave empty for root folder)',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'upload' },
|
||||
},
|
||||
@@ -115,6 +117,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
|
||||
],
|
||||
mimeType: 'application/vnd.microsoft.graph.folder',
|
||||
placeholder: 'Select a parent folder',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: 'create_folder' },
|
||||
},
|
||||
@@ -125,6 +128,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter parent folder ID (leave empty for root folder)',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'create_folder' },
|
||||
},
|
||||
@@ -146,6 +150,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
|
||||
],
|
||||
mimeType: 'application/vnd.microsoft.graph.folder',
|
||||
placeholder: 'Select a folder to list files from',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: 'list' },
|
||||
},
|
||||
@@ -156,6 +161,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter folder ID (leave empty for root folder)',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'list' },
|
||||
},
|
||||
|
||||
@@ -124,6 +124,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
|
||||
serviceId: 'outlook',
|
||||
requiredScopes: ['Mail.ReadWrite', 'Mail.ReadBasic', 'Mail.Read'],
|
||||
placeholder: 'Select Outlook folder',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: 'read_outlook' },
|
||||
},
|
||||
|
||||
@@ -61,6 +61,7 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
|
||||
],
|
||||
mimeType: 'application/vnd.microsoft.graph.folder',
|
||||
placeholder: 'Select a site',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: ['create_page', 'read_page', 'list_sites'] },
|
||||
},
|
||||
@@ -99,6 +100,7 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter site ID (leave empty for root site)',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'create_page' },
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ export const SupabaseBlock: BlockConfig<SupabaseResponse> = {
|
||||
{ label: 'Create a Row', id: 'insert' },
|
||||
{ label: 'Update a Row', id: 'update' },
|
||||
{ label: 'Delete a Row', id: 'delete' },
|
||||
{ label: 'Upsert a Row', id: 'upsert' },
|
||||
],
|
||||
value: () => 'query',
|
||||
},
|
||||
@@ -75,6 +76,15 @@ export const SupabaseBlock: BlockConfig<SupabaseResponse> = {
|
||||
condition: { field: 'operation', value: 'update' },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'data',
|
||||
title: 'Data',
|
||||
type: 'code',
|
||||
layout: 'full',
|
||||
placeholder: '{\n "column1": "value1",\n "column2": "value2"\n}',
|
||||
condition: { field: 'operation', value: 'upsert' },
|
||||
required: true,
|
||||
},
|
||||
// Filter for get_row, update, delete operations (required)
|
||||
{
|
||||
id: 'filter',
|
||||
@@ -138,6 +148,7 @@ export const SupabaseBlock: BlockConfig<SupabaseResponse> = {
|
||||
'supabase_get_row',
|
||||
'supabase_update',
|
||||
'supabase_delete',
|
||||
'supabase_upsert',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
@@ -152,6 +163,8 @@ export const SupabaseBlock: BlockConfig<SupabaseResponse> = {
|
||||
return 'supabase_update'
|
||||
case 'delete':
|
||||
return 'supabase_delete'
|
||||
case 'upsert':
|
||||
return 'supabase_upsert'
|
||||
default:
|
||||
throw new Error(`Invalid Supabase operation: ${params.operation}`)
|
||||
}
|
||||
@@ -164,8 +177,12 @@ export const SupabaseBlock: BlockConfig<SupabaseResponse> = {
|
||||
if (data && typeof data === 'string' && data.trim()) {
|
||||
try {
|
||||
parsedData = JSON.parse(data)
|
||||
} catch (_e) {
|
||||
throw new Error('Invalid JSON data format')
|
||||
} catch (parseError) {
|
||||
// Provide more detailed error information
|
||||
const errorMsg = parseError instanceof Error ? parseError.message : 'Unknown JSON error'
|
||||
throw new Error(
|
||||
`Invalid JSON data format: ${errorMsg}. Please check your JSON syntax (e.g., strings must be quoted like "value").`
|
||||
)
|
||||
}
|
||||
} else if (data && typeof data === 'object') {
|
||||
parsedData = data
|
||||
|
||||
@@ -171,6 +171,9 @@ export interface SubBlockConfig {
|
||||
// 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[]
|
||||
}
|
||||
|
||||
// Main block definition
|
||||
|
||||
@@ -209,6 +209,7 @@ describe('WorkflowBlockHandler', () => {
|
||||
success: true,
|
||||
childWorkflowName: 'Child Workflow',
|
||||
result: { data: 'test result' },
|
||||
childTraceSpans: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -248,6 +249,7 @@ describe('WorkflowBlockHandler', () => {
|
||||
success: true,
|
||||
childWorkflowName: 'Child Workflow',
|
||||
result: { nested: 'data' },
|
||||
childTraceSpans: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { generateInternalToken } from '@/lib/auth/internal'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { Executor } from '@/executor'
|
||||
@@ -104,18 +105,17 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
// Remove current execution from stack after completion
|
||||
WorkflowBlockHandler.executionStack.delete(executionId)
|
||||
|
||||
// Log execution completion
|
||||
logger.info(`Child workflow ${childWorkflowName} completed in ${Math.round(duration)}ms`)
|
||||
|
||||
// Map child workflow output to parent block output
|
||||
const childTraceSpans = this.captureChildWorkflowLogs(result, childWorkflowName, context)
|
||||
const mappedResult = this.mapChildOutputToParent(
|
||||
result,
|
||||
workflowId,
|
||||
childWorkflowName,
|
||||
duration
|
||||
duration,
|
||||
childTraceSpans
|
||||
)
|
||||
|
||||
// If the child workflow failed, throw an error to trigger proper error handling in the parent
|
||||
if ((mappedResult as any).success === false) {
|
||||
const childError = (mappedResult as any).error || 'Unknown error'
|
||||
throw new Error(`Error in child workflow "${childWorkflowName}": ${childError}`)
|
||||
@@ -125,19 +125,13 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
} catch (error: any) {
|
||||
logger.error(`Error executing child workflow ${workflowId}:`, error)
|
||||
|
||||
// Clean up execution stack in case of error
|
||||
const executionId = `${context.workflowId}_sub_${workflowId}_${block.id}`
|
||||
WorkflowBlockHandler.executionStack.delete(executionId)
|
||||
|
||||
// Get workflow name for error reporting
|
||||
const { workflows } = useWorkflowRegistry.getState()
|
||||
const workflowMetadata = workflows[workflowId]
|
||||
const childWorkflowName = workflowMetadata?.name || workflowId
|
||||
|
||||
// Enhance error message with child workflow context
|
||||
const originalError = error.message || 'Unknown error'
|
||||
|
||||
// Check if error message already has child workflow context to avoid duplication
|
||||
if (originalError.startsWith('Error in child workflow')) {
|
||||
throw error // Re-throw as-is to avoid duplication
|
||||
}
|
||||
@@ -151,12 +145,9 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
*/
|
||||
private async loadChildWorkflow(workflowId: string) {
|
||||
try {
|
||||
// Fetch workflow from API with internal authentication header
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
// Add internal auth header for server-side calls
|
||||
if (typeof window === 'undefined') {
|
||||
const token = await generateInternalToken()
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
@@ -182,16 +173,12 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
}
|
||||
|
||||
logger.info(`Loaded child workflow: ${workflowData.name} (${workflowId})`)
|
||||
|
||||
// Extract the workflow state (API returns normalized data in state field)
|
||||
const workflowState = workflowData.state
|
||||
|
||||
if (!workflowState || !workflowState.blocks) {
|
||||
logger.error(`Child workflow ${workflowId} has invalid state`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Use blocks directly since API returns data from normalized tables
|
||||
const serializedWorkflow = this.serializer.serializeWorkflow(
|
||||
workflowState.blocks,
|
||||
workflowState.edges || [],
|
||||
@@ -222,17 +209,101 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps child workflow output to parent block output format
|
||||
* Captures and transforms child workflow logs into trace spans
|
||||
*/
|
||||
private captureChildWorkflowLogs(
|
||||
childResult: any,
|
||||
childWorkflowName: string,
|
||||
parentContext: ExecutionContext
|
||||
): any[] {
|
||||
try {
|
||||
if (!childResult.logs || !Array.isArray(childResult.logs)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const { traceSpans } = buildTraceSpans(childResult)
|
||||
|
||||
if (!traceSpans || traceSpans.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const transformedSpans = traceSpans.map((span: any) => {
|
||||
return this.transformSpanForChildWorkflow(span, childWorkflowName)
|
||||
})
|
||||
|
||||
return transformedSpans
|
||||
} catch (error) {
|
||||
logger.error(`Error capturing child workflow logs for ${childWorkflowName}:`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms trace span for child workflow context
|
||||
*/
|
||||
private transformSpanForChildWorkflow(span: any, childWorkflowName: string): any {
|
||||
const transformedSpan = {
|
||||
...span,
|
||||
name: this.cleanChildSpanName(span.name, childWorkflowName),
|
||||
metadata: {
|
||||
...span.metadata,
|
||||
isFromChildWorkflow: true,
|
||||
childWorkflowName,
|
||||
},
|
||||
}
|
||||
|
||||
if (span.children && Array.isArray(span.children)) {
|
||||
transformedSpan.children = span.children.map((childSpan: any) =>
|
||||
this.transformSpanForChildWorkflow(childSpan, childWorkflowName)
|
||||
)
|
||||
}
|
||||
|
||||
if (span.output?.childTraceSpans) {
|
||||
transformedSpan.output = {
|
||||
...transformedSpan.output,
|
||||
childTraceSpans: span.output.childTraceSpans,
|
||||
}
|
||||
}
|
||||
|
||||
return transformedSpan
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up child span names for readability
|
||||
*/
|
||||
private cleanChildSpanName(spanName: string, childWorkflowName: string): string {
|
||||
if (spanName.includes(`${childWorkflowName}:`)) {
|
||||
const cleanName = spanName.replace(`${childWorkflowName}:`, '').trim()
|
||||
|
||||
if (cleanName === 'Workflow Execution') {
|
||||
return `${childWorkflowName} workflow`
|
||||
}
|
||||
|
||||
if (cleanName.startsWith('Agent ')) {
|
||||
return `${cleanName}`
|
||||
}
|
||||
|
||||
return `${cleanName}`
|
||||
}
|
||||
|
||||
if (spanName === 'Workflow Execution') {
|
||||
return `${childWorkflowName} workflow`
|
||||
}
|
||||
|
||||
return `${spanName}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps child workflow output to parent block output
|
||||
*/
|
||||
private mapChildOutputToParent(
|
||||
childResult: any,
|
||||
childWorkflowId: string,
|
||||
childWorkflowName: string,
|
||||
duration: number
|
||||
duration: number,
|
||||
childTraceSpans?: any[]
|
||||
): BlockOutput {
|
||||
const success = childResult.success !== false
|
||||
|
||||
// If child workflow failed, return minimal output
|
||||
if (!success) {
|
||||
logger.warn(`Child workflow ${childWorkflowName} failed`)
|
||||
return {
|
||||
@@ -241,18 +312,15 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
error: childResult.error || 'Child workflow execution failed',
|
||||
} as Record<string, any>
|
||||
}
|
||||
|
||||
// Extract the actual result content from the flattened structure
|
||||
let result = childResult
|
||||
if (childResult?.output) {
|
||||
result = childResult.output
|
||||
}
|
||||
|
||||
// Return a properly structured response with all required fields
|
||||
return {
|
||||
success: true,
|
||||
childWorkflowName,
|
||||
result,
|
||||
childTraceSpans: childTraceSpans || [],
|
||||
} as Record<string, any>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BlockPathCalculator } from '@/lib/block-path-calculator'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { TraceSpan } from '@/lib/logs/types'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import {
|
||||
@@ -225,7 +226,7 @@ export class Executor {
|
||||
|
||||
let hasMoreLayers = true
|
||||
let iteration = 0
|
||||
const maxIterations = 100 // Safety limit for infinite loops
|
||||
const maxIterations = 500 // Safety limit for infinite loops
|
||||
|
||||
while (hasMoreLayers && iteration < maxIterations && !this.isCancelled) {
|
||||
const nextLayer = this.getNextExecutionLayer(context)
|
||||
@@ -1510,6 +1511,9 @@ export class Executor {
|
||||
blockLog.durationMs = Math.round(executionTime)
|
||||
blockLog.endedAt = new Date().toISOString()
|
||||
|
||||
// Handle child workflow logs integration
|
||||
this.integrateChildWorkflowLogs(block, output)
|
||||
|
||||
context.blockLogs.push(blockLog)
|
||||
|
||||
// Skip console logging for infrastructure blocks like loops and parallels
|
||||
@@ -1617,6 +1621,9 @@ export class Executor {
|
||||
blockLog.durationMs = Math.round(executionTime)
|
||||
blockLog.endedAt = new Date().toISOString()
|
||||
|
||||
// Handle child workflow logs integration
|
||||
this.integrateChildWorkflowLogs(block, output)
|
||||
|
||||
context.blockLogs.push(blockLog)
|
||||
|
||||
// Skip console logging for infrastructure blocks like loops and parallels
|
||||
@@ -2003,4 +2010,22 @@ export class Executor {
|
||||
context.blockLogs.push(starterBlockLog)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserves child workflow trace spans for proper nesting
|
||||
*/
|
||||
private integrateChildWorkflowLogs(block: SerializedBlock, output: NormalizedBlockOutput): void {
|
||||
if (block.metadata?.id !== BlockType.WORKFLOW) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!output || typeof output !== 'object' || !output.childTraceSpans) {
|
||||
return
|
||||
}
|
||||
|
||||
const childTraceSpans = output.childTraceSpans as TraceSpan[]
|
||||
if (!Array.isArray(childTraceSpans) || childTraceSpans.length === 0) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,7 +804,7 @@ export function useCollaborativeWorkflow() {
|
||||
)
|
||||
|
||||
const collaborativeSetSubblockValue = useCallback(
|
||||
(blockId: string, subblockId: string, value: any) => {
|
||||
(blockId: string, subblockId: string, value: any, options?: { _visited?: Set<string> }) => {
|
||||
if (isApplyingRemoteChange.current) return
|
||||
|
||||
// Skip socket operations when in diff mode
|
||||
@@ -840,6 +840,28 @@ export function useCollaborativeWorkflow() {
|
||||
|
||||
// Apply locally first (immediate UI feedback)
|
||||
subBlockStore.setValue(blockId, subblockId, value)
|
||||
|
||||
// Declarative clearing: clear sub-blocks that depend on this subblockId
|
||||
try {
|
||||
const visited = options?._visited || new Set<string>()
|
||||
if (visited.has(subblockId)) return
|
||||
visited.add(subblockId)
|
||||
const blockType = useWorkflowStore.getState().blocks?.[blockId]?.type
|
||||
const blockConfig = blockType ? getBlock(blockType) : null
|
||||
if (blockConfig?.subBlocks && Array.isArray(blockConfig.subBlocks)) {
|
||||
const dependents = blockConfig.subBlocks.filter(
|
||||
(sb: any) => Array.isArray(sb.dependsOn) && sb.dependsOn.includes(subblockId)
|
||||
)
|
||||
for (const dep of dependents) {
|
||||
// Skip clearing if the dependent is the same field
|
||||
if (!dep?.id || dep.id === subblockId) continue
|
||||
// Cascade using the same collaborative path so it emits and further cascades
|
||||
collaborativeSetSubblockValue(blockId, dep.id, '', { _visited: visited })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort; do not block on clearing
|
||||
}
|
||||
},
|
||||
[
|
||||
subBlockStore,
|
||||
|
||||
@@ -1296,6 +1296,26 @@ export const auth = betterAuth({
|
||||
if (user.length > 0) {
|
||||
const currentUser = user[0]
|
||||
|
||||
// Store the original user ID before we change the referenceId
|
||||
const originalUserId = subscription.referenceId
|
||||
|
||||
// First, sync usage limits for the purchasing user with their new plan
|
||||
try {
|
||||
const { syncUsageLimitsFromSubscription } = await import('@/lib/billing')
|
||||
await syncUsageLimitsFromSubscription(originalUserId)
|
||||
logger.info(
|
||||
'Usage limits synced for purchasing user before organization creation',
|
||||
{
|
||||
userId: originalUserId,
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync usage limits for purchasing user', {
|
||||
userId: originalUserId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
// Create organization for the team
|
||||
const orgId = `org_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`
|
||||
const orgSlug = `${currentUser.name?.toLowerCase().replace(/\s+/g, '-') || 'team'}-${Date.now()}`
|
||||
@@ -1376,17 +1396,21 @@ export const auth = betterAuth({
|
||||
}
|
||||
}
|
||||
|
||||
// Sync usage limits and initialize billing period for the user/organization
|
||||
// Initialize billing period for the user/organization
|
||||
try {
|
||||
const { syncUsageLimitsFromSubscription } = await import('@/lib/billing')
|
||||
const { initializeBillingPeriod } = await import(
|
||||
'@/lib/billing/core/billing-periods'
|
||||
)
|
||||
|
||||
await syncUsageLimitsFromSubscription(subscription.referenceId)
|
||||
logger.info('Usage limits synced after subscription creation', {
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
// Note: Usage limits are already synced above for team plan users
|
||||
// For non-team plans, sync usage limits using the referenceId (which is the user ID)
|
||||
if (subscription.plan !== 'team') {
|
||||
const { syncUsageLimitsFromSubscription } = await import('@/lib/billing')
|
||||
await syncUsageLimitsFromSubscription(subscription.referenceId)
|
||||
logger.info('Usage limits synced after subscription creation', {
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize billing period for new subscription using Stripe dates
|
||||
if (subscription.plan !== 'free') {
|
||||
|
||||
@@ -90,29 +90,44 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
// Always add cost, token, and model information if available (regardless of provider timing)
|
||||
if (log.output?.cost) {
|
||||
;(span as any).cost = log.output.cost
|
||||
logger.debug(`Added cost to span ${span.id}`, {
|
||||
blockId: log.blockId,
|
||||
blockType: log.blockType,
|
||||
cost: log.output.cost,
|
||||
})
|
||||
}
|
||||
|
||||
if (log.output?.tokens) {
|
||||
;(span as any).tokens = log.output.tokens
|
||||
logger.debug(`Added tokens to span ${span.id}`, {
|
||||
blockId: log.blockId,
|
||||
blockType: log.blockType,
|
||||
tokens: log.output.tokens,
|
||||
})
|
||||
}
|
||||
|
||||
if (log.output?.model) {
|
||||
;(span as any).model = log.output.model
|
||||
logger.debug(`Added model to span ${span.id}`, {
|
||||
blockId: log.blockId,
|
||||
blockType: log.blockType,
|
||||
model: log.output.model,
|
||||
}
|
||||
|
||||
// Handle child workflow spans for workflow blocks
|
||||
if (
|
||||
log.blockType === 'workflow' &&
|
||||
log.output?.childTraceSpans &&
|
||||
Array.isArray(log.output.childTraceSpans)
|
||||
) {
|
||||
// Convert child trace spans to be direct children of this workflow block span
|
||||
const childTraceSpans = log.output.childTraceSpans as TraceSpan[]
|
||||
|
||||
// Process child workflow spans and add them as children
|
||||
const flatChildSpans: TraceSpan[] = []
|
||||
childTraceSpans.forEach((childSpan) => {
|
||||
// Skip the synthetic workflow span wrapper - we only want the actual block executions
|
||||
if (childSpan.type === 'workflow' && childSpan.name === 'Workflow Execution') {
|
||||
// Add its children directly, skipping the synthetic wrapper
|
||||
if (childSpan.children && Array.isArray(childSpan.children)) {
|
||||
flatChildSpans.push(...childSpan.children)
|
||||
}
|
||||
} else {
|
||||
// This is a regular span, add it directly
|
||||
// But first, ensure nested workflow blocks in this span are also processed
|
||||
const processedSpan = ensureNestedWorkflowsProcessed(childSpan)
|
||||
flatChildSpans.push(processedSpan)
|
||||
}
|
||||
})
|
||||
|
||||
// Add the child spans as children of this workflow block
|
||||
span.children = flatChildSpans
|
||||
}
|
||||
|
||||
// Enhanced approach: Use timeSegments for sequential flow if available
|
||||
@@ -163,20 +178,6 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
status: 'success',
|
||||
}
|
||||
})
|
||||
|
||||
logger.debug(
|
||||
`Created ${span.children?.length || 0} sequential segments for span ${span.id}`,
|
||||
{
|
||||
blockId: log.blockId,
|
||||
blockType: log.blockType,
|
||||
segments:
|
||||
span.children?.map((child) => ({
|
||||
name: child.name,
|
||||
type: child.type,
|
||||
duration: child.duration,
|
||||
})) || [],
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Fallback: Extract tool calls using the original approach for backwards compatibility
|
||||
// Tool calls handling for different formats:
|
||||
@@ -237,12 +238,6 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
}
|
||||
})
|
||||
.filter(Boolean) // Remove any null entries from failed processing
|
||||
|
||||
logger.debug(`Added ${span.toolCalls?.length || 0} tool calls to span ${span.id}`, {
|
||||
blockId: log.blockId,
|
||||
blockType: log.blockType,
|
||||
toolCallNames: span.toolCalls?.map((tc) => tc.name) || [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,6 +379,45 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
return { traceSpans: rootSpans, totalDuration }
|
||||
}
|
||||
|
||||
// Helper function to recursively process nested workflow blocks in trace spans
|
||||
function ensureNestedWorkflowsProcessed(span: TraceSpan): TraceSpan {
|
||||
// Create a copy to avoid mutating the original
|
||||
const processedSpan = { ...span }
|
||||
|
||||
// If this is a workflow block and it has childTraceSpans in its output, process them
|
||||
if (
|
||||
span.type === 'workflow' &&
|
||||
span.output?.childTraceSpans &&
|
||||
Array.isArray(span.output.childTraceSpans)
|
||||
) {
|
||||
const childTraceSpans = span.output.childTraceSpans as TraceSpan[]
|
||||
const nestedChildren: TraceSpan[] = []
|
||||
|
||||
childTraceSpans.forEach((childSpan) => {
|
||||
// Skip synthetic workflow wrappers and get the actual blocks
|
||||
if (childSpan.type === 'workflow' && childSpan.name === 'Workflow Execution') {
|
||||
if (childSpan.children && Array.isArray(childSpan.children)) {
|
||||
// Recursively process each child to handle deeper nesting
|
||||
childSpan.children.forEach((grandchildSpan) => {
|
||||
nestedChildren.push(ensureNestedWorkflowsProcessed(grandchildSpan))
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Regular span, recursively process it for potential deeper nesting
|
||||
nestedChildren.push(ensureNestedWorkflowsProcessed(childSpan))
|
||||
}
|
||||
})
|
||||
|
||||
// Set the processed children on this workflow block
|
||||
processedSpan.children = nestedChildren
|
||||
} else if (span.children && Array.isArray(span.children)) {
|
||||
// Recursively process regular children too
|
||||
processedSpan.children = span.children.map((child) => ensureNestedWorkflowsProcessed(child))
|
||||
}
|
||||
|
||||
return processedSpan
|
||||
}
|
||||
|
||||
export function stripCustomToolPrefix(name: string) {
|
||||
return name.startsWith('custom_') ? name.replace('custom_', '') : name
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import { nanoid } from 'nanoid'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { hasProcessedMessage, markMessageAsProcessed } from '@/lib/redis'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
|
||||
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { webhook } from '@/db/schema'
|
||||
import { account, webhook } from '@/db/schema'
|
||||
|
||||
const logger = new Logger('GmailPollingService')
|
||||
|
||||
@@ -81,20 +81,37 @@ export async function pollGmailWebhooks() {
|
||||
const requestId = nanoid()
|
||||
|
||||
try {
|
||||
// Extract user ID from webhook metadata if available
|
||||
// Extract metadata
|
||||
const metadata = webhookData.providerConfig as any
|
||||
const userId = metadata?.userId
|
||||
const credentialId: string | undefined = metadata?.credentialId
|
||||
const userId: string | undefined = metadata?.userId
|
||||
|
||||
if (!userId) {
|
||||
logger.error(`[${requestId}] No user ID found for webhook ${webhookId}`)
|
||||
return { success: false, webhookId, error: 'No user ID' }
|
||||
if (!credentialId && !userId) {
|
||||
logger.error(`[${requestId}] Missing credentialId and userId for webhook ${webhookId}`)
|
||||
return { success: false, webhookId, error: 'Missing credentialId and userId' }
|
||||
}
|
||||
|
||||
// Get OAuth token for Gmail API
|
||||
const accessToken = await getOAuthToken(userId, 'google-email')
|
||||
// Resolve owner and token
|
||||
let accessToken: string | null = null
|
||||
if (credentialId) {
|
||||
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
if (rows.length === 0) {
|
||||
logger.error(
|
||||
`[${requestId}] Credential ${credentialId} not found for webhook ${webhookId}`
|
||||
)
|
||||
return { success: false, webhookId, error: 'Credential not found' }
|
||||
}
|
||||
const ownerUserId = rows[0].userId
|
||||
accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId)
|
||||
} else if (userId) {
|
||||
// Backward-compat fallback to workflow owner token
|
||||
accessToken = await getOAuthToken(userId, 'google-email')
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error(`[${requestId}] Failed to get Gmail access token for webhook ${webhookId}`)
|
||||
logger.error(
|
||||
`[${requestId}] Failed to get Gmail access token for webhook ${webhookId} (cred or fallback)`
|
||||
)
|
||||
return { success: false, webhookId, error: 'No access token' }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import { nanoid } from 'nanoid'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { hasProcessedMessage, markMessageAsProcessed } from '@/lib/redis'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
|
||||
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { webhook } from '@/db/schema'
|
||||
import { account, webhook } from '@/db/schema'
|
||||
|
||||
const logger = new Logger('OutlookPollingService')
|
||||
|
||||
@@ -108,28 +108,37 @@ export async function pollOutlookWebhooks() {
|
||||
try {
|
||||
logger.info(`[${requestId}] Processing Outlook webhook: ${webhookId}`)
|
||||
|
||||
// Extract user ID from webhook metadata if available
|
||||
// Extract credentialId and/or userId
|
||||
const metadata = webhookData.providerConfig as any
|
||||
const userId = metadata?.userId
|
||||
const credentialId: string | undefined = metadata?.credentialId
|
||||
const userId: string | undefined = metadata?.userId
|
||||
|
||||
// Debug: Webhook metadata extraction
|
||||
logger.debug(
|
||||
`[${requestId}] Webhook ${webhookId} providerConfig:`,
|
||||
JSON.stringify(metadata, null, 2)
|
||||
)
|
||||
logger.debug(`[${requestId}] Extracted userId:`, userId)
|
||||
|
||||
if (!userId) {
|
||||
logger.error(`[${requestId}] No user ID found for webhook ${webhookId}`)
|
||||
logger.debug(`[${requestId}] No userId found in providerConfig for webhook ${webhookId}`)
|
||||
return { success: false, webhookId, error: 'No user ID' }
|
||||
if (!credentialId && !userId) {
|
||||
logger.error(`[${requestId}] Missing credentialId and userId for webhook ${webhookId}`)
|
||||
return { success: false, webhookId, error: 'Missing credentialId and userId' }
|
||||
}
|
||||
|
||||
// Get OAuth token for Outlook API
|
||||
const accessToken = await getOAuthToken(userId, 'outlook')
|
||||
// Resolve access token
|
||||
let accessToken: string | null = null
|
||||
if (credentialId) {
|
||||
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
if (!rows.length) {
|
||||
logger.error(
|
||||
`[${requestId}] Credential ${credentialId} not found for webhook ${webhookId}`
|
||||
)
|
||||
return { success: false, webhookId, error: 'Credential not found' }
|
||||
}
|
||||
const ownerUserId = rows[0].userId
|
||||
accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId)
|
||||
} else if (userId) {
|
||||
// Backward-compat fallback to workflow owner token
|
||||
accessToken = await getOAuthToken(userId, 'outlook')
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error(`[${requestId}] Failed to get Outlook access token for webhook ${webhookId}`)
|
||||
logger.error(
|
||||
`[${requestId}] Failed to get Outlook access token for webhook ${webhookId} (cred or fallback)`
|
||||
)
|
||||
return { success: false, webhookId, error: 'No access token' }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
|
||||
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { webhook } from '@/db/schema'
|
||||
import { account, webhook } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('WebhookUtils')
|
||||
|
||||
@@ -860,6 +860,31 @@ export async function fetchAndProcessAirtablePayloads(
|
||||
return // Exit early
|
||||
}
|
||||
|
||||
// Require credentialId
|
||||
const credentialId: string | undefined = localProviderConfig.credentialId
|
||||
if (!credentialId) {
|
||||
logger.error(
|
||||
`[${requestId}] Missing credentialId in providerConfig for Airtable webhook ${webhookData.id}.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve owner and access token strictly via credentialId (no fallback)
|
||||
let ownerUserId: string | null = null
|
||||
try {
|
||||
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
ownerUserId = rows.length ? rows[0].userId : null
|
||||
} catch (_e) {
|
||||
ownerUserId = null
|
||||
}
|
||||
|
||||
if (!ownerUserId) {
|
||||
logger.error(
|
||||
`[${requestId}] Could not resolve owner for Airtable credential ${credentialId} on webhook ${webhookData.id}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// --- Retrieve Stored Cursor from localProviderConfig ---
|
||||
const storedCursor = localProviderConfig.externalWebhookCursor
|
||||
|
||||
@@ -908,14 +933,13 @@ export async function fetchAndProcessAirtablePayloads(
|
||||
)
|
||||
}
|
||||
|
||||
// --- Get OAuth Token ---
|
||||
// --- Get OAuth Token (strict via credentialId) ---
|
||||
let accessToken: string | null = null
|
||||
try {
|
||||
accessToken = await getOAuthToken(workflowData.userId, 'airtable')
|
||||
accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId)
|
||||
if (!accessToken) {
|
||||
logger.error(
|
||||
`[${requestId}] Failed to obtain valid Airtable access token. Cannot proceed.`,
|
||||
{ userId: workflowData.userId }
|
||||
`[${requestId}] Failed to obtain valid Airtable access token via credential ${credentialId}.`
|
||||
)
|
||||
throw new Error('Airtable access token not found.')
|
||||
}
|
||||
@@ -923,11 +947,11 @@ export async function fetchAndProcessAirtablePayloads(
|
||||
logger.info(`[${requestId}] Successfully obtained Airtable access token`)
|
||||
} catch (tokenError: any) {
|
||||
logger.error(
|
||||
`[${requestId}] Failed to get Airtable OAuth token for user ${workflowData.userId}`,
|
||||
`[${requestId}] Failed to get Airtable OAuth token for credential ${credentialId}`,
|
||||
{
|
||||
error: tokenError.message,
|
||||
stack: tokenError.stack,
|
||||
userId: workflowData.userId,
|
||||
credentialId,
|
||||
}
|
||||
)
|
||||
// Error logging handled by logging session
|
||||
@@ -1102,7 +1126,7 @@ export async function fetchAndProcessAirtablePayloads(
|
||||
|
||||
// DEBUG: Log totals for this batch
|
||||
logger.debug(
|
||||
`[${requestId}] TRACE: Processed ${changeCount} changes in API call ${apiCallCount}`,
|
||||
`[${requestId}] TRACE: Processed ${changeCount} changes in API call ${apiCallCount})`,
|
||||
{
|
||||
currentMapSize: consolidatedChangesMap.size,
|
||||
}
|
||||
@@ -1257,14 +1281,47 @@ export async function configureGmailPolling(
|
||||
logger.info(`[${requestId}] Setting up Gmail polling for webhook ${webhookData.id}`)
|
||||
|
||||
try {
|
||||
const accessToken = await getOAuthToken(userId, 'google-email')
|
||||
if (!accessToken) {
|
||||
logger.error(`[${requestId}] Failed to retrieve Gmail access token for user ${userId}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const providerConfig = (webhookData.providerConfig as Record<string, any>) || {}
|
||||
|
||||
const credentialId: string | undefined = providerConfig.credentialId
|
||||
|
||||
let effectiveUserId: string | null = null
|
||||
let accessToken: string | null = null
|
||||
|
||||
if (credentialId) {
|
||||
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
if (rows.length === 0) {
|
||||
logger.error(
|
||||
`[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
effectiveUserId = rows[0].userId
|
||||
accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId)
|
||||
if (!accessToken) {
|
||||
logger.error(
|
||||
`[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
// Backward-compat: fall back to workflow owner
|
||||
if (!userId) {
|
||||
logger.error(
|
||||
`[${requestId}] Missing credentialId and userId for Gmail webhook ${webhookData.id}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
effectiveUserId = userId
|
||||
accessToken = await getOAuthToken(effectiveUserId, 'google-email')
|
||||
if (!accessToken) {
|
||||
logger.error(
|
||||
`[${requestId}] Failed to obtain Gmail token for user ${effectiveUserId} (fallback)`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const maxEmailsPerPoll =
|
||||
typeof providerConfig.maxEmailsPerPoll === 'string'
|
||||
? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25
|
||||
@@ -1282,7 +1339,8 @@ export async function configureGmailPolling(
|
||||
.set({
|
||||
providerConfig: {
|
||||
...providerConfig,
|
||||
userId, // Store user ID for OAuth access during polling
|
||||
userId: effectiveUserId,
|
||||
...(credentialId ? { credentialId } : {}),
|
||||
maxEmailsPerPoll,
|
||||
pollingInterval,
|
||||
markAsRead: providerConfig.markAsRead || false,
|
||||
@@ -1323,23 +1381,48 @@ export async function configureOutlookPolling(
|
||||
logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`)
|
||||
|
||||
try {
|
||||
const accessToken = await getOAuthToken(userId, 'outlook')
|
||||
if (!accessToken) {
|
||||
logger.error(`[${requestId}] Failed to retrieve Outlook access token for user ${userId}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const providerConfig = (webhookData.providerConfig as Record<string, any>) || {}
|
||||
|
||||
const maxEmailsPerPoll =
|
||||
typeof providerConfig.maxEmailsPerPoll === 'string'
|
||||
? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25
|
||||
: providerConfig.maxEmailsPerPoll || 25
|
||||
const credentialId: string | undefined = providerConfig.credentialId
|
||||
|
||||
const pollingInterval =
|
||||
typeof providerConfig.pollingInterval === 'string'
|
||||
? Number.parseInt(providerConfig.pollingInterval, 10) || 5
|
||||
: providerConfig.pollingInterval || 5
|
||||
let effectiveUserId: string | null = null
|
||||
let accessToken: string | null = null
|
||||
|
||||
if (credentialId) {
|
||||
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
if (rows.length === 0) {
|
||||
logger.error(
|
||||
`[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
effectiveUserId = rows[0].userId
|
||||
accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId)
|
||||
if (!accessToken) {
|
||||
logger.error(
|
||||
`[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
// Backward-compat: fall back to workflow owner
|
||||
if (!userId) {
|
||||
logger.error(
|
||||
`[${requestId}] Missing credentialId and userId for Outlook webhook ${webhookData.id}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
effectiveUserId = userId
|
||||
accessToken = await getOAuthToken(effectiveUserId, 'outlook')
|
||||
if (!accessToken) {
|
||||
logger.error(
|
||||
`[${requestId}] Failed to obtain Outlook token for user ${effectiveUserId} (fallback)`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const providerCfg = (webhookData.providerConfig as Record<string, any>) || {}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
@@ -1347,14 +1430,21 @@ export async function configureOutlookPolling(
|
||||
.update(webhook)
|
||||
.set({
|
||||
providerConfig: {
|
||||
...providerConfig,
|
||||
userId, // Store user ID for OAuth access during polling
|
||||
maxEmailsPerPoll,
|
||||
pollingInterval,
|
||||
markAsRead: providerConfig.markAsRead || false,
|
||||
includeRawEmail: providerConfig.includeRawEmail || false,
|
||||
folderIds: providerConfig.folderIds || ['inbox'],
|
||||
folderFilterBehavior: providerConfig.folderFilterBehavior || 'INCLUDE',
|
||||
...providerCfg,
|
||||
userId: effectiveUserId,
|
||||
...(credentialId ? { credentialId } : {}),
|
||||
maxEmailsPerPoll:
|
||||
typeof providerCfg.maxEmailsPerPoll === 'string'
|
||||
? Number.parseInt(providerCfg.maxEmailsPerPoll, 10) || 25
|
||||
: providerCfg.maxEmailsPerPoll || 25,
|
||||
pollingInterval:
|
||||
typeof providerCfg.pollingInterval === 'string'
|
||||
? Number.parseInt(providerCfg.pollingInterval, 10) || 5
|
||||
: providerCfg.pollingInterval || 5,
|
||||
markAsRead: providerCfg.markAsRead || false,
|
||||
includeRawEmail: providerCfg.includeRawEmail || false,
|
||||
folderIds: providerCfg.folderIds || ['inbox'],
|
||||
folderFilterBehavior: providerCfg.folderFilterBehavior || 'INCLUDE',
|
||||
lastCheckedTimestamp: now.toISOString(),
|
||||
setupCompleted: true,
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { redactApiKeys } from '@/lib/utils'
|
||||
import type { NormalizedBlockOutput } from '@/executor/types'
|
||||
import type { ConsoleEntry, ConsoleStore } from '@/stores/panel/console/types'
|
||||
|
||||
const MAX_ENTRIES = 50 // MAX across all workflows
|
||||
const MAX_ENTRIES = 500 // MAX across all workflows - allows for 100 loop iterations + other workflow logs
|
||||
const MAX_IMAGE_DATA_SIZE = 1000 // Maximum size of image data to store (in characters)
|
||||
const MAX_ANY_DATA_SIZE = 5000 // Maximum size of any data to store (in characters)
|
||||
const MAX_TOTAL_ENTRY_SIZE = 50000 // Maximum size of entire entry to prevent localStorage overflow
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('workflow store', () => {
|
||||
expect(state.loops.loop1.forEachItems).toEqual(['item1', 'item2', 'item3'])
|
||||
})
|
||||
|
||||
it('should clamp loop count between 1 and 50', () => {
|
||||
it('should clamp loop count between 1 and 100', () => {
|
||||
const { addBlock, updateLoopCount } = useWorkflowStore.getState()
|
||||
|
||||
// Add a loop block
|
||||
@@ -199,7 +199,7 @@ describe('workflow store', () => {
|
||||
expect(parsedDistribution).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should clamp parallel count between 1 and 50', () => {
|
||||
it('should clamp parallel count between 1 and 20', () => {
|
||||
const { addBlock, updateParallelCount } = useWorkflowStore.getState()
|
||||
|
||||
// Add a parallel block
|
||||
|
||||
@@ -192,7 +192,18 @@ export class ToolTester<P = any, R = any> {
|
||||
const response = await this.mockFetch(url, {
|
||||
method: method,
|
||||
headers: this.tool.request.headers(params),
|
||||
body: this.tool.request.body ? JSON.stringify(this.tool.request.body(params)) : undefined,
|
||||
body: this.tool.request.body
|
||||
? (() => {
|
||||
const bodyResult = this.tool.request.body(params)
|
||||
const headers = this.tool.request.headers(params)
|
||||
const isPreformattedContent =
|
||||
headers['Content-Type'] === 'application/x-ndjson' ||
|
||||
headers['Content-Type'] === 'application/x-www-form-urlencoded'
|
||||
return isPreformattedContent && typeof bodyResult === 'string'
|
||||
? bodyResult
|
||||
: JSON.stringify(bodyResult)
|
||||
})()
|
||||
: undefined,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -41,7 +41,7 @@ export const createFolderTool: ToolConfig<GoogleDriveToolParams, GoogleDriveUplo
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://www.googleapis.com/drive/v3/files',
|
||||
url: 'https://www.googleapis.com/drive/v3/files?supportsAllDrives=true',
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
|
||||
@@ -59,6 +59,10 @@ export const listTool: ToolConfig<GoogleDriveToolParams, GoogleDriveListResponse
|
||||
'fields',
|
||||
'files(id,name,mimeType,webViewLink,webContentLink,size,createdTime,modifiedTime,parents),nextPageToken'
|
||||
)
|
||||
// Ensure shared drives support
|
||||
url.searchParams.append('supportsAllDrives', 'true')
|
||||
url.searchParams.append('includeItemsFromAllDrives', 'true')
|
||||
url.searchParams.append('spaces', 'drive')
|
||||
|
||||
// Build the query conditions
|
||||
const conditions = ['trashed = false'] // Always exclude trashed files
|
||||
|
||||
@@ -57,7 +57,7 @@ export const uploadTool: ToolConfig<GoogleDriveToolParams, GoogleDriveUploadResp
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://www.googleapis.com/drive/v3/files',
|
||||
url: 'https://www.googleapis.com/drive/v3/files?supportsAllDrives=true',
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
@@ -115,7 +115,7 @@ export const uploadTool: ToolConfig<GoogleDriveToolParams, GoogleDriveUploadResp
|
||||
})
|
||||
|
||||
const uploadResponse = await fetch(
|
||||
`https://www.googleapis.com/upload/drive/v3/files/${fileId}?uploadType=media`,
|
||||
`https://www.googleapis.com/upload/drive/v3/files/${fileId}?uploadType=media&supportsAllDrives=true`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
@@ -144,7 +144,7 @@ export const uploadTool: ToolConfig<GoogleDriveToolParams, GoogleDriveUploadResp
|
||||
})
|
||||
|
||||
const updateNameResponse = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files/${fileId}`,
|
||||
`https://www.googleapis.com/drive/v3/files/${fileId}?supportsAllDrives=true`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
@@ -167,7 +167,7 @@ export const uploadTool: ToolConfig<GoogleDriveToolParams, GoogleDriveUploadResp
|
||||
|
||||
// Get the final file data
|
||||
const finalFileResponse = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,webViewLink,webContentLink,size,createdTime,modifiedTime,parents`,
|
||||
`https://www.googleapis.com/drive/v3/files/${fileId}?supportsAllDrives=true&fields=id,name,mimeType,webViewLink,webContentLink,size,createdTime,modifiedTime,parents`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
|
||||
@@ -109,6 +109,26 @@ describe('HTTP Request Tool', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should respect custom Content-Type headers', () => {
|
||||
// Custom Content-Type should not be overridden
|
||||
const headers = tester.getRequestHeaders({
|
||||
url: 'https://api.example.com',
|
||||
method: 'POST',
|
||||
body: { key: 'value' },
|
||||
headers: [{ Key: 'Content-Type', Value: 'application/x-www-form-urlencoded' }],
|
||||
})
|
||||
expect(headers['Content-Type']).toBe('application/x-www-form-urlencoded')
|
||||
|
||||
// Case-insensitive Content-Type should not be overridden
|
||||
const headers2 = tester.getRequestHeaders({
|
||||
url: 'https://api.example.com',
|
||||
method: 'POST',
|
||||
body: { key: 'value' },
|
||||
headers: [{ Key: 'content-type', Value: 'text/plain' }],
|
||||
})
|
||||
expect(headers2['content-type']).toBe('text/plain')
|
||||
})
|
||||
|
||||
it('should set dynamic Referer header correctly', async () => {
|
||||
const originalWindow = global.window
|
||||
Object.defineProperty(global, 'window', {
|
||||
@@ -164,6 +184,30 @@ describe('HTTP Request Tool', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Body Construction', () => {
|
||||
it.concurrent('should handle JSON bodies correctly', () => {
|
||||
const body = { username: 'test', password: 'secret' }
|
||||
|
||||
expect(
|
||||
tester.getRequestBody({
|
||||
url: 'https://api.example.com',
|
||||
body,
|
||||
})
|
||||
).toEqual(body)
|
||||
})
|
||||
|
||||
it.concurrent('should handle FormData correctly', () => {
|
||||
const formData = { file: 'test.txt', content: 'file content' }
|
||||
|
||||
const result = tester.getRequestBody({
|
||||
url: 'https://api.example.com',
|
||||
formData,
|
||||
})
|
||||
|
||||
expect(result).toBeInstanceOf(FormData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Request Execution', () => {
|
||||
it('should apply default and dynamic headers to requests', async () => {
|
||||
// Setup mock response
|
||||
@@ -253,6 +297,59 @@ describe('HTTP Request Tool', () => {
|
||||
expect(bodyArg).toEqual(body)
|
||||
})
|
||||
|
||||
it('should handle POST requests with URL-encoded form data', async () => {
|
||||
// Setup mock response
|
||||
tester.setup({ result: 'success' })
|
||||
|
||||
// Create test body
|
||||
const body = { username: 'testuser123', password: 'testpass456', email: 'test@example.com' }
|
||||
|
||||
// Execute the tool with form-urlencoded content type
|
||||
await tester.execute({
|
||||
url: 'https://api.example.com/oauth/token',
|
||||
method: 'POST',
|
||||
body,
|
||||
headers: [{ cells: { Key: 'Content-Type', Value: 'application/x-www-form-urlencoded' } }],
|
||||
})
|
||||
|
||||
// Verify the request was made with correct headers
|
||||
const fetchCall = (global.fetch as any).mock.calls[0]
|
||||
expect(fetchCall[0]).toBe('https://api.example.com/oauth/token')
|
||||
expect(fetchCall[1].method).toBe('POST')
|
||||
expect(fetchCall[1].headers['Content-Type']).toBe('application/x-www-form-urlencoded')
|
||||
|
||||
// Verify the body is URL-encoded (should not be JSON stringified)
|
||||
expect(fetchCall[1].body).toBe(
|
||||
'username=testuser123&password=testpass456&email=test%40example.com'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle OAuth client credentials requests', async () => {
|
||||
// Setup mock response for OAuth token endpoint
|
||||
tester.setup({ access_token: 'token123', token_type: 'Bearer' })
|
||||
|
||||
// Execute OAuth client credentials request
|
||||
await tester.execute({
|
||||
url: 'https://oauth.example.com/token',
|
||||
method: 'POST',
|
||||
body: { grant_type: 'client_credentials', scope: 'read write' },
|
||||
headers: [
|
||||
{ cells: { Key: 'Content-Type', Value: 'application/x-www-form-urlencoded' } },
|
||||
{ cells: { Key: 'Authorization', Value: 'Basic Y2xpZW50OnNlY3JldA==' } },
|
||||
],
|
||||
})
|
||||
|
||||
// Verify the OAuth request was properly formatted
|
||||
const fetchCall = (global.fetch as any).mock.calls[0]
|
||||
expect(fetchCall[0]).toBe('https://oauth.example.com/token')
|
||||
expect(fetchCall[1].method).toBe('POST')
|
||||
expect(fetchCall[1].headers['Content-Type']).toBe('application/x-www-form-urlencoded')
|
||||
expect(fetchCall[1].headers.Authorization).toBe('Basic Y2xpZW50OnNlY3JldA==')
|
||||
|
||||
// Verify the body is URL-encoded
|
||||
expect(fetchCall[1].body).toBe('grant_type=client_credentials&scope=read+write')
|
||||
})
|
||||
|
||||
it('should handle errors correctly', async () => {
|
||||
// Setup error response
|
||||
tester.setup(mockHttpResponses.error, { ok: false, status: 400 })
|
||||
|
||||
@@ -67,12 +67,12 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
|
||||
const processedUrl = processUrl(params.url, params.pathParams, params.params)
|
||||
const allHeaders = getDefaultHeaders(headers, processedUrl)
|
||||
|
||||
// Set appropriate Content-Type
|
||||
// Set appropriate Content-Type only if not already specified by user
|
||||
if (params.formData) {
|
||||
// Don't set Content-Type for FormData, browser will set it with boundary
|
||||
return allHeaders
|
||||
}
|
||||
if (params.body) {
|
||||
if (params.body && !allHeaders['Content-Type'] && !allHeaders['content-type']) {
|
||||
allHeaders['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
@@ -89,6 +89,24 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
|
||||
}
|
||||
|
||||
if (params.body) {
|
||||
// Check if user wants URL-encoded form data
|
||||
const headers = transformTable(params.headers || null)
|
||||
const contentType = headers['Content-Type'] || headers['content-type']
|
||||
|
||||
if (
|
||||
contentType === 'application/x-www-form-urlencoded' &&
|
||||
typeof params.body === 'object'
|
||||
) {
|
||||
// Convert JSON object to URL-encoded string
|
||||
const urlencoded = new URLSearchParams()
|
||||
Object.entries(params.body).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
urlencoded.append(key, String(value))
|
||||
}
|
||||
})
|
||||
return urlencoded.toString()
|
||||
}
|
||||
|
||||
return params.body
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,7 @@ import {
|
||||
supabaseInsertTool,
|
||||
supabaseQueryTool,
|
||||
supabaseUpdateTool,
|
||||
supabaseUpsertTool,
|
||||
} from '@/tools/supabase'
|
||||
import { tavilyExtractTool, tavilySearchTool } from '@/tools/tavily'
|
||||
import { telegramMessageTool } from '@/tools/telegram'
|
||||
@@ -190,6 +191,7 @@ export const tools: Record<string, ToolConfig> = {
|
||||
supabase_get_row: supabaseGetRowTool,
|
||||
supabase_update: supabaseUpdateTool,
|
||||
supabase_delete: supabaseDeleteTool,
|
||||
supabase_upsert: supabaseUpsertTool,
|
||||
typeform_responses: typeformResponsesTool,
|
||||
typeform_files: typeformFilesTool,
|
||||
typeform_insights: typeformInsightsTool,
|
||||
|
||||
@@ -59,28 +59,36 @@ export const deleteTool: ToolConfig<SupabaseDeleteParams, SupabaseDeleteResponse
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
// Handle empty response from delete operations
|
||||
const text = await response.text()
|
||||
let data
|
||||
|
||||
if (text?.trim()) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch (e) {
|
||||
// If we can't parse it, just use the text
|
||||
data = text
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase response: ${parseError}`)
|
||||
}
|
||||
} else {
|
||||
// Empty response means successful deletion
|
||||
data = []
|
||||
}
|
||||
|
||||
const deletedCount = Array.isArray(data) ? data.length : text ? 1 : 0
|
||||
const deletedCount = Array.isArray(data) ? data.length : 0
|
||||
|
||||
if (deletedCount === 0) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'No rows were deleted (no matching records found)',
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: `Successfully deleted ${deletedCount === 0 ? 'row(s)' : `${deletedCount} row(s)`}`,
|
||||
message: `Successfully deleted ${deletedCount} row${deletedCount === 1 ? '' : 's'}`,
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
|
||||
@@ -57,14 +57,21 @@ export const getRowTool: ToolConfig<SupabaseGetRowParams, SupabaseGetRowResponse
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
const row = data.length > 0 ? data[0] : null
|
||||
let data
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase response: ${parseError}`)
|
||||
}
|
||||
|
||||
const rowFound = data.length > 0
|
||||
const results = rowFound ? [data[0]] : []
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: row ? 'Successfully found row' : 'No row found matching the criteria',
|
||||
results: row,
|
||||
message: rowFound ? 'Successfully found 1 row' : 'No row found matching the criteria',
|
||||
results: results,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
@@ -72,6 +79,9 @@ export const getRowTool: ToolConfig<SupabaseGetRowParams, SupabaseGetRowResponse
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: { type: 'object', description: 'The row data if found, null if not found' },
|
||||
results: {
|
||||
type: 'array',
|
||||
description: 'Array containing the row data if found, empty array if not found',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { getRowTool } from '@/tools/supabase/get_row'
|
||||
import { insertTool } from '@/tools/supabase/insert'
|
||||
import { queryTool } from '@/tools/supabase/query'
|
||||
import { updateTool } from '@/tools/supabase/update'
|
||||
import { upsertTool } from '@/tools/supabase/upsert'
|
||||
|
||||
export const supabaseQueryTool = queryTool
|
||||
export const supabaseInsertTool = insertTool
|
||||
export const supabaseGetRowTool = getRowTool
|
||||
export const supabaseUpdateTool = updateTool
|
||||
export const supabaseDeleteTool = deleteTool
|
||||
export const supabaseUpsertTool = upsertTool
|
||||
|
||||
@@ -53,8 +53,8 @@ export const insertTool: ToolConfig<SupabaseInsertParams, SupabaseInsertResponse
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
// Handle empty response case
|
||||
const text = await response.text()
|
||||
|
||||
if (!text || text.trim() === '') {
|
||||
return {
|
||||
success: true,
|
||||
@@ -66,12 +66,34 @@ export const insertTool: ToolConfig<SupabaseInsertParams, SupabaseInsertResponse
|
||||
}
|
||||
}
|
||||
|
||||
const data = JSON.parse(text)
|
||||
let data
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase response: ${parseError}`)
|
||||
}
|
||||
|
||||
// Check if results array is empty and provide better feedback
|
||||
const resultsArray = Array.isArray(data) ? data : [data]
|
||||
const isEmpty = resultsArray.length === 0 || (resultsArray.length === 1 && !resultsArray[0])
|
||||
|
||||
if (isEmpty) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
message: 'No data was inserted into Supabase',
|
||||
results: data,
|
||||
},
|
||||
error:
|
||||
'No data was inserted into Supabase. This usually indicates invalid data format or schema mismatch. Please check that your JSON is valid and matches your table schema.',
|
||||
}
|
||||
}
|
||||
|
||||
const insertedCount = resultsArray.length
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'Successfully inserted data into Supabase',
|
||||
message: `Successfully inserted ${insertedCount} row${insertedCount === 1 ? '' : 's'} into Supabase`,
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
|
||||
@@ -79,12 +79,30 @@ export const queryTool: ToolConfig<SupabaseQueryParams, SupabaseQueryResponse> =
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
let data
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase response: ${parseError}`)
|
||||
}
|
||||
|
||||
const rowCount = Array.isArray(data) ? data.length : 0
|
||||
|
||||
if (rowCount === 0) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'No rows found matching the query criteria',
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'Successfully queried data from Supabase',
|
||||
message: `Successfully queried ${rowCount} row${rowCount === 1 ? '' : 's'} from Supabase`,
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
|
||||
@@ -38,6 +38,13 @@ export interface SupabaseDeleteParams {
|
||||
filter: string
|
||||
}
|
||||
|
||||
export interface SupabaseUpsertParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
table: string
|
||||
data: any
|
||||
}
|
||||
|
||||
export interface SupabaseBaseResponse extends ToolResponse {
|
||||
output: {
|
||||
message: string
|
||||
@@ -56,4 +63,6 @@ export interface SupabaseUpdateResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseDeleteResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseUpsertResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseResponse extends SupabaseBaseResponse {}
|
||||
|
||||
@@ -63,28 +63,36 @@ export const updateTool: ToolConfig<SupabaseUpdateParams, SupabaseUpdateResponse
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
// Handle potentially empty response from update operations
|
||||
const text = await response.text()
|
||||
let data
|
||||
|
||||
if (text?.trim()) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch (e) {
|
||||
// If we can't parse it, just use the text
|
||||
data = text
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase response: ${parseError}`)
|
||||
}
|
||||
} else {
|
||||
// Empty response means successful update
|
||||
data = []
|
||||
}
|
||||
|
||||
const updatedCount = Array.isArray(data) ? data.length : text ? 1 : 0
|
||||
const updatedCount = Array.isArray(data) ? data.length : 0
|
||||
|
||||
if (updatedCount === 0) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'No rows were updated (no matching records found)',
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: `Successfully updated ${updatedCount === 0 ? 'row(s)' : `${updatedCount} row(s)`}`,
|
||||
message: `Successfully updated ${updatedCount} row${updatedCount === 1 ? '' : 's'}`,
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
|
||||
107
apps/sim/tools/supabase/upsert.ts
Normal file
107
apps/sim/tools/supabase/upsert.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { SupabaseUpsertParams, SupabaseUpsertResponse } from '@/tools/supabase/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const upsertTool: ToolConfig<SupabaseUpsertParams, SupabaseUpsertResponse> = {
|
||||
id: 'supabase_upsert',
|
||||
name: 'Supabase Upsert',
|
||||
description: 'Insert or update data in a Supabase table (upsert operation)',
|
||||
version: '1.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
|
||||
},
|
||||
table: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'The name of the Supabase table to upsert data into',
|
||||
},
|
||||
data: {
|
||||
type: 'any',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The data to upsert (insert or update)',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => `https://${params.projectId}.supabase.co/rest/v1/${params.table}?select=*`,
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation,resolution=merge-duplicates',
|
||||
}),
|
||||
body: (params) => {
|
||||
// Prepare the data - if it's an object but not an array, wrap it in an array
|
||||
const dataToSend =
|
||||
typeof params.data === 'object' && !Array.isArray(params.data) ? [params.data] : params.data
|
||||
|
||||
return dataToSend
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const text = await response.text()
|
||||
|
||||
if (!text || text.trim() === '') {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'Successfully upserted data into Supabase (no data returned)',
|
||||
results: [],
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
let data
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase response: ${parseError}`)
|
||||
}
|
||||
|
||||
// Check if results array is empty and provide better feedback
|
||||
const resultsArray = Array.isArray(data) ? data : [data]
|
||||
const isEmpty = resultsArray.length === 0 || (resultsArray.length === 1 && !resultsArray[0])
|
||||
|
||||
if (isEmpty) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
message: 'No data was upserted into Supabase',
|
||||
results: data,
|
||||
},
|
||||
error:
|
||||
'No data was upserted into Supabase. This usually indicates invalid data format or schema mismatch. Please check that your JSON is valid and matches your table schema.',
|
||||
}
|
||||
}
|
||||
|
||||
const upsertedCount = resultsArray.length
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: `Successfully upserted ${upsertedCount} row${upsertedCount === 1 ? '' : 's'} into Supabase`,
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: { type: 'array', description: 'Array of upserted records' },
|
||||
},
|
||||
}
|
||||
@@ -164,7 +164,7 @@ describe('formatRequestParams', () => {
|
||||
})
|
||||
|
||||
// Return a preformatted body
|
||||
mockTool.request.body = vi.fn().mockReturnValue({ body: 'key1=value1&key2=value2' })
|
||||
mockTool.request.body = vi.fn().mockReturnValue('key1=value1&key2=value2')
|
||||
|
||||
const params = { method: 'POST' }
|
||||
const result = formatRequestParams(mockTool, params)
|
||||
@@ -179,9 +179,7 @@ describe('formatRequestParams', () => {
|
||||
})
|
||||
|
||||
// Return a preformatted body for NDJSON
|
||||
mockTool.request.body = vi.fn().mockReturnValue({
|
||||
body: '{"prompt": "Hello"}\n{"prompt": "World"}',
|
||||
})
|
||||
mockTool.request.body = vi.fn().mockReturnValue('{"prompt": "Hello"}\n{"prompt": "World"}')
|
||||
|
||||
const params = { method: 'POST' }
|
||||
const result = formatRequestParams(mockTool, params)
|
||||
|
||||
@@ -63,8 +63,8 @@ export function formatRequestParams(tool: ToolConfig, params: Record<string, any
|
||||
headers['Content-Type'] === 'application/x-ndjson' ||
|
||||
headers['Content-Type'] === 'application/x-www-form-urlencoded'
|
||||
const body = hasBody
|
||||
? isPreformattedContent && bodyResult
|
||||
? bodyResult.body
|
||||
? isPreformattedContent && typeof bodyResult === 'string'
|
||||
? bodyResult
|
||||
: JSON.stringify(bodyResult)
|
||||
: undefined
|
||||
|
||||
|
||||
Reference in New Issue
Block a user