mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
committed by
GitHub
parent
25ac91779b
commit
620ce97056
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 doesn’t 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])
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
88
apps/sim/hooks/queries/oauth-credentials.ts
Normal file
88
apps/sim/hooks/queries/oauth-credentials.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
61
apps/sim/hooks/selectors/helpers.ts
Normal file
61
apps/sim/hooks/selectors/helpers.ts
Normal 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
|
||||
}
|
||||
646
apps/sim/hooks/selectors/registry.ts
Normal file
646
apps/sim/hooks/selectors/registry.ts
Normal 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]
|
||||
}
|
||||
172
apps/sim/hooks/selectors/resolution.ts
Normal file
172
apps/sim/hooks/selectors/resolution.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
61
apps/sim/hooks/selectors/types.ts
Normal file
61
apps/sim/hooks/selectors/types.ts
Normal 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
|
||||
}
|
||||
61
apps/sim/hooks/selectors/use-selector-query.ts
Normal file
61
apps/sim/hooks/selectors/use-selector-query.ts
Normal 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])
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
22
apps/sim/hooks/use-knowledge-base-name.ts
Normal file
22
apps/sim/hooks/use-knowledge-base-name.ts
Normal 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
|
||||
}
|
||||
83
apps/sim/hooks/use-selector-display-name.ts
Normal file
83
apps/sim/hooks/use-selector-display-name.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user