improvement(selectors): consolidate all integration selectors to use the combobox (#2020)

* improvement(selectors): consolidate all integration selectors to use the combobox

* improved credential selector and file-upload styling to use emcn combobox

* update mcp subblocks to use emcn components, delete unused mcp server modal

* fix filterOptions change

* fix project selector

* attempted jira fix

* fix gdrive inf calls

* rewrite credential selector

* fix docs

* fix onedrive folder

* fix

* fix

* fix excel cred fetch

* fix excel part 2

---------

Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
Vikhyath Mondreti
2025-11-17 21:06:52 -08:00
committed by GitHub
parent 25ac91779b
commit 620ce97056
41 changed files with 2317 additions and 9583 deletions

View File

@@ -1,12 +1,9 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { validateMicrosoftGraphId } from '@/lib/security/input-validation'
import { generateRequestId } from '@/lib/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -15,15 +12,10 @@ const logger = createLogger('MicrosoftFileAPI')
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const fileId = searchParams.get('fileId')
const workflowId = searchParams.get('workflowId') || undefined
if (!credentialId || !fileId) {
return NextResponse.json({ error: 'Credential ID and File ID are required' }, { status: 400 })
@@ -35,19 +27,27 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: fileIdValidation.error }, { status: 400 })
}
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId,
requireWorkflowIdForInternal: false,
})
if (!credentials.length) {
if (!authz.ok || !authz.credentialOwnerUserId) {
const status = authz.error === 'Credential not found' ? 404 : 403
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
}
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
if (credential.userId !== session.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })

View File

@@ -1,11 +1,8 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -18,46 +15,39 @@ export async function GET(request: NextRequest) {
const requestId = generateRequestId()
try {
// Get the session
const session = await getSession()
// Check if the user is authenticated
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
// Get the credential ID from the query params
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const query = searchParams.get('query') || ''
const workflowId = searchParams.get('workflowId') || undefined
if (!credentialId) {
logger.warn(`[${requestId}] Missing credential ID`)
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
// Get the credential from the database
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId,
requireWorkflowIdForInternal: false,
})
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
if (!authz.ok || !authz.credentialOwnerUserId) {
const status = authz.error === 'Credential not found' ? 404 : 403
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
}
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
// Check if the credential belongs to the user
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })

View File

@@ -1,16 +1,14 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import {
type SlackChannelInfo,
SlackChannelSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/channel-selector/components/slack-channel-selector'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
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'
import type { SelectorContext } from '@/hooks/selectors/types'
interface ChannelSelectorInputProps {
blockId: string
@@ -41,14 +39,12 @@ export function ChannelSelectorInput({
const effectiveAuthMethod = previewContextValues?.authMethod ?? authMethod
const effectiveBotToken = previewContextValues?.botToken ?? botToken
const effectiveCredential = previewContextValues?.credential ?? connectedCredential
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
const [_channelInfo, setChannelInfo] = useState<SlackChannelInfo | null>(null)
const [_channelInfo, setChannelInfo] = useState<string | null>(null)
// Get provider-specific values
const provider = subBlock.provider || 'slack'
const isSlack = provider === 'slack'
// Central dependsOn gating
const { finalDisabled, dependsOn, dependencyValues } = useDependsOnGate(blockId, subBlock, {
const { finalDisabled, dependsOn } = useDependsOnGate(blockId, subBlock, {
disabled,
isPreview,
previewContextValues,
@@ -69,70 +65,60 @@ export function ChannelSelectorInput({
// Get the current value from the store or prop value if in preview mode (same pattern as file-selector)
useEffect(() => {
const val = isPreview && previewValue !== undefined ? previewValue : storeValue
if (val && typeof val === 'string') {
setSelectedChannelId(val)
if (typeof val === 'string') {
setChannelInfo(val)
}
}, [isPreview, previewValue, storeValue])
// Clear channel when any declared dependency changes (e.g., authMethod/credential)
const prevDepsSigRef = useRef<string>('')
useEffect(() => {
if (dependsOn.length === 0) return
const currentSig = JSON.stringify(dependencyValues)
if (prevDepsSigRef.current && prevDepsSigRef.current !== currentSig) {
if (!isPreview) {
setSelectedChannelId('')
setChannelInfo(null)
setStoreValue('')
}
}
prevDepsSigRef.current = currentSig
}, [dependsOn, dependencyValues, isPreview, setStoreValue])
const requiresCredential = dependsOn.includes('credential')
const missingCredential = !credential || credential.trim().length === 0
const shouldForceDisable = requiresCredential && (missingCredential || isForeignCredential)
// Handle channel selection (same pattern as file-selector)
const handleChannelChange = (channelId: string, info?: SlackChannelInfo) => {
setSelectedChannelId(channelId)
setChannelInfo(info || null)
if (!isPreview) {
setStoreValue(channelId)
}
onChannelSelect?.(channelId)
}
const context: SelectorContext = useMemo(
() => ({
credentialId: credential,
workflowId: workflowIdFromUrl,
}),
[credential, workflowIdFromUrl]
)
// Render Slack channel selector
if (isSlack) {
if (!isSlack) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<SlackChannelSelector
value={selectedChannelId}
onChange={(channelId: string, channelInfo?: SlackChannelInfo) => {
handleChannelChange(channelId, channelInfo)
}}
credential={credential}
label={subBlock.placeholder || 'Select Slack channel'}
disabled={finalDisabled}
workflowId={workflowIdFromUrl}
isForeignCredential={isForeignCredential}
/>
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
Channel selector not supported for provider: {provider}
</div>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>This channel selector is not yet implemented for {provider}</p>
</Tooltip.Content>
</Tooltip.Root>
)
}
// Default fallback for unsupported providers
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
Channel selector not supported for provider: {provider}
<div className='w-full'>
<SelectorCombobox
blockId={blockId}
subBlock={subBlock}
selectorKey='slack.channels'
selectorContext={context}
disabled={finalDisabled || shouldForceDisable || isForeignCredential}
isPreview={isPreview}
previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || 'Select Slack channel'}
onOptionChange={(value) => {
setChannelInfo(value)
if (!isPreview) {
onChannelSelect?.(value)
}
}}
/>
</div>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>This channel selector is not yet implemented for {provider}</p>
</Tooltip.Content>
</Tooltip.Root>
)
}

View File

@@ -1,219 +0,0 @@
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'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
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
name: string
isPrivate: boolean
}
interface SlackChannelSelectorProps {
value: string
onChange: (channelId: string, channelInfo?: SlackChannelInfo) => void
credential: string
label?: string
disabled?: boolean
workflowId?: string
isForeignCredential?: boolean
}
export function SlackChannelSelector({
value,
onChange,
credential,
label = 'Select Slack channel',
disabled = false,
workflowId,
isForeignCredential = false,
}: SlackChannelSelectorProps) {
const [channels, setChannels] = useState<SlackChannelInfo[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
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
setLoading(true)
setError(null)
try {
const res = await fetch('/api/tools/slack/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential, workflowId }),
})
if (!res.ok) {
const errorData = await res
.json()
.catch(() => ({ error: `HTTP error! status: ${res.status}` }))
setError(errorData.error || `HTTP error! status: ${res.status}`)
setChannels([])
setInitialFetchDone(true)
return
}
const data = await res.json()
if (data.error) {
setError(data.error)
setChannels([])
setInitialFetchDone(true)
} 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
setError((err as Error).message)
setChannels([])
setInitialFetchDone(true)
} finally {
setLoading(false)
}
}, [credential])
// Handle dropdown open/close - fetch channels when opening
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
// Only fetch channels when opening the dropdown and if we have valid credential
if (isOpen && credential && (!initialFetchDone || channels.length === 0)) {
fetchChannels()
}
}
const handleSelectChannel = (channel: SlackChannelInfo) => {
onChange(channel.id, channel)
setOpen(false)
}
const getChannelIcon = (channel: SlackChannelInfo) => {
return channel.isPrivate ? <Lock className='h-1.5 w-1.5' /> : <Hash className='h-1.5 w-1.5' />
}
const formatChannelName = (channel: SlackChannelInfo) => {
return channel.name
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='relative w-full justify-between'
disabled={disabled || !credential}
title={isForeignCredential ? 'Using a shared account' : undefined}
>
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
<SlackIcon className='h-4 w-4 text-[#611f69]' />
{cachedChannelName ? (
<span className='truncate font-normal'>{cachedChannelName}</span>
) : (
<span className='truncate text-muted-foreground'>{label}</span>
)}
</div>
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[250px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search channels...' />
<CommandList>
<CommandEmpty>
{loading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading channels...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : !credential ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>Missing credentials</p>
<p className='text-muted-foreground text-xs'>
Please configure Slack credentials.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No channels found</p>
<p className='text-muted-foreground text-xs'>
No channels available for this Slack workspace.
</p>
</div>
)}
</CommandEmpty>
{channels.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Channels
</div>
{channels.map((channel) => (
<CommandItem
key={channel.id}
value={`channel-${channel.id}-${channel.name}`}
onSelect={() => handleSelectChannel(channel)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<SlackIcon className='h-4 w-4 text-[#611f69]' />
{getChannelIcon(channel)}
<span className='truncate font-normal'>{formatChannelName(channel)}</span>
{channel.isPrivate && (
<span className='ml-auto text-muted-foreground text-xs'>Private</span>
)}
</div>
{channel.id === value && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,20 +1,10 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown, ExternalLink, RefreshCw } from 'lucide-react'
import { Button } from '@/components/emcn/components/button/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ExternalLink } from 'lucide-react'
import { Button, Combobox } from '@/components/emcn/components'
import { createLogger } from '@/lib/logs/console/logger'
import {
type Credential,
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
getServiceIdFromScopes,
@@ -25,9 +15,8 @@ import {
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
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'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
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')
@@ -47,262 +36,133 @@ export function CredentialSelector({
isPreview = false,
previewValue,
}: CredentialSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [isLoading, setIsLoading] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [selectedId, setSelectedId] = useState('')
const [hasForeignMeta, setHasForeignMeta] = useState(false)
const [inputValue, setInputValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
const { activeWorkflowId } = useWorkflowRegistry()
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
// Use collaborative state management via useSubBlockValue hook
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
// Extract values from subBlock config
const provider = subBlock.provider as OAuthProvider
const requiredScopes = subBlock.requiredScopes || []
const label = subBlock.placeholder || 'Select credential'
const serviceId = subBlock.serviceId
// Get the effective value (preview or store value)
const effectiveValue = isPreview && previewValue !== undefined ? previewValue : storeValue
const selectedId = typeof effectiveValue === 'string' ? effectiveValue : ''
const effectiveServiceId = useMemo(
() => serviceId || getServiceIdFromScopes(provider, requiredScopes),
[provider, requiredScopes, serviceId]
)
const effectiveProviderId = useMemo(
() => getProviderIdFromServiceId(effectiveServiceId),
[effectiveServiceId]
)
const {
data: credentials = [],
isFetching: credentialsLoading,
refetch: refetchCredentials,
} = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId))
const selectedCredential = useMemo(
() => credentials.find((cred) => cred.id === selectedId),
[credentials, selectedId]
)
const shouldFetchForeignMeta =
Boolean(selectedId) &&
!selectedCredential &&
Boolean(activeWorkflowId) &&
Boolean(effectiveProviderId)
const { data: foreignCredentials = [], isFetching: foreignMetaLoading } =
useOAuthCredentialDetail(
shouldFetchForeignMeta ? selectedId : undefined,
activeWorkflowId || undefined,
shouldFetchForeignMeta
)
const hasForeignMeta = foreignCredentials.length > 0
const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta)
const resolvedLabel = useMemo(() => {
if (selectedCredential) return selectedCredential.name
if (isForeign) return 'Saved by collaborator'
return ''
}, [selectedCredential, isForeign])
// Initialize selectedId with the effective value
useEffect(() => {
setSelectedId(effectiveValue || '')
}, [effectiveValue])
if (!isEditing) {
setInputValue(resolvedLabel)
}
}, [resolvedLabel, isEditing])
// Derive service and provider IDs using useMemo
const effectiveServiceId = useMemo(() => {
return serviceId || getServiceIdFromScopes(provider, requiredScopes)
}, [provider, requiredScopes, serviceId])
const invalidSelection =
!isPreview &&
Boolean(selectedId) &&
!selectedCredential &&
!hasForeignMeta &&
!credentialsLoading &&
!foreignMetaLoading
const effectiveProviderId = useMemo(() => {
return getProviderIdFromServiceId(effectiveServiceId)
}, [effectiveServiceId])
useEffect(() => {
if (!invalidSelection) return
logger.info('Clearing invalid credential selection - credential was disconnected', {
selectedId,
provider: effectiveProviderId,
})
setStoreValue('')
}, [invalidSelection, selectedId, effectiveProviderId, setStoreValue])
// Fetch available credentials for this provider
const fetchCredentials = useCallback(async () => {
setIsLoading(true)
try {
const response = await fetch(`/api/auth/oauth/credentials?provider=${effectiveProviderId}`)
if (response.ok) {
const data = await response.json()
const creds = data.credentials as Credential[]
let foreignMetaFound = false
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, provider)
// If persisted selection is not among viewer's credentials, attempt to fetch its metadata
if (
selectedId &&
!(creds || []).some((cred: Credential) => cred.id === selectedId) &&
activeWorkflowId
) {
try {
const metaResp = await fetch(
`/api/auth/oauth/credentials?credentialId=${selectedId}&workflowId=${activeWorkflowId}`
)
if (metaResp.ok) {
const meta = await metaResp.json()
if (meta.credentials?.length) {
// Mark as foreign, but do NOT merge into list to avoid leaking owner email
foreignMetaFound = true
}
}
} catch {
// ignore meta errors
}
}
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)
}
// Check if the currently selected credential still exists
const selectedCredentialStillExists = (creds || []).some(
(cred: Credential) => cred.id === selectedId
)
const shouldClearPersistedSelection =
!isPreview && selectedId && !selectedCredentialStillExists && !foreignMetaFound
if (shouldClearPersistedSelection) {
logger.info('Clearing invalid credential selection - credential was disconnected', {
selectedId,
provider: effectiveProviderId,
})
// Clear via setStoreValue to trigger cascade
setStoreValue('')
setSelectedId('')
if (effectiveProviderId) {
useDisplayNamesStore
.getState()
.removeDisplayName('credentials', effectiveProviderId, selectedId)
}
}
const handleOpenChange = useCallback(
(isOpen: boolean) => {
if (isOpen) {
void refetchCredentials()
}
} catch (error) {
logger.error('Error fetching credentials:', { error })
} finally {
setIsLoading(false)
}
}, [effectiveProviderId, selectedId, activeWorkflowId, isPreview, setStoreValue])
},
[refetchCredentials]
)
// Fetch credentials on initial mount and whenever the subblock value changes externally
useEffect(() => {
fetchCredentials()
}, [fetchCredentials, effectiveValue])
// When the selectedId changes (e.g., collaborator saved a credential), determine if it's foreign
useEffect(() => {
let aborted = false
;(async () => {
try {
if (!selectedId) {
setHasForeignMeta(false)
return
}
// If the selected credential exists in viewer's list, it's not foreign
if ((credentials || []).some((cred) => cred.id === selectedId)) {
setHasForeignMeta(false)
return
}
if (!activeWorkflowId) return
const metaResp = await fetch(
`/api/auth/oauth/credentials?credentialId=${selectedId}&workflowId=${activeWorkflowId}`
)
if (aborted) return
if (metaResp.ok) {
const meta = await metaResp.json()
setHasForeignMeta(!!meta.credentials?.length)
}
} catch {
// ignore
}
})()
return () => {
aborted = true
}
}, [selectedId, credentials, activeWorkflowId])
// This effect is no longer needed since we're using effectiveValue directly
// Listen for visibility changes to update credentials when user returns from settings
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
fetchCredentials()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [fetchCredentials])
// Also handle BFCache restores (back/forward navigation) where visibility change may not fire reliably
useEffect(() => {
const handlePageShow = (event: any) => {
if (event?.persisted) {
fetchCredentials()
}
}
window.addEventListener('pageshow', handlePageShow)
return () => {
window.removeEventListener('pageshow', handlePageShow)
}
}, [fetchCredentials])
// Listen for credential disconnection events from settings modal
useEffect(() => {
const handleCredentialDisconnected = (event: Event) => {
const customEvent = event as CustomEvent
const { providerId } = customEvent.detail
// Re-fetch if this disconnection affects our provider
if (providerId && (providerId === effectiveProviderId || providerId.startsWith(provider))) {
fetchCredentials()
}
}
window.addEventListener('credential-disconnected', handleCredentialDisconnected)
return () => {
window.removeEventListener('credential-disconnected', handleCredentialDisconnected)
}
}, [fetchCredentials, effectiveProviderId, provider])
// Handle popover open to fetch fresh credentials
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
if (isOpen) {
// Fetch fresh credentials when opening the dropdown
fetchCredentials()
}
}
// Get the selected credential
const selectedCredential = credentials.find((cred) => cred.id === selectedId)
const isForeign = !!(selectedId && !selectedCredential && hasForeignMeta)
// If the list doesnt contain the effective value but meta says it exists, synthesize a non-leaky placeholder to render stable UI
const displayName = selectedCredential
? selectedCredential.name
: isForeign
? 'Saved by collaborator'
: undefined
// Determine if additional permissions are required for the selected credential
const hasSelection = !!selectedCredential
const hasSelection = Boolean(selectedCredential)
const missingRequiredScopes = hasSelection
? getMissingRequiredScopes(selectedCredential, requiredScopes || [])
? getMissingRequiredScopes(selectedCredential!, requiredScopes || [])
: []
const needsUpdate =
hasSelection && missingRequiredScopes.length > 0 && !disabled && !isPreview && !isLoading
hasSelection &&
missingRequiredScopes.length > 0 &&
!disabled &&
!isPreview &&
!credentialsLoading
// Handle selection
const handleSelect = (credentialId: string) => {
const previousId = selectedId || (effectiveValue as string) || ''
setSelectedId(credentialId)
if (!isPreview) {
const handleSelect = useCallback(
(credentialId: string) => {
if (isPreview) return
setStoreValue(credentialId)
}
setOpen(false)
}
setIsEditing(false)
},
[isPreview, setStoreValue]
)
// Handle adding a new credential
const handleAddCredential = () => {
// Show the OAuth modal
const handleAddCredential = useCallback(() => {
setShowOAuthModal(true)
setOpen(false)
}
}, [])
// Get provider icon
const getProviderIcon = (providerName: OAuthProvider) => {
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
if (!baseProviderConfig) {
return <ExternalLink className='h-4 w-4' />
return <ExternalLink className='h-3 w-3' />
}
// Always use the base provider icon for a more consistent UI
return baseProviderConfig.icon({ className: 'h-4 w-4' })
}
return baseProviderConfig.icon({ className: 'h-3 w-3' })
}, [])
// Get provider name
const getProviderName = (providerName: OAuthProvider) => {
const getProviderName = useCallback((providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
@@ -310,88 +170,79 @@ export function CredentialSelector({
return baseProviderConfig.name
}
// Fallback: capitalize the provider name
return providerName
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
}, [])
const comboboxOptions = useMemo(() => {
const options = credentials.map((cred) => ({
label: cred.name,
value: cred.id,
}))
if (credentials.length === 0) {
options.push({
label: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
})
}
return options
}, [credentials, provider, getProviderName])
const selectedCredentialProvider = selectedCredential?.provider ?? provider
const overlayContent = useMemo(() => {
if (!inputValue) return null
return (
<div className='flex w-full items-center truncate'>
<div className='mr-2 flex-shrink-0 opacity-90'>
{getProviderIcon(selectedCredentialProvider)}
</div>
<span className='truncate'>{inputValue}</span>
</div>
)
}, [getProviderIcon, inputValue, selectedCredentialProvider])
const handleComboboxChange = useCallback(
(value: string) => {
if (value === '__connect_account__') {
handleAddCredential()
return
}
const matchedCred = credentials.find((c) => c.id === value)
if (matchedCred) {
setInputValue(matchedCred.name)
handleSelect(value)
return
}
setIsEditing(true)
setInputValue(value)
},
[credentials, handleAddCredential, handleSelect]
)
return (
<>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='relative w-full justify-between'
disabled={disabled}
>
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
{getProviderIcon(provider)}
<span
className={displayName ? 'truncate font-normal' : 'truncate text-muted-foreground'}
>
{displayName || label}
</span>
</div>
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[250px] p-0' align='start'>
<Command>
<CommandInput
placeholder='Search credentials...'
className='text-foreground placeholder:text-muted-foreground'
/>
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading credentials...</span>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No credentials found.</p>
<p className='text-muted-foreground text-xs'>
Connect a new account to continue.
</p>
</div>
)}
</CommandEmpty>
{credentials.length > 0 && (
<CommandGroup>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={cred.id}
onSelect={() => handleSelect(cred.id)}
>
<div className='flex items-center gap-2'>
{getProviderIcon(cred.provider)}
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-foreground'>
{getProviderIcon(provider)}
<span>Connect {getProviderName(provider)} account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Combobox
options={comboboxOptions}
value={inputValue}
selectedValue={selectedId}
onChange={handleComboboxChange}
onOpenChange={handleOpenChange}
placeholder={label}
disabled={disabled}
editable={true}
filterOptions={true}
isLoading={credentialsLoading}
overlayContent={overlayContent}
className={selectedId ? 'pl-[28px]' : ''}
/>
{needsUpdate && (
<div className='mt-2 flex items-center justify-between rounded-[6px] border border-amber-300/40 bg-amber-50/60 px-2 py-1 font-medium text-[12px] transition-colors dark:bg-amber-950/10'>
@@ -414,3 +265,49 @@ export function CredentialSelector({
</>
)
}
function useCredentialRefreshTriggers(
refetchCredentials: () => Promise<unknown>,
effectiveProviderId?: string,
provider?: OAuthProvider
) {
useEffect(() => {
const refresh = () => {
void refetchCredentials()
}
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refresh()
}
}
const handlePageShow = (event: Event) => {
if ('persisted' in event && (event as PageTransitionEvent).persisted) {
refresh()
}
}
const handleCredentialDisconnected = (event: Event) => {
const customEvent = event as CustomEvent<{ providerId?: string }>
const providerId = customEvent.detail?.providerId
if (
providerId &&
(providerId === effectiveProviderId || (provider && providerId.startsWith(provider)))
) {
refresh()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('pageshow', handlePageShow)
window.addEventListener('credential-disconnected', handleCredentialDisconnected)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pageshow', handlePageShow)
window.removeEventListener('credential-disconnected', handleCredentialDisconnected)
}
}, [refetchCredentials, effectiveProviderId, provider])
}

View File

@@ -1,23 +1,12 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Check, ChevronDown, FileText, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useCallback, useMemo } from 'react'
import { Tooltip } from '@/components/emcn'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
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'
import { useKnowledgeBaseDocuments } from '@/hooks/use-knowledge'
import { useDisplayNamesStore } from '@/stores/display-names/store'
import type { DocumentData } from '@/stores/knowledge/store'
import type { SelectorContext } from '@/hooks/selectors/types'
interface DocumentSelectorProps {
blockId: string
@@ -36,186 +25,54 @@ export function DocumentSelector({
isPreview = false,
previewValue,
}: DocumentSelectorProps) {
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [knowledgeBaseId] = useSubBlockValue(blockId, 'knowledgeBaseId')
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
const normalizedKnowledgeBaseId =
typeof knowledgeBaseId === 'string' && knowledgeBaseId.trim().length > 0
? knowledgeBaseId
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
? knowledgeBaseIdValue
: null
const value = isPreview ? previewValue : storeValue
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
const isDisabled = finalDisabled
const {
documents,
isLoading: documentsLoading,
error: documentsError,
refreshDocuments,
} = useKnowledgeBaseDocuments(normalizedKnowledgeBaseId ?? '', {
limit: 500,
offset: 0,
enabled: open && Boolean(normalizedKnowledgeBaseId),
})
const handleOpenChange = (isOpen: boolean) => {
if (isPreview || isDisabled) return
setOpen(isOpen)
if (isOpen && normalizedKnowledgeBaseId) {
void refreshDocuments()
}
}
const handleSelectDocument = (document: DocumentData) => {
if (isPreview) return
setStoreValue(document.id)
onDocumentSelect?.(document.id)
setOpen(false)
}
useEffect(() => {
if (!normalizedKnowledgeBaseId) {
setError(null)
}
}, [normalizedKnowledgeBaseId])
useEffect(() => {
setError(documentsError)
}, [documentsError])
useEffect(() => {
if (!normalizedKnowledgeBaseId || documents.length === 0) return
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> = {
pending: 'Processing pending',
processing: 'Processing...',
completed: 'Ready',
failed: 'Processing failed',
}
const status = statusMap[document.processingStatus] || document.processingStatus
const chunkText = `${document.chunkCount} chunk${document.chunkCount !== 1 ? 's' : ''}`
return `${status}${chunkText}`
}
const label = subBlock.placeholder || 'Select document'
const isLoading = documentsLoading && !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]
)
const selectorContext = useMemo<SelectorContext>(
() => ({
knowledgeBaseId: normalizedKnowledgeBaseId ?? undefined,
}),
[normalizedKnowledgeBaseId]
)
const handleDocumentChange = useCallback(
(documentId: string) => {
if (isPreview) return
onDocumentSelect?.(documentId)
},
[isPreview, onDocumentSelect]
)
const missingKnowledgeBase = !normalizedKnowledgeBaseId
const isDisabled = finalDisabled || missingKnowledgeBase
const placeholder = subBlock.placeholder || 'Select document'
return (
<div className='w-full'>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='relative w-full justify-between'
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<SelectorCombobox
blockId={blockId}
subBlock={subBlock}
selectorKey='knowledge.documents'
selectorContext={selectorContext}
disabled={isDisabled}
>
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
<FileText className='h-4 w-4 text-muted-foreground' />
{displayName ? (
<span className='truncate font-normal'>{displayName}</span>
) : (
<span className='truncate text-muted-foreground'>{label}</span>
)}
</div>
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search documents...' />
<CommandList>
<CommandEmpty>
{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>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : !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'>
Please select a knowledge base first.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No documents found</p>
<p className='text-muted-foreground text-xs'>
Upload documents to this knowledge base to get started.
</p>
</div>
)}
</CommandEmpty>
{documents.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Documents
</div>
{documents.map((document) => (
<CommandItem
key={document.id}
value={`doc-${document.id}-${document.filename}`}
onSelect={() => handleSelectDocument(document)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<FileText className='h-4 w-4 text-muted-foreground' />
<div className='min-w-0 flex-1 overflow-hidden'>
<div className='truncate font-normal'>{formatDocumentName(document)}</div>
<div className='truncate text-muted-foreground text-xs'>
{getDocumentDescription(document)}
</div>
</div>
</div>
{document.id === value && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
isPreview={isPreview}
previewValue={previewValue ?? null}
placeholder={placeholder}
onOptionChange={handleDocumentChange}
/>
</div>
</Tooltip.Trigger>
{missingKnowledgeBase && (
<Tooltip.Content side='top'>
<p>Select a knowledge base first.</p>
</Tooltip.Content>
)}
</Tooltip.Root>
)
}

View File

@@ -1,630 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
import { ConfluenceIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
import {
type Credential,
getProviderIdFromServiceId,
getServiceIdFromScopes,
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')
export interface ConfluenceFileInfo {
id: string
name: string
mimeType: string
webViewLink?: string
modifiedTime?: string
spaceId?: string
url?: string
}
interface ConfluenceFileSelectorProps {
value: string
onChange: (value: string, fileInfo?: ConfluenceFileInfo) => void
provider: OAuthProvider
requiredScopes?: string[]
label?: string
disabled?: boolean
serviceId?: string
domain: string
showPreview?: boolean
onFileInfoChange?: (fileInfo: ConfluenceFileInfo | null) => void
credentialId?: string
workflowId?: string
isForeignCredential?: boolean
}
export function ConfluenceFileSelector({
value,
onChange,
provider,
requiredScopes = [],
label = 'Select Confluence page',
disabled = false,
serviceId,
domain,
showPreview = true,
onFileInfoChange,
credentialId,
workflowId,
isForeignCredential = false,
}: ConfluenceFileSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [files, setFiles] = useState<ConfluenceFileInfo[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
const [selectedFileId, setSelectedFileId] = useState(value)
const [selectedFile, setSelectedFile] = useState<ConfluenceFileInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
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) {
setSelectedCredentialId(credentialId)
}
}, [credentialId, selectedCredentialId])
// Handle search with debounce
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const handleSearch = (value: string) => {
// Clear any existing timeout
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
// Set a new timeout
searchTimeoutRef.current = setTimeout(() => {
if (value.length > 2) {
fetchFiles(value)
} else if (value.length === 0) {
fetchFiles()
}
}, 500) // 500ms debounce
}
// Clean up the timeout on unmount
useEffect(() => {
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
}
}, [])
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
return getServiceIdFromScopes(provider, requiredScopes)
}
// Determine the appropriate provider ID based on service and scopes
const getProviderId = (): string => {
const effectiveServiceId = getServiceId()
return getProviderIdFromServiceId(effectiveServiceId)
}
// Fetch available credentials for this provider
const fetchCredentials = useCallback(async () => {
setIsLoading(true)
try {
const providerId = getProviderId()
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials)
}
} catch (error) {
logger.error('Error fetching credentials:', error)
} finally {
setIsLoading(false)
}
}, [provider, getProviderId, selectedCredentialId])
// Fetch page info when we have a selected file ID
const fetchPageInfo = useCallback(
async (pageId: string) => {
if (!selectedCredentialId || !domain) return
// Validate domain format
const trimmedDomain = domain.trim().toLowerCase()
if (!trimmedDomain.includes('.')) {
setError(
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
)
return
}
setIsLoading(true)
setError(null)
try {
// Get the access token from the selected credential
const tokenResponse = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credentialId: selectedCredentialId,
workflowId,
}),
})
if (!tokenResponse.ok) {
const errorData = await tokenResponse.json()
throw new Error(errorData.error || 'Failed to get access token')
}
const tokenData = await tokenResponse.json()
const accessToken = tokenData.accessToken
// Use the access token to fetch the page info
const response = await fetch('/api/tools/confluence/page', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
domain,
accessToken,
pageId,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to fetch page info')
}
const data = await response.json()
const fileInfo: ConfluenceFileInfo = {
id: data.id || pageId,
name: data.title || `Page ${pageId}`,
mimeType: 'confluence/page',
webViewLink: `https://${domain}/wiki/pages/${data.id}`,
modifiedTime: data.version?.when,
spaceId: data.spaceId,
url: `https://${domain}/wiki/pages/${data.id}`,
}
setSelectedFile(fileInfo)
onFileInfoChange?.(fileInfo)
// Cache the page name in display names store
if (selectedCredentialId) {
useDisplayNamesStore
.getState()
.setDisplayNames('files', selectedCredentialId, { [fileInfo.id]: fileInfo.name })
}
} catch (error) {
logger.error('Error fetching page info:', error)
setError((error as Error).message)
} finally {
setIsLoading(false)
}
},
[selectedCredentialId, domain, onFileInfoChange, workflowId]
)
// Fetch pages from Confluence
const fetchFiles = useCallback(
async (searchQuery?: string) => {
if (!selectedCredentialId || !domain) return
if (isForeignCredential) return
// Validate domain format
const trimmedDomain = domain.trim().toLowerCase()
if (!trimmedDomain.includes('.')) {
setError(
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
)
setFiles([])
setIsLoading(false)
return
}
setIsLoading(true)
setError(null)
try {
// Get the access token from the selected credential
const tokenResponse = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credentialId: selectedCredentialId,
workflowId,
}),
})
if (!tokenResponse.ok) {
const errorData = await tokenResponse.json()
logger.error('Access token error:', errorData)
// If there's a token error, we might need to reconnect the account
setError('Authentication failed. Please reconnect your Confluence account.')
setIsLoading(false)
return
}
const tokenData = await tokenResponse.json()
const accessToken = tokenData.accessToken
if (!accessToken) {
logger.error('No access token returned')
setError('Authentication failed. Please reconnect your Confluence account.')
setIsLoading(false)
return
}
// Simply fetch pages directly using the endpoint
const response = await fetch('/api/tools/confluence/pages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
domain,
accessToken,
title: searchQuery || undefined,
limit: 50,
}),
})
if (!response.ok) {
const errorData = await response.json()
if (response.status === 401 || response.status === 403) {
logger.info('Confluence pages fetch unauthorized (expected for collaborator)')
setFiles([])
setIsLoading(false)
return
}
logger.error('Confluence API error:', errorData)
throw new Error(errorData.error || 'Failed to fetch pages')
}
const data = await response.json()
logger.info(`Received ${data.files?.length || 0} files from API`)
setFiles(data.files || [])
// 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) {
setSelectedFile(fileInfo)
onFileInfoChange?.(fileInfo)
} else if (!searchQuery && selectedFileId) {
// If we can't find the file in the list, try to fetch it directly
fetchPageInfo(selectedFileId)
}
}
} catch (error) {
logger.error('Error fetching pages:', error)
setError((error as Error).message)
setFiles([])
} finally {
setIsLoading(false)
}
},
[
selectedCredentialId,
domain,
selectedFileId,
onFileInfoChange,
fetchPageInfo,
workflowId,
isForeignCredential,
]
)
// Fetch credentials on initial mount
useEffect(() => {
if (!initialFetchRef.current) {
fetchCredentials()
initialFetchRef.current = true
}
}, [fetchCredentials])
// Only fetch files when the dropdown is opened, not on credential selection
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
// Only fetch files when opening the dropdown and if we have valid credentials and domain
if (isOpen && !isForeignCredential && selectedCredentialId && domain && domain.includes('.')) {
fetchFiles()
}
}
// Keep internal selectedFileId in sync with the value prop
useEffect(() => {
if (value !== selectedFileId) {
setSelectedFileId(value)
}
}, [value])
// Clear callback when value is cleared
useEffect(() => {
if (!value) {
setSelectedFile(null)
onFileInfoChange?.(null)
}
}, [value, onFileInfoChange])
// Fetch page info on mount if we have a value but no selectedFile state
useEffect(() => {
if (value && selectedCredentialId && domain && !selectedFile) {
fetchPageInfo(value)
}
}, [value, selectedCredentialId, domain, selectedFile, fetchPageInfo])
// Handle file selection
const handleSelectFile = (file: ConfluenceFileInfo) => {
setSelectedFileId(file.id)
setSelectedFile(file)
onChange(file.id, file)
onFileInfoChange?.(file)
setOpen(false)
}
// Handle adding a new credential
const handleAddCredential = () => {
// Show the OAuth modal
setShowOAuthModal(true)
setOpen(false)
}
// Clear selection
const handleClearSelection = () => {
setSelectedFileId('')
onChange('', undefined)
onFileInfoChange?.(null)
}
return (
<>
<div className='space-y-2'>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='h-10 w-full min-w-0 justify-between'
disabled={disabled || !domain || isForeignCredential}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{cachedFileName ? (
<>
<ConfluenceIcon className='h-4 w-4' />
<span className='truncate font-normal'>{cachedFileName}</span>
</>
) : (
<>
<ConfluenceIcon className='h-4 w-4' />
<span className='truncate text-muted-foreground'>{label}</span>
</>
)}
</div>
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
{!isForeignCredential && (
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<ConfluenceIcon className='h-4 w-4' />
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
)}
<Command>
<CommandInput placeholder='Search pages...' onValueChange={handleSearch} />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading pages...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a Confluence account to continue.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No pages found.</p>
<p className='text-muted-foreground text-xs'>
Try a different search or account.
</p>
</div>
)}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
<ConfluenceIcon className='h-4 w-4' />
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && (
<Check className='ml-auto h-4 w-4' />
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Files list */}
{files.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Pages
</div>
{files.map((file) => (
<CommandItem
key={file.id}
value={`file-${file.id}-${file.name}`}
onSelect={() => handleSelectFile(file)}
>
<div className='flex items-center gap-2 overflow-hidden'>
<ConfluenceIcon className='h-4 w-4' />
<span className='truncate font-normal'>{file.name}</span>
</div>
{file.id === selectedFileId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-foreground'>
<ConfluenceIcon className='h-4 w-4' />
<span>Connect Confluence account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
{showPreview && selectedFile && selectedFileId && selectedFile.id === selectedFileId && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button
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'>
<ConfluenceIcon 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'>{selectedFile.name}</h4>
{selectedFile.modifiedTime && (
<span className='whitespace-nowrap text-muted-foreground text-xs'>
{new Date(selectedFile.modifiedTime).toLocaleDateString()}
</span>
)}
</div>
{selectedFile.webViewLink && (
<a
href={selectedFile.webViewLink}
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-1 text-foreground text-xs hover:underline'
onClick={(e) => e.stopPropagation()}
>
<span>Open in Confluence</span>
<ExternalLink className='h-3 w-3' />
</a>
)}
</div>
</div>
</div>
)}
</div>
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName='Confluence'
requiredScopes={requiredScopes}
serviceId={getServiceId()}
/>
)}
</>
)
}

