improvement(display-names): move display name mapping to central store + access pattern via hook (#1881)

* improvmenet(display-names-store): add displaynames store to map ids to human readable names for creds, etc

* fix workflow in workflow

* dot protection for secrets

* hide from preview certain fields

* fix rest of cases

* fix confluence

* fix type errors

* remove redundant workflow dropdown code

* remove comments

* revert preview card

* fix [object Object] bug

* fix lint
This commit is contained in:
Vikhyath Mondreti
2025-11-10 21:34:37 -08:00
committed by GitHub
parent 991b0e31ad
commit 82731850b2
72 changed files with 1470 additions and 577 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useState } from 'react'
import { Check, ChevronDown, Hash, Lock, RefreshCw } from 'lucide-react'
import { SlackIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
@@ -11,6 +11,7 @@ import {
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useDisplayNamesStore } from '@/stores/display-names/store'
export interface SlackChannelInfo {
id: string
@@ -41,9 +42,19 @@ export function SlackChannelSelector({
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [selectedChannel, setSelectedChannel] = useState<SlackChannelInfo | null>(null)
const [initialFetchDone, setInitialFetchDone] = useState(false)
// Get cached display name
const cachedChannelName = useDisplayNamesStore(
useCallback(
(state) => {
if (!credential || !value) return null
return state.cache.channels[credential]?.[value] || null
},
[credential, value]
)
)
// Fetch channels from Slack API
const fetchChannels = useCallback(async () => {
if (!credential) return
@@ -76,6 +87,18 @@ export function SlackChannelSelector({
} else {
setChannels(data.channels)
setInitialFetchDone(true)
// Cache channel names in display names store
if (credential) {
const channelMap = data.channels.reduce(
(acc: Record<string, string>, ch: SlackChannelInfo) => {
acc[ch.id] = `#${ch.name}`
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('channels', credential, channelMap)
}
}
} catch (err) {
if ((err as Error).name === 'AbortError') return
@@ -97,27 +120,7 @@ export function SlackChannelSelector({
}
}
// Sync selected channel with value prop
useEffect(() => {
if (value && channels.length > 0) {
const channelInfo = channels.find((c) => c.id === value)
setSelectedChannel(channelInfo || null)
} else if (!value) {
setSelectedChannel(null)
}
}, [value, channels])
// If we have a value but no channel info and haven't fetched yet, get just that channel
useEffect(() => {
if (value && !selectedChannel && !loading && !initialFetchDone && credential) {
// For now, we'll fetch all channels when needed
// In the future, we could optimize to fetch just the selected channel
fetchChannels()
}
}, [value, selectedChannel, loading, initialFetchDone, credential, fetchChannels])
const handleSelectChannel = (channel: SlackChannelInfo) => {
setSelectedChannel(channel)
onChange(channel.id, channel)
setOpen(false)
}
@@ -143,15 +146,10 @@ export function SlackChannelSelector({
>
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
<SlackIcon className='h-4 w-4 text-[#611f69]' />
{selectedChannel ? (
<>
{getChannelIcon(selectedChannel)}
<span className='truncate font-normal'>{formatChannelName(selectedChannel)}</span>
</>
) : value ? (
{cachedChannelName ? (
<>
<Hash className='h-1.5 w-1.5' />
<span className='truncate font-normal'>{value}</span>
<span className='truncate font-normal'>{cachedChannelName}</span>
</>
) : (
<span className='truncate text-muted-foreground'>{label}</span>

View File

@@ -27,6 +27,7 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import type { SubBlockConfig } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useDisplayNamesStore } from '@/stores/display-names/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('CredentialSelector')
@@ -116,6 +117,17 @@ export function CredentialSelector({
setHasForeignMeta(foreignMetaFound)
setCredentials(creds)
// Cache credential names in display names store
if (effectiveProviderId) {
const credentialMap = creds.reduce((acc: Record<string, string>, cred: Credential) => {
acc[cred.id] = cred.name
return acc
}, {})
useDisplayNamesStore
.getState()
.setDisplayNames('credentials', effectiveProviderId, credentialMap)
}
// Do not auto-select or reset. We only show what's persisted.
}
} catch (error) {

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown, FileText, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
@@ -15,24 +15,8 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
interface DocumentData {
id: string
knowledgeBaseId: string
filename: string
fileUrl: string
fileSize: number
mimeType: string
chunkCount: number
tokenCount: number
characterCount: number
processingStatus: string
processingStartedAt: Date | null
processingCompletedAt: Date | null
processingError: string | null
enabled: boolean
uploadedAt: Date
}
import { useDisplayNamesStore } from '@/stores/display-names/store'
import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'
interface DocumentSelectorProps {
blockId: string
@@ -51,110 +35,107 @@ export function DocumentSelector({
isPreview = false,
previewValue,
}: DocumentSelectorProps) {
const [documents, setDocuments] = useState<DocumentData[]>([])
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [selectedDocument, setSelectedDocument] = useState<DocumentData | null>(null)
const [loading, setLoading] = useState(false)
// Use the proper hook to get the current value and setter
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
// Get the knowledge base ID from the same block's knowledgeBaseId subblock
const [knowledgeBaseId] = useSubBlockValue(blockId, 'knowledgeBaseId')
const normalizedKnowledgeBaseId =
typeof knowledgeBaseId === 'string' && knowledgeBaseId.trim().length > 0
? knowledgeBaseId
: null
const documentsCache = useKnowledgeStore(
useCallback(
(state) =>
normalizedKnowledgeBaseId ? state.documents[normalizedKnowledgeBaseId] : undefined,
[normalizedKnowledgeBaseId]
)
)
const isDocumentsLoading = useKnowledgeStore(
useCallback(
(state) =>
normalizedKnowledgeBaseId ? state.isDocumentsLoading(normalizedKnowledgeBaseId) : false,
[normalizedKnowledgeBaseId]
)
)
const getDocuments = useKnowledgeStore((state) => state.getDocuments)
// 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) {
setDocuments([])
const documents = useMemo<DocumentData[]>(() => {
if (!documentsCache) return []
return documentsCache.documents ?? []
}, [documentsCache])
const loadDocuments = useCallback(async () => {
if (!normalizedKnowledgeBaseId) {
setError('No knowledge base selected')
return
}
setLoading(true)
setError(null)
try {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents`)
const fetchedDocuments = await getDocuments(normalizedKnowledgeBaseId)
if (!response.ok) {
throw new Error(`Failed to fetch documents: ${response.statusText}`)
if (fetchedDocuments.length > 0) {
const documentMap = fetchedDocuments.reduce<Record<string, string>>((acc, doc) => {
acc[doc.id] = doc.filename
return acc
}, {})
useDisplayNamesStore
.getState()
.setDisplayNames('documents', normalizedKnowledgeBaseId, documentMap)
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Failed to fetch documents')
}
const fetchedDocuments = result.data.documents || result.data || []
setDocuments(fetchedDocuments)
} catch (err) {
if ((err as Error).name === 'AbortError') return
setError((err as Error).message)
setDocuments([])
} finally {
setLoading(false)
if (err instanceof Error && err.name === 'AbortError') return
setError(err instanceof Error ? err.message : 'Failed to fetch documents')
}
}, [knowledgeBaseId])
}, [normalizedKnowledgeBaseId, getDocuments])
// Handle dropdown open/close - fetch documents when opening
const handleOpenChange = (isOpen: boolean) => {
if (isPreview) return
if (isDisabled) return
if (isPreview || isDisabled) return
setOpen(isOpen)
// Fetch fresh documents when opening the dropdown
if (isOpen) {
fetchDocuments()
if (isOpen && (!documentsCache || !documentsCache.documents.length)) {
void loadDocuments()
}
}
// Handle document selection
const handleSelectDocument = (document: DocumentData) => {
if (isPreview) return
setSelectedDocument(document)
setStoreValue(document.id)
onDocumentSelect?.(document.id)
setOpen(false)
}
// 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, isDisabled])
// Reset documents when knowledge base changes
useEffect(() => {
setDocuments([])
setSelectedDocument(null)
setError(null)
}, [knowledgeBaseId])
}, [normalizedKnowledgeBaseId])
// Fetch documents when knowledge base is available
useEffect(() => {
if (knowledgeBaseId && !isPreview && !isDisabled) {
fetchDocuments()
}
}, [knowledgeBaseId, isPreview, isDisabled, fetchDocuments])
if (!normalizedKnowledgeBaseId || documents.length === 0) return
const formatDocumentName = (document: DocumentData) => {
return document.filename
}
const documentMap = documents.reduce<Record<string, string>>((acc, doc) => {
acc[doc.id] = doc.filename
return acc
}, {})
useDisplayNamesStore
.getState()
.setDisplayNames('documents', normalizedKnowledgeBaseId as string, documentMap)
}, [documents, normalizedKnowledgeBaseId])
const formatDocumentName = (document: DocumentData) => document.filename
const getDocumentDescription = (document: DocumentData) => {
const statusMap: Record<string, string> = {
@@ -171,6 +152,18 @@ export function DocumentSelector({
}
const label = subBlock.placeholder || 'Select document'
const isLoading = isDocumentsLoading && !error
// Always use cached display name
const displayName = useDisplayNamesStore(
useCallback(
(state) => {
if (!normalizedKnowledgeBaseId || !value || typeof value !== 'string') return null
return state.cache.documents[normalizedKnowledgeBaseId]?.[value] || null
},
[normalizedKnowledgeBaseId, value]
)
)
return (
<div className='w-full'>
@@ -185,8 +178,8 @@ export function DocumentSelector({
>
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
<FileText className='h-4 w-4 text-muted-foreground' />
{selectedDocument ? (
<span className='truncate font-normal'>{formatDocumentName(selectedDocument)}</span>
{displayName ? (
<span className='truncate font-normal'>{displayName}</span>
) : (
<span className='truncate text-muted-foreground'>{label}</span>
)}
@@ -199,7 +192,7 @@ export function DocumentSelector({
<CommandInput placeholder='Search documents...' />
<CommandList>
<CommandEmpty>
{loading ? (
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading documents...</span>
@@ -208,7 +201,7 @@ export function DocumentSelector({
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : !knowledgeBaseId ? (
) : !normalizedKnowledgeBaseId ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No knowledge base selected</p>
<p className='text-muted-foreground text-xs'>

View File

@@ -21,6 +21,7 @@ import {
type OAuthProvider,
} from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { useDisplayNamesStore } from '@/stores/display-names/store'
const logger = createLogger('ConfluenceFileSelector')
@@ -75,6 +76,18 @@ export function ConfluenceFileSelector({
const [showOAuthModal, setShowOAuthModal] = useState(false)
const initialFetchRef = useRef(false)
const [error, setError] = useState<string | null>(null)
// Get cached display name
const cachedFileName = useDisplayNamesStore(
useCallback(
(state) => {
const effectiveCredentialId = credentialId || selectedCredentialId
if (!effectiveCredentialId || !value) return null
return state.cache.files[effectiveCredentialId]?.[value] || null
},
[credentialId, selectedCredentialId, value]
)
)
// Keep internal credential in sync with prop (handles late arrival and BFCache restores)
useEffect(() => {
if (credentialId && credentialId !== selectedCredentialId) {
@@ -306,7 +319,19 @@ export function ConfluenceFileSelector({
logger.info(`Received ${data.files?.length || 0} files from API`)
setFiles(data.files || [])
// If we have a selected file ID, find the file info
// Cache file names in display names store
if (selectedCredentialId && data.files) {
const fileMap = data.files.reduce(
(acc: Record<string, string>, file: ConfluenceFileInfo) => {
acc[file.id] = file.name
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, fileMap)
}
// If we have a selected file ID, update state and notify parent
if (selectedFileId) {
const fileInfo = data.files.find((file: ConfluenceFileInfo) => file.id === selectedFileId)
if (fileInfo) {
@@ -354,21 +379,6 @@ export function ConfluenceFileSelector({
}
}
// Fetch the selected page metadata once credentials and domain are ready or changed
useEffect(() => {
if (value && selectedCredentialId && !selectedFile && domain && domain.includes('.')) {
fetchPageInfo(value)
}
}, [
value,
selectedCredentialId,
selectedFile,
domain,
fetchPageInfo,
workflowId,
isForeignCredential,
])
// Keep internal selectedFileId in sync with the value prop
useEffect(() => {
if (value !== selectedFileId) {
@@ -376,7 +386,7 @@ export function ConfluenceFileSelector({
}
}, [value])
// Clear preview when value is cleared (e.g., collaborator cleared or domain change cascade)
// Clear callback when value is cleared
useEffect(() => {
if (!value) {
setSelectedFile(null)
@@ -403,7 +413,6 @@ export function ConfluenceFileSelector({
// Clear selection
const handleClearSelection = () => {
setSelectedFileId('')
setSelectedFile(null)
onChange('', undefined)
onFileInfoChange?.(null)
}
@@ -421,10 +430,10 @@ export function ConfluenceFileSelector({
disabled={disabled || !domain || isForeignCredential}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{selectedFile ? (
{cachedFileName ? (
<>
<ConfluenceIcon className='h-4 w-4' />
<span className='truncate font-normal'>{selectedFile.name}</span>
<span className='truncate font-normal'>{cachedFileName}</span>
</>
) : (
<>
@@ -554,7 +563,6 @@ export function ConfluenceFileSelector({
)}
</Popover>
{/* File preview */}
{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'>
@@ -580,7 +588,7 @@ export function ConfluenceFileSelector({
</span>
)}
</div>
{selectedFile.webViewLink ? (
{selectedFile.webViewLink && (
<a
href={selectedFile.webViewLink}
target='_blank'
@@ -591,8 +599,6 @@ export function ConfluenceFileSelector({
<span>Open in Confluence</span>
<ExternalLink className='h-3 w-3' />
</a>
) : (
<></>
)}
</div>
</div>

View File

@@ -14,6 +14,7 @@ import {
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
import { useDisplayNamesStore } from '@/stores/display-names/store'
const logger = createLogger('GoogleCalendarSelector')
@@ -56,6 +57,17 @@ export function GoogleCalendarSelector({
const [error, setError] = useState<string | null>(null)
const [initialFetchDone, setInitialFetchDone] = useState(false)
// Get cached display name
const cachedCalendarName = useDisplayNamesStore(
useCallback(
(state) => {
if (!credentialId || !value) return null
return state.cache.files[credentialId]?.[value] || null
},
[credentialId, value]
)
)
const fetchCalendarsFromAPI = useCallback(async (): Promise<GoogleCalendarInfo[]> => {
if (!credentialId) {
throw new Error('Google Calendar account is required')
@@ -87,15 +99,19 @@ export function GoogleCalendarSelector({
const calendars = await fetchCalendarsFromAPI()
setCalendars(calendars)
const currentSelectedId = selectedCalendarId
if (currentSelectedId) {
const calendarInfo = calendars.find(
(calendar: GoogleCalendarInfo) => calendar.id === currentSelectedId
)
if (calendarInfo) {
setSelectedCalendar(calendarInfo)
onCalendarInfoChange?.(calendarInfo)
}
// Cache calendar names
if (credentialId && calendars.length > 0) {
const calendarMap = calendars.reduce<Record<string, string>>((acc, cal) => {
acc[cal.id] = cal.summary
return acc
}, {})
useDisplayNamesStore.getState().setDisplayNames('files', credentialId, calendarMap)
}
// Update selected calendar if we have a value
if (selectedCalendarId && calendars.length > 0) {
const calendar = calendars.find((c) => c.id === selectedCalendarId)
setSelectedCalendar(calendar || null)
}
} catch (error) {
logger.error('Error fetching calendars:', error)
@@ -105,7 +121,7 @@ export function GoogleCalendarSelector({
setIsLoading(false)
setInitialFetchDone(true)
}
}, [fetchCalendarsFromAPI, selectedCalendarId, onCalendarInfoChange])
}, [fetchCalendarsFromAPI, credentialId])
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
@@ -115,67 +131,12 @@ export function GoogleCalendarSelector({
}
}
const fetchSelectedCalendarInfo = useCallback(async () => {
if (!selectedCalendarId) return
setIsLoading(true)
setError(null)
try {
const calendars = await fetchCalendarsFromAPI()
if (calendars.length > 0) {
const calendarInfo = calendars.find(
(calendar: GoogleCalendarInfo) => calendar.id === selectedCalendarId
)
if (calendarInfo) {
setSelectedCalendar(calendarInfo)
onCalendarInfoChange?.(calendarInfo)
}
}
} catch (error) {
logger.error('Error fetching calendar info:', error)
setError((error as Error).message)
} finally {
setIsLoading(false)
}
}, [fetchCalendarsFromAPI, selectedCalendarId, onCalendarInfoChange])
// Fetch selected calendar info when component mounts or dependencies change
useEffect(() => {
if (value && credentialId && (!selectedCalendar || selectedCalendar.id !== value)) {
fetchSelectedCalendarInfo()
}
}, [value, credentialId, selectedCalendar, fetchSelectedCalendarInfo])
// Sync with external value
// Sync selected ID with external value
useEffect(() => {
if (value !== selectedCalendarId) {
setSelectedCalendarId(value)
// Find calendar info for the new value
if (value && calendars.length > 0) {
const calendarInfo = calendars.find((calendar) => calendar.id === value)
setSelectedCalendar(calendarInfo || null)
onCalendarInfoChange?.(calendarInfo || null)
} else if (value) {
// If we have a value but no calendar info, we might need to fetch it
if (!selectedCalendar || selectedCalendar.id !== value) {
fetchSelectedCalendarInfo()
}
} else {
setSelectedCalendar(null)
onCalendarInfoChange?.(null)
}
}
}, [
value,
calendars,
selectedCalendarId,
selectedCalendar,
fetchSelectedCalendarInfo,
onCalendarInfoChange,
])
}, [value, selectedCalendarId])
// Handle calendar selection
const handleSelectCalendar = (calendar: GoogleCalendarInfo) => {
@@ -189,7 +150,6 @@ export function GoogleCalendarSelector({
// Clear selection
const handleClearSelection = () => {
setSelectedCalendarId('')
setSelectedCalendar(null)
onChange('', undefined)
onCalendarInfoChange?.(null)
setError(null)
@@ -215,17 +175,10 @@ export function GoogleCalendarSelector({
disabled={disabled || !credentialId}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{selectedCalendar ? (
{cachedCalendarName ? (
<>
<div
className='h-3 w-3 flex-shrink-0 rounded-full'
style={{
backgroundColor: selectedCalendar.backgroundColor || '#4285f4',
}}
/>
<span className='truncate font-normal'>
{getCalendarDisplayName(selectedCalendar)}
</span>
<GoogleCalendarIcon className='h-4 w-4' />
<span className='truncate font-normal'>{cachedCalendarName}</span>
</>
) : (
<>
@@ -298,7 +251,6 @@ export function GoogleCalendarSelector({
</PopoverContent>
</Popover>
{/* Calendar preview */}
{showPreview && selectedCalendar && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>

View File

@@ -17,6 +17,7 @@ import {
parseProvider,
} from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { useDisplayNamesStore } from '@/stores/display-names/store'
const logger = createLogger('GoogleDrivePicker')
@@ -100,6 +101,15 @@ export function GoogleDrivePicker({
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials)
const credentialMap = (data.credentials || []).reduce(
(acc: Record<string, string>, cred: Credential) => {
acc[cred.id] = cred.name
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('credentials', providerId, credentialMap)
if (credentialId && !data.credentials.some((c: any) => c.id === credentialId)) {
setSelectedCredentialId('')
}

View File

@@ -21,6 +21,7 @@ import {
type OAuthProvider,
} from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { useDisplayNamesStore } from '@/stores/display-names/store'
const logger = createLogger('JiraIssueSelector')
@@ -78,6 +79,18 @@ export function JiraIssueSelector({
const [error, setError] = useState<string | null>(null)
const [cloudId, setCloudId] = useState<string | null>(null)
// Get cached display name
const cachedIssueName = useDisplayNamesStore(
useCallback(
(state) => {
const effectiveCredentialId = credentialId || selectedCredentialId
if (!effectiveCredentialId || !value) return null
return state.cache.files[effectiveCredentialId]?.[value] || null
},
[credentialId, selectedCredentialId, value]
)
)
// Keep local credential state in sync with persisted credentialId prop
useEffect(() => {
if (credentialId && credentialId !== selectedCredentialId) {
@@ -224,8 +237,6 @@ export function JiraIssueSelector({
} catch (error) {
logger.error('Error fetching issue info:', error)
setError((error as Error).message)
// Clear selection on error to prevent infinite retry loops
setSelectedIssue(null)
onIssueInfoChange?.(null)
} finally {
setIsLoading(false)
@@ -342,7 +353,19 @@ export function JiraIssueSelector({
logger.info(`Received ${foundIssues.length} issues from API`)
setIssues(foundIssues)
// If we have a selected issue ID, find the issue info
// Cache issue names in display names store
if (selectedCredentialId && foundIssues.length > 0) {
const issueMap = foundIssues.reduce(
(acc: Record<string, string>, issue: JiraIssueInfo) => {
acc[issue.id] = issue.name
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, issueMap)
}
// If we have a selected issue ID, update state and notify parent
if (selectedIssueId) {
const issueInfo = foundIssues.find((issue: JiraIssueInfo) => issue.id === selectedIssueId)
if (issueInfo) {
@@ -397,18 +420,6 @@ export function JiraIssueSelector({
}
// Fetch selected issue metadata once credentials are ready or changed
useEffect(() => {
if (
value &&
selectedCredentialId &&
domain &&
domain.includes('.') &&
(!selectedIssue || selectedIssue.id !== value)
) {
fetchIssueInfo(value)
}
}, [value, selectedCredentialId, selectedIssue, domain, fetchIssueInfo])
// Keep internal selectedIssueId in sync with the value prop
useEffect(() => {
if (value !== selectedIssueId) {
@@ -422,7 +433,7 @@ export function JiraIssueSelector({
setError(null)
onIssueInfoChange?.(null)
}
}, [value])
}, [value, onIssueInfoChange])
// Handle issue selection
const handleSelectIssue = (issue: JiraIssueInfo) => {
@@ -443,8 +454,7 @@ export function JiraIssueSelector({
// Clear selection
const handleClearSelection = () => {
setSelectedIssueId('')
setSelectedIssue(null)
setError(null) // Clear any existing errors
setError(null)
onChange('', undefined)
onIssueInfoChange?.(null)
}
@@ -462,10 +472,10 @@ export function JiraIssueSelector({
disabled={disabled || !domain || !selectedCredentialId || isForeignCredential}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{selectedIssue ? (
{cachedIssueName ? (
<>
<JiraIcon className='h-4 w-4' />
<span className='truncate font-normal'>{selectedIssue.name}</span>
<span className='truncate font-normal'>{cachedIssueName}</span>
</>
) : (
<>
@@ -595,7 +605,6 @@ export function JiraIssueSelector({
)}
</Popover>
{/* Issue preview */}
{showPreview && selectedIssue && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
@@ -621,7 +630,7 @@ export function JiraIssueSelector({
</span>
)}
</div>
{selectedIssue.webViewLink ? (
{selectedIssue.webViewLink && (
<a
href={selectedIssue.webViewLink}
target='_blank'
@@ -632,8 +641,6 @@ export function JiraIssueSelector({
<span>Open in Jira</span>
<ExternalLink className='h-3 w-3' />
</a>
) : (
<></>
)}
</div>
</div>

View File

@@ -24,6 +24,7 @@ import {
parseProvider,
} from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { useDisplayNamesStore } from '@/stores/display-names/store'
import type { PlannerTask } from '@/tools/microsoft_planner/types'
const logger = createLogger('MicrosoftFileSelector')
@@ -90,14 +91,24 @@ export function MicrosoftFileSelector({
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [credentialsLoaded, setCredentialsLoaded] = useState(false)
const initialFetchRef = useRef(false)
// Track the last (credentialId, fileId) we attempted to resolve to avoid tight retry loops
const lastMetaAttemptRef = useRef<string>('')
// Handle Microsoft Planner task selection
const [plannerTasks, setPlannerTasks] = useState<PlannerTask[]>([])
const [isLoadingTasks, setIsLoadingTasks] = useState(false)
const [selectedTask, setSelectedTask] = useState<PlannerTask | null>(null)
// Get cached display name
const cachedFileName = useDisplayNamesStore(
useCallback(
(state) => {
const effectiveCredentialId = credentialId || selectedCredentialId
if (!effectiveCredentialId || !value) return null
return state.cache.files[effectiveCredentialId]?.[value] || null
},
[credentialId, selectedCredentialId, value]
)
)
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
@@ -179,6 +190,18 @@ export function MicrosoftFileSelector({
if (response.ok) {
const data = await response.json()
setAvailableFiles(data.files || [])
// Cache file names in display names store
if (selectedCredentialId && data.files) {
const fileMap = data.files.reduce(
(acc: Record<string, string>, file: MicrosoftFileInfo) => {
acc[file.id] = file.name
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, fileMap)
}
} else {
const txt = await response.text()
if (response.status === 401 || response.status === 403) {
@@ -472,11 +495,9 @@ export function MicrosoftFileSelector({
modifiedTime: task.createdDateTime,
}
// Update internal state first to avoid race with list refetch
setSelectedFileId(taskId)
setSelectedFile(taskAsFileInfo)
setSelectedTask(task)
// Then propagate up
setSelectedFile(taskAsFileInfo)
onChange(taskId, taskAsFileInfo)
onFileInfoChange?.(taskAsFileInfo)
setOpen(false)
@@ -500,76 +521,22 @@ export function MicrosoftFileSelector({
if (!selectedCredentialId) {
// No credentials - clear everything
if (selectedFile) {
setSelectedFile(null)
setSelectedFileId('')
onChange('')
}
setSelectedFileId('')
onChange('')
// Reset memo when credential is cleared
lastMetaAttemptRef.current = ''
} else if (prevCredentialId && prevCredentialId !== selectedCredentialId) {
// Credentials changed (not initial load) - clear file info to force refetch
if (selectedFile) {
setSelectedFile(null)
}
// Reset memo when switching credentials
lastMetaAttemptRef.current = ''
}
}, [selectedCredentialId, selectedFile, onChange])
}, [selectedCredentialId, onChange])
// Fetch the selected file metadata once credentials are loaded or changed
// Keep internal selectedFileId in sync with the value prop
useEffect(() => {
// Fetch metadata when the external value doesn't match our current selectedFile
if (
value &&
selectedCredentialId &&
credentialsLoaded &&
(!selectedFile || selectedFile.id !== value) &&
!isLoadingSelectedFile
) {
// Avoid tight retry loops by memoizing the last attempt tuple
const attemptKey = `${selectedCredentialId}::${value}`
if (lastMetaAttemptRef.current === attemptKey) {
return
}
lastMetaAttemptRef.current = attemptKey
if (serviceId === 'microsoft-planner') {
void fetchPlannerTaskById(value)
} else {
void fetchFileById(value)
}
if (value !== selectedFileId) {
setSelectedFileId(value)
}
}, [
value,
selectedCredentialId,
credentialsLoaded,
selectedFile,
isLoadingSelectedFile,
fetchFileById,
fetchPlannerTaskById,
serviceId,
])
// Resolve planner task selection for collaborators
useEffect(() => {
if (
value &&
selectedCredentialId &&
credentialsLoaded &&
!selectedTask &&
serviceId === 'microsoft-planner'
) {
void fetchPlannerTaskById(value)
}
}, [
value,
selectedCredentialId,
credentialsLoaded,
selectedTask,
serviceId,
fetchPlannerTaskById,
])
}, [value, selectedFileId])
// Handle selecting a file from the available files
const handleFileSelect = (file: MicrosoftFileInfo) => {
@@ -578,7 +545,7 @@ export function MicrosoftFileSelector({
onChange(file.id, file)
onFileInfoChange?.(file)
setOpen(false)
setSearchQuery('') // Clear search when file is selected
setSearchQuery('')
}
// Handle adding a new credential
@@ -593,6 +560,7 @@ export function MicrosoftFileSelector({
const handleClearSelection = () => {
setSelectedFileId('')
setSelectedFile(null)
setSelectedTask(null)
onChange('', undefined)
onFileInfoChange?.(null)
}
@@ -787,15 +755,10 @@ export function MicrosoftFileSelector({
}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{canShowPreview ? (
{cachedFileName ? (
<>
{getFileIcon(selectedFile, 'sm')}
<span className='truncate font-normal'>{selectedFile.name}</span>
</>
) : selectedFileId && isLoadingSelectedFile && selectedCredentialId ? (
<>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='truncate text-muted-foreground'>Loading document...</span>
{getProviderIcon(provider)}
<span className='truncate font-normal'>{cachedFileName}</span>
</>
) : (
<>
@@ -961,7 +924,6 @@ export function MicrosoftFileSelector({
)}
</Popover>
{/* File preview */}
{canShowPreview && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>

View File

@@ -21,6 +21,7 @@ import {
type OAuthProvider,
} from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { useDisplayNamesStore } from '@/stores/display-names/store'
const logger = createLogger('TeamsMessageSelector')
@@ -84,6 +85,17 @@ export function TeamsMessageSelector({
const [error, setError] = useState<string | null>(null)
const [selectionStage, setSelectionStage] = useState<'team' | 'channel' | 'chat'>(selectionType)
// Get cached display name
const cachedMessageName = useDisplayNamesStore(
useCallback(
(state) => {
if (!credential || !value) return null
return state.cache.files[credential]?.[value] || null
},
[credential, value]
)
)
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
@@ -156,6 +168,15 @@ export function TeamsMessageSelector({
setTeams(teamsData)
// Cache team names in display names store
if (selectedCredentialId && teamsData.length > 0) {
const teamMap = teamsData.reduce((acc: Record<string, string>, team: TeamsMessageInfo) => {
acc[team.id] = team.displayName
return acc
}, {})
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, teamMap)
}
// If we have a selected team ID, find it in the list
if (selectedTeamId) {
const team = teamsData.find((t: TeamsMessageInfo) => t.teamId === selectedTeamId)
@@ -702,10 +723,10 @@ export function TeamsMessageSelector({
disabled={disabled || isForeignCredential}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{selectedMessage ? (
{cachedMessageName ? (
<>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span className='truncate font-normal'>{selectedMessage.displayName}</span>
<span className='truncate font-normal'>{cachedMessageName}</span>
</>
) : (
<>

View File

@@ -1,7 +1,7 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, RefreshCw, X } from 'lucide-react'
import { Check, ChevronDown, X } from 'lucide-react'
import { WealthboxIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
@@ -20,6 +20,7 @@ import {
type OAuthProvider,
} from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { useDisplayNamesStore } from '@/stores/display-names/store'
const logger = createLogger('WealthboxFileSelector')
@@ -73,6 +74,18 @@ export function WealthboxFileSelector({
const [credentialsLoaded, setCredentialsLoaded] = useState(false)
const initialFetchRef = useRef(false)
// Get cached display name
const cachedItemName = useDisplayNamesStore(
useCallback(
(state) => {
const effectiveCredentialId = credentialId || selectedCredentialId
if (!effectiveCredentialId || !value) return null
return state.cache.files[effectiveCredentialId]?.[value] || null
},
[credentialId, selectedCredentialId, value]
)
)
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
@@ -135,6 +148,18 @@ export function WealthboxFileSelector({
if (response.ok) {
const data = await response.json()
setAvailableItems(data.items || [])
// Cache item names in display names store
if (selectedCredentialId && data.items) {
const itemMap = data.items.reduce(
(acc: Record<string, string>, item: WealthboxItemInfo) => {
acc[item.id] = item.name
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, itemMap)
}
} else {
logger.error('Error fetching available items:', {
error: await response.text(),
@@ -209,26 +234,6 @@ export function WealthboxFileSelector({
}, [selectedCredentialId, open, fetchAvailableItems])
// Fetch the selected item metadata only once when needed
useEffect(() => {
if (
value &&
value !== selectedItemId &&
selectedCredentialId &&
credentialsLoaded &&
!selectedItem &&
!isLoadingSelectedItem
) {
fetchItemById(value)
}
}, [
value,
selectedItemId,
selectedCredentialId,
credentialsLoaded,
selectedItem,
isLoadingSelectedItem,
fetchItemById,
])
// Handle search input changes with debouncing
const handleSearchChange = useCallback(
@@ -281,7 +286,6 @@ export function WealthboxFileSelector({
// Clear selection
const handleClearSelection = () => {
setSelectedItemId('')
setSelectedItem(null)
onChange('', undefined)
onFileInfoChange?.(null)
}
@@ -319,15 +323,10 @@ export function WealthboxFileSelector({
className='w-full justify-between'
disabled={disabled}
>
{selectedItem ? (
{cachedItemName ? (
<div className='flex items-center gap-2 overflow-hidden'>
<WealthboxIcon className='h-4 w-4' />
<span className='truncate font-normal'>{selectedItem.name}</span>
</div>
) : selectedItemId && isLoadingSelectedItem && selectedCredentialId ? (
<div className='flex items-center gap-2'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='text-muted-foreground'>Loading...</span>
<span className='truncate font-normal'>{cachedItemName}</span>
</div>
) : (
<div className='flex items-center gap-2'>

View File

@@ -16,6 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { createLogger } from '@/lib/logs/console/logger'
import { type Credential, getProviderIdFromServiceId, getServiceIdFromScopes } from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { useDisplayNamesStore } from '@/stores/display-names/store'
const logger = createLogger('FolderSelector')
@@ -65,12 +66,24 @@ export function FolderSelector({
credentialId || ''
)
const [selectedFolderId, setSelectedFolderId] = useState('')
const [selectedFolder, setSelectedFolder] = useState<FolderInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingSelectedFolder, setIsLoadingSelectedFolder] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const initialFetchRef = useRef(false)
// Get cached display name
const cachedFolderName = useDisplayNamesStore(
useCallback(
(state) => {
const effectiveCredentialId = credentialId || selectedCredentialId
const effectiveValue = isPreview && previewValue !== undefined ? previewValue : value
if (!effectiveCredentialId || !effectiveValue) return null
return state.cache.folders[effectiveCredentialId]?.[effectiveValue] || null
},
[credentialId, selectedCredentialId, value, isPreview, previewValue]
)
)
// Initialize selectedFolderId with the effective value
useEffect(() => {
if (isPreview && previewValue !== undefined) {
@@ -168,7 +181,6 @@ export function FolderSelector({
messagesTotal: folder.totalItemCount,
messagesUnread: folder.unreadItemCount,
}
setSelectedFolder(folderInfo)
onFolderInfoChange?.(folderInfo)
return folderInfo
}
@@ -181,7 +193,6 @@ export function FolderSelector({
if (response.ok) {
const data = await response.json()
if (data.label) {
setSelectedFolder(data.label)
onFolderInfoChange?.(data.label)
return data.label
}
@@ -239,14 +250,27 @@ export function FolderSelector({
const folderList = provider === 'outlook' ? data.folders : data.labels
setFolders(folderList || [])
// If we have a selected folder ID, find the folder info
if (selectedFolderId) {
// Cache folder names in display names store
if (selectedCredentialId && folderList) {
const folderMap = folderList.reduce(
(acc: Record<string, string>, folder: FolderInfo) => {
acc[folder.id] = folder.name
return acc
},
{}
)
useDisplayNamesStore
.getState()
.setDisplayNames('folders', selectedCredentialId, folderMap)
}
// Only notify parent if callback exists
if (selectedFolderId && onFolderInfoChange) {
const folderInfo = folderList.find(
(folder: FolderInfo) => folder.id === selectedFolderId
)
if (folderInfo) {
setSelectedFolder(folderInfo)
onFolderInfoChange?.(folderInfo)
onFolderInfoChange(folderInfo)
} else if (!searchQuery && provider !== 'outlook') {
// Only try to fetch by ID for Gmail if this is not a search query
// and we couldn't find the folder in the list
@@ -303,33 +327,11 @@ export function FolderSelector({
if (currentValue !== selectedFolderId) {
setSelectedFolderId(currentValue || '')
}
}, [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 &&
selectedCredentialId &&
(!selectedFolder || selectedFolder.id !== currentValue)
) {
fetchFolderById(currentValue)
}
}, [
value,
selectedCredentialId,
selectedFolder,
fetchFolderById,
isPreview,
previewValue,
disabled,
])
}, [value, isPreview, previewValue, disabled, selectedFolderId])
// Handle folder selection
const handleSelectFolder = (folder: FolderInfo) => {
setSelectedFolderId(folder.id)
setSelectedFolder(folder)
if (!isPreview) {
onChange(folder.id, folder)
}
@@ -385,10 +387,10 @@ export function FolderSelector({
className='w-full justify-between'
disabled={disabled || isForeignCredential}
>
{selectedFolder ? (
{cachedFolderName ? (
<div className='flex items-center gap-2 overflow-hidden'>
{getFolderIcon('sm')}
<span className='truncate font-normal'>{selectedFolder.name}</span>
<span className='truncate font-normal'>{cachedFolderName}</span>
</div>
) : (
<div className='flex items-center gap-2'>

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Combobox, type ComboboxOption } from '@/components/emcn/components/combobox/combobox'
@@ -29,12 +29,14 @@ export function KnowledgeBaseSelector({
const params = useParams()
const workspaceId = params.workspaceId as string
const { loadingKnowledgeBasesList } = useKnowledgeStore()
const knowledgeBasesList = useKnowledgeStore((state) => state.knowledgeBasesList)
const knowledgeBasesMap = useKnowledgeStore((state) => state.knowledgeBases)
const loadingKnowledgeBasesList = useKnowledgeStore((state) => state.loadingKnowledgeBasesList)
const getKnowledgeBasesList = useKnowledgeStore((state) => state.getKnowledgeBasesList)
const getKnowledgeBase = useKnowledgeStore((state) => state.getKnowledgeBase)
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBaseData[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [initialFetchDone, setInitialFetchDone] = useState(false)
const hasRequestedListRef = useRef(false)
// Use the proper hook to get the current value and setter - this prevents infinite loops
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
@@ -47,13 +49,24 @@ export function KnowledgeBaseSelector({
/**
* Convert knowledge bases to combobox options format
*/
const combinedKnowledgeBases = useMemo<KnowledgeBaseData[]>(() => {
const merged = new Map<string, KnowledgeBaseData>()
knowledgeBasesList.forEach((kb) => {
merged.set(kb.id, kb)
})
Object.values(knowledgeBasesMap).forEach((kb) => {
merged.set(kb.id, kb)
})
return Array.from(merged.values())
}, [knowledgeBasesList, knowledgeBasesMap])
const options = useMemo<ComboboxOption[]>(() => {
return knowledgeBases.map((kb) => ({
return combinedKnowledgeBases.map((kb) => ({
label: kb.name,
value: kb.id,
icon: PackageSearchIcon,
}))
}, [knowledgeBases])
}, [combinedKnowledgeBases])
/**
* Parse value into array of selected IDs
@@ -74,51 +87,18 @@ export function KnowledgeBaseSelector({
/**
* Compute selected knowledge bases for tag display
*/
const selectedKnowledgeBases = useMemo(() => {
if (selectedIds.length > 0 && knowledgeBases.length > 0) {
return knowledgeBases.filter((kb) => selectedIds.includes(kb.id))
}
return []
}, [selectedIds, knowledgeBases])
const selectedKnowledgeBases = useMemo<KnowledgeBaseData[]>(() => {
if (selectedIds.length === 0) return []
/**
* Fetch knowledge bases directly from API
*/
const fetchKnowledgeBases = useCallback(async () => {
setLoading(true)
setError(null)
const lookup = new Map<string, KnowledgeBaseData>()
combinedKnowledgeBases.forEach((kb) => {
lookup.set(kb.id, kb)
})
try {
const url = workspaceId ? `/api/knowledge?workspaceId=${workspaceId}` : '/api/knowledge'
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(
`Failed to fetch knowledge bases: ${response.status} ${response.statusText}`
)
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Failed to fetch knowledge bases')
}
const data = result.data || []
setKnowledgeBases(data)
setInitialFetchDone(true)
} catch (err) {
if ((err as Error).name === 'AbortError') return
setError((err as Error).message)
setKnowledgeBases([])
} finally {
setLoading(false)
}
}, [workspaceId])
return selectedIds
.map((id) => lookup.get(id))
.filter((kb): kb is KnowledgeBaseData => Boolean(kb))
}, [selectedIds, combinedKnowledgeBases])
/**
* Handle single selection
@@ -168,10 +148,39 @@ export function KnowledgeBaseSelector({
* Fetch knowledge bases on initial mount
*/
useEffect(() => {
if (!initialFetchDone && !loading && !isPreview) {
fetchKnowledgeBases()
if (hasRequestedListRef.current) return
let cancelled = false
hasRequestedListRef.current = true
setError(null)
getKnowledgeBasesList(workspaceId).catch((err) => {
if (cancelled) return
setError(err instanceof Error ? err.message : 'Failed to load knowledge bases')
})
return () => {
cancelled = true
}
}, [initialFetchDone, loading, isPreview, fetchKnowledgeBases])
}, [workspaceId, getKnowledgeBasesList])
/**
* Ensure selected knowledge bases are cached
*/
useEffect(() => {
if (selectedIds.length === 0) return
selectedIds.forEach((id) => {
const isKnown =
Boolean(knowledgeBasesMap[id]) ||
knowledgeBasesList.some((knowledgeBase) => knowledgeBase.id === id)
if (!isKnown) {
void getKnowledgeBase(id).catch(() => {
// Ignore fetch errors here; they will surface via display hooks if needed
})
}
})
}, [selectedIds, knowledgeBasesList, knowledgeBasesMap, getKnowledgeBase])
const label =
subBlock.placeholder || (isMultiSelect ? 'Select knowledge bases' : 'Select knowledge base')
@@ -212,7 +221,7 @@ export function KnowledgeBaseSelector({
onMultiSelectChange={handleMultiSelectChange}
placeholder={label}
disabled={disabled || isPreview}
isLoading={loading || loadingKnowledgeBasesList}
isLoading={loadingKnowledgeBasesList}
error={error}
/>
</div>

View File

@@ -1,7 +1,7 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
import { JiraIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
@@ -21,6 +21,7 @@ import {
type OAuthProvider,
} from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { useDisplayNamesStore } from '@/stores/display-names/store'
const logger = createLogger('JiraProjectSelector')
@@ -73,13 +74,24 @@ export function JiraProjectSelector({
const [projects, setProjects] = useState<JiraProjectInfo[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
const [selectedProjectId, setSelectedProjectId] = useState(value)
const [selectedProject, setSelectedProject] = useState<JiraProjectInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const initialFetchRef = useRef(false)
const [error, setError] = useState<string | null>(null)
const [cloudId, setCloudId] = useState<string | null>(null)
// Get cached display name
const cachedProjectName = useDisplayNamesStore(
useCallback(
(state) => {
const effectiveCredentialId = credentialId || selectedCredentialId
if (!effectiveCredentialId || !value) return null
return state.cache.projects[`jira-${effectiveCredentialId}`]?.[value] || null
},
[credentialId, selectedCredentialId, value]
)
)
// Handle search with debounce
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
@@ -198,10 +210,8 @@ export function JiraProjectSelector({
}
if (projectInfo) {
setSelectedProject(projectInfo)
onProjectInfoChange?.(projectInfo)
} else {
setSelectedProject(null)
onProjectInfoChange?.(null)
}
} catch (error) {
@@ -292,13 +302,26 @@ export function JiraProjectSelector({
logger.info(`Received ${foundProjects.length} projects from API`)
setProjects(foundProjects)
// Cache project names in display names store
if (selectedCredentialId && foundProjects.length > 0) {
const projectMap = foundProjects.reduce(
(acc: Record<string, string>, proj: JiraProjectInfo) => {
acc[proj.id] = proj.name
return acc
},
{}
)
useDisplayNamesStore
.getState()
.setDisplayNames('projects', `jira-${selectedCredentialId}`, projectMap)
}
// If we have a selected project ID, find the project info
if (selectedProjectId) {
const projectInfo = foundProjects.find(
(project: JiraProjectInfo) => project.id === selectedProjectId
)
if (projectInfo) {
setSelectedProject(projectInfo)
onProjectInfoChange?.(projectInfo)
} else if (!searchQuery && selectedProjectId) {
// If we can't find the project in the list, try to fetch it directly
@@ -337,26 +360,16 @@ export function JiraProjectSelector({
}
}, [credentialId, selectedCredentialId])
// Fetch the selected project metadata once credentials are ready or changed
useEffect(() => {
if (value && selectedCredentialId && domain && domain.includes('.')) {
if (!selectedProject || selectedProject.id !== value) {
fetchProjectInfo(value)
}
}
}, [value, selectedCredentialId, domain, fetchProjectInfo, selectedProject])
// Keep internal selectedProjectId in sync with the value prop
useEffect(() => {
if (value !== selectedProjectId) {
setSelectedProjectId(value)
}
}, [value])
}, [value, selectedProjectId])
// Clear local preview when value is cleared remotely or via collaborator
// Clear callback when value is cleared
useEffect(() => {
if (!value) {
setSelectedProject(null)
onProjectInfoChange?.(null)
}
}, [value, onProjectInfoChange])
@@ -373,7 +386,6 @@ export function JiraProjectSelector({
// Handle project selection
const handleSelectProject = (project: JiraProjectInfo) => {
setSelectedProjectId(project.id)
setSelectedProject(project)
onChange(project.id, project)
onProjectInfoChange?.(project)
setOpen(false)
@@ -389,14 +401,11 @@ export function JiraProjectSelector({
// Clear selection
const handleClearSelection = () => {
setSelectedProjectId('')
setSelectedProject(null)
setError(null)
onChange('', undefined)
onProjectInfoChange?.(null)
}
const canShowPreview = !!(showPreview && selectedProject && value && selectedProject.id === value)
return (
<>
<div className='space-y-2'>
@@ -409,15 +418,10 @@ export function JiraProjectSelector({
className='w-full justify-between'
disabled={disabled || !domain || !selectedCredentialId || isForeignCredential}
>
{canShowPreview ? (
{cachedProjectName ? (
<div className='flex items-center gap-2 overflow-hidden'>
<JiraIcon className='h-4 w-4' />
<span className='truncate font-normal'>{selectedProject.name}</span>
</div>
) : selectedProjectId ? (
<div className='flex items-center gap-2 overflow-hidden'>
<JiraIcon className='h-4 w-4' />
<span className='truncate font-normal'>{selectedProjectId}</span>
<span className='truncate font-normal'>{cachedProjectName}</span>
</div>
) : (
<div className='flex items-center gap-2'>
@@ -554,55 +558,6 @@ export function JiraProjectSelector({
</PopoverContent>
)}
</Popover>
{/* Project preview */}
{canShowPreview && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button
variant='ghost'
size='icon'
className='h-5 w-5 hover:bg-muted'
onClick={handleClearSelection}
>
<X className='h-3 w-3' />
</Button>
</div>
<div className='flex items-center gap-3 pr-4'>
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
{selectedProject.avatarUrl ? (
<img
src={selectedProject.avatarUrl}
alt={selectedProject.name}
className='h-4 w-4 rounded'
/>
) : (
<JiraIcon className='h-4 w-4' />
)}
</div>
<div className='min-w-0 flex-1 overflow-hidden'>
<div className='flex items-center gap-2'>
<h4 className='truncate font-medium text-xs'>{selectedProject.name}</h4>
<span className='whitespace-nowrap text-muted-foreground text-xs'>
{selectedProject.key}
</span>
</div>
{selectedProject.url && (
<a
href={selectedProject.url}
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-1 text-foreground text-xs hover:underline'
onClick={(e) => e.stopPropagation()}
>
<span>Open in Jira</span>
<ExternalLink className='h-3 w-3' />
</a>
)}
</div>
</div>
</div>
)}
</div>
{showOAuthModal && (

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
import { LinearIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
@@ -11,6 +11,7 @@ import {
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useDisplayNamesStore } from '@/stores/display-names/store'
export interface LinearProjectInfo {
id: string
@@ -40,7 +41,17 @@ export function LinearProjectSelector({
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [selectedProject, setSelectedProject] = useState<LinearProjectInfo | null>(null)
// Get cached display name
const cachedProjectName = useDisplayNamesStore(
useCallback(
(state) => {
if (!credential || !value) return null
return state.cache.projects[`linear-${credential}`]?.[value] || null
},
[credential, value]
)
)
useEffect(() => {
if (!credential || !teamId) return
@@ -68,10 +79,18 @@ export function LinearProjectSelector({
} else {
setProjects(data.projects)
// Find selected project info if we have a value
if (value) {
const projectInfo = data.projects.find((p: LinearProjectInfo) => p.id === value)
setSelectedProject(projectInfo || null)
// Cache project names in display names store
if (credential && data.projects) {
const projectMap = data.projects.reduce(
(acc: Record<string, string>, proj: LinearProjectInfo) => {
acc[proj.id] = proj.name
return acc
},
{}
)
useDisplayNamesStore
.getState()
.setDisplayNames('projects', `linear-${credential}`, projectMap)
}
}
})
@@ -84,18 +103,7 @@ export function LinearProjectSelector({
return () => controller.abort()
}, [credential, teamId, value, workflowId])
// Sync selected project with value prop
useEffect(() => {
if (value && projects.length > 0) {
const projectInfo = projects.find((p) => p.id === value)
setSelectedProject(projectInfo || null)
} else if (!value) {
setSelectedProject(null)
}
}, [value, projects])
const handleSelectProject = (project: LinearProjectInfo) => {
setSelectedProject(project)
onChange(project.id, project)
setOpen(false)
}
@@ -114,10 +122,10 @@ export function LinearProjectSelector({
className='w-full justify-between'
disabled={disabled || !credential || !teamId}
>
{selectedProject ? (
{cachedProjectName ? (
<div className='flex items-center gap-2 overflow-hidden'>
<LinearIcon className='h-4 w-4' />
<span className='truncate font-normal'>{selectedProject.name}</span>
<span className='truncate font-normal'>{cachedProjectName}</span>
</div>
) : (
<div className='flex items-center gap-2'>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
import { LinearIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
@@ -11,6 +11,7 @@ import {
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useDisplayNamesStore } from '@/stores/display-names/store'
export interface LinearTeamInfo {
id: string
@@ -39,7 +40,17 @@ export function LinearTeamSelector({
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [selectedTeam, setSelectedTeam] = useState<LinearTeamInfo | null>(null)
// Get cached display name
const cachedTeamName = useDisplayNamesStore(
useCallback(
(state) => {
if (!credential || !value) return null
return state.cache.projects[`linear-${credential}`]?.[value] || null
},
[credential, value]
)
)
useEffect(() => {
if (!credential) return
@@ -64,10 +75,18 @@ export function LinearTeamSelector({
} else {
setTeams(data.teams)
// Find selected team info if we have a value
if (value) {
const teamInfo = data.teams.find((t: LinearTeamInfo) => t.id === value)
setSelectedTeam(teamInfo || null)
// Cache team names in display names store
if (credential && data.teams) {
const teamMap = data.teams.reduce(
(acc: Record<string, string>, team: LinearTeamInfo) => {
acc[team.id] = team.name
return acc
},
{}
)
useDisplayNamesStore
.getState()
.setDisplayNames('projects', `linear-${credential}`, teamMap)
}
}
})
@@ -80,18 +99,7 @@ export function LinearTeamSelector({
return () => controller.abort()
}, [credential, value, workflowId])
// Sync selected team with value prop
useEffect(() => {
if (value && teams.length > 0) {
const teamInfo = teams.find((t) => t.id === value)
setSelectedTeam(teamInfo || null)
} else if (!value) {
setSelectedTeam(null)
}
}, [value, teams])
const handleSelectTeam = (team: LinearTeamInfo) => {
setSelectedTeam(team)
onChange(team.id, team)
setOpen(false)
}
@@ -110,10 +118,10 @@ export function LinearTeamSelector({
className='w-full justify-between'
disabled={disabled || !credential}
>
{selectedTeam ? (
{cachedTeamName ? (
<div className='flex items-center gap-2 overflow-hidden'>
<LinearIcon className='h-4 w-4' />
<span className='truncate font-normal'>{selectedTeam.name}</span>
<span className='truncate font-normal'>{cachedTeamName}</span>
</div>
) : (
<div className='flex items-center gap-2'>

View File

@@ -21,6 +21,7 @@ import {
} from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useDisplayNamesStore } from '@/stores/display-names/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('ToolCredentialSelector')
@@ -87,6 +88,18 @@ export function ToolCredentialSelector({
const data = await response.json()
setCredentials(data.credentials || [])
// Cache credential names for block previews
if (provider) {
const credentialMap = (data.credentials || []).reduce(
(acc: Record<string, string>, cred: Credential) => {
acc[cred.id] = cred.name
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('credentials', provider, credentialMap)
}
if (
value &&
!(data.credentials || []).some((cred: Credential) => cred.id === value) &&
@@ -99,7 +112,19 @@ export function ToolCredentialSelector({
if (metaResp.ok) {
const meta = await metaResp.json()
if (meta.credentials?.length) {
setCredentials([meta.credentials[0], ...(data.credentials || [])])
const combinedCredentials = [meta.credentials[0], ...(data.credentials || [])]
setCredentials(combinedCredentials)
const credentialMap = combinedCredentials.reduce(
(acc: Record<string, string>, cred: Credential) => {
acc[cred.id] = cred.name
return acc
},
{}
)
useDisplayNamesStore
.getState()
.setDisplayNames('credentials', provider, credentialMap)
}
}
} catch {

View File

@@ -9,6 +9,8 @@ import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import type { SubBlockConfig } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useCredentialDisplay } from '@/hooks/use-credential-display'
import { useDisplayName } from '@/hooks/use-display-name'
import { usePanelEditorStore } from '@/stores/panel-new/editor/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -85,7 +87,6 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
const getDisplayValue = (value: unknown): string => {
if (value == null || value === '') return '-'
// Handle table row arrays (from table component)
if (isTableRowArray(value)) {
const nonEmptyRows = value.filter((row) => {
const cellValues = Object.values(row.cells)
@@ -106,7 +107,6 @@ const getDisplayValue = (value: unknown): string => {
return `${nonEmptyRows.length} rows`
}
// Handle field format arrays (from input-format, response-format)
if (isFieldFormatArray(value)) {
const namedFields = value.filter((field) => field.name && field.name.trim() !== '')
if (namedFields.length === 0) return '-'
@@ -115,7 +115,6 @@ const getDisplayValue = (value: unknown): string => {
return `${namedFields[0].name}, ${namedFields[1].name} +${namedFields.length - 2}`
}
// Handle input mapping objects (from input-mapping component)
if (isPlainObject(value)) {
const entries = Object.entries(value).filter(
([, val]) => val !== null && val !== undefined && val !== ''
@@ -134,19 +133,27 @@ const getDisplayValue = (value: unknown): string => {
return entries.length > 2 ? `${preview} +${entries.length - 2}` : preview
}
// Handle arrays of primitives
if (Array.isArray(value)) {
const nonEmptyItems = value.filter((item) => item !== null && item !== undefined && item !== '')
if (nonEmptyItems.length === 0) return '-'
if (nonEmptyItems.length === 1) return String(nonEmptyItems[0])
if (nonEmptyItems.length === 2) return `${nonEmptyItems[0]}, ${nonEmptyItems[1]}`
return `${nonEmptyItems[0]}, ${nonEmptyItems[1]} +${nonEmptyItems.length - 2}`
const getItemDisplayValue = (item: unknown): string => {
if (typeof item === 'object' && item !== null) {
const obj = item as Record<string, unknown>
return String(obj.title || obj.name || obj.label || obj.id || JSON.stringify(item))
}
return String(item)
}
if (nonEmptyItems.length === 1) return getItemDisplayValue(nonEmptyItems[0])
if (nonEmptyItems.length === 2) {
return `${getItemDisplayValue(nonEmptyItems[0])}, ${getItemDisplayValue(nonEmptyItems[1])}`
}
return `${getItemDisplayValue(nonEmptyItems[0])}, ${getItemDisplayValue(nonEmptyItems[1])} +${nonEmptyItems.length - 2}`
}
// Handle primitive values
const stringValue = String(value)
if (stringValue === '[object Object]') {
// Fallback for unhandled object types - try to show something useful
try {
const json = JSON.stringify(value)
if (json.length <= 40) return json
@@ -161,19 +168,97 @@ const getDisplayValue = (value: unknown): string => {
/**
* Renders a single subblock row with title and optional value.
* Automatically hydrates IDs to display names for all selector types.
*/
const SubBlockRow = ({ title, value }: { title: string; value?: string }) => (
<div className='flex items-center gap-[8px]'>
<span className='min-w-0 truncate text-[#AEAEAE] text-[14px]' title={title}>
{title}
</span>
{value !== undefined && (
<span className='flex-1 truncate text-right text-[#FFFFFF] text-[14px]' title={value}>
{value}
const SubBlockRow = ({
title,
value,
subBlock,
rawValue,
workspaceId,
allSubBlockValues,
}: {
title: string
value?: string
subBlock?: SubBlockConfig
rawValue?: unknown
workspaceId?: string
allSubBlockValues?: Record<string, { value: unknown }>
}) => {
const getStringValue = useCallback(
(key?: string): string | undefined => {
if (!key || !allSubBlockValues) return undefined
const candidate = allSubBlockValues[key]?.value
return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined
},
[allSubBlockValues]
)
const dependencyValues = useMemo(() => {
if (!subBlock?.dependsOn?.length) return {}
return subBlock.dependsOn.reduce<Record<string, string>>((accumulator, dependency) => {
const dependencyValue = getStringValue(dependency)
if (dependencyValue) {
accumulator[dependency] = dependencyValue
}
return accumulator
}, {})
}, [getStringValue, subBlock?.dependsOn])
const { displayName: credentialName } = useCredentialDisplay(
subBlock?.type === 'oauth-input' && typeof rawValue === 'string' ? rawValue : undefined,
subBlock?.provider
)
const credentialId = dependencyValues.credential
const knowledgeBaseId = dependencyValues.knowledgeBaseId
const dropdownLabel = useMemo(() => {
if (!subBlock || (subBlock.type !== 'dropdown' && subBlock.type !== 'combobox')) return null
if (!rawValue || typeof rawValue !== 'string') return null
const options = typeof subBlock.options === 'function' ? subBlock.options() : subBlock.options
if (!options) return null
const option = options.find((opt) =>
typeof opt === 'string' ? opt === rawValue : opt.id === rawValue
)
if (!option) return null
return typeof option === 'string' ? option : option.label
}, [subBlock, rawValue])
const genericDisplayName = useDisplayName(subBlock, rawValue, {
workspaceId,
provider: subBlock?.provider,
credentialId: typeof credentialId === 'string' ? credentialId : undefined,
knowledgeBaseId: typeof knowledgeBaseId === 'string' ? knowledgeBaseId : undefined,
domain: getStringValue('domain'),
teamId: getStringValue('teamId'),
projectId: getStringValue('projectId'),
planId: getStringValue('planId'),
})
const isPasswordField = subBlock?.password === true
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
const displayValue = maskedValue || credentialName || dropdownLabel || genericDisplayName || value
return (
<div className='flex items-center gap-[8px]'>
<span className='min-w-0 truncate text-[#AEAEAE] text-[14px]' title={title}>
{title}
</span>
)}
</div>
)
{displayValue !== undefined && (
<span
className='flex-1 truncate text-right text-[#FFFFFF] text-[14px]'
title={displayValue}
>
{displayValue}
</span>
)}
</div>
)
}
export const WorkflowBlock = memo(function WorkflowBlock({
id,
@@ -186,6 +271,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
const params = useParams()
const currentWorkflowId = params.workflowId as string
const workspaceId = params.workspaceId as string
const currentWorkflow = useCurrentWorkflow()
const currentBlock = currentWorkflow.getBlockById(id)
@@ -345,6 +431,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
const visibleSubBlocks = config.subBlocks.filter((block) => {
if (block.hidden) return false
if (block.hideFromPreview) return false
if (block.requiresFeature && !isTruthy(getEnv(block.requiresFeature))) {
return false
@@ -547,7 +634,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
typeof currentStoreBlock?.height === 'number' ? currentStoreBlock.height : undefined
const prevWidth = 250 // fixed across the app for workflow blocks
// Only update store if something actually changed to prevent unnecessary reflows
if (prevHeight !== calculatedHeight || prevWidth !== FIXED_WIDTH) {
updateBlockLayoutMetrics(id, { width: FIXED_WIDTH, height: calculatedHeight })
updateNodeInternals(id)
@@ -791,13 +877,20 @@ export const WorkflowBlock = memo(function WorkflowBlock({
/>
))
: subBlockRows.map((row, rowIndex) =>
row.map((subBlock) => (
<SubBlockRow
key={`${subBlock.id}-${rowIndex}`}
title={subBlock.title ?? subBlock.id}
value={getDisplayValue(subBlockState[subBlock.id]?.value)}
/>
))
row.map((subBlock) => {
const rawValue = subBlockState[subBlock.id]?.value
return (
<SubBlockRow
key={`${subBlock.id}-${rowIndex}`}
title={subBlock.title ?? subBlock.id}
value={getDisplayValue(rawValue)}
subBlock={subBlock}
rawValue={rawValue}
workspaceId={workspaceId}
allSubBlockValues={subBlockState}
/>
)
})
)}
{shouldShowDefaultHandles && <SubBlockRow title='error' />}
</div>

View File

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

View File

@@ -0,0 +1,54 @@
import { useCallback, useEffect, useState } from 'react'
import { useDisplayNamesStore } from '@/stores/display-names/store'
/**
* Hook to get display name for a credential ID
* Automatically fetches if not cached
*/
export function useCredentialDisplay(credentialId: string | undefined, provider?: string) {
const [isLoading, setIsLoading] = useState(false)
// Select the actual cached value from the store (not just the getter)
// This ensures the component re-renders when the cache is populated
const displayName = useDisplayNamesStore(
useCallback(
(state) => {
if (!credentialId || !provider) return null
return state.cache.credentials[provider]?.[credentialId] || null
},
[credentialId, provider]
)
)
// Fetch if not cached
useEffect(() => {
if (!credentialId || !provider || displayName || isLoading) return
setIsLoading(true)
fetch(`/api/auth/oauth/credentials?provider=${encodeURIComponent(provider)}`)
.then((res) => res.json())
.then((data) => {
if (data.credentials) {
const credentialMap = data.credentials.reduce(
(acc: Record<string, string>, cred: { id: string; name: string }) => {
acc[cred.id] = cred.name
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('credentials', provider, credentialMap)
}
})
.catch(() => {
// Silently fail
})
.finally(() => {
setIsLoading(false)
})
}, [credentialId, provider, displayName, isLoading])
return {
displayName,
isLoading,
}
}

View File

@@ -0,0 +1,555 @@
import { useCallback, useEffect, useState } from 'react'
import type { SubBlockConfig } from '@/blocks/types'
import { useDisplayNamesStore } from '@/stores/display-names/store'
import { useKnowledgeStore } from '@/stores/knowledge/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
/**
* Generic hook to get display name for any selector value
* Automatically fetches if not cached
*/
export function useDisplayName(
subBlock: SubBlockConfig | undefined,
value: unknown,
context?: {
workspaceId?: string
credentialId?: string
provider?: string
knowledgeBaseId?: string
domain?: string
teamId?: string
projectId?: string
planId?: string
}
): string | null {
const getCachedKnowledgeBase = useKnowledgeStore((state) => state.getCachedKnowledgeBase)
const getKnowledgeBase = useKnowledgeStore((state) => state.getKnowledgeBase)
const getDocuments = useKnowledgeStore((state) => state.getDocuments)
const [isFetching, setIsFetching] = useState(false)
const cachedDisplayName = useDisplayNamesStore(
useCallback(
(state) => {
if (!subBlock || !value || typeof value !== 'string') return null
// Channels
if (subBlock.type === 'channel-selector' && context?.credentialId) {
return state.cache.channels[context.credentialId]?.[value] || null
}
// Workflows
if (subBlock.id === 'workflowId') {
return state.cache.workflows.global?.[value] || null
}
// Files
if (subBlock.type === 'file-selector' && context?.credentialId) {
return state.cache.files[context.credentialId]?.[value] || null
}
// Folders
if (subBlock.type === 'folder-selector' && context?.credentialId) {
return state.cache.folders[context.credentialId]?.[value] || null
}
// Projects
if (subBlock.type === 'project-selector' && context?.provider && context?.credentialId) {
const projectContext = `${context.provider}-${context.credentialId}`
return state.cache.projects[projectContext]?.[value] || null
}
// Documents
if (subBlock.type === 'document-selector' && context?.knowledgeBaseId) {
return state.cache.documents[context.knowledgeBaseId]?.[value] || null
}
return null
},
[subBlock, value, context?.credentialId, context?.provider, context?.knowledgeBaseId]
)
)
// Auto-fetch knowledge bases if needed
useEffect(() => {
if (
subBlock?.type === 'knowledge-base-selector' &&
typeof value === 'string' &&
value &&
!isFetching
) {
const kb = getCachedKnowledgeBase(value)
if (!kb) {
setIsFetching(true)
getKnowledgeBase(value)
.catch(() => {
// Silently fail
})
.finally(() => {
setIsFetching(false)
})
}
}
}, [subBlock?.type, value, isFetching, getCachedKnowledgeBase, getKnowledgeBase])
// Auto-fetch documents if needed
useEffect(() => {
if (
subBlock?.type === 'document-selector' &&
context?.knowledgeBaseId &&
typeof value === 'string' &&
value &&
!cachedDisplayName &&
!isFetching
) {
setIsFetching(true)
getDocuments(context.knowledgeBaseId)
.then((docs) => {
if (docs.length > 0) {
const documentMap = docs.reduce<Record<string, string>>((acc, doc) => {
acc[doc.id] = doc.filename
return acc
}, {})
useDisplayNamesStore
.getState()
.setDisplayNames('documents', context.knowledgeBaseId!, documentMap)
}
})
.catch(() => {
// Silently fail
})
.finally(() => {
setIsFetching(false)
})
}
}, [subBlock?.type, value, context?.knowledgeBaseId, cachedDisplayName, isFetching, getDocuments])
// Auto-fetch workflows if needed
useEffect(() => {
if (subBlock?.id !== 'workflowId' || typeof value !== 'string' || !value) return
if (cachedDisplayName || isFetching) return
const workflows = useWorkflowRegistry.getState().workflows
if (!workflows[value]) return
const workflowMap = Object.entries(workflows).reduce<Record<string, string>>(
(acc, [id, workflow]) => {
acc[id] = workflow.name || `Workflow ${id.slice(0, 8)}`
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('workflows', 'global', workflowMap)
}, [subBlock?.id, value, cachedDisplayName, isFetching])
// Auto-fetch channels if needed
useEffect(() => {
if (subBlock?.type !== 'channel-selector' || !context?.credentialId || !value) return
if (cachedDisplayName || isFetching) return
setIsFetching(true)
fetch('/api/tools/slack/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: context.credentialId }),
})
.then((res) => res.json())
.then((data) => {
if (data.channels) {
const channelMap = data.channels.reduce(
(acc: Record<string, string>, ch: { id: string; name: string }) => {
acc[ch.id] = ch.name
return acc
},
{}
)
useDisplayNamesStore
.getState()
.setDisplayNames('channels', context.credentialId!, channelMap)
}
})
.catch(() => {
// Silently fail
})
.finally(() => {
setIsFetching(false)
})
}, [subBlock?.type, value, context?.credentialId, cachedDisplayName, isFetching])
// Auto-fetch folders if needed (Gmail/Outlook)
useEffect(() => {
if (subBlock?.type !== 'folder-selector' || !context?.credentialId || !value) return
if (cachedDisplayName || isFetching) return
setIsFetching(true)
const provider = subBlock.provider || 'gmail'
const apiEndpoint =
provider === 'outlook'
? `/api/tools/outlook/folders?credentialId=${context.credentialId}`
: `/api/tools/gmail/labels?credentialId=${context.credentialId}`
fetch(apiEndpoint)
.then((res) => res.json())
.then((data) => {
const folderList = provider === 'outlook' ? data.folders : data.labels
if (folderList) {
const folderMap = folderList.reduce(
(acc: Record<string, string>, folder: { id: string; name: string }) => {
acc[folder.id] = folder.name
return acc
},
{}
)
useDisplayNamesStore
.getState()
.setDisplayNames('folders', context.credentialId!, folderMap)
}
})
.catch(() => {
// Silently fail
})
.finally(() => {
setIsFetching(false)
})
}, [
subBlock?.type,
subBlock?.provider,
value,
context?.credentialId,
cachedDisplayName,
isFetching,
])
// Auto-fetch projects if needed (Jira, Linear)
useEffect(() => {
if (
subBlock?.type !== 'project-selector' ||
!context?.credentialId ||
!context?.provider ||
!value
)
return
if (cachedDisplayName || isFetching) return
const projectContext = `${context.provider}-${context.credentialId}`
setIsFetching(true)
if (context.provider === 'jira' && context.domain) {
fetch('/api/tools/jira/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialId: context.credentialId, domain: context.domain }),
})
.then((res) => res.json())
.then((data) => {
if (data.projects) {
const projectMap = data.projects.reduce(
(acc: Record<string, string>, proj: { id: string; name: string }) => {
acc[proj.id] = proj.name
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('projects', projectContext, projectMap)
}
})
.catch(() => {})
.finally(() => setIsFetching(false))
} else if (context.provider === 'linear' && context.teamId) {
fetch('/api/tools/linear/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: context.credentialId, teamId: context.teamId }),
})
.then((res) => res.json())
.then((data) => {
if (data.projects) {
const projectMap = data.projects.reduce(
(acc: Record<string, string>, proj: { id: string; name: string }) => {
acc[proj.id] = proj.name
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('projects', projectContext, projectMap)
}
})
.catch(() => {})
.finally(() => setIsFetching(false))
} else {
setIsFetching(false)
}
}, [
subBlock?.type,
value,
context?.credentialId,
context?.provider,
context?.domain,
context?.teamId,
cachedDisplayName,
isFetching,
])
// Auto-fetch files if needed (provider-specific)
useEffect(() => {
if (subBlock?.type !== 'file-selector' || !context?.credentialId || !value) return
if (cachedDisplayName || isFetching) return
setIsFetching(true)
const provider = subBlock.provider || context.provider
const serviceId = subBlock.serviceId
// Google Calendar
if (provider === 'google-calendar') {
fetch(`/api/tools/google_calendar/calendars?credentialId=${context.credentialId}`)
.then((res) => res.json())
.then((data) => {
if (data.calendars) {
const calendarMap = data.calendars.reduce(
(acc: Record<string, string>, cal: { id: string; summary: string }) => {
acc[cal.id] = cal.summary
return acc
},
{}
)
useDisplayNamesStore
.getState()
.setDisplayNames('files', context.credentialId!, calendarMap)
}
})
.catch(() => {})
.finally(() => setIsFetching(false))
}
// Jira issues
else if (provider === 'jira' && context.domain && context.projectId) {
fetch('/api/tools/jira/issues', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
credentialId: context.credentialId,
domain: context.domain,
projectId: context.projectId,
}),
})
.then((res) => res.json())
.then((data) => {
if (data.issues) {
const issueMap = data.issues.reduce(
(acc: Record<string, string>, issue: { id: string; name: string }) => {
acc[issue.id] = issue.name
return acc
},
{}
)
useDisplayNamesStore
.getState()
.setDisplayNames('files', context.credentialId!, issueMap)
}
})
.catch(() => {})
.finally(() => setIsFetching(false))
}
// Confluence pages
else if (provider === 'confluence' && context.domain) {
fetch('/api/tools/confluence/pages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialId: context.credentialId, domain: context.domain }),
})
.then((res) => res.json())
.then((data) => {
if (data.files) {
const fileMap = data.files.reduce(
(acc: Record<string, string>, file: { id: string; name: string }) => {
acc[file.id] = file.name
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, fileMap)
}
})
.catch(() => {})
.finally(() => setIsFetching(false))
}
// Microsoft Teams
else if (provider === 'microsoft-teams' && context.teamId) {
fetch('/api/tools/microsoft_teams/teams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialId: context.credentialId }),
})
.then((res) => res.json())
.then((data) => {
if (data.teams) {
const teamMap = data.teams.reduce(
(acc: Record<string, string>, team: { id: string; displayName: string }) => {
acc[team.id] = team.displayName
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, teamMap)
}
})
.catch(() => {})
.finally(() => setIsFetching(false))
}
// Wealthbox
else if (provider === 'wealthbox') {
fetch('/api/tools/wealthbox/contacts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialId: context.credentialId }),
})
.then((res) => res.json())
.then((data) => {
if (data.contacts) {
const contactMap = data.contacts.reduce(
(acc: Record<string, string>, contact: { id: string; name: string }) => {
acc[contact.id] = contact.name
return acc
},
{}
)
useDisplayNamesStore
.getState()
.setDisplayNames('files', context.credentialId!, contactMap)
}
})
.catch(() => {})
.finally(() => setIsFetching(false))
}
// OneDrive files
else if (serviceId === 'onedrive' && subBlock.mimeType === 'file') {
fetch(`/api/tools/onedrive/files?credentialId=${context.credentialId}`)
.then((res) => res.json())
.then((data) => {
if (data.files) {
const fileMap = data.files.reduce(
(acc: Record<string, string>, file: { id: string; name: string }) => {
acc[file.id] = file.name
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, fileMap)
}
})
.catch(() => {})
.finally(() => setIsFetching(false))
}
// OneDrive folders
else if (serviceId === 'onedrive' && subBlock.mimeType !== 'file') {
fetch(`/api/tools/onedrive/folders?credentialId=${context.credentialId}`)
.then((res) => res.json())
.then((data) => {
if (data.files) {
const fileMap = data.files.reduce(
(acc: Record<string, string>, file: { id: string; name: string }) => {
acc[file.id] = file.name
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, fileMap)
}
})
.catch(() => {})
.finally(() => setIsFetching(false))
}
// SharePoint sites
else if (serviceId === 'sharepoint') {
fetch(`/api/tools/sharepoint/sites?credentialId=${context.credentialId}`)
.then((res) => res.json())
.then((data) => {
if (data.files) {
const fileMap = data.files.reduce(
(acc: Record<string, string>, file: { id: string; name: string }) => {
acc[file.id] = file.name
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, fileMap)
}
})
.catch(() => {})
.finally(() => setIsFetching(false))
}
// Microsoft Excel/Word
else if (provider === 'microsoft-excel' || provider === 'microsoft-word') {
fetch(`/api/auth/oauth/microsoft/files?credentialId=${context.credentialId}`)
.then((res) => res.json())
.then((data) => {
if (data.files) {
const fileMap = data.files.reduce(
(acc: Record<string, string>, file: { id: string; name: string }) => {
acc[file.id] = file.name
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, fileMap)
}
})
.catch(() => {})
.finally(() => setIsFetching(false))
}
// Microsoft Planner tasks
else if (provider === 'microsoft-planner' && context.planId) {
fetch(
`/api/tools/microsoft_planner/tasks?credentialId=${context.credentialId}&planId=${context.planId}`
)
.then((res) => res.json())
.then((data) => {
if (data.tasks) {
const taskMap = data.tasks.reduce(
(acc: Record<string, string>, task: { id: string; title: string }) => {
acc[task.id] = task.title
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, taskMap)
}
})
.catch(() => {})
.finally(() => setIsFetching(false))
} else {
setIsFetching(false)
}
}, [
subBlock?.type,
subBlock?.provider,
subBlock?.serviceId,
subBlock?.mimeType,
value,
context?.credentialId,
context?.provider,
context?.domain,
context?.projectId,
context?.teamId,
context?.planId,
cachedDisplayName,
isFetching,
])
if (!subBlock || !value || typeof value !== 'string') {
return null
}
// Credentials - handled separately by useCredentialDisplay
if (subBlock.type === 'oauth-input') {
return null
}
// Knowledge Bases - use existing knowledge store
if (subBlock.type === 'knowledge-base-selector') {
const kb = getCachedKnowledgeBase(value)
return kb?.name || null
}
// Return the cached display name (which triggers re-render when populated)
return cachedDisplayName
}

View File

@@ -0,0 +1,122 @@
import { create } from 'zustand'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('DisplayNamesStore')
/**
* Generic cache for ID-to-name mappings for all selector types
* Structure: { type: { context: { id: name } } }
*
*/
interface DisplayNamesCache {
credentials: Record<string, Record<string, string>> // provider -> id -> name
channels: Record<string, Record<string, string>> // credentialContext -> id -> name
knowledgeBases: Record<string, Record<string, string>> // workspaceId -> id -> name
workflows: Record<string, Record<string, string>> // always 'global' -> id -> name
files: Record<string, Record<string, string>> // credentialContext -> id -> name
folders: Record<string, Record<string, string>> // credentialContext -> id -> name
projects: Record<string, Record<string, string>> // provider-credential -> id -> name
documents: Record<string, Record<string, string>> // knowledgeBaseId -> id -> name
}
interface DisplayNamesStore {
cache: DisplayNamesCache
/**
* Set a display name for an ID
*/
setDisplayName: (type: keyof DisplayNamesCache, context: string, id: string, name: string) => void
/**
* Set multiple display names at once
*/
setDisplayNames: (
type: keyof DisplayNamesCache,
context: string,
items: Record<string, string>
) => void
/**
* Get a display name for an ID
*/
getDisplayName: (type: keyof DisplayNamesCache, context: string, id: string) => string | null
/**
* Clear all cached display names for a type/context
*/
clearContext: (type: keyof DisplayNamesCache, context: string) => void
/**
* Clear all cached display names
*/
clearAll: () => void
}
const initialCache: DisplayNamesCache = {
credentials: {},
channels: {},
knowledgeBases: {},
workflows: {},
files: {},
folders: {},
projects: {},
documents: {},
}
export const useDisplayNamesStore = create<DisplayNamesStore>((set, get) => ({
cache: initialCache,
setDisplayName: (type, context, id, name) => {
set((state) => ({
cache: {
...state.cache,
[type]: {
...state.cache[type],
[context]: {
...state.cache[type][context],
[id]: name,
},
},
},
}))
},
setDisplayNames: (type, context, items) => {
set((state) => ({
cache: {
...state.cache,
[type]: {
...state.cache[type],
[context]: {
...state.cache[type][context],
...items,
},
},
},
}))
logger.info(`Cached ${Object.keys(items).length} display names`, { type, context })
},
getDisplayName: (type, context, id) => {
const contextCache = get().cache[type][context]
return contextCache?.[id] || null
},
clearContext: (type, context) => {
set((state) => {
const newTypeCache = { ...state.cache[type] }
delete newTypeCache[context]
return {
cache: {
...state.cache,
[type]: newTypeCache,
},
}
})
},
clearAll: () => {
set({ cache: initialCache })
},
}))

View File

@@ -50,6 +50,7 @@ export const airtableWebhookTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Connect your Airtable account using the "Select Airtable credential" button above.',
@@ -70,6 +71,7 @@ export const airtableWebhookTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'airtable_webhook',
},

View File

@@ -59,6 +59,7 @@ export const genericWebhookTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Copy the webhook URL and use it in your external service or API.',
@@ -79,6 +80,7 @@ export const genericWebhookTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'generic_webhook',
},

View File

@@ -78,6 +78,7 @@ export const githubIssueClosedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Go to your GitHub Repository > Settings > Webhooks.',
@@ -104,6 +105,7 @@ export const githubIssueClosedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_issue_closed',
condition: {

View File

@@ -78,6 +78,7 @@ export const githubIssueCommentTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Go to your GitHub Repository > Settings > Webhooks.',
@@ -105,6 +106,7 @@ export const githubIssueCommentTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_issue_comment',
condition: {

View File

@@ -99,6 +99,7 @@ export const githubIssueOpenedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Go to your GitHub Repository > Settings > Webhooks.',
@@ -125,6 +126,7 @@ export const githubIssueOpenedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_issue_opened',
condition: {

View File

@@ -79,6 +79,7 @@ export const githubPRClosedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Go to your GitHub Repository > Settings > Webhooks.',
@@ -105,6 +106,7 @@ export const githubPRClosedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_pr_closed',
condition: {

View File

@@ -78,6 +78,7 @@ export const githubPRCommentTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Go to your GitHub Repository > Settings > Webhooks.',
@@ -105,6 +106,7 @@ export const githubPRCommentTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_pr_comment',
condition: {

View File

@@ -78,6 +78,7 @@ export const githubPRMergedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Go to your GitHub Repository > Settings > Webhooks.',
@@ -104,6 +105,7 @@ export const githubPRMergedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_pr_merged',
condition: {

View File

@@ -78,6 +78,7 @@ export const githubPROpenedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Go to your GitHub Repository > Settings > Webhooks.',
@@ -104,6 +105,7 @@ export const githubPROpenedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_pr_opened',
condition: {

View File

@@ -79,6 +79,7 @@ export const githubPRReviewedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Go to your GitHub Repository > Settings > Webhooks.',
@@ -105,6 +106,7 @@ export const githubPRReviewedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_pr_reviewed',
condition: {

View File

@@ -78,6 +78,7 @@ export const githubPushTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Go to your GitHub Repository > Settings > Webhooks.',
@@ -104,6 +105,7 @@ export const githubPushTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_push',
condition: {

View File

@@ -78,6 +78,7 @@ export const githubReleasePublishedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Go to your GitHub Repository > Settings > Webhooks.',
@@ -104,6 +105,7 @@ export const githubReleasePublishedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_release_published',
condition: {

View File

@@ -75,6 +75,7 @@ export const githubWebhookTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Go to your GitHub Repository > Settings > Webhooks.',
@@ -101,6 +102,7 @@ export const githubWebhookTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_webhook',
condition: {

View File

@@ -79,6 +79,7 @@ export const githubWorkflowRunTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Go to your GitHub Repository > Settings > Webhooks.',
@@ -105,6 +106,7 @@ export const githubWorkflowRunTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'github_workflow_run',
condition: {

View File

@@ -105,6 +105,7 @@ export const gmailPollingTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Connect your Gmail account using OAuth credentials',
@@ -122,6 +123,7 @@ export const gmailPollingTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'gmail_poller',
},

View File

@@ -62,6 +62,7 @@ export const googleFormsWebhookTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Open your Google Form → More (⋮) → Script editor.',
@@ -148,6 +149,7 @@ export const googleFormsWebhookTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'google_forms_webhook',
},

View File

@@ -32,6 +32,7 @@ export function getTrigger(triggerId: string): TriggerConfig {
readOnly: true,
collapsible: true,
defaultCollapsed: true,
hideFromPreview: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',

View File

@@ -59,6 +59,7 @@ export const jiraIssueCommentedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: jiraSetupInstructions('comment_created'),
mode: 'trigger',
@@ -71,6 +72,7 @@ export const jiraIssueCommentedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'jira_issue_commented',
condition: {

View File

@@ -68,6 +68,7 @@ export const jiraIssueCreatedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: jiraSetupInstructions('jira:issue_created'),
mode: 'trigger',
@@ -80,6 +81,7 @@ export const jiraIssueCreatedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'jira_issue_created',
condition: {

View File

@@ -59,6 +59,7 @@ export const jiraIssueDeletedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: jiraSetupInstructions('jira:issue_deleted'),
mode: 'trigger',
@@ -71,6 +72,7 @@ export const jiraIssueDeletedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'jira_issue_deleted',
condition: {

View File

@@ -73,6 +73,7 @@ export const jiraIssueUpdatedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: jiraSetupInstructions('jira:issue_updated'),
mode: 'trigger',
@@ -85,6 +86,7 @@ export const jiraIssueUpdatedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'jira_issue_updated',
condition: {

View File

@@ -46,6 +46,7 @@ export const jiraWebhookTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: jiraSetupInstructions('All Events'),
mode: 'trigger',
@@ -58,6 +59,7 @@ export const jiraWebhookTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'jira_webhook',
condition: {

View File

@@ -59,6 +59,7 @@ export const jiraWorklogCreatedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: jiraSetupInstructions('worklog_created'),
mode: 'trigger',
@@ -71,6 +72,7 @@ export const jiraWorklogCreatedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'jira_worklog_created',
condition: {

View File

@@ -42,6 +42,7 @@ export const linearCommentCreatedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Comment (create)'),
mode: 'trigger',
@@ -54,6 +55,7 @@ export const linearCommentCreatedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'linear_comment_created',
condition: {

View File

@@ -42,6 +42,7 @@ export const linearCommentUpdatedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Comment (update)'),
mode: 'trigger',
@@ -54,6 +55,7 @@ export const linearCommentUpdatedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'linear_comment_updated',
condition: {

View File

@@ -42,6 +42,7 @@ export const linearCustomerRequestCreatedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Customer Requests'),
mode: 'trigger',
@@ -54,6 +55,7 @@ export const linearCustomerRequestCreatedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'linear_customer_request_created',
condition: {

View File

@@ -42,6 +42,7 @@ export const linearCustomerRequestUpdatedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('CustomerNeed (update)'),
mode: 'trigger',
@@ -54,6 +55,7 @@ export const linearCustomerRequestUpdatedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'linear_customer_request_updated',
condition: {

View File

@@ -42,6 +42,7 @@ export const linearCycleCreatedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Cycle (create)'),
mode: 'trigger',
@@ -54,6 +55,7 @@ export const linearCycleCreatedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'linear_cycle_created',
condition: {

View File

@@ -42,6 +42,7 @@ export const linearCycleUpdatedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Cycle (update)'),
mode: 'trigger',
@@ -54,6 +55,7 @@ export const linearCycleUpdatedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'linear_cycle_updated',
condition: {

View File

@@ -51,6 +51,7 @@ export const linearIssueCreatedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Issue (create)'),
mode: 'trigger',
@@ -63,6 +64,7 @@ export const linearIssueCreatedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'linear_issue_created',
condition: {

View File

@@ -42,6 +42,7 @@ export const linearIssueRemovedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Issue (remove)'),
mode: 'trigger',
@@ -54,6 +55,7 @@ export const linearIssueRemovedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'linear_issue_removed',
condition: {

View File

@@ -42,6 +42,7 @@ export const linearIssueUpdatedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Issue (update)'),
mode: 'trigger',
@@ -54,6 +55,7 @@ export const linearIssueUpdatedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'linear_issue_updated',
condition: {

View File

@@ -42,6 +42,7 @@ export const linearLabelCreatedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('IssueLabel (create)'),
mode: 'trigger',
@@ -54,6 +55,7 @@ export const linearLabelCreatedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'linear_label_created',
condition: {

View File

@@ -42,6 +42,7 @@ export const linearLabelUpdatedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('IssueLabel (update)'),
mode: 'trigger',
@@ -54,6 +55,7 @@ export const linearLabelUpdatedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'linear_label_updated',
condition: {

View File

@@ -42,6 +42,7 @@ export const linearProjectCreatedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Project (create)'),
mode: 'trigger',
@@ -54,6 +55,7 @@ export const linearProjectCreatedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'linear_project_created',
condition: {

View File

@@ -42,6 +42,7 @@ export const linearProjectUpdateCreatedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('ProjectUpdate (create)'),
mode: 'trigger',
@@ -54,6 +55,7 @@ export const linearProjectUpdateCreatedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'linear_project_update_created',
condition: {

View File

@@ -42,6 +42,7 @@ export const linearProjectUpdatedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions('Project (update)'),
mode: 'trigger',
@@ -54,6 +55,7 @@ export const linearProjectUpdatedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'linear_project_updated',
condition: {

View File

@@ -42,6 +42,7 @@ export const linearWebhookTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: linearSetupInstructions(
'all events',
@@ -57,6 +58,7 @@ export const linearWebhookTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'linear_webhook',
condition: {

View File

@@ -54,6 +54,7 @@ export const microsoftTeamsChatSubscriptionTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Connect your Microsoft Teams account and grant the required permissions.',
@@ -75,6 +76,7 @@ export const microsoftTeamsChatSubscriptionTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'microsoftteams_chat_subscription',
condition: {

View File

@@ -54,6 +54,7 @@ export const microsoftTeamsWebhookTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Open Microsoft Teams and go to the team where you want to add the webhook.',
@@ -79,6 +80,7 @@ export const microsoftTeamsWebhookTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'microsoftteams_webhook',
condition: {

View File

@@ -95,6 +95,7 @@ export const outlookPollingTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Connect your Microsoft account using OAuth credentials',
@@ -112,6 +113,7 @@ export const outlookPollingTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'outlook_poller',
},

View File

@@ -48,12 +48,14 @@ export const slackWebhookTrigger: TriggerConfig = {
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join(''),
hideFromPreview: true,
mode: 'trigger',
},
{
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'slack_webhook',
},

View File

@@ -168,6 +168,7 @@ export const stripeWebhookTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Go to your Stripe Dashboard at <a href="https://dashboard.stripe.com/webhooks" target="_blank" rel="noopener noreferrer">https://dashboard.stripe.com/webhooks</a>',
@@ -190,6 +191,7 @@ export const stripeWebhookTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'stripe_webhook',
},

View File

@@ -33,6 +33,7 @@ export const telegramWebhookTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Message "/newbot" to <a href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer" class="text-muted-foreground underline transition-colors hover:text-muted-foreground/80">@BotFather</a> in Telegram to create a bot and copy its token.',
@@ -50,6 +51,7 @@ export const telegramWebhookTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'telegram_webhook',
},

View File

@@ -52,6 +52,7 @@ export const twilioVoiceWebhookTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Enter a TwiML Response above - this tells Twilio what to do when a call comes in (e.g., play a message, record, gather input). Note: Use square brackets [Tag] instead of angle brackets for TwiML tags',
@@ -72,6 +73,7 @@ export const twilioVoiceWebhookTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'twilio_voice_webhook',
},

View File

@@ -64,6 +64,7 @@ export const typeformWebhookTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Get your Typeform Personal Access Token from <a href="https://admin.typeform.com/account#/section/tokens" target="_blank" rel="noopener noreferrer">https://admin.typeform.com/account#/section/tokens</a>',
@@ -84,6 +85,7 @@ export const typeformWebhookTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'typeform_webhook',
},

View File

@@ -56,6 +56,7 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Connect your Webflow account using the "Select Webflow credential" button above.',
@@ -80,6 +81,7 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'webflow_collection_item_changed',
condition: {

View File

@@ -69,6 +69,7 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Connect your Webflow account using the "Select Webflow credential" button above.',
@@ -93,6 +94,7 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'webflow_collection_item_created',
condition: {

View File

@@ -56,6 +56,7 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Connect your Webflow account using the "Select Webflow credential" button above.',
@@ -81,6 +82,7 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'webflow_collection_item_deleted',
condition: {

View File

@@ -43,6 +43,7 @@ export const webflowFormSubmissionTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Connect your Webflow account using the "Select Webflow credential" button above.',
@@ -64,6 +65,7 @@ export const webflowFormSubmissionTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'webflow_form_submission',
},

View File

@@ -34,6 +34,7 @@ export const whatsappWebhookTrigger: TriggerConfig = {
{
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: [
'Go to your <a href="https://developers.facebook.com/apps/" target="_blank" rel="noopener noreferrer" class="text-muted-foreground underline transition-colors hover:text-muted-foreground/80">Meta for Developers Apps</a> page and navigate to the "Build with us" --> "App Events" section.',
@@ -56,6 +57,7 @@ export const whatsappWebhookTrigger: TriggerConfig = {
id: 'triggerSave',
title: '',
type: 'trigger-save',
hideFromPreview: true,
mode: 'trigger',
triggerId: 'whatsapp_webhook',
},