View File

@@ -1,288 +0,0 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Check, ChevronDown, RefreshCw, X } from 'lucide-react'
import { GoogleCalendarIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} 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')
export interface GoogleCalendarInfo {
id: string
summary: string
description?: string
primary?: boolean
accessRole: string
backgroundColor?: string
foregroundColor?: string
}
interface GoogleCalendarSelectorProps {
value: string
onChange: (value: string, calendarInfo?: GoogleCalendarInfo) => void
label?: string
disabled?: boolean
showPreview?: boolean
onCalendarInfoChange?: (info: GoogleCalendarInfo | null) => void
credentialId: string
workflowId?: string
}
export function GoogleCalendarSelector({
value,
onChange,
label = 'Select Google Calendar',
disabled = false,
showPreview = true,
onCalendarInfoChange,
credentialId,
workflowId,
}: GoogleCalendarSelectorProps) {
const [open, setOpen] = useState(false)
const [calendars, setCalendars] = useState<GoogleCalendarInfo[]>([])
const [selectedCalendarId, setSelectedCalendarId] = useState(value)
const [selectedCalendar, setSelectedCalendar] = useState<GoogleCalendarInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
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')
}
const queryParams = new URLSearchParams({
credentialId: credentialId,
})
if (workflowId) {
queryParams.set('workflowId', workflowId)
}
const response = await fetch(`/api/tools/google_calendar/calendars?${queryParams.toString()}`)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to fetch Google Calendar calendars')
}
const data = await response.json()
return data.calendars || []
}, [credentialId])
const fetchCalendars = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const calendars = await fetchCalendarsFromAPI()
setCalendars(calendars)
// 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)
setError((error as Error).message)
setCalendars([])
} finally {
setIsLoading(false)
setInitialFetchDone(true)
}
}, [fetchCalendarsFromAPI, credentialId])
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
if (isOpen && credentialId && (!initialFetchDone || calendars.length === 0)) {
fetchCalendars()
}
}
// Sync selected ID with external value
useEffect(() => {
if (value !== selectedCalendarId) {
setSelectedCalendarId(value)
}
}, [value, selectedCalendarId])
// Handle calendar selection
const handleSelectCalendar = (calendar: GoogleCalendarInfo) => {
setSelectedCalendarId(calendar.id)
setSelectedCalendar(calendar)
onChange(calendar.id, calendar)
onCalendarInfoChange?.(calendar)
setOpen(false)
}
// Clear selection
const handleClearSelection = () => {
setSelectedCalendarId('')
onChange('', undefined)
onCalendarInfoChange?.(null)
setError(null)
}
// Get calendar display name
const getCalendarDisplayName = (calendar: GoogleCalendarInfo) => {
if (calendar.primary) {
return `${calendar.summary} (Primary)`
}
return calendar.summary
}
return (
<div className='space-y-2'>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='h-10 w-full min-w-0 justify-between'
disabled={disabled || !credentialId}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{cachedCalendarName ? (
<>
<GoogleCalendarIcon className='h-4 w-4' />
<span className='truncate font-normal'>{cachedCalendarName}</span>
</>
) : (
<>
<GoogleCalendarIcon className='h-4 w-4' />
<span className='truncate text-muted-foreground'>{label}</span>
</>
)}
</div>
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search calendars...' />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading calendars...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : calendars.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No calendars found</p>
<p className='text-muted-foreground text-xs'>
Please check your Google Calendar account access
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No matching calendars</p>
</div>
)}
</CommandEmpty>
{calendars.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Calendars
</div>
{calendars.map((calendar) => (
<CommandItem
key={calendar.id}
value={`calendar-${calendar.id}-${calendar.summary}`}
onSelect={() => handleSelectCalendar(calendar)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<div
className='h-3 w-3 flex-shrink-0 rounded-full'
style={{
backgroundColor: calendar.backgroundColor || '#4285f4',
}}
/>
<span className='truncate font-normal'>
{getCalendarDisplayName(calendar)}
</span>
</div>
{calendar.id === selectedCalendarId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{showPreview && selectedCalendar && (
<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'>
<div
className='h-3 w-3 rounded-full'
style={{
backgroundColor: selectedCalendar.backgroundColor || '#4285f4',
}}
/>
</div>
<div className='min-w-0 flex-1 overflow-hidden'>
<h4 className='truncate font-medium text-xs'>
{getCalendarDisplayName(selectedCalendar)}
</h4>
<div className='text-muted-foreground text-xs'>
Access: {selectedCalendar.accessRole}
</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,572 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ExternalLink, FileIcon, FolderIcon, RefreshCw, X } from 'lucide-react'
import useDrivePicker from 'react-google-drive-picker'
import { GoogleDocsIcon, GoogleSheetsIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import { getEnv } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import {
type Credential,
getProviderIdFromServiceId,
getServiceByProviderAndId,
getServiceIdFromScopes,
OAUTH_PROVIDERS,
type OAuthProvider,
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')
export interface FileInfo {
id: string
name: string
mimeType: string
iconLink?: string
webViewLink?: string
thumbnailLink?: string
createdTime?: string
modifiedTime?: string
size?: string
owners?: { displayName: string; emailAddress: string }[]
}
interface GoogleDrivePickerProps {
value: string
onChange: (value: string, fileInfo?: FileInfo) => void
provider: OAuthProvider
requiredScopes?: string[]
label?: string
disabled?: boolean
serviceId?: string
mimeTypeFilter?: string
showPreview?: boolean
onFileInfoChange?: (fileInfo: FileInfo | null) => void
clientId: string
apiKey: string
credentialId?: string
workflowId?: string
}
export function GoogleDrivePicker({
value,
onChange,
provider,
requiredScopes = [],
label = 'Select file',
disabled = false,
serviceId,
mimeTypeFilter,
showPreview = true,
onFileInfoChange,
clientId,
apiKey,
credentialId,
workflowId,
}: GoogleDrivePickerProps) {
const [credentials, setCredentials] = useState<Credential[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
const [selectedFileId, setSelectedFileId] = useState(value)
const [selectedFile, setSelectedFile] = useState<FileInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingSelectedFile, setIsLoadingSelectedFile] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [credentialsLoaded, setCredentialsLoaded] = useState(false)
const initialFetchRef = useRef(false)
const [openPicker, _authResponse] = useDrivePicker()
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
return getServiceIdFromScopes(provider, requiredScopes)
}
// Determine the appropriate provider ID based on service and scopes
const getProviderId = (): string => {
const effectiveServiceId = getServiceId()
return getProviderIdFromServiceId(effectiveServiceId)
}
// Fetch available credentials for this provider
const fetchCredentials = useCallback(async () => {
setIsLoading(true)
setCredentialsLoaded(false)
try {
const providerId = getProviderId()
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
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('')
}
}
} catch (error) {
logger.error('Error fetching credentials:', { error })
} finally {
setIsLoading(false)
setCredentialsLoaded(true)
}
}, [provider, getProviderId, selectedCredentialId])
// Prefer persisted credentialId if provided
useEffect(() => {
if (credentialId && credentialId !== selectedCredentialId) {
setSelectedCredentialId(credentialId)
}
}, [credentialId, selectedCredentialId])
// Fetch a single file by ID when we have a selectedFileId but no metadata
const fetchFileById = useCallback(
async (fileId: string) => {
if (!selectedCredentialId || !fileId) return null
setIsLoadingSelectedFile(true)
try {
// Construct query parameters
const queryParams = new URLSearchParams({
credentialId: selectedCredentialId,
fileId: fileId,
})
if (workflowId) queryParams.set('workflowId', workflowId)
const response = await fetch(`/api/tools/drive/file?${queryParams.toString()}`)
if (response.ok) {
const data = await response.json()
if (data.file) {
setSelectedFile(data.file)
onFileInfoChange?.(data.file)
// Cache the file name
if (selectedCredentialId && data.file.id && data.file.name) {
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, {
[data.file.id]: data.file.name,
})
}
return data.file
}
} else {
const errorText = await response.text()
logger.error('Error fetching file by ID:', { error: errorText })
// If file not found or access denied, clear the selection
if (response.status === 404 || response.status === 403) {
logger.info('File not accessible, clearing selection')
setSelectedFileId('')
onChange('')
onFileInfoChange?.(null)
}
if (response.status === 401) {
logger.info('Credential unauthorized (401), clearing selection and prompting re-auth')
setSelectedFileId('')
onChange('')
onFileInfoChange?.(null)
setShowOAuthModal(true)
}
}
return null
} catch (error) {
logger.error('Error fetching file by ID:', { error })
return null
} finally {
setIsLoadingSelectedFile(false)
}
},
[selectedCredentialId, onChange, onFileInfoChange]
)
// Fetch credentials on initial mount
useEffect(() => {
if (!initialFetchRef.current) {
fetchCredentials()
initialFetchRef.current = true
}
}, [fetchCredentials])
// Keep internal selectedFileId in sync with the value prop
useEffect(() => {
if (value !== selectedFileId) {
const previousFileId = selectedFileId
setSelectedFileId(value)
// Only clear selected file info if we had a different file before (not initial load)
if (previousFileId && previousFileId !== value && selectedFile) {
setSelectedFile(null)
}
}
}, [value, selectedFileId, selectedFile])
// Track previous credential ID to detect changes
const prevCredentialIdRef = useRef<string>('')
// Clear selected file when credentials are removed or changed
useEffect(() => {
const prevCredentialId = prevCredentialIdRef.current
prevCredentialIdRef.current = selectedCredentialId
if (!selectedCredentialId) {
// No credentials - clear everything
if (selectedFile) {
setSelectedFile(null)
setSelectedFileId('')
onChange('')
}
} else if (prevCredentialId && prevCredentialId !== selectedCredentialId) {
// Credentials changed (not initial load) - clear file info to force refetch
if (selectedFile) {
setSelectedFile(null)
}
}
}, [selectedCredentialId, selectedFile, onChange])
// Fetch the selected file metadata once credentials are loaded or changed
useEffect(() => {
// Only fetch if we have both a file ID and credentials, credentials are loaded, but no file info yet
if (
value &&
selectedCredentialId &&
credentialsLoaded &&
!selectedFile &&
!isLoadingSelectedFile
) {
fetchFileById(value)
}
}, [
value,
selectedCredentialId,
credentialsLoaded,
selectedFile,
isLoadingSelectedFile,
fetchFileById,
])
// Fetch the access token for the selected credential
const fetchAccessToken = async (credentialOverrideId?: string): Promise<string | null> => {
const effectiveCredentialId = credentialOverrideId || selectedCredentialId
if (!effectiveCredentialId) {
logger.error('No credential ID selected for Google Drive Picker')
return null
}
setIsLoading(true)
try {
const response = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialId: effectiveCredentialId, workflowId }),
})
if (!response.ok) {
throw new Error(`Failed to fetch access token: ${response.status}`)
}
const data = await response.json()
return data.accessToken || null
} catch (error) {
logger.error('Error fetching access token:', { error })
return null
} finally {
setIsLoading(false)
}
}
// Handle opening the Google Drive Picker
const handleOpenPicker = async (credentialOverrideId?: string) => {
try {
// First, get the access token for the selected credential
const accessToken = await fetchAccessToken(credentialOverrideId)
if (!accessToken) {
logger.error('Failed to get access token for Google Drive Picker')
return
}
const viewIdForMimeType = () => {
// Return appropriate view based on mime type filter
if (mimeTypeFilter?.includes('folder')) {
return 'FOLDERS'
}
if (mimeTypeFilter?.includes('spreadsheet')) {
return 'SPREADSHEETS'
}
if (mimeTypeFilter?.includes('document')) {
return 'DOCUMENTS'
}
return 'DOCS' // Default view
}
openPicker({
clientId,
developerKey: apiKey,
viewId: viewIdForMimeType(),
token: accessToken, // Use the fetched access token
showUploadView: true,
showUploadFolders: true,
supportDrives: true,
multiselect: false,
appId: getEnv('NEXT_PUBLIC_GOOGLE_PROJECT_NUMBER'),
// Enable folder selection when mimeType is folder
setSelectFolderEnabled: !!mimeTypeFilter?.includes('folder'),
callbackFunction: (data) => {
if (data.action === 'picked') {
const file = data.docs[0]
if (file) {
const fileInfo: FileInfo = {
id: file.id,
name: file.name,
mimeType: file.mimeType,
iconLink: file.iconUrl,
webViewLink: file.url,
// thumbnailLink is not directly available from the picker
thumbnailLink: file.iconUrl, // Use iconUrl as fallback
modifiedTime: file.lastEditedUtc
? new Date(file.lastEditedUtc).toISOString()
: undefined,
}
setSelectedFileId(file.id)
setSelectedFile(fileInfo)
onChange(file.id, fileInfo)
onFileInfoChange?.(fileInfo)
// Cache the selected file name
if (selectedCredentialId) {
useDisplayNamesStore
.getState()
.setDisplayNames('files', selectedCredentialId, { [file.id]: file.name })
}
}
}
},
})
} catch (error) {
logger.error('Error opening Google Drive Picker:', { error })
}
}
// Handle adding a new credential
const handleAddCredential = () => {
// Show the OAuth modal
setShowOAuthModal(true)
}
// Clear selection
const handleClearSelection = () => {
setSelectedFileId('')
setSelectedFile(null)
onChange('', undefined)
onFileInfoChange?.(null)
}
// Get provider icon
const getProviderIcon = (providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
if (!baseProviderConfig) {
return <ExternalLink className='h-4 w-4' />
}
// For compound providers, find the specific service
if (providerName.includes('-')) {
for (const service of Object.values(baseProviderConfig.services)) {
if (service.providerId === providerName) {
return service.icon({ className: 'h-4 w-4' })
}
}
}
// Fallback to base provider icon
return baseProviderConfig.icon({ className: 'h-4 w-4' })
}
// Get provider name
const getProviderName = (providerName: OAuthProvider) => {
const effectiveServiceId = getServiceId()
try {
// First try to get the service by provider and service ID
const service = getServiceByProviderAndId(providerName, effectiveServiceId)
return service.name
} catch (_error) {
// If that fails, try to get the service by parsing the provider
try {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
// For compound providers like 'google-drive', try to find the specific service
if (providerName.includes('-')) {
const serviceKey = providerName.split('-')[1] || ''
for (const [key, service] of Object.entries(baseProviderConfig?.services || {})) {
if (key === serviceKey || key === providerName || service.providerId === providerName) {
return service.name
}
}
}
// Fallback to provider name if service not found
if (baseProviderConfig) {
return baseProviderConfig.name
}
} catch (_parseError) {
// Ignore parse error and continue to final fallback
}
// Final fallback: capitalize the provider name
return providerName
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
}
// Get file icon based on mime type
const getFileIcon = (file: FileInfo, size: 'sm' | 'md' = 'sm') => {
const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5'
if (file.mimeType === 'application/vnd.google-apps.folder') {
return <FolderIcon className={`${iconSize} text-muted-foreground`} />
}
if (file.mimeType === 'application/vnd.google-apps.spreadsheet') {
return <GoogleSheetsIcon className={iconSize} />
}
if (file.mimeType === 'application/vnd.google-apps.document') {
return <GoogleDocsIcon className={iconSize} />
}
return <FileIcon className={`${iconSize} text-muted-foreground`} />
}
const canShowPreview = !!(
showPreview &&
selectedFile &&
selectedFileId &&
selectedFile.id === selectedFileId
)
return (
<>
<div className='space-y-2'>
<Button
variant='outline'
role='combobox'
className='h-10 w-full min-w-0 justify-between'
disabled={disabled || isLoading}
onClick={async () => {
// Decide which credential to use
let idToUse = selectedCredentialId
if (!idToUse && credentials.length === 1) {
idToUse = credentials[0].id
setSelectedCredentialId(idToUse)
}
if (!idToUse) {
// No credentials — prompt OAuth
handleAddCredential()
return
}
await handleOpenPicker(idToUse)
}}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{canShowPreview ? (
<>
{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 text-muted-foreground'>{label}</span>
</>
)}
</div>
</Button>
{/* 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'>
<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'>
{getFileIcon(selectedFile, 'sm')}
</div>
<div className='min-w-0 flex-1 overflow-hidden'>
<div className='flex items-center gap-2'>
<h4 className='truncate font-medium text-xs'>{selectedFile.name}</h4>
{selectedFile.modifiedTime && (
<span className='whitespace-nowrap text-muted-foreground text-xs'>
{new Date(selectedFile.modifiedTime).toLocaleDateString()}
</span>
)}
</div>
{selectedFile.webViewLink ? (
<a
href={selectedFile.webViewLink}
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-1 text-muted-foreground text-xs hover:underline'
onClick={(e) => e.stopPropagation()}
>
<span>Open in Drive</span>
<ExternalLink className='h-3 w-3' />
</a>
) : (
<a
href={`https://drive.google.com/file/d/${selectedFile.id}/view`}
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-1 text-muted-foreground text-xs hover:underline'
onClick={(e) => e.stopPropagation()}
>
<span>Open in Drive</span>
<ExternalLink className='h-3 w-3' />
</a>
)}
</div>
</div>
</div>
)}
</div>
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName={getProviderName(provider)}
requiredScopes={requiredScopes}
serviceId={getServiceId()}
/>
)}
</>
)
}

View File

@@ -1,14 +0,0 @@
export type { ConfluenceFileInfo } from './confluence-file-selector'
export { ConfluenceFileSelector } from './confluence-file-selector'
export type { GoogleCalendarInfo } from './google-calendar-selector'
export { GoogleCalendarSelector } from './google-calendar-selector'
export type { FileInfo } from './google-drive-picker'
export { GoogleDrivePicker } from './google-drive-picker'
export type { JiraIssueInfo } from './jira-issue-selector'
export { JiraIssueSelector } from './jira-issue-selector'
export type { MicrosoftFileInfo } from './microsoft-file-selector'
export { MicrosoftFileSelector } from './microsoft-file-selector'
export type { TeamsMessageInfo } from './teams-message-selector'
export { TeamsMessageSelector } from './teams-message-selector'
export type { WealthboxItemInfo } from './wealthbox-file-selector'
export { WealthboxFileSelector } from './wealthbox-file-selector'

View File

@@ -1,670 +0,0 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
import { JiraIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
import {
type Credential,
getProviderIdFromServiceId,
getServiceIdFromScopes,
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')
export interface JiraIssueInfo {
id: string
name: string
mimeType: string
webViewLink?: string
modifiedTime?: string
spaceId?: string
url?: string
}
interface JiraIssueSelectorProps {
value: string
onChange: (value: string, issueInfo?: JiraIssueInfo) => void
provider: OAuthProvider
requiredScopes?: string[]
label?: string
disabled?: boolean
serviceId?: string
domain: string
showPreview?: boolean
onIssueInfoChange?: (issueInfo: JiraIssueInfo | null) => void
projectId?: string
credentialId?: string
isForeignCredential?: boolean
workflowId?: string
}
export function JiraIssueSelector({
value,
onChange,
provider,
requiredScopes = [],
label = 'Select Jira issue',
disabled = false,
serviceId,
domain,
showPreview = true,
onIssueInfoChange,
projectId,
credentialId,
isForeignCredential = false,
workflowId,
}: JiraIssueSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [issues, setIssues] = useState<JiraIssueInfo[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
const [selectedIssueId, setSelectedIssueId] = useState(value)
const [selectedIssue, setSelectedIssue] = useState<JiraIssueInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
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) {
setSelectedCredentialId(credentialId)
} else if (!credentialId && selectedCredentialId) {
setSelectedCredentialId('')
}
}, [credentialId, selectedCredentialId])
// Handle search with debounce
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const handleSearch = (value: string) => {
// Clear any existing timeout
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
// Set a new timeout
searchTimeoutRef.current = setTimeout(() => {
if (value.length >= 1) {
// Changed from > 2 to >= 1 to be more responsive
fetchIssues(value)
} else {
setIssues([]) // Clear issues if search is empty
}
}, 500) // 500ms debounce
}
// Clean up the timeout on unmount
useEffect(() => {
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
}
}, [])
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
return getServiceIdFromScopes(provider, requiredScopes)
}
// Determine the appropriate provider ID based on service and scopes (stabilized)
const providerId = useMemo(() => {
const effectiveServiceId = getServiceId()
return getProviderIdFromServiceId(effectiveServiceId)
}, [serviceId, provider, requiredScopes])
// Fetch available credentials for this provider
const fetchCredentials = useCallback(async () => {
if (!providerId) return
setIsLoading(true)
try {
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials)
}
} catch (error) {
logger.error('Error fetching credentials:', error)
} finally {
setIsLoading(false)
}
}, [providerId])
// Fetch issue info when we have a selected issue ID
const fetchIssueInfo = useCallback(
async (issueId: string) => {
// Validate domain format
const trimmedDomain = domain.trim().toLowerCase()
if (!trimmedDomain.includes('.')) {
setError(
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
)
return
}
setIsLoading(true)
setError(null)
try {
// Get the access token from the selected credential
const tokenResponse = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credentialId: selectedCredentialId,
workflowId,
}),
})
if (!tokenResponse.ok) {
const errorData = await tokenResponse.json()
throw new Error(errorData.error || 'Failed to get access token')
}
const tokenData = await tokenResponse.json()
const accessToken = tokenData.accessToken
if (!accessToken) {
throw new Error('No access token received')
}
// Use the access token to fetch the issue info
const response = await fetch('/api/tools/jira/issue', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
domain,
accessToken,
issueId,
cloudId,
}),
})
if (!response.ok) {
const errorData = await response.json()
logger.error('Failed to fetch issue info:', errorData)
throw new Error(errorData.error || 'Failed to fetch issue info')
}
const data = await response.json()
if (data.cloudId) {
logger.info('Using cloud ID:', data.cloudId)
setCloudId(data.cloudId)
}
if (data.issue) {
logger.info('Successfully fetched issue:', data.issue.name)
setSelectedIssue(data.issue)
onIssueInfoChange?.(data.issue)
} else {
logger.warn('No issue data received in response')
setSelectedIssue(null)
onIssueInfoChange?.(null)
}
} catch (error) {
logger.error('Error fetching issue info:', error)
setError((error as Error).message)
onIssueInfoChange?.(null)
} finally {
setIsLoading(false)
}
},
[selectedCredentialId, domain, onIssueInfoChange, cloudId]
)
// Fetch issues from Jira
const fetchIssues = useCallback(
async (searchQuery?: string) => {
if (!selectedCredentialId || !domain) return
// If no search query is provided, require a projectId before fetching
if (!searchQuery && !projectId) {
setIssues([])
return
}
// Validate domain format
const trimmedDomain = domain.trim().toLowerCase()
if (!trimmedDomain.includes('.')) {
setError(
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
)
setIssues([])
setIsLoading(false)
return
}
setIsLoading(true)
setError(null)
try {
// Get the access token from the selected credential
const tokenResponse = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credentialId: selectedCredentialId,
workflowId,
}),
})
if (!tokenResponse.ok) {
const errorData = await tokenResponse.json()
logger.error('Access token error:', errorData)
// If there's a token error, we might need to reconnect the account
setError('Authentication failed. Please reconnect your Jira account.')
setIsLoading(false)
return
}
const tokenData = await tokenResponse.json()
const accessToken = tokenData.accessToken
if (!accessToken) {
logger.error('No access token returned')
setError('Authentication failed. Please reconnect your Jira account.')
setIsLoading(false)
return
}
// Build query parameters for the issues endpoint
const queryParams = new URLSearchParams({
domain,
accessToken,
...(projectId && { projectId }),
...(searchQuery && { query: searchQuery }),
...(cloudId && { cloudId }),
})
const response = await fetch(`/api/tools/jira/issues?${queryParams.toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const errorData = await response.json()
logger.error('Jira API error:', errorData)
throw new Error(errorData.error || 'Failed to fetch issues')
}
const data = await response.json()
if (data.cloudId) {
setCloudId(data.cloudId)
}
// Process the issue picker results
let foundIssues: JiraIssueInfo[] = []
// Handle the sections returned by the issue picker API
if (data.sections) {
// Combine issues from all sections
data.sections.forEach((section: any) => {
if (section.issues && section.issues.length > 0) {
const sectionIssues = section.issues.map((issue: any) => ({
id: issue.key,
name: issue.summary || issue.summaryText || issue.key,
mimeType: 'jira/issue',
url: `https://${domain}/browse/${issue.key}`,
webViewLink: `https://${domain}/browse/${issue.key}`,
}))
foundIssues = [...foundIssues, ...sectionIssues]
}
})
}
logger.info(`Received ${foundIssues.length} issues from API`)
setIssues(foundIssues)
// 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) {
setSelectedIssue(issueInfo)
onIssueInfoChange?.(issueInfo)
} else if (!searchQuery && selectedIssueId) {
// If we can't find the issue in the list, try to fetch it directly
fetchIssueInfo(selectedIssueId)
}
}
} catch (error) {
logger.error('Error fetching issues:', error)
setError((error as Error).message)
setIssues([])
} finally {
setIsLoading(false)
}
},
[
selectedCredentialId,
domain,
selectedIssueId,
onIssueInfoChange,
fetchIssueInfo,
cloudId,
projectId,
]
)
// Fetch credentials when the dropdown opens (avoid fetching on mount with no credential)
useEffect(() => {
if (open) {
fetchCredentials()
}
}, [open, fetchCredentials])
// Handle open change
const handleOpenChange = (isOpen: boolean) => {
if (disabled || isForeignCredential) {
setOpen(false)
return
}
setOpen(isOpen)
// Only fetch recent/default issues when opening the dropdown
if (isOpen && selectedCredentialId && domain && domain.includes('.')) {
// Only fetch on open when a project is selected; otherwise wait for user search
if (projectId) {
fetchIssues('')
}
}
}
// Fetch selected issue metadata once credentials are ready or changed
// Keep internal selectedIssueId in sync with the value prop
useEffect(() => {
if (value !== selectedIssueId) {
setSelectedIssueId(value)
}
// When the upstream value is cleared (e.g., project changed or remote user cleared),
// clear local selection and preview immediately
if (!value) {
setSelectedIssue(null)
setIssues([])
setError(null)
onIssueInfoChange?.(null)
}
}, [value, onIssueInfoChange])
// Fetch issue info on mount if we have a value but no selectedIssue state
useEffect(() => {
if (value && selectedCredentialId && domain && projectId && !selectedIssue) {
fetchIssueInfo(value)
}
}, [value, selectedCredentialId, domain, projectId, selectedIssue, fetchIssueInfo])
// Handle issue selection
const handleSelectIssue = (issue: JiraIssueInfo) => {
setSelectedIssueId(issue.id)
setSelectedIssue(issue)
onChange(issue.id, issue)
onIssueInfoChange?.(issue)
setOpen(false)
}
// Handle adding a new credential
const handleAddCredential = () => {
// Show the OAuth modal
setShowOAuthModal(true)
setOpen(false)
}
// Clear selection
const handleClearSelection = () => {
setSelectedIssueId('')
setError(null)
onChange('', undefined)
onIssueInfoChange?.(null)
}
return (
<>
<div className='space-y-2'>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='h-10 w-full min-w-0 justify-between'
disabled={disabled || !domain || !selectedCredentialId || isForeignCredential}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{cachedIssueName ? (
<>
<JiraIcon className='h-4 w-4' />
<span className='truncate font-normal'>{cachedIssueName}</span>
</>
) : (
<>
<JiraIcon className='h-4 w-4' />
<span className='truncate text-muted-foreground'>{label}</span>
</>
)}
</div>
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
{!isForeignCredential && (
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<JiraIcon className='h-4 w-4' />
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
)}
<Command>
<CommandInput placeholder='Search issues...' onValueChange={handleSearch} />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading issues...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a Jira account to continue.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No issues found.</p>
<p className='text-muted-foreground text-xs'>
Try a different search or account.
</p>
</div>
)}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
<JiraIcon className='h-4 w-4' />
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && (
<Check className='ml-auto h-4 w-4' />
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Issues list */}
{issues.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Issues
</div>
{issues.map((issue) => (
<CommandItem
key={issue.id}
value={`issue-${issue.id}-${issue.name}`}
onSelect={() => handleSelectIssue(issue)}
>
<div className='flex items-center gap-2 overflow-hidden'>
<JiraIcon className='h-4 w-4' />
<span className='truncate font-normal'>{issue.name}</span>
</div>
{issue.id === selectedIssueId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-foreground'>
<JiraIcon className='h-4 w-4' />
<span>Connect Jira account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
{showPreview && selectedIssue && (
<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'>
<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'>{selectedIssue.name}</h4>
{selectedIssue.modifiedTime && (
<span className='whitespace-nowrap text-muted-foreground text-xs'>
{new Date(selectedIssue.modifiedTime).toLocaleDateString()}
</span>
)}
</div>
{selectedIssue.webViewLink && (
<a
href={selectedIssue.webViewLink}
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 && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName='Jira'
requiredScopes={requiredScopes}
serviceId={getServiceId()}
/>
)}
</>
)
}

View File

@@ -1,961 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
import { MicrosoftTeamsIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
import {
type Credential,
getProviderIdFromServiceId,
getServiceIdFromScopes,
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')
export interface TeamsMessageInfo {
id: string
displayName: string
type: 'team' | 'channel' | 'chat'
teamId?: string
channelId?: string
chatId?: string
webViewLink?: string
}
interface TeamsMessageSelectorProps {
value: string
onChange: (value: string, messageInfo?: TeamsMessageInfo) => void
provider: OAuthProvider
requiredScopes?: string[]
label?: string
disabled?: boolean
serviceId?: string
showPreview?: boolean
onMessageInfoChange?: (messageInfo: TeamsMessageInfo | null) => void
credential: string
selectionType?: 'team' | 'channel' | 'chat'
initialTeamId?: string
workflowId: string
isForeignCredential?: boolean
}
export function TeamsMessageSelector({
value,
onChange,
provider,
requiredScopes = [],
label = 'Select Teams message location',
disabled = false,
serviceId,
showPreview = true,
onMessageInfoChange,
credential,
selectionType = 'team',
initialTeamId,
workflowId,
isForeignCredential = false,
}: TeamsMessageSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [teams, setTeams] = useState<TeamsMessageInfo[]>([])
const [channels, setChannels] = useState<TeamsMessageInfo[]>([])
const [chats, setChats] = useState<TeamsMessageInfo[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credential || '')
const [selectedTeamId, setSelectedTeamId] = useState<string>('')
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
const [selectedChatId, setSelectedChatId] = useState<string>('')
const [selectedMessageId, setSelectedMessageId] = useState(value)
const [selectedMessage, setSelectedMessage] = useState<TeamsMessageInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const initialFetchRef = useRef(false)
const [error, setError] = useState<string | null>(null)
const [selectionStage, setSelectionStage] = useState<'team' | 'channel' | 'chat'>(selectionType)
const lastRestoredValueRef = useRef<string | null>(null)
// 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
return getServiceIdFromScopes(provider, requiredScopes)
}
// Determine the appropriate provider ID based on service and scopes
const getProviderId = (): string => {
const effectiveServiceId = getServiceId()
return getProviderIdFromServiceId(effectiveServiceId)
}
const fetchCredentials = useCallback(async () => {
setIsLoading(true)
try {
const providerId = getProviderId()
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials)
}
} catch (error) {
logger.error('Error fetching credentials:', error)
} finally {
setIsLoading(false)
}
}, [provider, getProviderId, selectedCredentialId])
// Fetch teams
const fetchTeams = useCallback(async () => {
if (!selectedCredentialId) return
setIsLoading(true)
setError(null)
try {
const response = await fetch('/api/tools/microsoft-teams/teams', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credential: selectedCredentialId,
workflowId,
}),
})
if (!response.ok) {
const errorData = await response.json()
// If server indicates auth is required, show the auth modal
if (response.status === 401 && errorData.authRequired) {
logger.warn('Authentication required for Microsoft Teams')
setShowOAuthModal(true)
throw new Error('Microsoft Teams authentication required')
}
throw new Error(errorData.error || 'Failed to fetch teams')
}
const data = await response.json()
const teamsData = data.teams.map((team: { id: string; displayName: string }) => ({
id: team.id,
displayName: team.displayName,
type: 'team' as const,
teamId: team.id,
webViewLink: `https://teams.microsoft.com/l/team/${team.id}`,
}))
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)
if (team) {
setSelectedMessage(team)
onMessageInfoChange?.(team)
}
}
} catch (error) {
logger.error('Error fetching teams:', error)
setError((error as Error).message)
setTeams([])
} finally {
setIsLoading(false)
}
}, [selectedCredentialId, selectedTeamId, onMessageInfoChange, workflowId])
// Fetch channels for a selected team
const fetchChannels = useCallback(
async (teamId: string) => {
if (!selectedCredentialId || !teamId) return
setIsLoading(true)
setError(null)
try {
const response = await fetch('/api/tools/microsoft-teams/channels', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credential: selectedCredentialId,
teamId,
workflowId,
}),
})
if (!response.ok) {
const errorData = await response.json()
// If server indicates auth is required, show the auth modal
if (response.status === 401 && errorData.authRequired) {
logger.warn('Authentication required for Microsoft Teams')
setShowOAuthModal(true)
throw new Error('Microsoft Teams authentication required')
}
throw new Error(errorData.error || 'Failed to fetch channels')
}
const data = await response.json()
const channelsData = data.channels.map((channel: { id: string; displayName: string }) => ({
id: `${teamId}-${channel.id}`,
displayName: channel.displayName,
type: 'channel' as const,
teamId,
channelId: channel.id,
webViewLink: `https://teams.microsoft.com/l/channel/${teamId}/${encodeURIComponent(channel.displayName)}/${channel.id}`,
}))
setChannels(channelsData)
// Cache channel names in display names store
if (selectedCredentialId && channelsData.length > 0) {
const channelMap = channelsData.reduce(
(acc: Record<string, string>, channel: TeamsMessageInfo) => {
acc[channel.channelId!] = channel.displayName
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, channelMap)
}
// If we have a selected channel ID, find it in the list
if (selectedChannelId) {
const channel = channelsData.find(
(c: TeamsMessageInfo) => c.channelId === selectedChannelId
)
if (channel) {
setSelectedMessage(channel)
onMessageInfoChange?.(channel)
}
}
} catch (error) {
logger.error('Error fetching channels:', error)
setError((error as Error).message)
setChannels([])
} finally {
setIsLoading(false)
}
},
[selectedCredentialId, selectedChannelId, onMessageInfoChange, workflowId]
)
// Fetch chats
const fetchChats = useCallback(async () => {
if (!selectedCredentialId) return
setIsLoading(true)
setError(null)
try {
const response = await fetch('/api/tools/microsoft-teams/chats', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credential: selectedCredentialId,
workflowId: workflowId, // Pass the workflowId for server-side authentication
}),
})
if (!response.ok) {
const errorData = await response.json()
// If server indicates auth is required, show the auth modal
if (response.status === 401 && errorData.authRequired) {
logger.warn('Authentication required for Microsoft Teams')
setShowOAuthModal(true)
throw new Error('Microsoft Teams authentication required')
}
throw new Error(errorData.error || 'Failed to fetch chats')
}
const data = await response.json()
const chatsData = data.chats.map((chat: { id: string; displayName: string }) => ({
id: chat.id,
displayName: chat.displayName,
type: 'chat' as const,
chatId: chat.id,
webViewLink: `https://teams.microsoft.com/l/chat/${chat.id}`,
}))
setChats(chatsData)
if (selectedCredentialId && chatsData.length > 0) {
const chatMap = chatsData.reduce((acc: Record<string, string>, chat: TeamsMessageInfo) => {
acc[chat.id] = chat.displayName
return acc
}, {})
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, chatMap)
}
// If we have a selected chat ID, find it in the list
if (selectedChatId) {
const chat = chatsData.find((c: TeamsMessageInfo) => c.chatId === selectedChatId)
if (chat) {
setSelectedMessage(chat)
onMessageInfoChange?.(chat)
}
}
} catch (error) {
logger.error('Error fetching chats:', error)
setError((error as Error).message)
setChats([])
} finally {
setIsLoading(false)
}
}, [selectedCredentialId, selectedChatId, onMessageInfoChange, workflowId])
// Update selection stage based on selected values and selectionType
useEffect(() => {
// If we have explicit values selected, use those to determine the stage
if (selectedChatId) {
setSelectionStage('chat')
} else if (selectedChannelId) {
setSelectionStage('channel')
} else if (selectionType === 'channel' && selectedTeamId) {
// If we're in channel mode and have a team selected, go to channel selection
setSelectionStage('channel')
} else if (selectionType !== 'team' && !selectedTeamId) {
// If no selections but we have a specific selection type, use that
// But for channel selection, start with team selection if no team is selected
if (selectionType === 'channel') {
setSelectionStage('team')
} else {
setSelectionStage(selectionType)
}
} else {
// Default to team selection
setSelectionStage('team')
}
}, [selectedTeamId, selectedChannelId, selectedChatId, selectionType])
// Handle open change
const handleOpenChange = (isOpen: boolean) => {
if (disabled || isForeignCredential) {
setOpen(false)
return
}
setOpen(isOpen)
// Only fetch data when opening the dropdown
if (isOpen && selectedCredentialId) {
if (selectionStage === 'team') {
fetchTeams()
} else if (selectionStage === 'channel' && selectedTeamId) {
fetchChannels(selectedTeamId)
} else if (selectionStage === 'chat') {
fetchChats()
}
}
}
// Keep internal selectedMessageId in sync with the value prop
useEffect(() => {
if (value !== selectedMessageId) {
setSelectedMessageId(value)
}
}, [value])
// Handle team selection
const handleSelectTeam = (team: TeamsMessageInfo) => {
setSelectedTeamId(team.teamId || '')
setSelectedChannelId('')
setSelectedChatId('')
setSelectedMessage(team)
setSelectedMessageId(team.id)
onChange(team.id, team)
onMessageInfoChange?.(team)
setSelectionStage('channel')
fetchChannels(team.teamId || '')
setOpen(false)
}
// Handle channel selection
const handleSelectChannel = (channel: TeamsMessageInfo) => {
setSelectedChannelId(channel.channelId || '')
setSelectedChatId('')
setSelectedMessage(channel)
setSelectedMessageId(channel.channelId || '')
onChange(channel.channelId || '', channel)
onMessageInfoChange?.(channel)
setOpen(false)
}
// Handle chat selection
const handleSelectChat = (chat: TeamsMessageInfo) => {
setSelectedChatId(chat.chatId || '')
setSelectedMessage(chat)
setSelectedMessageId(chat.id)
onChange(chat.id, chat)
onMessageInfoChange?.(chat)
setOpen(false)
}
// Handle adding a new credential
const handleAddCredential = () => {
// Show the OAuth modal
setShowOAuthModal(true)
setOpen(false)
}
// Clear selection
const handleClearSelection = () => {
setSelectedMessageId('')
setSelectedTeamId('')
setSelectedChannelId('')
setSelectedChatId('')
setSelectedMessage(null)
setError(null)
onChange('', undefined)
onMessageInfoChange?.(null)
setSelectionStage(selectionType) // Reset to the initial selection type
}
// Render dropdown options based on the current selection stage
const renderSelectionOptions = () => {
if (selectionStage === 'team' && teams.length > 0) {
return (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>Teams</div>
{teams.map((team) => (
<CommandItem
key={team.id}
value={`team-${team.id}-${team.displayName}`}
onSelect={() => handleSelectTeam(team)}
>
<div className='flex items-center gap-2 overflow-hidden'>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span className='truncate font-normal'>{team.displayName}</span>
</div>
{team.teamId === selectedTeamId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)
}
if (selectionStage === 'channel' && channels.length > 0) {
return (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>Channels</div>
{channels.map((channel) => (
<CommandItem
key={channel.id}
value={`channel-${channel.id}-${channel.displayName}`}
onSelect={() => handleSelectChannel(channel)}
>
<div className='flex items-center gap-2 overflow-hidden'>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span className='truncate font-normal'>{channel.displayName}</span>
</div>
{channel.channelId === selectedChannelId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)
}
if (selectionStage === 'chat' && chats.length > 0) {
return (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>Chats</div>
{chats.map((chat) => (
<CommandItem
key={chat.id}
value={`chat-${chat.id}-${chat.displayName}`}
onSelect={() => handleSelectChat(chat)}
>
<div className='flex items-center gap-2 overflow-hidden'>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span className='truncate font-normal'>{chat.displayName}</span>
</div>
{chat.chatId === selectedChatId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)
}
return null
}
// Restore team selection on page refresh
const restoreTeamSelection = useCallback(
async (teamId: string) => {
if (!selectedCredentialId || !teamId || selectionType !== 'team') return
setIsLoading(true)
try {
const response = await fetch('/api/tools/microsoft-teams/teams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: selectedCredentialId, workflowId }),
})
if (response.ok) {
const data = await response.json()
const team = data.teams.find((t: { id: string; displayName: string }) => t.id === teamId)
if (team) {
const teamInfo: TeamsMessageInfo = {
id: team.id,
displayName: team.displayName,
type: 'team',
teamId: team.id,
webViewLink: `https://teams.microsoft.com/l/team/${team.id}`,
}
setSelectedTeamId(team.id)
setSelectedMessage(teamInfo)
onMessageInfoChange?.(teamInfo)
}
}
} catch (error) {
logger.error('Error restoring team selection:', error)
} finally {
setIsLoading(false)
}
},
[selectedCredentialId, selectionType, onMessageInfoChange, workflowId]
)
// Restore chat selection on page refresh
const restoreChatSelection = useCallback(
async (chatId: string) => {
if (!selectedCredentialId || !chatId || selectionType !== 'chat') return
setIsLoading(true)
try {
const response = await fetch('/api/tools/microsoft-teams/chats', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: selectedCredentialId, workflowId }),
})
if (response.ok) {
const data = await response.json()
// Cache all chat names
if (data.chats && selectedCredentialId) {
const chatMap = data.chats.reduce(
(acc: Record<string, string>, c: { id: string; displayName: string }) => {
acc[c.id] = c.displayName
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, chatMap)
}
const chat = data.chats.find((c: { id: string; displayName: string }) => c.id === chatId)
if (chat) {
const chatInfo: TeamsMessageInfo = {
id: chat.id,
displayName: chat.displayName,
type: 'chat',
chatId: chat.id,
webViewLink: `https://teams.microsoft.com/l/chat/${chat.id}`,
}
setSelectedChatId(chat.id)
setSelectedMessage(chatInfo)
onMessageInfoChange?.(chatInfo)
}
}
} catch (error) {
logger.error('Error restoring chat selection:', error)
} finally {
setIsLoading(false)
}
},
[selectedCredentialId, selectionType, onMessageInfoChange, workflowId]
)
// Restore channel selection on page refresh
const restoreChannelSelection = useCallback(
async (channelId: string) => {
if (!selectedCredentialId || !channelId || selectionType !== 'channel') return
setIsLoading(true)
try {
// First fetch teams to search through them
const teamsResponse = await fetch('/api/tools/microsoft-teams/teams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: selectedCredentialId, workflowId }),
})
if (teamsResponse.ok) {
const teamsData = await teamsResponse.json()
// Create parallel promises for all teams to search for the channel
const channelSearchPromises = teamsData.teams.map(
async (team: { id: string; displayName: string }) => {
try {
const channelsResponse = await fetch('/api/tools/microsoft-teams/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
credential: selectedCredentialId,
teamId: team.id,
workflowId,
}),
})
if (channelsResponse.ok) {
const channelsData = await channelsResponse.json()
const channel = channelsData.channels.find(
(c: { id: string; displayName: string }) => c.id === channelId
)
if (channel) {
return {
team,
channel,
channelInfo: {
id: `${team.id}-${channel.id}`,
displayName: channel.displayName,
type: 'channel' as const,
teamId: team.id,
channelId: channel.id,
webViewLink: `https://teams.microsoft.com/l/channel/${team.id}/${encodeURIComponent(channel.displayName)}/${channel.id}`,
},
}
}
}
} catch (error) {
logger.warn(
`Error searching for channel in team ${team.id}:`,
error instanceof Error ? error.message : String(error)
)
}
return null
}
)
// Wait for all parallel requests to complete (or fail)
const results = await Promise.allSettled(channelSearchPromises)
// Find the first successful result that contains our channel
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
const { channelInfo } = result.value
setSelectedTeamId(channelInfo.teamId!)
setSelectedChannelId(channelInfo.channelId!)
setSelectedMessage(channelInfo)
onMessageInfoChange?.(channelInfo)
return // Found the channel, exit successfully
}
}
// If we get here, the channel wasn't found in any team
logger.warn(`Channel ${channelId} not found in any accessible team`)
}
} catch (error) {
logger.error('Error restoring channel selection:', error)
} finally {
setIsLoading(false)
}
},
[selectedCredentialId, selectionType, onMessageInfoChange, workflowId]
)
// Set initial team ID if provided
useEffect(() => {
if (initialTeamId && !selectedTeamId && selectionType === 'channel') {
setSelectedTeamId(initialTeamId)
}
}, [initialTeamId, selectedTeamId, selectionType])
// Clear selection when selectionType changes to allow proper restoration
useEffect(() => {
setSelectedMessage(null)
setSelectedTeamId('')
setSelectedChannelId('')
setSelectedChatId('')
}, [selectionType])
// Fetch appropriate data on initial mount based on selectionType
useEffect(() => {
if (!initialFetchRef.current) {
fetchCredentials()
initialFetchRef.current = true
}
}, [fetchCredentials])
// Keep local credential state in sync with persisted credential
useEffect(() => {
if (credential && credential !== selectedCredentialId) {
setSelectedCredentialId(credential)
}
}, [credential, selectedCredentialId])
// Restore selection whenever the canonical value changes
useEffect(() => {
if (value && selectedCredentialId) {
// Only restore if we haven't already restored this value
if (lastRestoredValueRef.current !== value) {
lastRestoredValueRef.current = value
if (selectionType === 'team') {
restoreTeamSelection(value)
} else if (selectionType === 'chat') {
restoreChatSelection(value)
} else if (selectionType === 'channel') {
restoreChannelSelection(value)
}
}
} else {
lastRestoredValueRef.current = null
setSelectedMessage(null)
}
}, [
value,
selectedCredentialId,
selectionType,
restoreTeamSelection,
restoreChatSelection,
restoreChannelSelection,
])
return (
<>
<div className='space-y-2'>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='h-10 w-full min-w-0 justify-between'
disabled={disabled || isForeignCredential}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{cachedMessageName ? (
<>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span className='truncate font-normal'>{cachedMessageName}</span>
</>
) : (
<>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span className='truncate text-muted-foreground'>
{selectionType === 'channel' && selectionStage === 'team'
? 'Select a team first'
: label}
</span>
</>
)}
</div>
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
{!isForeignCredential && (
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
)}
<Command>
<CommandInput placeholder={`Search ${selectionStage}s...`} />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading {selectionStage}s...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
{selectionStage === 'chat' && error.includes('teams') && (
<p className='mt-1 text-muted-foreground text-xs'>
There was an issue fetching chats. Please try again or connect a
different account.
</p>
)}
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a Microsoft Teams account to{' '}
{selectionStage === 'chat'
? 'access your chats'
: selectionStage === 'channel'
? 'see your channels'
: 'continue'}
.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No {selectionStage}s found.</p>
<p className='text-muted-foreground text-xs'>
{selectionStage === 'team'
? 'Try a different account.'
: selectionStage === 'channel'
? selectedTeamId
? 'This team has no channels or you may not have access.'
: 'Please select a team first to see its channels.'
: 'Try a different account or check if you have any active chats.'}
</p>
</div>
)}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => {
setSelectedCredentialId(cred.id)
setOpen(false)
}}
>
<div className='flex items-center gap-2'>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && (
<Check className='ml-auto h-4 w-4' />
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Display appropriate options based on selection stage */}
{renderSelectionOptions()}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-foreground'>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span>Connect Microsoft Teams account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
{/* Selection preview */}
{showPreview && selectedMessage && (
<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'>
<MicrosoftTeamsIcon 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'>{selectedMessage.displayName}</h4>
<span className='whitespace-nowrap text-muted-foreground text-xs'>
{selectedMessage.type}
</span>
</div>
{selectedMessage.webViewLink ? (
<a
href={selectedMessage.webViewLink}
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-1 text-foreground text-xs hover:underline'
onClick={(e) => e.stopPropagation()}
>
<span>Open in Microsoft Teams</span>
<ExternalLink className='h-3 w-3' />
</a>
) : (
<></>
)}
</div>
</div>
</div>
)}
</div>
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName='Microsoft Teams'
requiredScopes={requiredScopes}
serviceId={getServiceId()}
/>
)}
</>
)
}

View File

@@ -1,484 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, X } from 'lucide-react'
import { WealthboxIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
import {
type Credential,
getProviderIdFromServiceId,
getServiceIdFromScopes,
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')
export interface WealthboxItemInfo {
id: string
name: string
type: 'contact'
content?: string
createdAt?: string
updatedAt?: string
}
interface WealthboxFileSelectorProps {
value: string
onChange: (value: string, itemInfo?: WealthboxItemInfo) => void
provider: OAuthProvider
requiredScopes?: string[]
label?: string
disabled?: boolean
serviceId?: string
showPreview?: boolean
onFileInfoChange?: (itemInfo: WealthboxItemInfo | null) => void
itemType?: 'contact'
credentialId?: string
}
export function WealthboxFileSelector({
value,
onChange,
provider,
requiredScopes = [],
label = 'Select item',
disabled = false,
serviceId,
showPreview = true,
onFileInfoChange,
itemType = 'contact',
credentialId,
}: WealthboxFileSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
const [selectedItemId, setSelectedItemId] = useState(value)
const [selectedItem, setSelectedItem] = useState<WealthboxItemInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingSelectedItem, setIsLoadingSelectedItem] = useState(false)
const [isLoadingItems, setIsLoadingItems] = useState(false)
const [availableItems, setAvailableItems] = useState<WealthboxItemInfo[]>([])
const [searchQuery, setSearchQuery] = useState<string>('')
const [showOAuthModal, setShowOAuthModal] = useState(false)
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
return getServiceIdFromScopes(provider, requiredScopes)
}
// Determine the appropriate provider ID based on service and scopes
const getProviderId = (): string => {
const effectiveServiceId = getServiceId()
return getProviderIdFromServiceId(effectiveServiceId)
}
// Fetch available credentials for this provider
const fetchCredentials = useCallback(async () => {
setIsLoading(true)
setCredentialsLoaded(false)
try {
const providerId = getProviderId()
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials)
}
} catch (error) {
logger.error('Error fetching credentials:', { error })
} finally {
setIsLoading(false)
setCredentialsLoaded(true)
}
}, [provider, getProviderId, selectedCredentialId])
// Keep local credential state in sync with persisted credential
useEffect(() => {
if (credentialId && credentialId !== selectedCredentialId) {
setSelectedCredentialId(credentialId)
}
}, [credentialId, selectedCredentialId])
// Debounced search function
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null)
// Fetch available items for the selected credential
const fetchAvailableItems = useCallback(async () => {
if (!selectedCredentialId) return
setIsLoadingItems(true)
try {
const queryParams = new URLSearchParams({
credentialId: selectedCredentialId,
type: itemType,
})
if (searchQuery.trim()) {
queryParams.append('query', searchQuery.trim())
}
const response = await fetch(`/api/auth/oauth/wealthbox/items?${queryParams.toString()}`)
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(),
})
setAvailableItems([])
}
} catch (error) {
logger.error('Error fetching available items:', { error })
setAvailableItems([])
} finally {
setIsLoadingItems(false)
}
}, [selectedCredentialId, searchQuery, itemType])
// Fetch a single item by ID
const fetchItemById = useCallback(
async (itemId: string) => {
if (!selectedCredentialId || !itemId) return null
setIsLoadingSelectedItem(true)
try {
const queryParams = new URLSearchParams({
credentialId: selectedCredentialId,
itemId: itemId,
type: itemType,
})
const response = await fetch(`/api/auth/oauth/wealthbox/item?${queryParams.toString()}`)
if (response.ok) {
const data = await response.json()
if (data.item) {
setSelectedItem(data.item)
onFileInfoChange?.(data.item)
// Cache the item name in display names store
if (selectedCredentialId) {
useDisplayNamesStore
.getState()
.setDisplayNames('files', selectedCredentialId, { [data.item.id]: data.item.name })
}
return data.item
}
} else {
const errorText = await response.text()
logger.error('Error fetching item by ID:', { error: errorText })
if (response.status === 404 || response.status === 403) {
logger.info('Item not accessible, clearing selection')
setSelectedItemId('')
onChange('')
onFileInfoChange?.(null)
}
}
return null
} catch (error) {
logger.error('Error fetching item by ID:', { error })
return null
} finally {
setIsLoadingSelectedItem(false)
}
},
[selectedCredentialId, itemType, onFileInfoChange, onChange]
)
// Fetch credentials on initial mount
useEffect(() => {
if (!initialFetchRef.current) {
fetchCredentials()
initialFetchRef.current = true
}
}, [fetchCredentials])
// Fetch available items only when dropdown is opened
useEffect(() => {
if (selectedCredentialId && open) {
fetchAvailableItems()
}
}, [selectedCredentialId, open, fetchAvailableItems])
// Fetch item info on mount if we have a value but no selectedItem state
useEffect(() => {
if (value && selectedCredentialId && !selectedItem) {
fetchItemById(value)
}
}, [value, selectedCredentialId, selectedItem, fetchItemById])
// Clear selectedItem when value is cleared
useEffect(() => {
if (!value) {
setSelectedItem(null)
onFileInfoChange?.(null)
}
}, [value, onFileInfoChange])
// Handle search input changes with debouncing
const handleSearchChange = useCallback(
(newQuery: string) => {
setSearchQuery(newQuery)
// Clear existing timeout
if (searchTimeout) {
clearTimeout(searchTimeout)
}
// Set new timeout for search
const timeout = setTimeout(() => {
if (selectedCredentialId) {
fetchAvailableItems()
}
}, 300) // 300ms debounce
setSearchTimeout(timeout)
},
[selectedCredentialId, fetchAvailableItems, searchTimeout]
)
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
}
}, [searchTimeout])
// Handle selecting an item
const handleItemSelect = (item: WealthboxItemInfo) => {
setSelectedItemId(item.id)
setSelectedItem(item)
onChange(item.id, item)
onFileInfoChange?.(item)
setOpen(false)
setSearchQuery('')
}
// Handle adding a new credential
const handleAddCredential = () => {
setShowOAuthModal(true)
setOpen(false)
setSearchQuery('')
}
// Clear selection
const handleClearSelection = () => {
setSelectedItemId('')
onChange('', undefined)
onFileInfoChange?.(null)
}
const getItemTypeLabel = () => {
switch (itemType) {
case 'contact':
return 'Contacts'
default:
return 'Contacts'
}
}
return (
<>
<div className='space-y-2'>
<Popover
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen)
if (!isOpen) {
setSearchQuery('')
if (searchTimeout) {
clearTimeout(searchTimeout)
setSearchTimeout(null)
}
}
}}
>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='w-full justify-between'
disabled={disabled}
>
{cachedItemName ? (
<div className='flex items-center gap-2 overflow-hidden'>
<WealthboxIcon className='h-4 w-4' />
<span className='truncate font-normal'>{cachedItemName}</span>
</div>
) : (
<div className='flex items-center gap-2'>
<WealthboxIcon className='h-4 w-4' />
<span className='text-muted-foreground'>{label}</span>
</div>
)}
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command shouldFilter={false}>
<div className='flex items-center border-b px-3' cmdk-input-wrapper=''>
<input
placeholder={`Search ${itemType}s...`}
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className='flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50'
/>
</div>
<CommandList>
<CommandEmpty>
{isLoadingItems ? `Loading ${itemType}s...` : `No ${itemType}s found.`}
</CommandEmpty>
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
<WealthboxIcon className='h-4 w-4' />
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{availableItems.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
{getItemTypeLabel()}
</div>
{availableItems.map((item) => (
<CommandItem
key={item.id}
value={`item-${item.id}-${item.name}`}
onSelect={() => handleItemSelect(item)}
>
<div className='flex items-center gap-2 overflow-hidden'>
<WealthboxIcon className='h-4 w-4' />
<div className='min-w-0 flex-1'>
<span className='truncate font-normal'>{item.name}</span>
{item.updatedAt && (
<div className='text-muted-foreground text-xs'>
Updated {new Date(item.updatedAt).toLocaleDateString()}
</div>
)}
</div>
</div>
{item.id === selectedItemId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-foreground'>
<WealthboxIcon className='h-4 w-4' />
<span>Connect Wealthbox account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{showPreview && selectedItem && (
<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'>
<WealthboxIcon 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'>{selectedItem.name}</h4>
{selectedItem.updatedAt && (
<span className='whitespace-nowrap text-muted-foreground text-xs'>
{new Date(selectedItem.updatedAt).toLocaleDateString()}
</span>
)}
</div>
<div className='text-muted-foreground text-xs capitalize'>{selectedItem.type}</div>
</div>
</div>
</div>
)}
</div>
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
toolName='Wealthbox'
provider={provider}
requiredScopes={requiredScopes}
serviceId={getServiceId()}
/>
)}
</>
)
}

View File

@@ -1,22 +1,14 @@
'use client'
import { useMemo } from 'react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { getEnv } from '@/lib/env'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import {
ConfluenceFileSelector,
GoogleCalendarSelector,
GoogleDrivePicker,
JiraIssueSelector,
MicrosoftFileSelector,
TeamsMessageSelector,
WealthboxFileSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
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'
import { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -41,506 +33,108 @@ export function FileSelectorInput({
const { activeWorkflowId } = useWorkflowRegistry()
const params = useParams()
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
// Central dependsOn gating for this selector instance
const { finalDisabled, dependsOn } = useDependsOnGate(blockId, subBlock, {
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
disabled,
isPreview,
previewContextValues,
})
// Helper to coerce various preview value shapes into a string ID
const coerceToIdString = (val: unknown): string => {
if (!val) return ''
if (typeof val === 'string') return val
if (typeof val === 'number') return String(val)
if (typeof val === 'object') {
const obj = val as Record<string, any>
return (obj.id ||
obj.fileId ||
obj.value ||
obj.documentId ||
obj.spreadsheetId ||
'') as string
}
return ''
}
// Use the proper hook to get the current value and setter
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
const [domainValueFromStore] = useSubBlockValue(blockId, 'domain')
const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId')
const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId')
const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId')
const [operationValueFromStore] = useSubBlockValue(blockId, 'operation')
// Use previewContextValues if provided (for tools inside agent blocks), otherwise use store values
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
const domainValue = previewContextValues?.domain ?? domainValueFromStore
const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore
const planIdValue = previewContextValues?.planId ?? planIdValueFromStore
const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore
const operationValue = previewContextValues?.operation ?? operationValueFromStore
// Determine if the persisted credential belongs to the current viewer
// Use service providerId where available (e.g., onedrive/sharepoint) instead of base provider ("microsoft")
const foreignCheckProvider = subBlock.serviceId
? getProviderIdFromServiceId(subBlock.serviceId)
: (subBlock.provider as string) || ''
const normalizedCredentialId = coerceToIdString(connectedCredential)
const providerForForeignCheck = foreignCheckProvider || (subBlock.provider as string) || undefined
const normalizedCredentialId =
typeof connectedCredential === 'string'
? connectedCredential
: typeof connectedCredential === 'object' && connectedCredential !== null
? ((connectedCredential as Record<string, any>).id ?? '')
: ''
const { isForeignCredential } = useForeignCredential(
providerForForeignCheck,
subBlock.serviceId || subBlock.provider,
normalizedCredentialId
)
// Get provider-specific values
const provider = subBlock.provider || 'google-drive'
const isConfluence = provider === 'confluence'
const isJira = provider === 'jira'
const isMicrosoftTeams = provider === 'microsoft-teams'
const isMicrosoftExcel = provider === 'microsoft-excel'
const isMicrosoftWord = provider === 'microsoft-word'
const isMicrosoftOneDrive = provider === 'microsoft' && subBlock.serviceId === 'onedrive'
const isGoogleCalendar = subBlock.provider === 'google-calendar'
const isWealthbox = provider === 'wealthbox'
const isMicrosoftSharePoint = provider === 'microsoft' && subBlock.serviceId === 'sharepoint'
const isMicrosoftPlanner = provider === 'microsoft-planner'
const selectorResolution = useMemo<SelectorResolution | null>(() => {
return resolveSelectorForSubBlock(subBlock, {
workflowId: workflowIdFromUrl,
credentialId: normalizedCredentialId,
domain: (domainValue as string) || undefined,
projectId: (projectIdValue as string) || undefined,
planId: (planIdValue as string) || undefined,
teamId: (teamIdValue as string) || undefined,
})
}, [
subBlock,
workflowIdFromUrl,
normalizedCredentialId,
domainValue,
projectIdValue,
planIdValue,
teamIdValue,
])
// For Confluence and Jira, we need the domain and credentials
const domain =
isConfluence || isJira
? (isPreview && previewContextValues?.domain?.value) || (domainValue as string) || ''
: ''
const jiraCredential = isJira
? (isPreview && previewContextValues?.credential?.value) ||
(connectedCredential as string) ||
''
: ''
const missingCredential = !normalizedCredentialId
const missingDomain =
selectorResolution?.key &&
(selectorResolution.key === 'confluence.pages' || selectorResolution.key === 'jira.issues') &&
!selectorResolution.context.domain
const missingProject =
selectorResolution?.key === 'jira.issues' &&
subBlock.dependsOn?.includes('projectId') &&
!selectorResolution.context.projectId
const missingPlan =
selectorResolution?.key === 'microsoft.planner' && !selectorResolution.context.planId
// Discord channel selector removed; no special values used here
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
const credentialDependencySatisfied = (() => {
if (!dependsOn.includes('credential')) return true
if (!normalizedCredentialId || normalizedCredentialId.trim().length === 0) {
return false
}
if (isForeignCredential) {
return false
}
return true
})()
const shouldForceDisable = !credentialDependencySatisfied
// For Google Drive
const clientId = getEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID') || ''
const apiKey = getEnv('NEXT_PUBLIC_GOOGLE_API_KEY') || ''
// Render Google Calendar selector
if (isGoogleCalendar) {
const credential = (connectedCredential as string) || ''
const disabledReason =
finalDisabled ||
isForeignCredential ||
missingCredential ||
missingDomain ||
missingProject ||
missingPlan ||
!selectorResolution?.key
if (!selectorResolution?.key) {
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<GoogleCalendarSelector
value={
(isPreview && previewValue !== undefined
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val: string) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
label={subBlock.placeholder || 'Select Google Calendar'}
disabled={finalDisabled || shouldForceDisable}
showPreview={true}
credentialId={credential}
workflowId={workflowIdFromUrl}
/>
</div>
</Tooltip.Trigger>
</Tooltip.Root>
</Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
File selector not supported for provider: {subBlock.provider || subBlock.serviceId}
</div>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>This file selector is not implemented for {subBlock.provider || subBlock.serviceId}</p>
</Tooltip.Content>
</Tooltip.Root>
)
}
// Render the appropriate picker based on provider
if (isConfluence) {
const credential = (connectedCredential as string) || ''
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<ConfluenceFileSelector
value={
(isPreview && previewValue !== undefined
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
domain={domain}
provider='confluence'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Confluence page'}
disabled={finalDisabled || shouldForceDisable}
showPreview={true}
credentialId={credential}
workflowId={workflowIdFromUrl}
isForeignCredential={isForeignCredential}
/>
</div>
</Tooltip.Trigger>
</Tooltip.Root>
</Tooltip.Provider>
)
}
if (isJira) {
const credential = (connectedCredential as string) || ''
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<JiraIssueSelector
value={
(isPreview && previewValue !== undefined
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(issueKey) => {
collaborativeSetSubblockValue(blockId, subBlock.id, issueKey)
}}
domain={domain}
provider='jira'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Jira issue'}
disabled={finalDisabled || shouldForceDisable}
showPreview={true}
credentialId={credential}
projectId={(projectIdValue as string) || ''}
isForeignCredential={isForeignCredential}
workflowId={activeWorkflowId || ''}
/>
</div>
</Tooltip.Trigger>
</Tooltip.Root>
</Tooltip.Provider>
)
}
if (isMicrosoftExcel) {
const credential = (connectedCredential as string) || ''
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft-excel'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Microsoft Excel file'}
disabled={finalDisabled || shouldForceDisable}
showPreview={true}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</Tooltip.Trigger>
</Tooltip.Root>
</Tooltip.Provider>
)
}
// Microsoft Word selector
if (isMicrosoftWord) {
const credential = (connectedCredential as string) || ''
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft-word'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Microsoft Word document'}
disabled={finalDisabled || shouldForceDisable}
showPreview={true}
/>
</div>
</Tooltip.Trigger>
</Tooltip.Root>
</Tooltip.Provider>
)
}
// Microsoft OneDrive selector
if (isMicrosoftOneDrive) {
const credential = (connectedCredential as string) || ''
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
mimeType={subBlock.mimeType}
label={subBlock.placeholder || 'Select OneDrive folder'}
disabled={finalDisabled || shouldForceDisable}
showPreview={true}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</Tooltip.Trigger>
</Tooltip.Root>
</Tooltip.Provider>
)
}
// Microsoft SharePoint selector
if (isMicrosoftSharePoint) {
const credential = (connectedCredential as string) || ''
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select SharePoint site'}
disabled={finalDisabled || shouldForceDisable}
showPreview={true}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</Tooltip.Trigger>
{!credential && (
<Tooltip.Content side='top'>
<p>Please select SharePoint credentials first</p>
</Tooltip.Content>
)}
</Tooltip.Root>
</Tooltip.Provider>
)
}
// Microsoft Planner task selector
if (isMicrosoftPlanner) {
const credential = (connectedCredential as string) || ''
const planId = (planIdValue as string) || ''
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft-planner'
requiredScopes={subBlock.requiredScopes || []}
serviceId='microsoft-planner'
label={subBlock.placeholder || 'Select task'}
disabled={finalDisabled || shouldForceDisable}
showPreview={true}
planId={planId}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</Tooltip.Trigger>
{!credential ? (
<Tooltip.Content side='top'>
<p>Please select Microsoft Planner credentials first</p>
</Tooltip.Content>
) : !planId ? (
<Tooltip.Content side='top'>
<p>Please enter a Plan ID first</p>
</Tooltip.Content>
) : null}
</Tooltip.Root>
</Tooltip.Provider>
)
}
// Microsoft Teams selector
if (isMicrosoftTeams) {
const credential = (connectedCredential as string) || ''
// Determine the selector type based on the subBlock ID / operation
let selectionType: 'team' | 'channel' | 'chat' = 'team'
if (subBlock.id === 'teamId') selectionType = 'team'
else if (subBlock.id === 'channelId') selectionType = 'channel'
else if (subBlock.id === 'chatId') selectionType = 'chat'
else {
const operation = (operationValue as string) || ''
if (operation.includes('chat')) selectionType = 'chat'
else if (operation.includes('channel')) selectionType = 'channel'
}
const selectedTeamId = (teamIdValue as string) || ''
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<TeamsMessageSelector
value={
(isPreview && previewValue !== undefined
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
provider='microsoft-teams'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Teams message location'}
disabled={finalDisabled || shouldForceDisable}
showPreview={true}
credential={credential}
selectionType={selectionType}
initialTeamId={selectedTeamId}
workflowId={activeWorkflowId || ''}
isForeignCredential={isForeignCredential}
/>
</div>
</Tooltip.Trigger>
{!credential && (
<Tooltip.Content side='top'>
<p>Please select Microsoft Teams credentials first</p>
</Tooltip.Content>
)}
</Tooltip.Root>
</Tooltip.Provider>
)
}
// Wealthbox selector
if (isWealthbox) {
const credential = (connectedCredential as string) || ''
if (subBlock.id === 'contactId') {
const itemType = 'contact'
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<WealthboxFileSelector
value={
(isPreview && previewValue !== undefined
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
provider='wealthbox'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || `Select ${itemType}`}
disabled={finalDisabled || shouldForceDisable}
showPreview={true}
credentialId={credential}
itemType={itemType}
/>
</div>
</Tooltip.Trigger>
{!credential && (
<Tooltip.Content side='top'>
<p>Please select Wealthbox credentials first</p>
</Tooltip.Content>
)}
</Tooltip.Root>
</Tooltip.Provider>
)
}
// noteId or taskId now use short-input
return null
}
// Default to Google Drive picker
{
const credential = ((isPreview && previewContextValues?.credential?.value) ||
(connectedCredential as string) ||
'') as string
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<GoogleDrivePicker
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
provider={provider}
requiredScopes={subBlock.requiredScopes || []}
label={subBlock.placeholder || 'Select file'}
disabled={finalDisabled || shouldForceDisable}
serviceId={subBlock.serviceId}
mimeTypeFilter={subBlock.mimeType}
showPreview={true}
clientId={clientId}
apiKey={apiKey}
credentialId={credential}
workflowId={workflowIdFromUrl}
/>
</div>
</Tooltip.Trigger>
{!credential && (
<Tooltip.Content side='top'>
<p>Please select Google Drive credentials first</p>
</Tooltip.Content>
)}
</Tooltip.Root>
</Tooltip.Provider>
)
}
return (
<SelectorCombobox
blockId={blockId}
subBlock={subBlock}
selectorKey={selectorResolution.key}
selectorContext={selectorResolution.context}
disabled={disabledReason}
isPreview={isPreview}
previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || 'Select resource'}
allowSearch={selectorResolution.allowSearch}
onOptionChange={(value) => {
if (!isPreview) {
collaborativeSetSubblockValue(blockId, subBlock.id, value)
}
}}
/>
)
}

View File

@@ -1,20 +1,9 @@
'use client'
import { useRef, useState } from 'react'
import { ChevronDown, X } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui'
import { Button } from '@/components/ui/button'
import { Button, Combobox } from '@/components/emcn/components'
import { Progress } from '@/components/ui/progress'
import { createLogger } from '@/lib/logs/console/logger'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
@@ -59,31 +48,24 @@ export function FileUpload({
previewValue,
disabled = false,
}: FileUploadProps) {
// State management - handle both single file and array of files
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
const [uploadProgress, setUploadProgress] = useState(0)
const [workspaceFiles, setWorkspaceFiles] = useState<WorkspaceFileRecord[]>([])
const [loadingWorkspaceFiles, setLoadingWorkspaceFiles] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const [addMoreOpen, setAddMoreOpen] = useState(false)
const [pickerOpen, setPickerOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
// For file deletion status
const [deletingFiles, setDeletingFiles] = useState<Record<string, boolean>>({})
// Refs
const fileInputRef = useRef<HTMLInputElement>(null)
// Stores
const { activeWorkflowId } = useWorkflowRegistry()
const params = useParams()
const workspaceId = params?.workspaceId as string
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
// Load workspace files function
const loadWorkspaceFiles = async () => {
if (!workspaceId || isPreview) return
@@ -102,10 +84,8 @@ export function FileUpload({
}
}
// Filter out already selected files
const availableWorkspaceFiles = workspaceFiles.filter((workspaceFile) => {
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
// Check if this workspace file is already added (match by name or key)
return !existingFiles.some(
(existing) =>
existing.name === workspaceFile.name ||
@@ -114,9 +94,12 @@ export function FileUpload({
)
})
useEffect(() => {
void loadWorkspaceFiles()
}, [workspaceId])
/**
* Opens file dialog
* Prevents event propagation to avoid ReactFlow capturing the event
*/
const handleOpenFileDialog = (e: React.MouseEvent) => {
e.preventDefault()
@@ -159,18 +142,15 @@ export function FileUpload({
const files = e.target.files
if (!files || files.length === 0) return
// Get existing files and their total size
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
const existingTotalSize = existingFiles.reduce((sum, file) => sum + file.size, 0)
// Validate file sizes
const maxSizeInBytes = maxSize * 1024 * 1024
const validFiles: File[] = []
let totalNewSize = 0
for (let i = 0; i < files.length; i++) {
const file = files[i]
// Check if adding this file would exceed the total limit
if (existingTotalSize + totalNewSize + file.size > maxSizeInBytes) {
logger.error(
`Adding ${file.name} would exceed the maximum size limit of ${maxSize}MB`,
@@ -184,7 +164,6 @@ export function FileUpload({
if (validFiles.length === 0) return
// Create placeholder uploading files - ensure unique IDs
const uploading = validFiles.map((file) => ({
id: `upload-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
name: file.name,
@@ -194,13 +173,11 @@ export function FileUpload({
setUploadingFiles(uploading)
setUploadProgress(0)
// Track progress simulation interval
let progressInterval: NodeJS.Timeout | null = null
try {
setUploadError(null) // Clear previous errors
setUploadError(null)
// Simulate upload progress
progressInterval = setInterval(() => {
setUploadProgress((prev) => {
const newProgress = prev + Math.random() * 10
@@ -211,20 +188,16 @@ export function FileUpload({
const uploadedFiles: UploadedFile[] = []
const uploadErrors: string[] = []
// Upload each file via server (workspace files need DB records)
for (const file of validFiles) {
try {
// Create FormData for upload
const formData = new FormData()
formData.append('file', file)
formData.append('context', 'workspace')
// Add workspace ID for workspace-scoped storage
if (workspaceId) {
formData.append('workspaceId', workspaceId)
}
// Upload the file via server
const response = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
@@ -232,37 +205,30 @@ export function FileUpload({
const data = await response.json()
// Handle error response
if (!response.ok) {
const errorMessage = data.error || `Failed to upload file: ${response.status}`
uploadErrors.push(`${file.name}: ${errorMessage}`)
// Set error message with conditional auto-dismiss
setUploadError(errorMessage)
// Only auto-dismiss duplicate errors, keep other errors (like storage limits) visible
if (data.isDuplicate || response.status === 409) {
setTimeout(() => setUploadError(null), 5000)
}
continue
}
// Check if response has error even with 200 status
if (data.success === false) {
const errorMessage = data.error || 'Upload failed'
uploadErrors.push(`${file.name}: ${errorMessage}`)
// Set error message with conditional auto-dismiss
setUploadError(errorMessage)
// Only auto-dismiss duplicate errors, keep other errors (like storage limits) visible
if (data.isDuplicate) {
setTimeout(() => setUploadError(null), 5000)
}
continue
}
// Process successful upload - handle both workspace and regular uploads
uploadedFiles.push({
name: file.name,
path: data.file?.url || data.url, // Workspace: data.file.url, Non-workspace: data.url
@@ -277,7 +243,6 @@ export function FileUpload({
}
}
// Clear progress interval
if (progressInterval) {
clearInterval(progressInterval)
progressInterval = null
@@ -285,11 +250,9 @@ export function FileUpload({
setUploadProgress(100)
// Send consolidated notification about uploaded files
if (uploadedFiles.length > 0) {
setUploadError(null) // Clear error on successful upload
setUploadError(null)
// Refresh workspace files list to keep dropdown up to date
if (workspaceId) {
void loadWorkspaceFiles()
}
@@ -304,7 +267,6 @@ export function FileUpload({
}
}
// Send consolidated error notification if any
if (uploadErrors.length > 0) {
if (uploadErrors.length === 1) {
logger.error(uploadErrors[0], activeWorkflowId)
@@ -316,30 +278,23 @@ export function FileUpload({
}
}
// Update the file value in state based on multiple setting
if (multiple) {
// For multiple files: Append to existing files if any
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
// Create a map to identify duplicates by url
const uniqueFiles = new Map()
// Add existing files to the map
existingFiles.forEach((file) => {
uniqueFiles.set(file.url || file.path, file) // Use url, fallback to path for backward compatibility
uniqueFiles.set(file.url || file.path, file)
})
// Add new files to the map (will overwrite if same path)
uploadedFiles.forEach((file) => {
uniqueFiles.set(file.path, file)
})
// Convert map values back to array
const newFiles = Array.from(uniqueFiles.values())
setStoreValue(newFiles)
useWorkflowStore.getState().triggerUpdate()
} else {
// For single file: Replace with last uploaded file
setStoreValue(uploadedFiles[0] || null)
useWorkflowStore.getState().triggerUpdate()
}
@@ -349,7 +304,6 @@ export function FileUpload({
activeWorkflowId
)
} finally {
// Clean up and reset upload state
if (progressInterval) {
clearInterval(progressInterval)
}
@@ -368,8 +322,6 @@ export function FileUpload({
const selectedFile = workspaceFiles.find((f) => f.id === fileId)
if (!selectedFile) return
// Convert workspace file record to uploaded file format
// Path will be converted to presigned URL during execution if needed
const uploadedFile: UploadedFile = {
name: selectedFile.name,
path: selectedFile.path,
@@ -378,7 +330,6 @@ export function FileUpload({
}
if (multiple) {
// For multiple files: Append to existing
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
const uniqueFiles = new Map()
@@ -391,7 +342,6 @@ export function FileUpload({
setStoreValue(newFiles)
} else {
// For single file: Replace
setStoreValue(uploadedFile)
}
@@ -408,19 +358,15 @@ export function FileUpload({
e.stopPropagation()
}
// Mark this file as being deleted
setDeletingFiles((prev) => ({ ...prev, [file.path || '']: true }))
try {
// Check if this is a workspace file (decoded path contains workspaceId pattern)
const decodedPath = file.path ? decodeURIComponent(file.path) : ''
const isWorkspaceFile =
workspaceId &&
(decodedPath.includes(`/${workspaceId}/`) || decodedPath.includes(`${workspaceId}/`))
if (!isWorkspaceFile) {
// Only delete from storage if it's NOT a workspace file
// Workspace files are permanent and managed through Settings
const response = await fetch('/api/files/delete', {
method: 'POST',
headers: {
@@ -436,14 +382,11 @@ export function FileUpload({
}
}
// Update the UI state (remove from selection)
if (multiple) {
// For multiple files: Remove the specific file
const filesArray = Array.isArray(value) ? value : value ? [value] : []
const updatedFiles = filesArray.filter((f) => f.path !== file.path)
setStoreValue(updatedFiles.length > 0 ? updatedFiles : null)
} else {
// For single file: Clear the value
setStoreValue(null)
}
@@ -454,7 +397,6 @@ export function FileUpload({
activeWorkflowId
)
} finally {
// Remove file from the deleting state
setDeletingFiles((prev) => {
const updated = { ...prev }
delete updated[file.path || '']
@@ -463,80 +405,6 @@ export function FileUpload({
}
}
/**
* Handles deletion of all files (for multiple mode)
*/
const handleRemoveAllFiles = async (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!value) return
const filesToDelete = Array.isArray(value) ? value : [value]
// Mark all files as deleting
const deletingStatus: Record<string, boolean> = {}
filesToDelete.forEach((file) => {
deletingStatus[file.path || ''] = true
})
setDeletingFiles(deletingStatus)
// Clear input state immediately for better UX
setStoreValue(null)
useWorkflowStore.getState().triggerUpdate()
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
// Track successful and failed deletions
const deletionResults = {
success: 0,
failures: [] as string[],
}
// Delete each file
for (const file of filesToDelete) {
try {
const response = await fetch('/api/files/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filePath: file.path }),
})
if (response.ok) {
deletionResults.success++
} else {
const errorData = await response.json().catch(() => ({ error: response.statusText }))
const errorMessage = errorData.error || `Failed to delete file: ${response.status}`
deletionResults.failures.push(`${file.name}: ${errorMessage}`)
}
} catch (error) {
logger.error(`Failed to delete file ${file.name}:`, error)
deletionResults.failures.push(
`${file.name}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
// Show error notification if any deletions failed
if (deletionResults.failures.length > 0) {
if (deletionResults.failures.length === 1) {
logger.error(`Failed to delete file: ${deletionResults.failures[0]}`, activeWorkflowId)
} else {
logger.error(
`Failed to delete ${deletionResults.failures.length} files: ${deletionResults.failures.join('; ')}`,
activeWorkflowId
)
}
}
setDeletingFiles({})
}
// Helper to render a single file item
const renderFileItem = (file: UploadedFile) => {
const fileKey = file.path || ''
const isDeleting = deletingFiles[fileKey]
@@ -544,19 +412,16 @@ export function FileUpload({
return (
<div
key={fileKey}
className='flex items-center justify-between rounded border border-border bg-background px-3 py-2'
className='flex items-center justify-between rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] px-[8px] py-[6px] hover:border-[var(--surface-14)] hover:bg-[var(--surface-9)] dark:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-11)]'
>
<div className='flex-1 truncate pr-2'>
<div className='truncate font-normal text-sm' title={file.name}>
{truncateMiddle(file.name)}
</div>
<div className='text-muted-foreground text-xs'>{formatFileSize(file.size)}</div>
<div className='flex-1 truncate pr-2 text-sm' title={file.name}>
<span className='text-[var(--text-primary)]'>{truncateMiddle(file.name)}</span>
<span className='ml-2 text-[var(--text-muted)]'>({formatFileSize(file.size)})</span>
</div>
<Button
type='button'
variant='ghost'
size='icon'
className='h-8 w-8 shrink-0'
className='h-6 w-6 shrink-0 p-0'
onClick={(e) => handleRemoveFile(file, e)}
disabled={isDeleting}
>
@@ -570,16 +435,15 @@ export function FileUpload({
)
}
// Render a placeholder item for files being uploaded
const renderUploadingItem = (file: UploadingFile) => {
return (
<div
key={file.id}
className='flex items-center justify-between rounded border border-border bg-background px-3 py-2'
className='flex items-center justify-between rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] px-[8px] py-[6px] dark:bg-[var(--surface-9)]'
>
<div className='flex-1 truncate pr-2'>
<div className='truncate font-normal text-sm'>{file.name}</div>
<div className='text-muted-foreground text-xs'>{formatFileSize(file.size)}</div>
<div className='flex-1 truncate pr-2 text-sm'>
<span className='text-[var(--text-primary)]'>{file.name}</span>
<span className='ml-2 text-[var(--text-muted)]'>({formatFileSize(file.size)})</span>
</div>
<div className='flex h-8 w-8 shrink-0 items-center justify-center'>
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
@@ -588,11 +452,43 @@ export function FileUpload({
)
}
// Get files array regardless of multiple setting
const filesArray = Array.isArray(value) ? value : value ? [value] : []
const hasFiles = filesArray.length > 0
const isUploading = uploadingFiles.length > 0
const comboboxOptions = useMemo(
() => [
{ label: 'Upload New File', value: '__upload_new__' },
...availableWorkspaceFiles.map((file) => ({
label: file.name,
value: file.id,
})),
],
[availableWorkspaceFiles]
)
const handleComboboxChange = (value: string) => {
setInputValue(value)
const isValidOption =
value === '__upload_new__' || availableWorkspaceFiles.some((file) => file.id === value)
if (!isValidOption) {
return
}
setInputValue('')
if (value === '__upload_new__') {
handleOpenFileDialog({
preventDefault: () => {},
stopPropagation: () => {},
} as React.MouseEvent)
} else {
handleSelectWorkspaceFile(value)
}
}
return (
<div className='w-full' onClick={(e) => e.stopPropagation()}>
<input
@@ -614,7 +510,6 @@ export function FileUpload({
<div className='mb-2 space-y-2'>
{/* Only show files that aren't currently uploading */}
{filesArray.map((file) => {
// Don't show files that have duplicates in the uploading list
const isCurrentlyUploading = uploadingFiles.some(
(uploadingFile) => uploadingFile.name === file.name
)
@@ -641,73 +536,19 @@ export function FileUpload({
{/* Add More dropdown for multiple files */}
{hasFiles && multiple && !isUploading && (
<div>
<Popover
open={addMoreOpen}
<Combobox
options={comboboxOptions}
value={inputValue}
onChange={handleComboboxChange}
onOpenChange={(open) => {
setAddMoreOpen(open)
if (open) void loadWorkspaceFiles()
}}
>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={addMoreOpen}
className='relative w-full justify-between'
disabled={disabled || loadingWorkspaceFiles}
>
<span className='truncate font-normal'>+ Add More</span>
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[320px] p-0' align='start'>
<Command>
<CommandInput
placeholder='Search files...'
className='text-foreground placeholder:text-muted-foreground'
/>
<CommandList onWheel={(e) => e.stopPropagation()}>
<CommandGroup>
<CommandItem
value='upload_new'
onSelect={() => {
setAddMoreOpen(false)
handleOpenFileDialog({
preventDefault: () => {},
stopPropagation: () => {},
} as React.MouseEvent)
}}
>
Upload New File
</CommandItem>
</CommandGroup>
<CommandEmpty>
{availableWorkspaceFiles.length === 0
? 'No files available.'
: 'No files found.'}
</CommandEmpty>
{availableWorkspaceFiles.length > 0 && (
<CommandGroup heading='Workspace Files'>
{availableWorkspaceFiles.map((file) => (
<CommandItem
key={file.id}
value={file.name}
onSelect={() => {
handleSelectWorkspaceFile(file.id)
setAddMoreOpen(false)
}}
>
<span className='truncate' title={file.name}>
{truncateMiddle(file.name)}
</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
placeholder={loadingWorkspaceFiles ? 'Loading files...' : '+ Add More'}
disabled={disabled || loadingWorkspaceFiles}
editable={true}
filterOptions={true}
isLoading={loadingWorkspaceFiles}
/>
</div>
)}
</div>
@@ -715,75 +556,19 @@ export function FileUpload({
{/* Show dropdown selector if no files and not uploading */}
{!hasFiles && !isUploading && (
<div className='flex items-center'>
<Popover
open={pickerOpen}
<Combobox
options={comboboxOptions}
value={inputValue}
onChange={handleComboboxChange}
onOpenChange={(open) => {
setPickerOpen(open)
if (open) void loadWorkspaceFiles()
}}
>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={pickerOpen}
className='relative w-full justify-between'
disabled={disabled || loadingWorkspaceFiles}
>
<span className='truncate font-normal'>
{loadingWorkspaceFiles ? 'Loading files...' : 'Select or upload file'}
</span>
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[320px] p-0' align='start'>
<Command>
<CommandInput
placeholder='Search files...'
className='text-foreground placeholder:text-muted-foreground'
/>
<CommandList onWheel={(e) => e.stopPropagation()}>
<CommandGroup>
<CommandItem
value='upload_new'
onSelect={() => {
setPickerOpen(false)
handleOpenFileDialog({
preventDefault: () => {},
stopPropagation: () => {},
} as React.MouseEvent)
}}
>
Upload New File
</CommandItem>
</CommandGroup>
<CommandEmpty>
{availableWorkspaceFiles.length === 0
? 'No files available.'
: 'No files found.'}
</CommandEmpty>
{availableWorkspaceFiles.length > 0 && (
<CommandGroup heading='Workspace Files'>
{availableWorkspaceFiles.map((file) => (
<CommandItem
key={file.id}
value={file.name}
onSelect={() => {
handleSelectWorkspaceFile(file.id)
setPickerOpen(false)
}}
>
<span className='truncate' title={file.name}>
{truncateMiddle(file.name)}
</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
placeholder={loadingWorkspaceFiles ? 'Loading files...' : 'Select or upload file'}
disabled={disabled || loadingWorkspaceFiles}
editable={true}
filterOptions={true}
isLoading={loadingWorkspaceFiles}
/>
</div>
)}
</div>

View File

@@ -1,14 +1,12 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import {
type FolderInfo,
FolderSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/folder-selector/folder-selector'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
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'
import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -27,19 +25,19 @@ export function FolderSelectorInput({
isPreview = false,
previewValue,
}: FolderSelectorInputProps) {
const [storeValue, _setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [storeValue] = useSubBlockValue(blockId, subBlock.id)
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const { activeWorkflowId } = useWorkflowRegistry()
const [selectedFolderId, setSelectedFolderId] = useState<string>('')
const [_folderInfo, setFolderInfo] = useState<FolderInfo | null>(null)
const provider = (subBlock.provider || subBlock.serviceId || 'google-email').toLowerCase()
const providerKey = (subBlock.provider ?? subBlock.serviceId ?? '').toLowerCase()
const credentialProvider = subBlock.serviceId ?? subBlock.provider
const isCopyDestinationSelector =
subBlock.canonicalParamId === 'copyDestinationId' ||
subBlock.id === 'copyDestinationFolder' ||
subBlock.id === 'manualCopyDestinationFolder'
const { isForeignCredential } = useForeignCredential(
subBlock.provider || subBlock.serviceId || 'outlook',
credentialProvider,
(connectedCredential as string) || ''
)
@@ -48,26 +46,22 @@ export function FolderSelectorInput({
// Get the current value from the store or prop value if in preview mode
useEffect(() => {
// When gated/disabled, do not set defaults or write to store
if (finalDisabled) return
if (isPreview && previewValue !== undefined) {
setSelectedFolderId(previewValue)
return
}
const current = storeValue as string | undefined
if (current && typeof current === 'string') {
if (current) {
setSelectedFolderId(current)
return
}
const shouldDefaultInbox = provider !== 'outlook' && !isCopyDestinationSelector
const shouldDefaultInbox = providerKey === 'gmail' && !isCopyDestinationSelector
if (shouldDefaultInbox) {
const defaultValue = 'INBOX'
setSelectedFolderId(defaultValue)
setSelectedFolderId('INBOX')
if (!isPreview) {
collaborativeSetSubblockValue(blockId, subBlock.id, defaultValue)
collaborativeSetSubblockValue(blockId, subBlock.id, 'INBOX')
}
} else {
setSelectedFolderId('')
}
}, [
blockId,
@@ -77,33 +71,46 @@ export function FolderSelectorInput({
isPreview,
previewValue,
finalDisabled,
providerKey,
isCopyDestinationSelector,
])
// Handle folder selection
const handleFolderChange = useCallback(
(folderId: string, info?: FolderInfo) => {
setSelectedFolderId(folderId)
setFolderInfo(info || null)
const credentialId = (connectedCredential as string) || ''
const missingCredential = credentialId.length === 0
const selectorResolution = useMemo(
() =>
resolveSelectorForSubBlock(subBlock, {
credentialId: credentialId || undefined,
workflowId: activeWorkflowId || undefined,
}),
[subBlock, credentialId, activeWorkflowId]
)
const handleChange = useCallback(
(value: string) => {
setSelectedFolderId(value)
if (!isPreview) {
collaborativeSetSubblockValue(blockId, subBlock.id, folderId)
collaborativeSetSubblockValue(blockId, subBlock.id, value)
}
},
[blockId, subBlock.id, collaborativeSetSubblockValue, isPreview]
)
return (
<FolderSelector
value={selectedFolderId}
onChange={handleFolderChange}
provider={provider}
requiredScopes={subBlock.requiredScopes || []}
label={subBlock.placeholder || 'Select folder'}
disabled={finalDisabled}
serviceId={subBlock.serviceId}
onFolderInfoChange={setFolderInfo}
credentialId={(connectedCredential as string) || ''}
workflowId={activeWorkflowId || ''}
isForeignCredential={isForeignCredential}
<SelectorCombobox
blockId={blockId}
subBlock={subBlock}
selectorKey={selectorResolution?.key ?? 'gmail.labels'}
selectorContext={
selectorResolution?.context ?? { credentialId, workflowId: activeWorkflowId || '' }
}
disabled={
finalDisabled || isForeignCredential || missingCredential || !selectorResolution?.key
}
isPreview={isPreview}
previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || 'Select folder'}
onOptionChange={handleChange}
/>
)
}

View File

@@ -1,533 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
import { GmailIcon, OutlookIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
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')
export interface FolderInfo {
id: string
name: string
type: string
messagesTotal?: number
messagesUnread?: number
}
interface FolderSelectorProps {
value: string
onChange: (value: string, folderInfo?: FolderInfo) => void
provider: string
requiredScopes?: string[]
label?: string
disabled?: boolean
serviceId?: string
onFolderInfoChange?: (folderInfo: FolderInfo | null) => void
isPreview?: boolean
previewValue?: any | null
credentialId?: string
workflowId?: string
isForeignCredential?: boolean
}
export function FolderSelector({
value,
onChange,
provider,
requiredScopes = [],
label = 'Select folder',
disabled = false,
serviceId,
onFolderInfoChange,
isPreview = false,
previewValue,
credentialId,
workflowId,
isForeignCredential = false,
}: FolderSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [folders, setFolders] = useState<FolderInfo[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<Credential['id'] | ''>(
credentialId || ''
)
const [selectedFolderId, setSelectedFolderId] = useState('')
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) {
setSelectedFolderId(previewValue || '')
} else {
setSelectedFolderId(value)
}
}, [value, isPreview, previewValue])
// Keep internal credential in sync with prop
useEffect(() => {
if (credentialId && credentialId !== selectedCredentialId) {
setSelectedCredentialId(credentialId)
}
}, [credentialId, selectedCredentialId])
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
return getServiceIdFromScopes(provider, requiredScopes)
}
// Determine the appropriate provider ID based on service and scopes
const getProviderId = (): string => {
const effectiveServiceId = getServiceId()
return getProviderIdFromServiceId(effectiveServiceId)
}
// Fetch available credentials for this provider
const fetchCredentials = useCallback(async () => {
setIsLoading(true)
try {
const providerId = getProviderId()
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials)
// Auto-select logic for credentials
if (data.credentials.length > 0) {
// If we already have a selected credential ID, check if it's valid
if (
selectedCredentialId &&
data.credentials.some((cred: Credential) => cred.id === selectedCredentialId)
) {
// Keep the current selection
} else {
// Otherwise, select the default or first credential
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
if (defaultCred) {
setSelectedCredentialId(defaultCred.id)
} else if (data.credentials.length === 1) {
setSelectedCredentialId(data.credentials[0].id)
}
}
}
}
} catch (error) {
logger.error('Error fetching credentials:', { error })
} finally {
setIsLoading(false)
}
}, [provider, getProviderId, selectedCredentialId])
// Fetch a single folder by ID when we have a selectedFolderId but no metadata
const fetchFolderById = useCallback(
async (folderId: string) => {
if (!selectedCredentialId || !folderId) return null
setIsLoadingSelectedFolder(true)
try {
if (provider === 'outlook') {
// Resolve Outlook folder name with owner-scoped token
const tokenRes = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialId: selectedCredentialId, workflowId }),
})
if (!tokenRes.ok) return null
const { accessToken } = await tokenRes.json()
if (!accessToken) return null
const resp = await fetch(
`https://graph.microsoft.com/v1.0/me/mailFolders/${encodeURIComponent(folderId)}`,
{
headers: { Authorization: `Bearer ${accessToken}` },
}
)
if (!resp.ok) return null
const folder = await resp.json()
const folderInfo: FolderInfo = {
id: folder.id,
name: folder.displayName,
type: 'folder',
messagesTotal: folder.totalItemCount,
messagesUnread: folder.unreadItemCount,
}
onFolderInfoChange?.(folderInfo)
return folderInfo
}
// Gmail label resolution
const queryParams = new URLSearchParams({
credentialId: selectedCredentialId,
labelId: folderId,
})
const response = await fetch(`/api/tools/gmail/label?${queryParams.toString()}`)
if (response.ok) {
const data = await response.json()
if (data.label) {
onFolderInfoChange?.(data.label)
return data.label
}
} else {
logger.error('Error fetching folder by ID:', {
error: await response.text(),
})
}
return null
} catch (error) {
logger.error('Error fetching folder by ID:', { error })
return null
} finally {
setIsLoadingSelectedFolder(false)
}
},
[selectedCredentialId, onFolderInfoChange, provider, workflowId]
)
// Fetch folders from Gmail or Outlook
const fetchFolders = useCallback(
async (searchQuery?: string) => {
if (!selectedCredentialId) return
setIsLoading(true)
try {
// Construct query parameters
const queryParams = new URLSearchParams({
credentialId: selectedCredentialId,
})
if (searchQuery) {
queryParams.append('query', searchQuery)
}
// Determine the API endpoint based on provider
let apiEndpoint: string
if (provider === 'outlook') {
// Skip list fetch for collaborators; only show selected
if (isForeignCredential) {
setFolders([])
setIsLoading(false)
return
}
apiEndpoint = `/api/tools/outlook/folders?${queryParams.toString()}`
} else {
// Default to Gmail
apiEndpoint = `/api/tools/gmail/labels?${queryParams.toString()}`
}
const response = await fetch(apiEndpoint)
if (response.ok) {
const data = await response.json()
const folderList = provider === 'outlook' ? data.folders : data.labels
setFolders(folderList || [])
// 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) {
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
fetchFolderById(selectedFolderId)
}
}
} else {
const text = await response.text()
if (response.status === 401 || response.status === 403) {
logger.info('Folder list fetch unauthorized (expected for collaborator)')
} else {
logger.warn('Error fetching folders', { status: response.status, text })
}
setFolders([])
}
} catch (error) {
logger.error('Error fetching folders:', { error })
setFolders([])
} finally {
setIsLoading(false)
}
},
[
selectedCredentialId,
selectedFolderId,
onFolderInfoChange,
fetchFolderById,
provider,
isForeignCredential,
]
)
// Fetch credentials on initial mount
useEffect(() => {
if (disabled) return
if (!initialFetchRef.current) {
fetchCredentials()
initialFetchRef.current = true
}
}, [fetchCredentials, disabled])
// Fetch folders when credential is selected
useEffect(() => {
if (disabled) return
if (selectedCredentialId) {
fetchFolders()
}
}, [selectedCredentialId, fetchFolders, disabled])
// Keep internal selectedFolderId in sync with the value prop
useEffect(() => {
if (disabled) return
const currentValue = isPreview ? previewValue : value
if (currentValue !== selectedFolderId) {
setSelectedFolderId(currentValue || '')
}
}, [value, isPreview, previewValue, disabled, selectedFolderId])
// Handle folder selection
const handleSelectFolder = (folder: FolderInfo) => {
setSelectedFolderId(folder.id)
if (!isPreview) {
onChange(folder.id, folder)
}
onFolderInfoChange?.(folder)
setOpen(false)
}
// Handle adding a new credential
const handleAddCredential = () => {
// Show the OAuth modal
setShowOAuthModal(true)
setOpen(false)
}
const handleSearch = (value: string) => {
if (value.length > 2) {
fetchFolders(value)
} else if (value.length === 0) {
fetchFolders()
}
}
const getFolderIcon = (size: 'sm' | 'md' = 'sm') => {
const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5'
if (provider === 'gmail') {
return <GmailIcon className={iconSize} />
}
if (provider === 'outlook') {
return <OutlookIcon className={iconSize} />
}
return null
}
const getProviderName = () => {
if (provider === 'outlook') return 'Outlook'
return 'Gmail'
}
const getFolderLabel = () => {
if (provider === 'outlook') return 'folders'
return 'labels'
}
return (
<>
<div className='space-y-2'>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='w-full justify-between'
disabled={disabled || isForeignCredential}
>
{cachedFolderName ? (
<div className='flex items-center gap-2 overflow-hidden'>
{getFolderIcon('sm')}
<span className='truncate font-normal'>{cachedFolderName}</span>
</div>
) : (
<div className='flex items-center gap-2'>
{getFolderIcon('sm')}
<span className='text-muted-foreground'>{label}</span>
</div>
)}
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
{!isForeignCredential && (
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
)}
<Command>
<CommandInput
placeholder={`Search ${getFolderLabel()}...`}
onValueChange={handleSearch}
/>
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading {getFolderLabel()}...</span>
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a {getProviderName()} account to continue.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No {getFolderLabel()} found.</p>
<p className='text-muted-foreground text-xs'>
Try a different search or account.
</p>
</div>
)}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && (
<Check className='ml-auto h-4 w-4' />
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Folders list */}
{folders.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
{getFolderLabel().charAt(0).toUpperCase() + getFolderLabel().slice(1)}
</div>
{folders.map((folder) => (
<CommandItem
key={folder.id}
value={`folder-${folder.id}-${folder.name}`}
onSelect={() => handleSelectFolder(folder)}
>
<div className='flex w-full items-center gap-2 overflow-hidden'>
{getFolderIcon('sm')}
<span className='truncate font-normal'>{folder.name}</span>
{folder.id === selectedFolderId && (
<Check className='ml-auto h-4 w-4' />
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-foreground'>
<span>Connect {getProviderName()} account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
</div>
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName={getProviderName()}
requiredScopes={requiredScopes}
serviceId={getServiceId()}
/>
)}
</>
)
}

View File

@@ -1,17 +1,8 @@
import { useCallback, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useParams } from 'next/navigation'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Combobox, Input, Label, Textarea } from '@/components/emcn/components'
import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
@@ -174,7 +165,6 @@ function McpTextareaWithTags({
onChange(newValue)
setCursorPosition(newCursorPosition)
// Check for tag trigger
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
setShowTags(tagTrigger.show)
}
@@ -308,7 +298,6 @@ export function McpDynamicArgs({
if (disabled) return
const current = currentArgs()
// Store the value as-is, preserving types (number, boolean, etc.)
const updated = { ...current, [paramName]: value }
setToolArgs(updated)
},
@@ -357,29 +346,38 @@ export function McpDynamicArgs({
</div>
)
case 'dropdown':
case 'dropdown': {
const dropdownOptions = useMemo(
() =>
(paramSchema.enum || []).map((option: any) => ({
label: String(option),
value: String(option),
})),
[paramSchema.enum]
)
return (
<div key={`${paramName}-dropdown`}>
<Select
<Combobox
options={dropdownOptions}
value={value || ''}
onValueChange={(selectedValue) => updateParameter(paramName, selectedValue)}
selectedValue={value || ''}
onChange={(selectedValue) => {
const matchedOption = dropdownOptions.find(
(opt: { label: string; value: string }) => opt.value === selectedValue
)
if (matchedOption) {
updateParameter(paramName, selectedValue)
}
}}
placeholder={`Select ${formatParameterLabel(paramName).toLowerCase()}`}
disabled={disabled}
>
<SelectTrigger className='w-full'>
<SelectValue
placeholder={`Select ${formatParameterLabel(paramName).toLowerCase()}`}
/>
</SelectTrigger>
<SelectContent>
{paramSchema.enum?.map((option: any) => (
<SelectItem key={String(option)} value={String(option)}>
{String(option)}
</SelectItem>
))}
</SelectContent>
</Select>
editable={false}
filterOptions={true}
/>
</div>
)
}
case 'slider': {
const minValue = paramSchema.minimum ?? 0

View File

@@ -1,18 +1,8 @@
'use client'
import { useState } from 'react'
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Combobox } from '@/components/emcn/components'
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'
import { useMcpServers } from '@/hooks/queries/mcp'
@@ -34,7 +24,7 @@ export function McpServerSelector({
}: McpServerSelectorProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const [open, setOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
const { data: servers = [], isLoading, error } = useMcpServers(workspaceId)
const enabledServers = servers.filter((s) => s.enabled && !s.deletedAt)
@@ -48,87 +38,47 @@ export function McpServerSelector({
const selectedServer = enabledServers.find((server) => server.id === selectedServerId)
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
// React Query automatically keeps server list fresh
}
const comboboxOptions = useMemo(
() =>
enabledServers.map((server) => ({
label: server.name,
value: server.id,
})),
[enabledServers]
)
const handleSelect = (serverId: string) => {
if (!isPreview) {
setStoreValue(serverId)
const handleComboboxChange = (value: string) => {
const matchedServer = enabledServers.find((s) => s.id === value)
if (matchedServer) {
setInputValue(matchedServer.name)
if (!isPreview) {
setStoreValue(value)
}
} else {
setInputValue(value)
}
setOpen(false)
}
const getDisplayText = () => {
useEffect(() => {
if (selectedServer) {
return <span className='truncate font-normal'>{selectedServer.name}</span>
setInputValue(selectedServer.name)
} else {
setInputValue('')
}
return <span className='truncate text-muted-foreground'>{label}</span>
}
}, [selectedServer])
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='relative w-full justify-between'
disabled={disabled}
>
<div className='flex max-w-[calc(100%-20px)] items-center overflow-hidden'>
{getDisplayText()}
</div>
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[250px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search servers...' />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading servers...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='font-medium text-destructive text-sm'>Error loading servers</p>
<p className='text-muted-foreground text-xs'>
{error instanceof Error ? error.message : 'Unknown error'}
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No MCP servers found</p>
<p className='text-muted-foreground text-xs'>
Configure MCP servers in workspace settings
</p>
</div>
)}
</CommandEmpty>
{enabledServers.length > 0 && (
<CommandGroup>
{enabledServers.map((server) => (
<CommandItem
key={server.id}
value={`server-${server.id}-${server.name}`}
onSelect={() => handleSelect(server.id)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<span className='truncate font-normal'>{server.name}</span>
</div>
{server.id === selectedServerId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Combobox
options={comboboxOptions}
value={inputValue}
selectedValue={selectedServerId}
onChange={handleComboboxChange}
placeholder={label}
disabled={disabled}
editable={true}
filterOptions={true}
isLoading={isLoading}
error={error instanceof Error ? error.message : null}
/>
)
}

View File

@@ -1,18 +1,8 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Combobox } from '@/components/emcn/components'
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'
import { useMcpTools } from '@/hooks/use-mcp-tools'
@@ -34,7 +24,7 @@ export function McpToolSelector({
}: McpToolSelectorProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const [open, setOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
const { mcpTools, isLoading, error, refreshTools, getToolsByServer } = useMcpTools(workspaceId)
@@ -73,105 +63,59 @@ export function McpToolSelector({
}
}, [serverValue, availableTools, storeValue, setStoreValue, isPreview, disabled])
const comboboxOptions = useMemo(
() =>
availableTools.map((tool) => ({
label: tool.name,
value: tool.id,
})),
[availableTools]
)
const handleComboboxChange = (value: string) => {
const matchedTool = availableTools.find((t) => t.id === value)
if (matchedTool) {
setInputValue(matchedTool.name)
if (!isPreview) {
setStoreValue(value)
if (matchedTool.inputSchema) {
setSchemaCache(matchedTool.inputSchema)
}
}
} else {
setInputValue(value)
}
}
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
if (isOpen && serverValue) {
refreshTools()
}
}
const handleSelect = (toolId: string) => {
if (!isPreview) {
setStoreValue(toolId)
const tool = availableTools.find((t) => t.id === toolId)
if (tool?.inputSchema) {
setSchemaCache(tool.inputSchema)
}
}
setOpen(false)
}
const getDisplayText = () => {
useEffect(() => {
if (selectedTool) {
return <span className='truncate font-normal'>{selectedTool.name}</span>
setInputValue(selectedTool.name)
} else {
setInputValue('')
}
return (
<span className='truncate text-muted-foreground'>
{serverValue ? label : 'Select server first'}
</span>
)
}
}, [selectedTool])
const isDisabled = disabled || !serverValue
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='relative w-full justify-between'
disabled={isDisabled}
>
<div className='flex max-w-[calc(100%-20px)] items-center overflow-hidden'>
{getDisplayText()}
</div>
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[250px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search tools...' />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading tools...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='font-medium text-destructive text-sm'>Error loading tools</p>
<p className='text-muted-foreground text-xs'>{error}</p>
</div>
) : !serverValue ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No server selected</p>
<p className='text-muted-foreground text-xs'>
Select an MCP server first to see available tools
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No tools found</p>
<p className='text-muted-foreground text-xs'>
The selected server has no available tools
</p>
</div>
)}
</CommandEmpty>
{availableTools.length > 0 && (
<CommandGroup>
{availableTools.map((tool) => (
<CommandItem
key={tool.id}
value={`tool-${tool.id}-${tool.name}`}
onSelect={() => handleSelect(tool.id)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<span className='truncate font-normal'>{tool.name}</span>
</div>
{tool.id === selectedToolId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Combobox
options={comboboxOptions}
value={inputValue}
selectedValue={selectedToolId}
onChange={handleComboboxChange}
onOpenChange={handleOpenChange}
placeholder={serverValue ? label : 'Select server first'}
disabled={isDisabled}
editable={true}
filterOptions={true}
isLoading={isLoading}
error={error || null}
/>
)
}

View File

@@ -1,638 +0,0 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
import { JiraIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
import {
type Credential,
getProviderIdFromServiceId,
getServiceIdFromScopes,
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')
export interface JiraProjectInfo {
id: string
key: string
name: string
url?: string
avatarUrl?: string
description?: string
projectTypeKey?: string
simplified?: boolean
style?: string
isPrivate?: boolean
}
interface JiraProjectSelectorProps {
value: string
onChange: (value: string, projectInfo?: JiraProjectInfo) => void
provider: OAuthProvider
requiredScopes?: string[]
label?: string
disabled?: boolean
serviceId?: string
domain: string
showPreview?: boolean
onProjectInfoChange?: (projectInfo: JiraProjectInfo | null) => void
credentialId?: string
isForeignCredential?: boolean
workflowId?: string
}
export function JiraProjectSelector({
value,
onChange,
provider,
requiredScopes = [],
label = 'Select Jira project',
disabled = false,
serviceId,
domain,
showPreview = true,
onProjectInfoChange,
credentialId,
isForeignCredential = false,
workflowId,
}: JiraProjectSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
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)
const handleSearch = (value: string) => {
// Clear any existing timeout
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
// Set a new timeout
searchTimeoutRef.current = setTimeout(() => {
if (value.length >= 1) {
fetchProjects(value)
} else {
fetchProjects() // Fetch all projects if no search term
}
}, 500) // 500ms debounce
}
// Clean up the timeout on unmount
useEffect(() => {
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
}
}, [])
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
return getServiceIdFromScopes(provider, requiredScopes)
}
// Determine the appropriate provider ID based on service and scopes (stabilized)
const providerId = useMemo(() => {
const effectiveServiceId = getServiceId()
return getProviderIdFromServiceId(effectiveServiceId)
}, [serviceId, provider, requiredScopes])
// Fetch available credentials for this provider
const fetchCredentials = useCallback(async () => {
if (!providerId) return
setIsLoading(true)
try {
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials)
// Do not auto-select credentials. Only use the credentialId provided by the parent.
}
} catch (error) {
logger.error('Error fetching credentials:', error)
} finally {
setIsLoading(false)
}
}, [providerId])
// Fetch detailed project information
const fetchProjectInfo = useCallback(
async (projectId: string) => {
if (!selectedCredentialId || !domain || !projectId) return
setIsLoading(true)
setError(null)
try {
// Get the access token from the selected credential
const tokenResponse = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credentialId: selectedCredentialId,
workflowId,
}),
})
if (!tokenResponse.ok) {
const errorData = await tokenResponse.json()
logger.error('Access token error:', errorData)
setError('Authentication failed. Please reconnect your Jira account.')
return
}
const tokenData = await tokenResponse.json()
const accessToken = tokenData.accessToken
if (!accessToken) {
logger.error('No access token returned')
setError('Authentication failed. Please reconnect your Jira account.')
return
}
// Use POST /api/tools/jira/projects to fetch a single project by id
const response = await fetch(`/api/tools/jira/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain, accessToken, projectId, cloudId }),
})
if (!response.ok) {
const errorData = await response.json()
logger.error('Jira API error:', errorData)
throw new Error(errorData.error || 'Failed to fetch project details')
}
const json = await response.json()
const projectInfo = json?.project
const newCloudId = json?.cloudId
if (newCloudId) {
setCloudId(newCloudId)
}
if (projectInfo) {
setSelectedProject(projectInfo)
onProjectInfoChange?.(projectInfo)
} else {
setSelectedProject(null)
onProjectInfoChange?.(null)
}
} catch (error) {
logger.error('Error fetching project details:', error)
setError((error as Error).message)
} finally {
setIsLoading(false)
}
},
[selectedCredentialId, domain, onProjectInfoChange, cloudId]
)
// Fetch projects from Jira
const fetchProjects = useCallback(
async (searchQuery?: string) => {
if (!selectedCredentialId || !domain) return
// Validate domain format
const trimmedDomain = domain.trim().toLowerCase()
if (!trimmedDomain.includes('.')) {
setError(
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
)
setProjects([])
setIsLoading(false)
return
}
setIsLoading(true)
setError(null)
try {
// Get the access token from the selected credential
const tokenResponse = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credentialId: selectedCredentialId,
workflowId,
}),
})
if (!tokenResponse.ok) {
const errorData = await tokenResponse.json()
logger.error('Access token error:', errorData)
setError('Authentication failed. Please reconnect your Jira account.')
setIsLoading(false)
return
}
const tokenData = await tokenResponse.json()
const accessToken = tokenData.accessToken
if (!accessToken) {
logger.error('No access token returned')
setError('Authentication failed. Please reconnect your Jira account.')
setIsLoading(false)
return
}
// Build query parameters for the projects endpoint
const queryParams = new URLSearchParams({
domain,
accessToken,
...(searchQuery && { query: searchQuery }),
...(cloudId && { cloudId }),
})
// Use the GET endpoint for project search
const response = await fetch(`/api/tools/jira/projects?${queryParams.toString()}`)
if (!response.ok) {
const errorData = await response.json()
logger.error('Jira API error:', errorData)
throw new Error(errorData.error || 'Failed to fetch projects')
}
const data = await response.json()
if (data.cloudId) {
setCloudId(data.cloudId)
}
// Process the projects results
const foundProjects = data.projects || []
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
fetchProjectInfo(selectedProjectId)
}
}
} catch (error) {
logger.error('Error fetching projects:', error)
setError((error as Error).message)
setProjects([])
} finally {
setIsLoading(false)
}
},
[
selectedCredentialId,
domain,
selectedProjectId,
onProjectInfoChange,
fetchProjectInfo,
cloudId,
]
)
// Fetch credentials list when dropdown opens (for account switching UI), not on mount
useEffect(() => {
if (open) {
fetchCredentials()
}
}, [open, fetchCredentials])
// Keep local credential state in sync with persisted credential
useEffect(() => {
if (credentialId && credentialId !== selectedCredentialId) {
setSelectedCredentialId(credentialId)
}
}, [credentialId, selectedCredentialId])
// Keep internal selectedProjectId in sync with the value prop
useEffect(() => {
if (value !== selectedProjectId) {
setSelectedProjectId(value)
}
}, [value, selectedProjectId])
// Clear callback when value is cleared
useEffect(() => {
if (!value) {
setSelectedProject(null)
onProjectInfoChange?.(null)
}
}, [value, onProjectInfoChange])
// Fetch project info on mount if we have a value but no selectedProject state
useEffect(() => {
if (value && selectedCredentialId && domain && !selectedProject) {
fetchProjectInfo(value)
}
}, [value, selectedCredentialId, domain, selectedProject, fetchProjectInfo])
// Handle open change
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
// Only fetch projects when a credential is present; otherwise, do nothing
if (isOpen && selectedCredentialId && domain && domain.includes('.')) {
fetchProjects('')
}
}
// Handle project selection
const handleSelectProject = (project: JiraProjectInfo) => {
setSelectedProjectId(project.id)
setSelectedProject(project)
onChange(project.id, project)
onProjectInfoChange?.(project)
setOpen(false)
}
// Handle adding a new credential
const handleAddCredential = () => {
// Show the OAuth modal
setShowOAuthModal(true)
setOpen(false)
}
// Clear selection
const handleClearSelection = () => {
setSelectedProjectId('')
setSelectedProject(null)
setError(null)
onChange('', undefined)
onProjectInfoChange?.(null)
}
return (
<>
<div className='space-y-2'>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='w-full justify-between'
disabled={disabled || !domain || !selectedCredentialId || isForeignCredential}
>
{cachedProjectName ? (
<div className='flex items-center gap-2 overflow-hidden'>
<JiraIcon className='h-4 w-4' />
<span className='truncate font-normal'>{cachedProjectName}</span>
</div>
) : (
<div className='flex items-center gap-2'>
<JiraIcon className='h-4 w-4' />
<span className='text-muted-foreground'>{label}</span>
</div>
)}
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
{!isForeignCredential && (
<PopoverContent className='w-[300px] p-0' align='start'>
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<JiraIcon className='h-4 w-4' />
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
)}
<Command>
<CommandInput placeholder='Search projects...' onValueChange={handleSearch} />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading projects...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a Jira account to continue.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No projects found.</p>
<p className='text-muted-foreground text-xs'>
Try a different search or account.
</p>
</div>
)}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
<JiraIcon className='h-4 w-4' />
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && (
<Check className='ml-auto h-4 w-4' />
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Projects list */}
{projects.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Projects
</div>
{projects.map((project) => (
<CommandItem
key={project.id}
value={`project-${project.id}-${project.name}`}
onSelect={() => handleSelectProject(project)}
>
<div className='flex items-center gap-2 overflow-hidden'>
{project.avatarUrl ? (
<img
src={project.avatarUrl}
alt={project.name}
className='h-4 w-4 rounded'
/>
) : (
<JiraIcon className='h-4 w-4' />
)}
<span className='truncate font-normal'>{project.name}</span>
</div>
{project.id === selectedProjectId && (
<Check className='ml-auto h-4 w-4' />
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-foreground'>
<JiraIcon className='h-4 w-4' />
<span>Connect Jira account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
{/* Project preview */}
{showPreview && selectedProject && (
<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-6 w-6 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>
) : null}
</div>
</div>
</div>
)}
</div>
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName='Jira'
requiredScopes={requiredScopes}
serviceId={getServiceId()}
/>
)}
</>
)
}

View File

@@ -1,196 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
import { LinearIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
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
name: string
}
interface LinearProjectSelectorProps {
value: string
onChange: (projectId: string, projectInfo?: LinearProjectInfo) => void
credential: string
teamId: string
label?: string
disabled?: boolean
workflowId?: string
}
export function LinearProjectSelector({
value,
onChange,
credential,
teamId,
label = 'Select Linear project',
disabled = false,
workflowId,
}: LinearProjectSelectorProps) {
const [projects, setProjects] = useState<LinearProjectInfo[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
// 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
const controller = new AbortController()
setLoading(true)
setError(null)
fetch('/api/tools/linear/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential, teamId, workflowId }),
signal: controller.signal,
})
.then(async (res) => {
if (!res.ok) {
const errorText = await res.text()
throw new Error(`HTTP error! status: ${res.status} - ${errorText}`)
}
return res.json()
})
.then((data) => {
if (data.error) {
setError(data.error)
setProjects([])
} else {
setProjects(data.projects)
// 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)
}
}
})
.catch((err) => {
if (err.name === 'AbortError') return
setError(err.message)
setProjects([])
})
.finally(() => setLoading(false))
return () => controller.abort()
}, [credential, teamId, value, workflowId])
const handleSelectProject = (project: LinearProjectInfo) => {
onChange(project.id, project)
setOpen(false)
}
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='w-full justify-between'
disabled={disabled || !credential || !teamId}
>
{cachedProjectName ? (
<div className='flex items-center gap-2 overflow-hidden'>
<LinearIcon className='h-4 w-4' />
<span className='truncate font-normal'>{cachedProjectName}</span>
</div>
) : (
<div className='flex items-center gap-2'>
<LinearIcon className='h-4 w-4' />
<span className='text-muted-foreground'>{label}</span>
</div>
)}
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search projects...' />
<CommandList>
<CommandEmpty>
{loading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading projects...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : !credential || !teamId ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>Missing credentials or team</p>
<p className='text-muted-foreground text-xs'>
Please configure Linear credentials and select a team.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No projects found</p>
<p className='text-muted-foreground text-xs'>
No projects available for the selected team.
</p>
</div>
)}
</CommandEmpty>
{projects.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Projects
</div>
{projects.map((project) => (
<CommandItem
key={project.id}
value={`project-${project.id}-${project.name}`}
onSelect={() => handleSelectProject(project)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<LinearIcon className='h-4 w-4' />
<span className='truncate font-normal'>{project.name}</span>
</div>
{project.id === value && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,190 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
import { LinearIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
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
name: string
}
interface LinearTeamSelectorProps {
value: string
onChange: (teamId: string, teamInfo?: LinearTeamInfo) => void
credential: string
label?: string
disabled?: boolean
workflowId?: string
showPreview?: boolean
}
export function LinearTeamSelector({
value,
onChange,
credential,
label = 'Select Linear team',
disabled = false,
workflowId,
}: LinearTeamSelectorProps) {
const [teams, setTeams] = useState<LinearTeamInfo[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
// 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
const controller = new AbortController()
setLoading(true)
setError(null)
fetch('/api/tools/linear/teams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential, workflowId }),
signal: controller.signal,
})
.then((res) => {
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`)
return res.json()
})
.then((data) => {
if (data.error) {
setError(data.error)
setTeams([])
} else {
setTeams(data.teams)
// 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)
}
}
})
.catch((err) => {
if (err.name === 'AbortError') return
setError(err.message)
setTeams([])
})
.finally(() => setLoading(false))
return () => controller.abort()
}, [credential, value, workflowId])
const handleSelectTeam = (team: LinearTeamInfo) => {
onChange(team.id, team)
setOpen(false)
}
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='w-full justify-between'
disabled={disabled || !credential}
>
{cachedTeamName ? (
<div className='flex items-center gap-2 overflow-hidden'>
<LinearIcon className='h-4 w-4' />
<span className='truncate font-normal'>{cachedTeamName}</span>
</div>
) : (
<div className='flex items-center gap-2'>
<LinearIcon className='h-4 w-4' />
<span className='text-muted-foreground'>{label}</span>
</div>
)}
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search teams...' />
<CommandList>
<CommandEmpty>
{loading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading teams...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : !credential ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>Missing credentials</p>
<p className='text-muted-foreground text-xs'>
Please configure Linear credentials.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No teams found</p>
<p className='text-muted-foreground text-xs'>
No teams available for this Linear account.
</p>
</div>
)}
</CommandEmpty>
{teams.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>Teams</div>
{teams.map((team) => (
<CommandItem
key={team.id}
value={`team-${team.id}-${team.name}`}
onSelect={() => handleSelectTeam(team)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<LinearIcon className='h-4 w-4' />
<span className='truncate font-normal'>{team.name}</span>
</div>
{team.id === value && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,23 +1,14 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import {
type JiraProjectInfo,
JiraProjectSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/jira-project-selector'
import {
type LinearProjectInfo,
LinearProjectSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-project-selector'
import {
type LinearTeamInfo,
LinearTeamSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-team-selector'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
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'
import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -41,10 +32,10 @@ export function ProjectSelectorInput({
previewContextValues,
}: ProjectSelectorInputProps) {
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const params = useParams()
const [selectedProjectId, setSelectedProjectId] = useState<string>('')
const [_projectInfo, setProjectInfo] = useState<any | null>(null)
// Use the proper hook to get the current value and setter
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [storeValue] = useSubBlockValue(blockId, subBlock.id)
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
const [linearTeamIdFromStore] = useSubBlockValue(blockId, 'teamId')
const [jiraDomainFromStore] = useSubBlockValue(blockId, 'domain')
@@ -60,6 +51,7 @@ export function ProjectSelectorInput({
(connectedCredential as string) || ''
)
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
disabled,
isPreview,
@@ -87,91 +79,58 @@ export function ProjectSelectorInput({
}
}, [isPreview, previewValue, storeValue])
// Handle project selection
const handleProjectChange = (
projectId: string,
info?: JiraProjectInfo | LinearTeamInfo | LinearProjectInfo
) => {
setSelectedProjectId(projectId)
setProjectInfo(info || null)
setStoreValue(projectId)
const selectorResolution = useMemo(() => {
return resolveSelectorForSubBlock(subBlock, {
workflowId: workflowIdFromUrl || undefined,
credentialId: (isLinear ? linearCredential : jiraCredential) as string | undefined,
domain,
teamId: (linearTeamId as string) || undefined,
})
}, [
subBlock,
workflowIdFromUrl,
isLinear,
linearCredential,
jiraCredential,
domain,
linearTeamId,
])
onProjectSelect?.(projectId)
const missingCredential = !selectorResolution?.context.credentialId
const handleChange = (value: string) => {
setSelectedProjectId(value)
onProjectSelect?.(value)
}
// Discord no longer uses a server selector; fall through to other providers
// Render Linear team/project selector if provider is linear
if (isLinear) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
{subBlock.id === 'teamId' ? (
<LinearTeamSelector
value={selectedProjectId}
onChange={(teamId: string, teamInfo?: LinearTeamInfo) => {
handleProjectChange(teamId, teamInfo)
}}
credential={(linearCredential as string) || ''}
label={subBlock.placeholder || 'Select Linear team'}
disabled={finalDisabled}
showPreview={true}
workflowId={activeWorkflowId || ''}
/>
) : (
(() => {
const credential = (linearCredential as string) || ''
const teamId = (linearTeamId as string) || ''
const isDisabled = finalDisabled
return (
<LinearProjectSelector
value={selectedProjectId}
onChange={(projectId: string, projectInfo?: LinearProjectInfo) => {
handleProjectChange(projectId, projectInfo)
}}
credential={credential}
teamId={teamId}
label={subBlock.placeholder || 'Select Linear project'}
disabled={isDisabled}
workflowId={activeWorkflowId || ''}
/>
)
})()
)}
</div>
</Tooltip.Trigger>
{!(linearCredential as string) && (
<Tooltip.Content side='top'>
<p>Please select a Linear account first</p>
</Tooltip.Content>
)}
</Tooltip.Root>
)
}
// Default to Jira project selector
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<JiraProjectSelector
value={selectedProjectId}
onChange={handleProjectChange}
domain={domain}
provider='jira'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Jira project'}
disabled={finalDisabled}
showPreview={true}
onProjectInfoChange={setProjectInfo}
credentialId={(jiraCredential as string) || ''}
isForeignCredential={isForeignCredential}
workflowId={activeWorkflowId || ''}
/>
{selectorResolution?.key ? (
<SelectorCombobox
blockId={blockId}
subBlock={subBlock}
selectorKey={selectorResolution.key}
selectorContext={selectorResolution.context}
disabled={finalDisabled || isForeignCredential || missingCredential}
isPreview={isPreview}
previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || 'Select project'}
onOptionChange={handleChange}
/>
) : (
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
Project selector not supported for provider: {subBlock.provider || 'unknown'}
</div>
)}
</div>
</Tooltip.Trigger>
{missingCredential && (
<Tooltip.Content side='top'>
<p>Please select an account first</p>
</Tooltip.Content>
)}
</Tooltip.Root>
)
}

View File

@@ -0,0 +1,145 @@
import type React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Combobox as EditableCombobox } from '@/components/emcn/components'
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/sub-block-input-controller'
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'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
import {
useSelectorOptionDetail,
useSelectorOptionMap,
useSelectorOptions,
} from '@/hooks/selectors/use-selector-query'
interface SelectorComboboxProps {
blockId: string
subBlock: SubBlockConfig
selectorKey: SelectorKey
selectorContext: SelectorContext
disabled?: boolean
isPreview?: boolean
previewValue?: string | null
placeholder?: string
readOnly?: boolean
onOptionChange?: (value: string) => void
allowSearch?: boolean
}
export function SelectorCombobox({
blockId,
subBlock,
selectorKey,
selectorContext,
disabled,
isPreview,
previewValue,
placeholder,
readOnly,
onOptionChange,
allowSearch = true,
}: SelectorComboboxProps) {
const [storeValueRaw, setStoreValue] = useSubBlockValue<string | null | undefined>(
blockId,
subBlock.id
)
const storeValue = storeValueRaw ?? undefined
const previewedValue = previewValue ?? undefined
const activeValue: string | undefined = isPreview ? previewedValue : storeValue
const [searchTerm, setSearchTerm] = useState('')
const [isEditing, setIsEditing] = useState(false)
const {
data: options = [],
isLoading,
error,
} = useSelectorOptions(selectorKey, {
context: selectorContext,
search: allowSearch ? searchTerm : undefined,
})
const { data: detailOption } = useSelectorOptionDetail(selectorKey, {
context: selectorContext,
detailId: activeValue,
})
const optionMap = useSelectorOptionMap(options, detailOption ?? undefined)
const selectedLabel = activeValue ? (optionMap.get(activeValue)?.label ?? activeValue) : ''
const [inputValue, setInputValue] = useState(selectedLabel)
const previousActiveValue = useRef<string | undefined>(activeValue)
useEffect(() => {
if (previousActiveValue.current !== activeValue) {
previousActiveValue.current = activeValue
setIsEditing(false)
}
}, [activeValue])
useEffect(() => {
if (!allowSearch) return
if (!isEditing) {
setInputValue(selectedLabel)
}
}, [selectedLabel, allowSearch, isEditing])
const comboboxOptions = useMemo(
() =>
Array.from(optionMap.values()).map((option) => ({
label: option.label,
value: option.id,
})),
[optionMap]
)
const handleSelection = useCallback(
(value: string) => {
if (readOnly || disabled) return
setStoreValue(value)
setIsEditing(false)
onOptionChange?.(value)
},
[setStoreValue, onOptionChange, readOnly, disabled]
)
return (
<div className='w-full'>
<SubBlockInputController
blockId={blockId}
subBlockId={subBlock.id}
config={subBlock}
value={activeValue ?? ''}
disabled={disabled || readOnly}
isPreview={isPreview}
>
{({ ref, onDrop, onDragOver }) => (
<EditableCombobox
options={comboboxOptions}
value={allowSearch ? inputValue : selectedLabel}
selectedValue={activeValue ?? ''}
onChange={(newValue) => {
const matched = optionMap.get(newValue)
if (matched) {
setInputValue(matched.label)
setIsEditing(false)
handleSelection(matched.id)
return
}
if (allowSearch) {
setInputValue(newValue)
setIsEditing(true)
setSearchTerm(newValue)
}
}}
placeholder={placeholder || subBlock.placeholder || 'Select an option'}
disabled={disabled || readOnly}
editable={allowSearch}
filterOptions={allowSearch}
inputRef={ref as React.RefObject<HTMLInputElement>}
inputProps={{
onDrop: onDrop as (e: React.DragEvent<HTMLInputElement>) => void,
onDragOver: onDragOver as (e: React.DragEvent<HTMLInputElement>) => void,
}}
isLoading={isLoading}
error={error instanceof Error ? error.message : null}
/>
)}
</SubBlockInputController>
</div>
)
}

View File

@@ -1,588 +0,0 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { createLogger } from '@/lib/logs/console/logger'
import type { McpTransport } from '@/lib/mcp/types'
import {
checkEnvVarTrigger,
EnvVarDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useCreateMcpServer } from '@/hooks/queries/mcp'
import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
const logger = createLogger('McpServerModal')
interface McpServerModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onServerCreated?: () => void
blockId: string
}
interface McpServerFormData {
name: string
transport: McpTransport
url?: string
headers?: Record<string, string>
}
export function McpServerModal({
open,
onOpenChange,
onServerCreated,
blockId,
}: McpServerModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const [formData, setFormData] = useState<McpServerFormData>({
name: '',
transport: 'streamable-http',
url: '',
headers: { '': '' },
})
const createServerMutation = useCreateMcpServer()
const [localError, setLocalError] = useState<string | null>(null)
// MCP server testing
const { testResult, isTestingConnection, testConnection, clearTestResult } = useMcpServerTest()
// Environment variable dropdown state
const [showEnvVars, setShowEnvVars] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [cursorPosition, setCursorPosition] = useState(0)
const [activeInputField, setActiveInputField] = useState<
'url' | 'header-key' | 'header-value' | null
>(null)
const [activeHeaderIndex, setActiveHeaderIndex] = useState<number | null>(null)
const urlInputRef = useRef<HTMLInputElement>(null)
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
const error = localError || createServerMutation.error?.message
const resetForm = () => {
setFormData({
name: '',
transport: 'streamable-http',
url: '',
headers: { '': '' },
})
setLocalError(null)
createServerMutation.reset()
setShowEnvVars(false)
setActiveInputField(null)
setActiveHeaderIndex(null)
clearTestResult()
}
// Handle environment variable selection
const handleEnvVarSelect = useCallback(
(newValue: string) => {
if (activeInputField === 'url') {
setFormData((prev) => ({ ...prev, url: newValue }))
} else if (activeInputField === 'header-key' && activeHeaderIndex !== null) {
const headerEntries = Object.entries(formData.headers || {})
const [oldKey, value] = headerEntries[activeHeaderIndex]
const newHeaders = { ...formData.headers }
delete newHeaders[oldKey]
newHeaders[newValue.replace(/[{}]/g, '')] = value
setFormData((prev) => ({ ...prev, headers: newHeaders }))
} else if (activeInputField === 'header-value' && activeHeaderIndex !== null) {
const headerEntries = Object.entries(formData.headers || {})
const [key] = headerEntries[activeHeaderIndex]
setFormData((prev) => ({
...prev,
headers: { ...prev.headers, [key]: newValue },
}))
}
setShowEnvVars(false)
setActiveInputField(null)
setActiveHeaderIndex(null)
},
[activeInputField, activeHeaderIndex, formData.headers]
)
// Handle input change with env var detection
const handleInputChange = useCallback(
(field: 'url' | 'header-key' | 'header-value', value: string, headerIndex?: number) => {
const input = document.activeElement as HTMLInputElement
const pos = input?.selectionStart || 0
setCursorPosition(pos)
// Clear test result when any field changes
if (testResult) {
clearTestResult()
}
// Check if we should show the environment variables dropdown
const envVarTrigger = checkEnvVarTrigger(value, pos)
setShowEnvVars(envVarTrigger.show)
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
if (envVarTrigger.show) {
setActiveInputField(field)
setActiveHeaderIndex(headerIndex ?? null)
} else {
setActiveInputField(null)
setActiveHeaderIndex(null)
}
// Update form data
if (field === 'url') {
setFormData((prev) => ({ ...prev, url: value }))
} else if (field === 'header-key' && headerIndex !== undefined) {
const headerEntries = Object.entries(formData.headers || {})
const [oldKey, headerValue] = headerEntries[headerIndex]
const newHeaders = { ...formData.headers }
delete newHeaders[oldKey]
newHeaders[value] = headerValue
// Add a new empty header row if this is the last row and both key and value have content
const isLastRow = headerIndex === headerEntries.length - 1
const hasContent = value.trim() !== '' && headerValue.trim() !== ''
if (isLastRow && hasContent) {
newHeaders[''] = ''
}
setFormData((prev) => ({ ...prev, headers: newHeaders }))
} else if (field === 'header-value' && headerIndex !== undefined) {
const headerEntries = Object.entries(formData.headers || {})
const [key] = headerEntries[headerIndex]
const newHeaders = { ...formData.headers, [key]: value }
// Add a new empty header row if this is the last row and both key and value have content
const isLastRow = headerIndex === headerEntries.length - 1
const hasContent = key.trim() !== '' && value.trim() !== ''
if (isLastRow && hasContent) {
newHeaders[''] = ''
}
setFormData((prev) => ({ ...prev, headers: newHeaders }))
}
},
[formData.headers, testResult, clearTestResult]
)
const handleTestConnection = useCallback(async () => {
if (!formData.name.trim() || !formData.url?.trim()) return
await testConnection({
name: formData.name,
transport: formData.transport,
url: formData.url,
headers: formData.headers,
timeout: 30000,
workspaceId,
})
}, [formData, testConnection, workspaceId])
const handleSubmit = useCallback(async () => {
if (!formData.name.trim()) {
setLocalError('Server name is required')
return
}
if (!formData.url?.trim()) {
setLocalError('Server URL is required for HTTP/SSE transport')
return
}
setLocalError(null)
createServerMutation.reset()
try {
// If no test has been done, test first
if (!testResult) {
const result = await testConnection({
name: formData.name,
transport: formData.transport,
url: formData.url,
headers: formData.headers,
timeout: 30000,
workspaceId,
})
// If test fails, don't proceed
if (!result.success) {
return
}
}
// If we have a failed test result, don't proceed
if (testResult && !testResult.success) {
return
}
// Filter out empty headers
const cleanHeaders = Object.fromEntries(
Object.entries(formData.headers || {}).filter(
([key, value]) => key.trim() !== '' && value.trim() !== ''
)
)
await createServerMutation.mutateAsync({
workspaceId,
config: {
name: formData.name.trim(),
transport: formData.transport,
url: formData.url,
timeout: 30000,
headers: cleanHeaders,
enabled: true,
},
})
logger.info(`Added MCP server: ${formData.name}`)
// Close modal and reset form immediately after successful creation
resetForm()
onOpenChange(false)
onServerCreated?.()
} catch (error) {
logger.error('Failed to add MCP server:', error)
setLocalError(error instanceof Error ? error.message : 'Failed to add MCP server')
}
}, [
formData,
testResult,
testConnection,
onOpenChange,
onServerCreated,
createServerMutation,
workspaceId,
])
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[600px]'>
<DialogHeader>
<DialogTitle>Add MCP Server</DialogTitle>
<DialogDescription>
Configure a new Model Context Protocol server to extend your workflow capabilities.
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='grid grid-cols-2 gap-4'>
<div>
<Label htmlFor='server-name'>Server Name</Label>
<Input
id='server-name'
placeholder='e.g., My MCP Server'
value={formData.name}
onChange={(e) => {
if (testResult) clearTestResult()
setFormData((prev) => ({ ...prev, name: e.target.value }))
}}
className='h-9'
/>
</div>
<div>
<Label htmlFor='transport'>Transport Type</Label>
<Select
value={formData.transport}
onValueChange={(value: 'http' | 'sse' | 'streamable-http') => {
if (testResult) clearTestResult()
setFormData((prev) => ({
...prev,
transport: value as McpTransport,
}))
}}
>
<SelectTrigger className='h-9'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='streamable-http'>Streamable HTTP</SelectItem>
<SelectItem value='http'>HTTP</SelectItem>
<SelectItem value='sse'>Server-Sent Events</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className='relative'>
<Label htmlFor='server-url'>Server URL</Label>
<div className='relative'>
<Input
ref={urlInputRef}
id='server-url'
placeholder='https://mcp.server.dev/{{YOUR_API_KEY}}/sse'
value={formData.url}
onChange={(e) => handleInputChange('url', e.target.value)}
onScroll={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setUrlScrollLeft(scrollLeft)
}}
onInput={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setUrlScrollLeft(scrollLeft)
}}
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
/>
{/* Overlay for styled text display */}
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
<div
className='whitespace-nowrap'
style={{ transform: `translateX(-${urlScrollLeft}px)` }}
>
{formatDisplayText(formData.url || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
</div>
{/* Environment Variables Dropdown */}
{showEnvVars && activeInputField === 'url' && (
<EnvVarDropdown
visible={showEnvVars}
onSelect={handleEnvVarSelect}
searchTerm={searchTerm}
inputValue={formData.url || ''}
cursorPosition={cursorPosition}
workspaceId={workspaceId}
onClose={() => {
setShowEnvVars(false)
setActiveInputField(null)
}}
className='w-full'
maxHeight='250px'
/>
)}
</div>
<div>
<Label>Headers (Optional)</Label>
<div className='space-y-2'>
{Object.entries(formData.headers || {}).map(([key, value], index) => (
<div key={index} className='relative flex gap-2'>
{/* Header Name Input */}
<div className='relative flex-1'>
<Input
placeholder='Name'
value={key}
onChange={(e) => handleInputChange('header-key', e.target.value, index)}
onScroll={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setHeaderScrollLeft((prev) => ({ ...prev, [`key-${index}`]: scrollLeft }))
}}
onInput={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setHeaderScrollLeft((prev) => ({ ...prev, [`key-${index}`]: scrollLeft }))
}}
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
<div
className='whitespace-nowrap'
style={{
transform: `translateX(-${headerScrollLeft[`key-${index}`] || 0}px)`,
}}
>
{formatDisplayText(key || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
</div>
{/* Header Value Input */}
<div className='relative flex-1'>
<Input
placeholder='Value'
value={value}
onChange={(e) => handleInputChange('header-value', e.target.value, index)}
onScroll={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setHeaderScrollLeft((prev) => ({ ...prev, [`value-${index}`]: scrollLeft }))
}}
onInput={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setHeaderScrollLeft((prev) => ({ ...prev, [`value-${index}`]: scrollLeft }))
}}
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
<div
className='whitespace-nowrap'
style={{
transform: `translateX(-${headerScrollLeft[`value-${index}`] || 0}px)`,
}}
>
{formatDisplayText(value || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
</div>
<Button
type='button'
variant='ghost'
onClick={() => {
const headerEntries = Object.entries(formData.headers || {})
if (headerEntries.length === 1) {
// If this is the only header, just clear it instead of deleting
setFormData((prev) => ({ ...prev, headers: { '': '' } }))
} else {
// Delete this header
const newHeaders = { ...formData.headers }
delete newHeaders[key]
setFormData((prev) => ({ ...prev, headers: newHeaders }))
}
}}
className='h-9 w-9 p-0 text-muted-foreground hover:text-foreground'
>
<X className='h-3 w-3' />
</Button>
{/* Environment Variables Dropdown for Header Key */}
{showEnvVars &&
activeInputField === 'header-key' &&
activeHeaderIndex === index && (
<EnvVarDropdown
visible={showEnvVars}
onSelect={handleEnvVarSelect}
searchTerm={searchTerm}
inputValue={key}
cursorPosition={cursorPosition}
workspaceId={workspaceId}
onClose={() => {
setShowEnvVars(false)
setActiveInputField(null)
setActiveHeaderIndex(null)
}}
className='w-full'
maxHeight='150px'
style={{
position: 'absolute',
top: '100%',
left: 0,
zIndex: 9999,
}}
/>
)}
{/* Environment Variables Dropdown for Header Value */}
{showEnvVars &&
activeInputField === 'header-value' &&
activeHeaderIndex === index && (
<EnvVarDropdown
visible={showEnvVars}
onSelect={handleEnvVarSelect}
searchTerm={searchTerm}
inputValue={value}
cursorPosition={cursorPosition}
workspaceId={workspaceId}
onClose={() => {
setShowEnvVars(false)
setActiveInputField(null)
setActiveHeaderIndex(null)
}}
className='w-full'
maxHeight='250px'
style={{
position: 'absolute',
top: '100%',
right: 0,
zIndex: 9999,
}}
/>
)}
</div>
))}
</div>
</div>
{error && (
<div className='rounded-md bg-destructive/10 px-3 py-2 text-destructive text-sm'>
{error}
</div>
)}
{/* Test Connection and Actions */}
<div className='border-t pt-4'>
<div className='flex items-center justify-between'>
<div className='space-y-2'>
<div className='flex items-center gap-2'>
<Button
type='button'
variant='ghost'
size='sm'
onClick={handleTestConnection}
disabled={isTestingConnection || !formData.name.trim() || !formData.url?.trim()}
className='text-muted-foreground hover:text-foreground'
>
{isTestingConnection ? 'Testing...' : 'Test Connection'}
</Button>
{testResult?.success && (
<span className='text-green-600 text-xs'> Connected</span>
)}
</div>
{testResult && !testResult.success && (
<div className='rounded border border-red-200 bg-red-50 px-2 py-1.5 text-red-600 text-xs dark:border-red-800 dark:bg-red-950/20'>
<div className='font-medium'>Connection failed</div>
<div className='text-red-500 dark:text-red-400'>
{testResult.error || testResult.message}
</div>
</div>
)}
</div>
<div className='flex gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => {
resetForm()
onOpenChange(false)
}}
disabled={createServerMutation.isPending}
>
Cancel
</Button>
<Button
size='sm'
onClick={handleSubmit}
disabled={
createServerMutation.isPending || !formData.name.trim() || !formData.url?.trim()
}
>
{createServerMutation.isPending ? 'Adding...' : 'Add Server'}
</Button>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown, ExternalLink, Plus, RefreshCw } from 'lucide-react'
import { Button } from '@/components/emcn/components/button/button'
import {
@@ -11,7 +11,6 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
import {
type Credential,
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
OAUTH_PROVIDERS,
@@ -20,8 +19,8 @@ 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 { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
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')
@@ -70,8 +69,6 @@ export function ToolCredentialSelector({
disabled = false,
}: ToolCredentialSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [isLoading, setIsLoading] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [selectedId, setSelectedId] = useState('')
const { activeWorkflowId } = useWorkflowRegistry()
@@ -80,80 +77,43 @@ export function ToolCredentialSelector({
setSelectedId(value)
}, [value])
const fetchCredentials = useCallback(async () => {
setIsLoading(true)
try {
const response = await fetch(`/api/auth/oauth/credentials?provider=${provider}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials || [])
const {
data: fetchedCredentials = [],
isFetching: credentialsLoading,
refetch: refetchCredentials,
} = useOAuthCredentials(provider, true)
// 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)
}
const shouldFetchDetail =
Boolean(value) &&
!fetchedCredentials.some((cred) => cred.id === value) &&
Boolean(activeWorkflowId)
if (
value &&
!(data.credentials || []).some((cred: Credential) => cred.id === value) &&
activeWorkflowId
) {
try {
const metaResp = await fetch(
`/api/auth/oauth/credentials?credentialId=${value}&workflowId=${activeWorkflowId}`
)
if (metaResp.ok) {
const meta = await metaResp.json()
if (meta.credentials?.length) {
const combinedCredentials = [meta.credentials[0], ...(data.credentials || [])]
setCredentials(combinedCredentials)
const { data: collaboratorCredentials = [], isFetching: collaboratorLoading } =
useOAuthCredentialDetail(
shouldFetchDetail ? value : undefined,
activeWorkflowId || undefined,
shouldFetchDetail
)
const credentialMap = combinedCredentials.reduce(
(acc: Record<string, string>, cred: Credential) => {
acc[cred.id] = cred.name
return acc
},
{}
)
useDisplayNamesStore
.getState()
.setDisplayNames('credentials', provider, credentialMap)
}
}
} catch {
// ignore
}
}
} else {
logger.error('Error fetching credentials:', { error: await response.text() })
setCredentials([])
}
} catch (error) {
logger.error('Error fetching credentials:', { error })
setCredentials([])
} finally {
setIsLoading(false)
const credentials = useMemo(() => {
if (collaboratorCredentials.length === 0) {
return fetchedCredentials
}
}, [provider, value, onChange])
// Fetch credentials on initial mount only
useEffect(() => {
fetchCredentials()
// This effect should only run once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const collaborator = collaboratorCredentials[0]
if (!collaborator) {
return fetchedCredentials
}
const alreadyIncluded = fetchedCredentials.some((cred) => cred.id === collaborator.id)
if (alreadyIncluded) {
return fetchedCredentials
}
return [collaborator, ...fetchedCredentials]
}, [fetchedCredentials, collaboratorCredentials])
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
fetchCredentials()
void refetchCredentials()
}
}
@@ -162,7 +122,7 @@ export function ToolCredentialSelector({
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [fetchCredentials])
}, [refetchCredentials])
const handleSelect = (credentialId: string) => {
setSelectedId(credentialId)
@@ -172,13 +132,13 @@ export function ToolCredentialSelector({
const handleOAuthClose = () => {
setShowOAuthModal(false)
fetchCredentials()
void refetchCredentials()
}
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
if (isOpen) {
fetchCredentials()
void refetchCredentials()
}
}
@@ -190,7 +150,8 @@ export function ToolCredentialSelector({
const missingRequiredScopes = hasSelection
? getMissingRequiredScopes(selectedCredential, requiredScopes || [])
: []
const needsUpdate = hasSelection && missingRequiredScopes.length > 0 && !disabled && !isLoading
const needsUpdate =
hasSelection && missingRequiredScopes.length > 0 && !disabled && !credentialsLoading
return (
<>
@@ -224,7 +185,7 @@ export function ToolCredentialSelector({
<Command>
<CommandList>
<CommandEmpty>
{isLoading ? (
{credentialsLoading || collaboratorLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading...</span>

View File

@@ -0,0 +1,211 @@
'use client'
import { useMemo } from 'react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
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'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface FileSelectorInputProps {
blockId: string
subBlock: SubBlockConfig
disabled: boolean
isPreview?: boolean
previewValue?: any | null
previewContextValues?: Record<string, any>
}
export function FileSelectorInput({
blockId,
subBlock,
disabled,
isPreview = false,
previewValue,
previewContextValues,
}: FileSelectorInputProps) {
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const { activeWorkflowId } = useWorkflowRegistry()
const params = useParams()
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
disabled,
isPreview,
previewContextValues,
})
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
const [domainValueFromStore] = useSubBlockValue(blockId, 'domain')
const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId')
const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId')
const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId')
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
const domainValue = previewContextValues?.domain ?? domainValueFromStore
const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore
const planIdValue = previewContextValues?.planId ?? planIdValueFromStore
const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore
const normalizedCredentialId =
typeof connectedCredential === 'string'
? connectedCredential
: typeof connectedCredential === 'object' && connectedCredential !== null
? ((connectedCredential as Record<string, any>).id ?? '')
: ''
const { isForeignCredential } = useForeignCredential(
subBlock.provider || subBlock.serviceId || 'google-drive',
normalizedCredentialId
)
const selectorResolution = useMemo(() => {
return resolveSelector({
provider: subBlock.provider || '',
serviceId: subBlock.serviceId,
mimeType: subBlock.mimeType,
credentialId: normalizedCredentialId,
workflowId: workflowIdFromUrl,
domain: (domainValue as string) || '',
projectId: (projectIdValue as string) || '',
planId: (planIdValue as string) || '',
teamId: (teamIdValue as string) || '',
})
}, [
subBlock.provider,
subBlock.serviceId,
subBlock.mimeType,
normalizedCredentialId,
workflowIdFromUrl,
domainValue,
projectIdValue,
planIdValue,
teamIdValue,
])
const missingCredential = !normalizedCredentialId
const missingDomain =
selectorResolution.key &&
(selectorResolution.key === 'confluence.pages' || selectorResolution.key === 'jira.issues') &&
!selectorResolution.context.domain
const missingProject =
selectorResolution.key === 'jira.issues' &&
subBlock.dependsOn?.includes('projectId') &&
!selectorResolution.context.projectId
const missingPlan =
selectorResolution.key === 'microsoft.planner' && !selectorResolution.context.planId
const disabledReason =
finalDisabled ||
isForeignCredential ||
missingCredential ||
missingDomain ||
missingProject ||
missingPlan ||
selectorResolution.key === null
if (selectorResolution.key === null) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
File selector not supported for provider: {subBlock.provider || subBlock.serviceId}
</div>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>This file selector is not implemented for {subBlock.provider || subBlock.serviceId}</p>
</Tooltip.Content>
</Tooltip.Root>
)
}
return (
<SelectorCombobox
blockId={blockId}
subBlock={subBlock}
selectorKey={selectorResolution.key}
selectorContext={selectorResolution.context}
disabled={disabledReason}
isPreview={isPreview}
previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || 'Select resource'}
allowSearch={selectorResolution.allowSearch}
onOptionChange={(value) => {
if (!isPreview) {
collaborativeSetSubblockValue(blockId, subBlock.id, value)
}
}}
/>
)
}
interface SelectorParams {
provider: string
serviceId?: string
mimeType?: string
credentialId: string
workflowId: string
domain?: string
projectId?: string
planId?: string
teamId?: string
}
function resolveSelector(params: SelectorParams): {
key: SelectorKey | null
context: SelectorContext
allowSearch: boolean
} {
const baseContext: SelectorContext = {
credentialId: params.credentialId,
workflowId: params.workflowId,
domain: params.domain,
projectId: params.projectId,
planId: params.planId,
teamId: params.teamId,
mimeType: params.mimeType,
}
switch (params.provider) {
case 'google-calendar':
return { key: 'google.calendar', context: baseContext, allowSearch: false }
case 'confluence':
return { key: 'confluence.pages', context: baseContext, allowSearch: true }
case 'jira':
return { key: 'jira.issues', context: baseContext, allowSearch: true }
case 'microsoft-teams':
return { key: 'microsoft.teams', context: baseContext, allowSearch: true }
case 'wealthbox':
return { key: 'wealthbox.contacts', context: baseContext, allowSearch: true }
case 'microsoft-planner':
return { key: 'microsoft.planner', context: baseContext, allowSearch: true }
case 'microsoft-excel':
return { key: 'microsoft.excel', context: baseContext, allowSearch: true }
case 'microsoft-word':
return { key: 'microsoft.word', context: baseContext, allowSearch: true }
case 'google-drive':
return { key: 'google.drive', context: baseContext, allowSearch: true }
default:
break
}
if (params.serviceId === 'onedrive') {
const key: SelectorKey = params.mimeType === 'file' ? 'onedrive.files' : 'onedrive.folders'
return { key, context: baseContext, allowSearch: true }
}
if (params.serviceId === 'sharepoint') {
return { key: 'sharepoint.sites', context: baseContext, allowSearch: true }
}
if (params.serviceId === 'google-drive') {
return { key: 'google.drive', context: baseContext, allowSearch: true }
}
return { key: null, context: baseContext, allowSearch: true }
}

View File

@@ -13,9 +13,10 @@ import {
useBlockDimensions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
import { useCredentialName } from '@/hooks/queries/oauth-credentials'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useCredentialDisplay } from '@/hooks/use-credential-display'
import { useDisplayName } from '@/hooks/use-display-name'
import { useKnowledgeBaseName } from '@/hooks/use-knowledge-base-name'
import { useSelectorDisplayName } from '@/hooks/use-selector-display-name'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -230,9 +231,12 @@ const SubBlockRow = ({
}, {})
}, [getStringValue, subBlock?.dependsOn])
const { displayName: credentialName } = useCredentialDisplay(
subBlock?.type === 'oauth-input' && typeof rawValue === 'string' ? rawValue : undefined,
subBlock?.provider
const credentialSourceId =
subBlock?.type === 'oauth-input' && typeof rawValue === 'string' ? rawValue : undefined
const { displayName: credentialName } = useCredentialName(
credentialSourceId,
subBlock?.provider,
workflowId
)
const credentialId = dependencyValues.credential
@@ -253,17 +257,35 @@ const SubBlockRow = ({
return typeof option === 'string' ? option : option.label
}, [subBlock, rawValue])
const genericDisplayName = useDisplayName(subBlock, rawValue, {
workspaceId,
provider: subBlock?.provider,
const domainValue = getStringValue('domain')
const teamIdValue = getStringValue('teamId')
const projectIdValue = getStringValue('projectId')
const planIdValue = getStringValue('planId')
const { displayName: selectorDisplayName } = useSelectorDisplayName({
subBlock,
value: rawValue,
workflowId,
credentialId: typeof credentialId === 'string' ? credentialId : undefined,
knowledgeBaseId: typeof knowledgeBaseId === 'string' ? knowledgeBaseId : undefined,
domain: getStringValue('domain'),
teamId: getStringValue('teamId'),
projectId: getStringValue('projectId'),
planId: getStringValue('planId'),
domain: domainValue,
teamId: teamIdValue,
projectId: projectIdValue,
planId: planIdValue,
})
const knowledgeBaseDisplayName = useKnowledgeBaseName(
subBlock?.type === 'knowledge-base-selector' && typeof rawValue === 'string'
? rawValue
: undefined
)
const workflowMap = useWorkflowRegistry((state) => state.workflows)
const workflowSelectionName =
subBlock?.id === 'workflowId' && typeof rawValue === 'string'
? (workflowMap[rawValue]?.name ?? null)
: null
// Subscribe to variables store to reactively update when variables change
const allVariables = useVariablesStore((state) => state.variables)
@@ -300,7 +322,12 @@ const SubBlockRow = ({
const isSelectorType = subBlock?.type && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlock.type)
const hydratedName =
credentialName || dropdownLabel || variablesDisplayValue || genericDisplayName
credentialName ||
dropdownLabel ||
variablesDisplayValue ||
knowledgeBaseDisplayName ||
workflowSelectionName ||
selectorDisplayName
const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value)
return (

View File

@@ -0,0 +1,88 @@
import { useQuery } from '@tanstack/react-query'
import type { Credential } from '@/lib/oauth'
import { fetchJson } from '@/hooks/selectors/helpers'
interface CredentialListResponse {
credentials?: Credential[]
}
interface CredentialDetailResponse {
credentials?: Credential[]
}
export const oauthCredentialKeys = {
list: (providerId?: string) => ['oauthCredentials', providerId ?? 'none'] as const,
detail: (credentialId?: string, workflowId?: string) =>
['oauthCredentialDetail', credentialId ?? 'none', workflowId ?? 'none'] as const,
}
export async function fetchOAuthCredentials(providerId: string): Promise<Credential[]> {
if (!providerId) return []
const data = await fetchJson<CredentialListResponse>('/api/auth/oauth/credentials', {
searchParams: { provider: providerId },
})
return data.credentials ?? []
}
export async function fetchOAuthCredentialDetail(
credentialId: string,
workflowId?: string
): Promise<Credential[]> {
if (!credentialId) return []
const data = await fetchJson<CredentialDetailResponse>('/api/auth/oauth/credentials', {
searchParams: {
credentialId,
workflowId,
},
})
return data.credentials ?? []
}
export function useOAuthCredentials(providerId?: string, enabled = true) {
return useQuery<Credential[]>({
queryKey: oauthCredentialKeys.list(providerId),
queryFn: () => fetchOAuthCredentials(providerId ?? ''),
enabled: Boolean(providerId) && enabled,
staleTime: 60 * 1000,
})
}
export function useOAuthCredentialDetail(
credentialId?: string,
workflowId?: string,
enabled = true
) {
return useQuery<Credential[]>({
queryKey: oauthCredentialKeys.detail(credentialId, workflowId),
queryFn: () => fetchOAuthCredentialDetail(credentialId ?? '', workflowId),
enabled: Boolean(credentialId) && enabled,
staleTime: 60 * 1000,
})
}
export function useCredentialName(credentialId?: string, providerId?: string, workflowId?: string) {
const { data: credentials = [], isFetching: credentialsLoading } = useOAuthCredentials(
providerId,
Boolean(providerId)
)
const selectedCredential = credentials.find((cred) => cred.id === credentialId)
const shouldFetchDetail = Boolean(credentialId && !selectedCredential && providerId && workflowId)
const { data: foreignCredentials = [], isFetching: foreignLoading } = useOAuthCredentialDetail(
shouldFetchDetail ? credentialId : undefined,
workflowId,
shouldFetchDetail
)
const hasForeignMeta = foreignCredentials.length > 0
const displayName = selectedCredential?.name ?? (hasForeignMeta ? 'Saved by collaborator' : null)
return {
displayName,
isLoading: credentialsLoading || foreignLoading,
hasForeignMeta,
}
}

View File

@@ -0,0 +1,61 @@
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('SelectorHelpers')
interface FetchJsonOptions extends RequestInit {
searchParams?: Record<string, string | number | undefined | null>
}
export async function fetchJson<T>(url: string, options: FetchJsonOptions = {}): Promise<T> {
const { searchParams, headers, ...rest } = options
let finalUrl = url
if (searchParams) {
const params = new URLSearchParams()
Object.entries(searchParams).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return
params.set(key, String(value))
})
const qs = params.toString()
if (qs) {
finalUrl = `${url}${url.includes('?') ? '&' : '?'}${qs}`
}
}
const response = await fetch(finalUrl, {
headers: {
'Content-Type': 'application/json',
...headers,
},
...rest,
})
if (!response.ok) {
let message = `Failed request ${response.status}`
try {
const err = await response.json()
message = err.error || err.message || message
} catch (error) {
logger.warn('Failed to parse error response', { error })
}
throw new Error(message)
}
return response.json()
}
interface TokenResponse {
accessToken?: string
}
export async function fetchOAuthToken(
credentialId: string,
workflowId?: string
): Promise<string | null> {
if (!credentialId) return null
const body = JSON.stringify({ credentialId, workflowId })
const token = await fetchJson<TokenResponse>('/api/auth/oauth/token', {
method: 'POST',
body,
})
return token.accessToken ?? null
}

View File

@@ -0,0 +1,646 @@
import { fetchJson, fetchOAuthToken } from './helpers'
import type {
SelectorContext,
SelectorDefinition,
SelectorKey,
SelectorOption,
SelectorQueryArgs,
} from './types'
const SELECTOR_STALE = 60 * 1000
type SlackChannel = { id: string; name: string }
type FolderResponse = { id: string; name: string }
type PlannerTask = { id: string; title: string }
const ensureCredential = (context: SelectorContext, key: SelectorKey): string => {
if (!context.credentialId) {
throw new Error(`Missing credential for selector ${key}`)
}
return context.credentialId
}
const ensureDomain = (context: SelectorContext, key: SelectorKey): string => {
if (!context.domain) {
throw new Error(`Missing domain for selector ${key}`)
}
return context.domain
}
const ensureKnowledgeBase = (context: SelectorContext): string => {
if (!context.knowledgeBaseId) {
throw new Error('Missing knowledge base id')
}
return context.knowledgeBaseId
}
const registry: Record<SelectorKey, SelectorDefinition> = {
'slack.channels': {
key: 'slack.channels',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'slack.channels',
context.credentialId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const body = JSON.stringify({
credential: context.credentialId,
workflowId: context.workflowId,
})
const data = await fetchJson<{ channels: SlackChannel[] }>('/api/tools/slack/channels', {
method: 'POST',
body,
})
return (data.channels || []).map((channel) => ({
id: channel.id,
label: `#${channel.name}`,
}))
},
},
'gmail.labels': {
key: 'gmail.labels',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'gmail.labels',
context.credentialId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const data = await fetchJson<{ labels: FolderResponse[] }>('/api/tools/gmail/labels', {
searchParams: { credentialId: context.credentialId },
})
return (data.labels || []).map((label) => ({
id: label.id,
label: label.name,
}))
},
},
'outlook.folders': {
key: 'outlook.folders',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'outlook.folders',
context.credentialId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const data = await fetchJson<{ folders: FolderResponse[] }>('/api/tools/outlook/folders', {
searchParams: { credentialId: context.credentialId },
})
return (data.folders || []).map((folder) => ({
id: folder.id,
label: folder.name,
}))
},
},
'google.calendar': {
key: 'google.calendar',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'google.calendar',
context.credentialId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const data = await fetchJson<{ calendars: { id: string; summary: string }[] }>(
'/api/tools/google_calendar/calendars',
{ searchParams: { credentialId: context.credentialId } }
)
return (data.calendars || []).map((calendar) => ({
id: calendar.id,
label: calendar.summary,
}))
},
},
'microsoft.teams': {
key: 'microsoft.teams',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.teams',
context.credentialId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const body = JSON.stringify({ credential: context.credentialId })
const data = await fetchJson<{ teams: { id: string; displayName: string }[] }>(
'/api/tools/microsoft-teams/teams',
{ method: 'POST', body }
)
return (data.teams || []).map((team) => ({
id: team.id,
label: team.displayName,
}))
},
},
'wealthbox.contacts': {
key: 'wealthbox.contacts',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'wealthbox.contacts',
context.credentialId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const data = await fetchJson<{ items: { id: string; name: string }[] }>(
'/api/tools/wealthbox/items',
{
searchParams: { credentialId: context.credentialId, type: 'contact' },
}
)
return (data.items || []).map((item) => ({
id: item.id,
label: item.name,
}))
},
},
'sharepoint.sites': {
key: 'sharepoint.sites',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'sharepoint.sites',
context.credentialId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
'/api/tools/sharepoint/sites',
{
searchParams: { credentialId: context.credentialId },
}
)
return (data.files || []).map((file) => ({
id: file.id,
label: file.name,
}))
},
},
'microsoft.planner': {
key: 'microsoft.planner',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.planner',
context.credentialId ?? 'none',
context.planId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.planId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const data = await fetchJson<{ tasks: PlannerTask[] }>('/api/tools/microsoft_planner/tasks', {
searchParams: {
credentialId: context.credentialId,
planId: context.planId,
},
})
return (data.tasks || []).map((task) => ({
id: task.id,
label: task.title,
}))
},
},
'jira.projects': {
key: 'jira.projects',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'jira.projects',
context.credentialId ?? 'none',
context.domain ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.credentialId && context.domain),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'jira.projects')
const domain = ensureDomain(context, 'jira.projects')
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
if (!accessToken) {
throw new Error('Missing Jira access token')
}
const data = await fetchJson<{ projects: { id: string; name: string }[] }>(
'/api/tools/jira/projects',
{
searchParams: {
domain,
accessToken,
query: search ?? '',
},
}
)
return (data.projects || []).map((project) => ({
id: project.id,
label: project.name,
}))
},
fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'jira.projects')
const domain = ensureDomain(context, 'jira.projects')
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
if (!accessToken) {
throw new Error('Missing Jira access token')
}
const data = await fetchJson<{ project?: { id: string; name: string } }>(
'/api/tools/jira/projects',
{
method: 'POST',
body: JSON.stringify({
domain,
accessToken,
projectId: detailId,
}),
}
)
if (!data.project) return null
return {
id: data.project.id,
label: data.project.name,
}
},
},
'jira.issues': {
key: 'jira.issues',
staleTime: 15 * 1000,
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'jira.issues',
context.credentialId ?? 'none',
context.domain ?? 'none',
context.projectId ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.credentialId && context.domain),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'jira.issues')
const domain = ensureDomain(context, 'jira.issues')
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
if (!accessToken) {
throw new Error('Missing Jira access token')
}
const data = await fetchJson<{
sections?: { issues: { id?: string; key?: string; summary?: string }[] }[]
}>('/api/tools/jira/issues', {
searchParams: {
domain,
accessToken,
projectId: context.projectId,
query: search ?? '',
},
})
const issues =
data.sections?.flatMap((section) =>
(section.issues || []).map((issue) => ({
id: issue.id || issue.key || '',
name: issue.summary || issue.key || '',
}))
) || []
return issues
.filter((issue) => issue.id)
.map((issue) => ({ id: issue.id, label: issue.name || issue.id }))
},
fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'jira.issues')
const domain = ensureDomain(context, 'jira.issues')
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
if (!accessToken) {
throw new Error('Missing Jira access token')
}
const data = await fetchJson<{ issues?: { id: string; name: string }[] }>(
'/api/tools/jira/issues',
{
method: 'POST',
body: JSON.stringify({
domain,
accessToken,
issueKeys: [detailId],
}),
}
)
const issue = data.issues?.[0]
if (!issue) return null
return { id: issue.id, label: issue.name }
},
},
'linear.teams': {
key: 'linear.teams',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'linear.teams',
context.credentialId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'linear.teams')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
const data = await fetchJson<{ teams: { id: string; name: string }[] }>(
'/api/tools/linear/teams',
{
method: 'POST',
body,
}
)
return (data.teams || []).map((team) => ({
id: team.id,
label: team.name,
}))
},
},
'linear.projects': {
key: 'linear.projects',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'linear.projects',
context.credentialId ?? 'none',
context.teamId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.teamId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'linear.projects')
const body = JSON.stringify({
credential: credentialId,
teamId: context.teamId,
workflowId: context.workflowId,
})
const data = await fetchJson<{ projects: { id: string; name: string }[] }>(
'/api/tools/linear/projects',
{
method: 'POST',
body,
}
)
return (data.projects || []).map((project) => ({
id: project.id,
label: project.name,
}))
},
},
'confluence.pages': {
key: 'confluence.pages',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'confluence.pages',
context.credentialId ?? 'none',
context.domain ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.credentialId && context.domain),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'confluence.pages')
const domain = ensureDomain(context, 'confluence.pages')
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
if (!accessToken) {
throw new Error('Missing Confluence access token')
}
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
'/api/tools/confluence/pages',
{
method: 'POST',
body: JSON.stringify({
domain,
accessToken,
title: search,
}),
}
)
return (data.files || []).map((file) => ({
id: file.id,
label: file.name,
}))
},
fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'confluence.pages')
const domain = ensureDomain(context, 'confluence.pages')
const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
if (!accessToken) {
throw new Error('Missing Confluence access token')
}
const data = await fetchJson<{ id: string; title: string }>('/api/tools/confluence/page', {
method: 'POST',
body: JSON.stringify({
domain,
accessToken,
pageId: detailId,
}),
})
return { id: data.id, label: data.title }
},
},
'onedrive.files': {
key: 'onedrive.files',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'onedrive.files',
context.credentialId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'onedrive.files')
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
'/api/tools/onedrive/files',
{
searchParams: { credentialId },
}
)
return (data.files || []).map((file) => ({
id: file.id,
label: file.name,
}))
},
},
'onedrive.folders': {
key: 'onedrive.folders',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'onedrive.folders',
context.credentialId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'onedrive.folders')
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
'/api/tools/onedrive/folders',
{
searchParams: { credentialId },
}
)
return (data.files || []).map((file) => ({
id: file.id,
label: file.name,
}))
},
},
'google.drive': {
key: 'google.drive',
staleTime: 15 * 1000,
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'google.drive',
context.credentialId ?? 'none',
context.mimeType ?? 'any',
context.fileId ?? 'root',
search ?? '',
],
enabled: ({ context }) => Boolean(context.credentialId),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'google.drive')
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
'/api/tools/drive/files',
{
searchParams: {
credentialId,
mimeType: context.mimeType,
parentId: context.fileId,
query: search,
workflowId: context.workflowId,
},
}
)
return (data.files || []).map((file) => ({
id: file.id,
label: file.name,
}))
},
fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
if (!detailId) return null
const credentialId = ensureCredential(context, 'google.drive')
const data = await fetchJson<{ file?: { id: string; name: string } }>(
'/api/tools/drive/file',
{
searchParams: {
credentialId,
fileId: detailId,
workflowId: context.workflowId,
},
}
)
const file = data.file
if (!file) return null
return { id: file.id, label: file.name }
},
},
'microsoft.excel': {
key: 'microsoft.excel',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'microsoft.excel',
context.credentialId ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.credentialId),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.excel')
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
'/api/auth/oauth/microsoft/files',
{
searchParams: {
credentialId,
query: search,
workflowId: context.workflowId,
},
}
)
return (data.files || []).map((file) => ({
id: file.id,
label: file.name,
}))
},
},
'microsoft.word': {
key: 'microsoft.word',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'microsoft.word',
context.credentialId ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.credentialId),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.word')
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
'/api/auth/oauth/microsoft/files',
{
searchParams: {
credentialId,
query: search,
workflowId: context.workflowId,
},
}
)
return (data.files || []).map((file) => ({
id: file.id,
label: file.name,
}))
},
},
'knowledge.documents': {
key: 'knowledge.documents',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'knowledge.documents',
context.knowledgeBaseId ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.knowledgeBaseId),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const knowledgeBaseId = ensureKnowledgeBase(context)
const data = await fetchJson<{
data?: { documents: { id: string; filename: string }[] }
}>(`/api/knowledge/${knowledgeBaseId}/documents`, {
searchParams: {
limit: 200,
search,
},
})
const documents = data.data?.documents || []
return documents.map((doc) => ({
id: doc.id,
label: doc.filename,
}))
},
fetchById: async ({ context, detailId }: SelectorQueryArgs) => {
if (!detailId) return null
const knowledgeBaseId = ensureKnowledgeBase(context)
const data = await fetchJson<{ data?: { document?: { id: string; filename: string } } }>(
`/api/knowledge/${knowledgeBaseId}/documents/${detailId}`,
{
searchParams: { includeDisabled: 'true' },
}
)
const doc = data.data?.document
if (!doc) return null
return { id: doc.id, label: doc.filename }
},
},
}
export function getSelectorDefinition(key: SelectorKey): SelectorDefinition {
const definition = registry[key]
if (!definition) {
throw new Error(`Missing selector definition for ${key}`)
}
return definition
}
export function mergeOption(options: SelectorOption[], option?: SelectorOption | null) {
if (!option) return options
if (options.some((item) => item.id === option.id)) {
return options
}
return [option, ...options]
}

View File

@@ -0,0 +1,172 @@
import type { SubBlockConfig } from '@/blocks/types'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
export interface SelectorResolution {
key: SelectorKey | null
context: SelectorContext
allowSearch: boolean
}
export interface SelectorResolutionArgs {
workflowId?: string
credentialId?: string
domain?: string
projectId?: string
planId?: string
teamId?: string
knowledgeBaseId?: string
}
const defaultContext: SelectorContext = {}
export function resolveSelectorForSubBlock(
subBlock: SubBlockConfig,
args: SelectorResolutionArgs
): SelectorResolution | null {
switch (subBlock.type) {
case 'file-selector':
return resolveFileSelector(subBlock, args)
case 'folder-selector':
return resolveFolderSelector(subBlock, args)
case 'channel-selector':
return resolveChannelSelector(subBlock, args)
case 'project-selector':
return resolveProjectSelector(subBlock, args)
case 'document-selector':
return resolveDocumentSelector(subBlock, args)
default:
return null
}
}
function buildBaseContext(
args: SelectorResolutionArgs,
extra?: Partial<SelectorContext>
): SelectorContext {
return {
...defaultContext,
workflowId: args.workflowId,
credentialId: args.credentialId,
domain: args.domain,
projectId: args.projectId,
planId: args.planId,
teamId: args.teamId,
knowledgeBaseId: args.knowledgeBaseId,
...extra,
}
}
function resolveFileSelector(
subBlock: SubBlockConfig,
args: SelectorResolutionArgs
): SelectorResolution {
const context = buildBaseContext(args, {
mimeType: subBlock.mimeType,
})
const provider = subBlock.provider || subBlock.serviceId || ''
switch (provider) {
case 'google-calendar':
return { key: 'google.calendar', context, allowSearch: false }
case 'confluence':
return { key: 'confluence.pages', context, allowSearch: true }
case 'jira':
return { key: 'jira.issues', context, allowSearch: true }
case 'microsoft-teams':
return { key: 'microsoft.teams', context, allowSearch: true }
case 'wealthbox':
return { key: 'wealthbox.contacts', context, allowSearch: true }
case 'microsoft-planner':
return { key: 'microsoft.planner', context, allowSearch: true }
case 'microsoft-excel':
return { key: 'microsoft.excel', context, allowSearch: true }
case 'microsoft-word':
return { key: 'microsoft.word', context, allowSearch: true }
case 'google-drive':
return { key: 'google.drive', context, allowSearch: true }
case 'google-sheets':
return { key: 'google.drive', context, allowSearch: true }
case 'google-docs':
return { key: 'google.drive', context, allowSearch: true }
default:
break
}
if (subBlock.serviceId === 'onedrive') {
const key: SelectorKey = subBlock.mimeType === 'file' ? 'onedrive.files' : 'onedrive.folders'
return { key, context, allowSearch: true }
}
if (subBlock.serviceId === 'sharepoint') {
return { key: 'sharepoint.sites', context, allowSearch: true }
}
if (subBlock.serviceId === 'google-sheets') {
return { key: 'google.drive', context, allowSearch: true }
}
return { key: null, context, allowSearch: true }
}
function resolveFolderSelector(
subBlock: SubBlockConfig,
args: SelectorResolutionArgs
): SelectorResolution {
const provider = (subBlock.provider || subBlock.serviceId || 'gmail').toLowerCase()
const key: SelectorKey = provider === 'outlook' ? 'outlook.folders' : 'gmail.labels'
return {
key,
context: buildBaseContext(args),
allowSearch: true,
}
}
function resolveChannelSelector(
subBlock: SubBlockConfig,
args: SelectorResolutionArgs
): SelectorResolution {
const provider = subBlock.provider || 'slack'
if (provider !== 'slack') {
return { key: null, context: buildBaseContext(args), allowSearch: true }
}
return {
key: 'slack.channels',
context: buildBaseContext(args),
allowSearch: true,
}
}
function resolveProjectSelector(
subBlock: SubBlockConfig,
args: SelectorResolutionArgs
): SelectorResolution {
const provider = subBlock.provider || 'jira'
const context = buildBaseContext(args)
if (provider === 'linear') {
const key: SelectorKey = subBlock.id === 'teamId' ? 'linear.teams' : 'linear.projects'
return {
key,
context,
allowSearch: true,
}
}
return {
key: 'jira.projects',
context,
allowSearch: true,
}
}
function resolveDocumentSelector(
_subBlock: SubBlockConfig,
args: SelectorResolutionArgs
): SelectorResolution {
return {
key: 'knowledge.documents',
context: buildBaseContext(args),
allowSearch: true,
}
}

View File

@@ -0,0 +1,61 @@
import type React from 'react'
import type { QueryKey } from '@tanstack/react-query'
export type SelectorKey =
| 'slack.channels'
| 'gmail.labels'
| 'outlook.folders'
| 'google.calendar'
| 'jira.issues'
| 'jira.projects'
| 'linear.projects'
| 'linear.teams'
| 'confluence.pages'
| 'microsoft.teams'
| 'wealthbox.contacts'
| 'onedrive.files'
| 'onedrive.folders'
| 'sharepoint.sites'
| 'microsoft.excel'
| 'microsoft.word'
| 'microsoft.planner'
| 'google.drive'
| 'knowledge.documents'
export interface SelectorOption {
id: string
label: string
icon?: React.ComponentType<{ className?: string }>
meta?: Record<string, unknown>
}
export interface SelectorContext {
workspaceId?: string
workflowId?: string
credentialId?: string
provider?: string
serviceId?: string
domain?: string
teamId?: string
projectId?: string
knowledgeBaseId?: string
planId?: string
mimeType?: string
fileId?: string
}
export interface SelectorQueryArgs {
key: SelectorKey
context: SelectorContext
search?: string
detailId?: string
}
export interface SelectorDefinition {
key: SelectorKey
getQueryKey: (args: SelectorQueryArgs) => QueryKey
fetchList: (args: SelectorQueryArgs) => Promise<SelectorOption[]>
fetchById?: (args: SelectorQueryArgs) => Promise<SelectorOption | null>
enabled?: (args: SelectorQueryArgs) => boolean
staleTime?: number
}

View File

@@ -0,0 +1,61 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getSelectorDefinition, mergeOption } from './registry'
import type { SelectorKey, SelectorOption, SelectorQueryArgs } from './types'
interface SelectorHookArgs extends Omit<SelectorQueryArgs, 'key'> {
search?: string
detailId?: string
enabled?: boolean
}
export function useSelectorOptions(key: SelectorKey, args: SelectorHookArgs) {
const definition = getSelectorDefinition(key)
const queryArgs: SelectorQueryArgs = {
key,
context: args.context,
search: args.search,
}
const isEnabled = args.enabled ?? (definition.enabled ? definition.enabled(queryArgs) : true)
return useQuery<SelectorOption[]>({
queryKey: definition.getQueryKey(queryArgs),
queryFn: () => definition.fetchList(queryArgs),
enabled: isEnabled,
staleTime: definition.staleTime ?? 30_000,
})
}
export function useSelectorOptionDetail(
key: SelectorKey,
args: SelectorHookArgs & { detailId?: string }
) {
const definition = getSelectorDefinition(key)
const queryArgs: SelectorQueryArgs = {
key,
context: args.context,
detailId: args.detailId,
}
const baseEnabled =
Boolean(args.detailId) && definition.fetchById !== undefined
? definition.enabled
? definition.enabled(queryArgs)
: true
: false
const enabled = args.enabled ?? baseEnabled
const query = useQuery<SelectorOption | null>({
queryKey: [...definition.getQueryKey(queryArgs), 'detail', args.detailId ?? 'none'],
queryFn: () => definition.fetchById!(queryArgs),
enabled,
staleTime: definition.staleTime ?? 300_000,
})
return query
}
export function useSelectorOptionMap(options: SelectorOption[], extra?: SelectorOption | null) {
return useMemo(() => {
const merged = mergeOption(options, extra)
return new Map(merged.map((option) => [option.id, option]))
}, [options, extra])
}

View File

@@ -1,54 +0,0 @@
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

@@ -1,590 +0,0 @@
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 && context.credentialId) {
// Fetch access token then get project info
fetch('/api/auth/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialId: context.credentialId }),
})
.then((res) => res.json())
.then((tokenData) => {
if (!tokenData.accessToken) throw new Error('No access token')
return fetch('/api/tools/jira/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
domain: context.domain,
accessToken: tokenData.accessToken,
projectId: value,
}),
})
})
.then((res) => res.json())
.then((data) => {
if (data.project) {
useDisplayNamesStore
.getState()
.setDisplayNames('projects', projectContext, { [value as string]: data.project.name })
}
})
.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,
])
// 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 && context.credentialId) {
// Fetch access token then get issue info
fetch('/api/auth/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialId: context.credentialId }),
})
.then((res) => res.json())
.then((tokenData) => {
if (!tokenData.accessToken) throw new Error('No access token')
return fetch('/api/tools/jira/issues', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
domain: context.domain,
accessToken: tokenData.accessToken,
issueKeys: [value],
}),
})
})
.then((res) => res.json())
.then((data) => {
if (data.issues?.[0]) {
useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, {
[value as string]: data.issues[0].name,
})
}
})
.catch(() => {})
.finally(() => setIsFetching(false))
}
// Confluence pages
else if (provider === 'confluence' && context.domain && context.credentialId) {
// Fetch access token then get page info
fetch('/api/auth/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialId: context.credentialId }),
})
.then((res) => res.json())
.then((tokenData) => {
if (!tokenData.accessToken) throw new Error('No access token')
return fetch('/api/tools/confluence/page', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
domain: context.domain,
accessToken: tokenData.accessToken,
pageId: value,
}),
})
})
.then((res) => res.json())
.then((data) => {
if (data.id && data.title) {
useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, {
[data.id]: data.title,
})
}
})
.catch(() => {})
.finally(() => setIsFetching(false))
}
// Microsoft Teams
else if (provider === 'microsoft-teams' && context.credentialId) {
fetch('/api/tools/microsoft-teams/teams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: 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' && context.credentialId) {
fetch(`/api/tools/wealthbox/items?credentialId=${context.credentialId}&type=contact`)
.then((res) => res.json())
.then((data) => {
if (data.items) {
const contactMap = data.items.reduce(
(acc: Record<string, string>, item: { id: string; name: string }) => {
acc[item.id] = item.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))
}
// Google Drive files/folders (fetch by ID since no list endpoint via Picker API)
else if (
(provider === 'google-drive' || subBlock.serviceId === 'google-drive') &&
typeof value === 'string' &&
value
) {
const queryParams = new URLSearchParams({
credentialId: context.credentialId,
fileId: value,
})
fetch(`/api/tools/drive/file?${queryParams.toString()}`)
.then((res) => res.json())
.then((data) => {
if (data.file?.id && data.file.name) {
useDisplayNamesStore
.getState()
.setDisplayNames('files', context.credentialId!, { [data.file.id]: data.file.name })
}
})
.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,
])
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,22 @@
import { useEffect, useState } from 'react'
import { useKnowledgeStore } from '@/stores/knowledge/store'
export function useKnowledgeBaseName(knowledgeBaseId?: string | null) {
const getCachedKnowledgeBase = useKnowledgeStore((state) => state.getCachedKnowledgeBase)
const getKnowledgeBase = useKnowledgeStore((state) => state.getKnowledgeBase)
const [isLoading, setIsLoading] = useState(false)
const cached = knowledgeBaseId ? getCachedKnowledgeBase(knowledgeBaseId) : null
useEffect(() => {
if (!knowledgeBaseId || cached || isLoading) return
setIsLoading(true)
getKnowledgeBase(knowledgeBaseId)
.catch(() => {
// ignore
})
.finally(() => setIsLoading(false))
}, [knowledgeBaseId, cached, isLoading, getKnowledgeBase])
return cached?.name ?? null
}

View File

@@ -0,0 +1,83 @@
import { useMemo } from 'react'
import type { SubBlockConfig } from '@/blocks/types'
import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
import type { SelectorKey } from '@/hooks/selectors/types'
import {
useSelectorOptionDetail,
useSelectorOptionMap,
useSelectorOptions,
} from '@/hooks/selectors/use-selector-query'
interface SelectorDisplayNameArgs {
subBlock?: SubBlockConfig
value: unknown
workflowId?: string
credentialId?: string
domain?: string
projectId?: string
planId?: string
teamId?: string
knowledgeBaseId?: string
}
export function useSelectorDisplayName({
subBlock,
value,
workflowId,
credentialId,
domain,
projectId,
planId,
teamId,
knowledgeBaseId,
}: SelectorDisplayNameArgs) {
const detailId = typeof value === 'string' && value.length > 0 ? value : undefined
const resolution = useMemo(() => {
if (!subBlock || !detailId) return null
return resolveSelectorForSubBlock(subBlock, {
workflowId,
credentialId,
domain,
projectId,
planId,
teamId,
knowledgeBaseId,
})
}, [
subBlock,
detailId,
workflowId,
credentialId,
domain,
projectId,
planId,
teamId,
knowledgeBaseId,
])
const key = resolution?.key
const context = resolution?.context ?? {}
const enabled = Boolean(key && detailId)
const resolvedKey: SelectorKey = (key ?? 'slack.channels') as SelectorKey
const resolvedContext = enabled ? context : {}
const { data: options = [], isFetching: listLoading } = useSelectorOptions(resolvedKey, {
context: resolvedContext,
enabled,
})
const { data: detailOption, isLoading: detailLoading } = useSelectorOptionDetail(resolvedKey, {
context: resolvedContext,
detailId: enabled ? detailId : undefined,
enabled,
})
const optionMap = useSelectorOptionMap(options, detailOption ?? undefined)
const displayName = detailId ? (optionMap.get(detailId)?.label ?? null) : null
return {
displayName: enabled ? displayName : null,
isLoading: enabled ? listLoading || detailLoading : false,
}
}

View File

@@ -1,143 +0,0 @@
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
/**
* Remove a single display name
*/
removeDisplayName: (type: keyof DisplayNamesCache, context: string, id: string) => void
/**
* 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
},
removeDisplayName: (type, context, id) => {
set((state) => {
const contextCache = { ...state.cache[type][context] }
delete contextCache[id]
return {
cache: {
...state.cache,
[type]: {
...state.cache[type],
[context]: contextCache,
},
},
}
})
},
clearContext: (type, context) => {
set((state) => {
const newTypeCache = { ...state.cache[type] }
delete newTypeCache[context]
return {
cache: {
...state.cache,
[type]: newTypeCache,
},
}
})
},
clearAll: () => {
set({ cache: initialCache })
},
}))