diff --git a/apps/sim/app/api/auth/oauth/microsoft/file/route.ts b/apps/sim/app/api/auth/oauth/microsoft/file/route.ts index b1f7411dac3..c6400477ce0 100644 --- a/apps/sim/app/api/auth/oauth/microsoft/file/route.ts +++ b/apps/sim/app/api/auth/oauth/microsoft/file/route.ts @@ -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) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + if (!authz.ok || !authz.credentialOwnerUserId) { + const status = authz.error === 'Credential not found' ? 404 : 403 + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status }) } - const credential = credentials[0] - - if (credential.userId !== session.user.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId) + if (!credential) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) } - 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 }) diff --git a/apps/sim/app/api/auth/oauth/microsoft/files/route.ts b/apps/sim/app/api/auth/oauth/microsoft/files/route.ts index c3cc6e7f622..020fec07c29 100644 --- a/apps/sim/app/api/auth/oauth/microsoft/files/route.ts +++ b/apps/sim/app/api/auth/oauth/microsoft/files/route.ts @@ -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 }) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + if (!authz.ok || !authz.credentialOwnerUserId) { + const status = authz.error === 'Credential not found' ? 404 : 403 + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status }) } - 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 }) + const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId) + if (!credential) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) } // 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 }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/channel-selector/channel-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/channel-selector/channel-selector-input.tsx index be15a2b0328..95d566f911a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/channel-selector/channel-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/channel-selector/channel-selector-input.tsx @@ -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('') - const [_channelInfo, setChannelInfo] = useState(null) + const [_channelInfo, setChannelInfo] = useState(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('') - 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 ( -
- { - handleChannelChange(channelId, channelInfo) - }} - credential={credential} - label={subBlock.placeholder || 'Select Slack channel'} - disabled={finalDisabled} - workflowId={workflowIdFromUrl} - isForeignCredential={isForeignCredential} - /> +
+ Channel selector not supported for provider: {provider}
+ +

This channel selector is not yet implemented for {provider}

+
) } - // Default fallback for unsupported providers return ( -
- Channel selector not supported for provider: {provider} +
+ { + setChannelInfo(value) + if (!isPreview) { + onChannelSelect?.(value) + } + }} + />
- -

This channel selector is not yet implemented for {provider}

-
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx deleted file mode 100644 index 56190fb6cbd..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx +++ /dev/null @@ -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([]) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(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, 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 ? : - } - - const formatChannelName = (channel: SlackChannelInfo) => { - return channel.name - } - - return ( - - - - - - - - - - {loading ? ( -
- - Loading channels... -
- ) : error ? ( -
-

{error}

-
- ) : !credential ? ( -
-

Missing credentials

-

- Please configure Slack credentials. -

-
- ) : ( -
-

No channels found

-

- No channels available for this Slack workspace. -

-
- )} -
- - {channels.length > 0 && ( - -
- Channels -
- {channels.map((channel) => ( - handleSelectChannel(channel)} - className='cursor-pointer' - > -
- - {getChannelIcon(channel)} - {formatChannelName(channel)} - {channel.isPrivate && ( - Private - )} -
- {channel.id === value && } -
- ))} -
- )} -
-
-
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index 29d8ee05a9c..437dccf75fc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -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([]) - 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() - - // Use collaborative state management via useSubBlockValue hook - const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) + 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 : '' - // Initialize selectedId with the effective value - useEffect(() => { - setSelectedId(effectiveValue || '') - }, [effectiveValue]) - - // Derive service and provider IDs using useMemo - const effectiveServiceId = useMemo(() => { - return serviceId || getServiceIdFromScopes(provider, requiredScopes) - }, [provider, requiredScopes, serviceId]) - - const effectiveProviderId = useMemo(() => { - return getProviderIdFromServiceId(effectiveServiceId) - }, [effectiveServiceId]) - - // 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 - - // 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, 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) - } - } - } - } catch (error) { - logger.error('Error fetching credentials:', { error }) - } finally { - setIsLoading(false) - } - }, [effectiveProviderId, selectedId, activeWorkflowId, isPreview, setStoreValue]) + const effectiveServiceId = useMemo( + () => serviceId || getServiceIdFromScopes(provider, requiredScopes), + [provider, requiredScopes, serviceId] + ) - // Fetch credentials on initial mount and whenever the subblock value changes externally - useEffect(() => { - fetchCredentials() - }, [fetchCredentials, effectiveValue]) + const effectiveProviderId = useMemo( + () => getProviderIdFromServiceId(effectiveServiceId), + [effectiveServiceId] + ) - // 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]) + const { + data: credentials = [], + isFetching: credentialsLoading, + refetch: refetchCredentials, + } = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId)) - // This effect is no longer needed since we're using effectiveValue directly + const selectedCredential = useMemo( + () => credentials.find((cred) => cred.id === selectedId), + [credentials, selectedId] + ) - // Listen for visibility changes to update credentials when user returns from settings - useEffect(() => { - const handleVisibilityChange = () => { - if (document.visibilityState === 'visible') { - fetchCredentials() - } - } + const shouldFetchForeignMeta = + Boolean(selectedId) && + !selectedCredential && + Boolean(activeWorkflowId) && + Boolean(effectiveProviderId) - document.addEventListener('visibilitychange', handleVisibilityChange) + const { data: foreignCredentials = [], isFetching: foreignMetaLoading } = + useOAuthCredentialDetail( + shouldFetchForeignMeta ? selectedId : undefined, + activeWorkflowId || undefined, + shouldFetchForeignMeta + ) - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange) - } - }, [fetchCredentials]) + const hasForeignMeta = foreignCredentials.length > 0 + const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta) - // 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]) + const resolvedLabel = useMemo(() => { + if (selectedCredential) return selectedCredential.name + if (isForeign) return 'Saved by collaborator' + return '' + }, [selectedCredential, isForeign]) - // 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() - } + if (!isEditing) { + setInputValue(resolvedLabel) } + }, [resolvedLabel, isEditing]) - 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) + const invalidSelection = + !isPreview && + Boolean(selectedId) && + !selectedCredential && + !hasForeignMeta && + !credentialsLoading && + !foreignMetaLoading - // 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 + useEffect(() => { + if (!invalidSelection) return + logger.info('Clearing invalid credential selection - credential was disconnected', { + selectedId, + provider: effectiveProviderId, + }) + setStoreValue('') + }, [invalidSelection, selectedId, effectiveProviderId, setStoreValue]) + + useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, provider) + + const handleOpenChange = useCallback( + (isOpen: boolean) => { + if (isOpen) { + void refetchCredentials() + } + }, + [refetchCredentials] + ) - // 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 - // Handle selection - const handleSelect = (credentialId: string) => { - const previousId = selectedId || (effectiveValue as string) || '' - setSelectedId(credentialId) - if (!isPreview) { + const needsUpdate = + hasSelection && + missingRequiredScopes.length > 0 && + !disabled && + !isPreview && + !credentialsLoading + + 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 + return } - // 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 ( +
+
+ {getProviderIcon(selectedCredentialProvider)} +
+ {inputValue} +
+ ) + }, [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 ( <> - - - - - - - - - - {isLoading ? ( -
- - Loading credentials... -
- ) : ( -
-

No credentials found.

-

- Connect a new account to continue. -

-
- )} -
- {credentials.length > 0 && ( - - {credentials.map((cred) => ( - handleSelect(cred.id)} - > -
- {getProviderIcon(cred.provider)} - {cred.name} -
- {cred.id === selectedId && } -
- ))} -
- )} - {credentials.length === 0 && ( - - -
- {getProviderIcon(provider)} - Connect {getProviderName(provider)} account -
-
-
- )} -
-
-
-
+ {needsUpdate && (
@@ -414,3 +265,49 @@ export function CredentialSelector({ ) } + +function useCredentialRefreshTriggers( + refetchCredentials: () => Promise, + 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]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/document-selector/document-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/document-selector/document-selector.tsx index 5257c770a33..84be85bf9f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/document-selector/document-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/document-selector/document-selector.tsx @@ -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(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>((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 = { - 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 + const selectorContext = useMemo( + () => ({ + knowledgeBaseId: normalizedKnowledgeBaseId ?? undefined, + }), + [normalizedKnowledgeBaseId] + ) - // 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 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 ( -
- - - - - - - - - - {isLoading ? ( -
- - Loading documents... -
- ) : error ? ( -
-

{error}

-
- ) : !normalizedKnowledgeBaseId ? ( -
-

No knowledge base selected

-

- Please select a knowledge base first. -

-
- ) : ( -
-

No documents found

-

- Upload documents to this knowledge base to get started. -

-
- )} -
- - {documents.length > 0 && ( - -
- Documents -
- {documents.map((document) => ( - handleSelectDocument(document)} - className='cursor-pointer' - > -
- -
-
{formatDocumentName(document)}
-
- {getDocumentDescription(document)} -
-
-
- {document.id === value && } -
- ))} -
- )} -
-
-
-
-
+ isPreview={isPreview} + previewValue={previewValue ?? null} + placeholder={placeholder} + onOptionChange={handleDocumentChange} + /> +
+ + {missingKnowledgeBase && ( + +

Select a knowledge base first.

+
+ )} + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/confluence-file-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/confluence-file-selector.tsx deleted file mode 100644 index edc34632a45..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/confluence-file-selector.tsx +++ /dev/null @@ -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([]) - const [files, setFiles] = useState([]) - const [selectedCredentialId, setSelectedCredentialId] = useState(credentialId || '') - const [selectedFileId, setSelectedFileId] = useState(value) - const [selectedFile, setSelectedFile] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [showOAuthModal, setShowOAuthModal] = useState(false) - const initialFetchRef = useRef(false) - const [error, setError] = useState(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(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, 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 ( - <> -
- - - - - {!isForeignCredential && ( - - {/* Current account indicator */} - {selectedCredentialId && credentials.length > 0 && ( -
-
- - - {credentials.find((cred) => cred.id === selectedCredentialId)?.name || - 'Unknown'} - -
- {credentials.length > 1 && ( - - )} -
- )} - - - - - - {isLoading ? ( -
- - Loading pages... -
- ) : error ? ( -
-

{error}

-
- ) : credentials.length === 0 ? ( -
-

No accounts connected.

-

- Connect a Confluence account to continue. -

-
- ) : ( -
-

No pages found.

-

- Try a different search or account. -

-
- )} -
- - {/* Account selection - only show if we have multiple accounts */} - {credentials.length > 1 && ( - -
- Switch Account -
- {credentials.map((cred) => ( - setSelectedCredentialId(cred.id)} - > -
- - {cred.name} -
- {cred.id === selectedCredentialId && ( - - )} -
- ))} -
- )} - - {/* Files list */} - {files.length > 0 && ( - -
- Pages -
- {files.map((file) => ( - handleSelectFile(file)} - > -
- - {file.name} -
- {file.id === selectedFileId && } -
- ))} -
- )} - - {/* Connect account option - only show if no credentials */} - {credentials.length === 0 && ( - - -
- - Connect Confluence account -
-
-
- )} -
-
-
- )} -
- - {showPreview && selectedFile && selectedFileId && selectedFile.id === selectedFileId && ( -
-
- -
-
-
- -
-
-
-

{selectedFile.name}

- {selectedFile.modifiedTime && ( - - {new Date(selectedFile.modifiedTime).toLocaleDateString()} - - )} -
- {selectedFile.webViewLink && ( - e.stopPropagation()} - > - Open in Confluence - - - )} -
-
-
- )} -
- - {showOAuthModal && ( - setShowOAuthModal(false)} - provider={provider} - toolName='Confluence' - requiredScopes={requiredScopes} - serviceId={getServiceId()} - /> - )} - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/google-calendar-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/google-calendar-selector.tsx deleted file mode 100644 index 06c64836521..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/google-calendar-selector.tsx +++ /dev/null @@ -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([]) - const [selectedCalendarId, setSelectedCalendarId] = useState(value) - const [selectedCalendar, setSelectedCalendar] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(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 => { - 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>((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 ( -
- - - - - - - - - - {isLoading ? ( -
- - Loading calendars... -
- ) : error ? ( -
-

{error}

-
- ) : calendars.length === 0 ? ( -
-

No calendars found

-

- Please check your Google Calendar account access -

-
- ) : ( -
-

No matching calendars

-
- )} -
- - {calendars.length > 0 && ( - -
- Calendars -
- {calendars.map((calendar) => ( - handleSelectCalendar(calendar)} - className='cursor-pointer' - > -
-
- - {getCalendarDisplayName(calendar)} - -
- {calendar.id === selectedCalendarId && } - - ))} - - )} - - - - - - {showPreview && selectedCalendar && ( -
-
- -
-
-
-
-
-
-

- {getCalendarDisplayName(selectedCalendar)} -

-
- Access: {selectedCalendar.accessRole} -
-
-
-
- )} -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/google-drive-picker.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/google-drive-picker.tsx deleted file mode 100644 index 9d0c6384fd2..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/google-drive-picker.tsx +++ /dev/null @@ -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([]) - const [selectedCredentialId, setSelectedCredentialId] = useState('') - const [selectedFileId, setSelectedFileId] = useState(value) - const [selectedFile, setSelectedFile] = useState(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, 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('') - - // 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 => { - 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 - } - - // 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 - } - if (file.mimeType === 'application/vnd.google-apps.spreadsheet') { - return - } - if (file.mimeType === 'application/vnd.google-apps.document') { - return - } - return - } - - const canShowPreview = !!( - showPreview && - selectedFile && - selectedFileId && - selectedFile.id === selectedFileId - ) - - return ( - <> -
- - - {/* File preview */} - {canShowPreview && ( -
-
- -
-
-
- {getFileIcon(selectedFile, 'sm')} -
-
-
-

{selectedFile.name}

- {selectedFile.modifiedTime && ( - - {new Date(selectedFile.modifiedTime).toLocaleDateString()} - - )} -
- {selectedFile.webViewLink ? ( - e.stopPropagation()} - > - Open in Drive - - - ) : ( - e.stopPropagation()} - > - Open in Drive - - - )} -
-
-
- )} -
- - {showOAuthModal && ( - setShowOAuthModal(false)} - provider={provider} - toolName={getProviderName(provider)} - requiredScopes={requiredScopes} - serviceId={getServiceId()} - /> - )} - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/index.ts deleted file mode 100644 index d4ed1957007..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/index.ts +++ /dev/null @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/jira-issue-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/jira-issue-selector.tsx deleted file mode 100644 index d7e2b26f3be..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/jira-issue-selector.tsx +++ /dev/null @@ -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([]) - const [issues, setIssues] = useState([]) - const [selectedCredentialId, setSelectedCredentialId] = useState(credentialId || '') - const [selectedIssueId, setSelectedIssueId] = useState(value) - const [selectedIssue, setSelectedIssue] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [showOAuthModal, setShowOAuthModal] = useState(false) - const [error, setError] = useState(null) - const [cloudId, setCloudId] = useState(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(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, 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 ( - <> -
- - - - - {!isForeignCredential && ( - - {/* Current account indicator */} - {selectedCredentialId && credentials.length > 0 && ( -
-
- - - {credentials.find((cred) => cred.id === selectedCredentialId)?.name || - 'Unknown'} - -
- {credentials.length > 1 && ( - - )} -
- )} - - - - - - {isLoading ? ( -
- - Loading issues... -
- ) : error ? ( -
-

{error}

-
- ) : credentials.length === 0 ? ( -
-

No accounts connected.

-

- Connect a Jira account to continue. -

-
- ) : ( -
-

No issues found.

-

- Try a different search or account. -

-
- )} -
- - {/* Account selection - only show if we have multiple accounts */} - {credentials.length > 1 && ( - -
- Switch Account -
- {credentials.map((cred) => ( - setSelectedCredentialId(cred.id)} - > -
- - {cred.name} -
- {cred.id === selectedCredentialId && ( - - )} -
- ))} -
- )} - - {/* Issues list */} - {issues.length > 0 && ( - -
- Issues -
- {issues.map((issue) => ( - handleSelectIssue(issue)} - > -
- - {issue.name} -
- {issue.id === selectedIssueId && } -
- ))} -
- )} - - {/* Connect account option - only show if no credentials */} - {credentials.length === 0 && ( - - -
- - Connect Jira account -
-
-
- )} -
-
-
- )} -
- - {showPreview && selectedIssue && ( -
-
- -
-
-
- -
-
-
-

{selectedIssue.name}

- {selectedIssue.modifiedTime && ( - - {new Date(selectedIssue.modifiedTime).toLocaleDateString()} - - )} -
- {selectedIssue.webViewLink && ( - e.stopPropagation()} - > - Open in Jira - - - )} -
-
-
- )} -
- - {showOAuthModal && ( - setShowOAuthModal(false)} - provider={provider} - toolName='Jira' - requiredScopes={requiredScopes} - serviceId={getServiceId()} - /> - )} - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx deleted file mode 100644 index abed39499aa..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx +++ /dev/null @@ -1,1001 +0,0 @@ -'use client' - -import { useCallback, useEffect, useRef, useState } from 'react' -import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react' -import { MicrosoftExcelIcon } 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, - 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' -import type { PlannerTask } from '@/tools/microsoft_planner/types' - -const logger = createLogger('MicrosoftFileSelector') - -export interface MicrosoftFileInfo { - id: string - name: string - mimeType: string - iconLink?: string - webViewLink?: string - thumbnailLink?: string - createdTime?: string - modifiedTime?: string - size?: string - owners?: { displayName: string; emailAddress: string }[] -} - -// Union type for items that can be displayed in the file selector -type SelectableItem = MicrosoftFileInfo | PlannerTask - -interface MicrosoftFileSelectorProps { - value: string - onChange: (value: string, fileInfo?: MicrosoftFileInfo) => void - provider: OAuthProvider - requiredScopes?: string[] - label?: string - disabled?: boolean - serviceId?: string - mimeType?: string // Filter type: 'file' for files only, 'application/vnd.microsoft.graph.folder' for folders only - showPreview?: boolean - onFileInfoChange?: (fileInfo: MicrosoftFileInfo | null) => void - planId?: string - workflowId?: string - credentialId?: string - isForeignCredential?: boolean -} - -export function MicrosoftFileSelector({ - value, - onChange, - provider, - requiredScopes = [], - label = 'Select file', - disabled = false, - serviceId, - mimeType, - showPreview = true, - onFileInfoChange, - planId, - workflowId, - credentialId, - isForeignCredential = false, -}: MicrosoftFileSelectorProps) { - const [open, setOpen] = useState(false) - const [credentials, setCredentials] = useState([]) - const [selectedCredentialId, setSelectedCredentialId] = useState(credentialId || '') - const [selectedFileId, setSelectedFileId] = useState(value) - const [selectedFile, setSelectedFile] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [isLoadingSelectedFile, setIsLoadingSelectedFile] = useState(false) - const [isLoadingFiles, setIsLoadingFiles] = useState(false) - const [availableFiles, setAvailableFiles] = useState([]) - const [searchQuery, setSearchQuery] = useState('') - const [showOAuthModal, setShowOAuthModal] = useState(false) - const [credentialsLoaded, setCredentialsLoaded] = useState(false) - const initialFetchRef = useRef(false) - const lastMetaAttemptRef = useRef('') - - const [plannerTasks, setPlannerTasks] = useState([]) - const [isLoadingTasks, setIsLoadingTasks] = useState(false) - const [selectedTask, setSelectedTask] = useState(null) - - // Get cached display name - const cachedFileName = useDisplayNamesStore( - useCallback( - (state) => { - const effectiveCredentialId = credentialId || selectedCredentialId - if (!effectiveCredentialId || !value) return null - return state.cache.files[effectiveCredentialId]?.[value] || null - }, - [credentialId, selectedCredentialId, value] - ) - ) - - // Determine the appropriate service ID based on provider and scopes - const getServiceId = (): string => { - if (serviceId) return serviceId - 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) - - // If a credentialId prop is provided (collaborator case), do not auto-select - if (!credentialId && data.credentials.length > 0 && !selectedCredentialId) { - 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) - setCredentialsLoaded(true) - } - }, [provider, getProviderId, selectedCredentialId, credentialId]) - - // Keep internal credential in sync with prop - useEffect(() => { - if (credentialId && credentialId !== selectedCredentialId) { - setSelectedCredentialId(credentialId) - } - }, [credentialId, selectedCredentialId]) - - // Fetch available files for the selected credential - const fetchAvailableFiles = useCallback(async () => { - if (!selectedCredentialId || isForeignCredential) return - - setIsLoadingFiles(true) - try { - const queryParams = new URLSearchParams({ - credentialId: selectedCredentialId, - }) - - // Add search query if provided - if (searchQuery.trim()) { - queryParams.append('query', searchQuery.trim()) - } - - // Route to correct endpoint based on service and mimeType - let endpoint: string - if (serviceId === 'onedrive') { - // Use files endpoint if mimeType is 'file', otherwise use folders endpoint - if (mimeType === 'file') { - endpoint = `/api/tools/onedrive/files?${queryParams.toString()}` - } else { - endpoint = `/api/tools/onedrive/folders?${queryParams.toString()}` - } - } else if (serviceId === 'sharepoint') { - endpoint = `/api/tools/sharepoint/sites?${queryParams.toString()}` - } else { - endpoint = `/api/auth/oauth/microsoft/files?${queryParams.toString()}` - } - - const response = await fetch(endpoint) - - if (response.ok) { - const data = await response.json() - setAvailableFiles(data.files || []) - - // Cache file names in display names store - if (selectedCredentialId && data.files) { - const fileMap = data.files.reduce( - (acc: Record, file: MicrosoftFileInfo) => { - acc[file.id] = file.name - return acc - }, - {} - ) - useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, fileMap) - } - } else { - const txt = await response.text() - if (response.status === 401 || response.status === 403) { - // Suppress noisy auth errors for collaborators; lists are intentionally gated - logger.info('Skipping list fetch (auth)', { status: response.status }) - } else { - logger.warn('Non-OK list fetch', { status: response.status, txt }) - } - setAvailableFiles([]) - } - } catch (error) { - logger.error('Error fetching available files:', { error }) - setAvailableFiles([]) - } finally { - setIsLoadingFiles(false) - } - }, [selectedCredentialId, searchQuery, serviceId, mimeType, isForeignCredential]) - - // 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 { - // Use owner-scoped token for OneDrive items (files/folders) and Excel - if (serviceId !== 'sharepoint') { - const tokenRes = await fetch('/api/auth/oauth/token', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ credentialId: selectedCredentialId, workflowId }), - }) - if (!tokenRes.ok) { - const err = await tokenRes.text() - logger.error('Failed to get access token for Microsoft file fetch', { err }) - return null - } - const { accessToken } = await tokenRes.json() - if (!accessToken) return null - - const graphUrl = - `https://graph.microsoft.com/v1.0/me/drive/items/${encodeURIComponent(fileId)}?` + - new URLSearchParams({ - $select: - 'id,name,webUrl,thumbnails,createdDateTime,lastModifiedDateTime,size,createdBy,file,folder', - }).toString() - const resp = await fetch(graphUrl, { - headers: { Authorization: `Bearer ${accessToken}` }, - }) - if (!resp.ok) { - const t = await resp.text() - // For 404/403, keep current selection; this often means the item moved or is shared differently. - if (resp.status !== 404 && resp.status !== 403) { - logger.warn('Graph error fetching file by ID', { status: resp.status, t }) - } - return null - } - const file = await resp.json() - const fileInfo: MicrosoftFileInfo = { - id: file.id, - name: file.name, - mimeType: - file?.file?.mimeType || (file.folder ? 'application/vnd.ms-onedrive.folder' : ''), - iconLink: file.thumbnails?.[0]?.small?.url, - webViewLink: file.webUrl, - thumbnailLink: file.thumbnails?.[0]?.medium?.url, - createdTime: file.createdDateTime, - modifiedTime: file.lastModifiedDateTime, - size: file.size?.toString(), - owners: file.createdBy - ? [ - { - displayName: file.createdBy.user?.displayName || 'Unknown', - emailAddress: file.createdBy.user?.email || '', - }, - ] - : [], - } - setSelectedFile(fileInfo) - onFileInfoChange?.(fileInfo) - return fileInfo - } - - // SharePoint site: fetch via Graph sites endpoint for collaborator visibility - 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: spToken } = await tokenRes.json() - if (!spToken) return null - const spResp = await fetch( - `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(fileId)}?$select=id,displayName,webUrl`, - { - headers: { Authorization: `Bearer ${spToken}` }, - } - ) - if (!spResp.ok) return null - const site = await spResp.json() - const siteInfo: MicrosoftFileInfo = { - id: site.id, - name: site.displayName, - mimeType: 'sharepoint/site', - webViewLink: site.webUrl, - } - setSelectedFile(siteInfo) - onFileInfoChange?.(siteInfo) - return siteInfo - } catch (error) { - logger.error('Error fetching file by ID:', { error }) - return null - } finally { - setIsLoadingSelectedFile(false) - } - }, - [selectedCredentialId, onFileInfoChange, serviceId, workflowId, onChange] - ) - - // Fetch Microsoft Planner tasks when planId and credentials are available - const fetchPlannerTasks = useCallback(async () => { - if ( - !selectedCredentialId || - !planId || - serviceId !== 'microsoft-planner' || - isForeignCredential - ) { - logger.info('Skipping task fetch - missing requirements:', { - selectedCredentialId: !!selectedCredentialId, - planId: !!planId, - serviceId, - isForeignCredential, - }) - return - } - - logger.info('Fetching Planner tasks with:', { - credentialId: selectedCredentialId, - planId, - serviceId, - }) - - setIsLoadingTasks(true) - try { - const queryParams = new URLSearchParams({ - credentialId: selectedCredentialId, - planId: planId, - }) - - const url = `/api/tools/microsoft_planner/tasks?${queryParams.toString()}` - logger.info('Calling API endpoint:', url) - - const response = await fetch(url) - - if (response.ok) { - const data = await response.json() - logger.info('Received task data:', data) - const tasks = data.tasks || [] - - // Transform tasks to match file info format for consistency - const transformedTasks = tasks.map((task: PlannerTask) => ({ - id: task.id, - name: task.title, - mimeType: 'planner/task', - webViewLink: `https://tasks.office.com/planner/task/${task.id}`, - modifiedTime: task.createdDateTime, - createdTime: task.createdDateTime, - planId: task.planId, - bucketId: task.bucketId, - percentComplete: task.percentComplete, - priority: task.priority, - dueDateTime: task.dueDateTime, - })) - - logger.info('Transformed tasks:', transformedTasks) - setPlannerTasks(transformedTasks) - } else { - const errorText = await response.text() - if (response.status === 401 || response.status === 403) { - logger.info('Planner list fetch unauthorized (expected for collaborator)', { - status: response.status, - }) - } else { - logger.warn('Planner tasks fetch non-OK', { - status: response.status, - statusText: response.statusText, - errorText, - }) - } - setPlannerTasks([]) - } - } catch (error) { - logger.error('Network/fetch error:', error) - setPlannerTasks([]) - } finally { - setIsLoadingTasks(false) - } - }, [selectedCredentialId, planId, serviceId, isForeignCredential]) - - // Fetch a single planner task by ID for collaborator preview - const fetchPlannerTaskById = useCallback( - async (taskId: string) => { - if (!selectedCredentialId || !taskId || serviceId !== 'microsoft-planner') return null - setIsLoadingTasks(true) - try { - 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/planner/tasks/${encodeURIComponent(taskId)}`, - { - headers: { Authorization: `Bearer ${accessToken}` }, - } - ) - if (!resp.ok) return null - const task = await resp.json() - const taskAsFileInfo: MicrosoftFileInfo = { - id: task.id, - name: task.title, - mimeType: 'planner/task', - webViewLink: `https://tasks.office.com/planner/task/${task.id}`, - createdTime: task.createdDateTime, - modifiedTime: task.createdDateTime, - } - setSelectedTask(task) - setSelectedFile(taskAsFileInfo) - onFileInfoChange?.(taskAsFileInfo) - return taskAsFileInfo - } catch { - return null - } finally { - setIsLoadingTasks(false) - } - }, - [selectedCredentialId, workflowId, onFileInfoChange, serviceId] - ) - - // Fetch credentials on initial mount - useEffect(() => { - if (!initialFetchRef.current) { - fetchCredentials() - initialFetchRef.current = true - } - }, [fetchCredentials]) - - // Fetch available files when credential changes - useEffect(() => { - if (selectedCredentialId) { - fetchAvailableFiles() - } - }, [selectedCredentialId, fetchAvailableFiles]) - - // Refetch files when search query changes - useEffect(() => { - if (selectedCredentialId && searchQuery !== undefined) { - const timeoutId = setTimeout(() => { - fetchAvailableFiles() - }, 300) // Debounce search - - return () => clearTimeout(timeoutId) - } - }, [searchQuery, selectedCredentialId, fetchAvailableFiles]) - - // Fetch planner tasks when credentials and planId change - useEffect(() => { - if ( - serviceId === 'microsoft-planner' && - selectedCredentialId && - planId && - !isForeignCredential - ) { - fetchPlannerTasks() - } - }, [selectedCredentialId, planId, serviceId, isForeignCredential, fetchPlannerTasks]) - - // Handle task selection for planner - const handleTaskSelect = (task: PlannerTask) => { - const taskId = task.id || '' - // Convert PlannerTask to MicrosoftFileInfo format for compatibility - const taskAsFileInfo: MicrosoftFileInfo = { - id: taskId, - name: task.title, - mimeType: 'planner/task', - webViewLink: `https://tasks.office.com/planner/task/${taskId}`, - createdTime: task.createdDateTime, - modifiedTime: task.createdDateTime, - } - - setSelectedFileId(taskId) - setSelectedTask(task) - setSelectedFile(taskAsFileInfo) - onChange(taskId, taskAsFileInfo) - onFileInfoChange?.(taskAsFileInfo) - setOpen(false) - setSearchQuery('') - } - - // Keep internal selectedFileId in sync with the value prop (do not clear selectedFile; we'll resolve new metadata below) - useEffect(() => { - if (value !== selectedFileId) { - setSelectedFileId(value) - } - }, [value, selectedFileId]) - - // Track previous credential ID to detect changes - const prevCredentialIdRef = useRef('') - - // Clear selected file when credentials are removed or changed - useEffect(() => { - const prevCredentialId = prevCredentialIdRef.current - prevCredentialIdRef.current = selectedCredentialId - - if (!selectedCredentialId) { - // No credentials - clear everything - setSelectedFileId('') - onChange('') - // Reset memo when credential is cleared - lastMetaAttemptRef.current = '' - } else if (prevCredentialId && prevCredentialId !== selectedCredentialId) { - // Reset memo when switching credentials - lastMetaAttemptRef.current = '' - } - }, [selectedCredentialId, onChange]) - - // Keep internal selectedFileId in sync with the value prop - useEffect(() => { - if (value !== selectedFileId) { - setSelectedFileId(value) - } - }, [value, selectedFileId]) - - // Handle selecting a file from the available files - const handleFileSelect = (file: MicrosoftFileInfo) => { - setSelectedFileId(file.id) - setSelectedFile(file) - onChange(file.id, file) - onFileInfoChange?.(file) - setOpen(false) - setSearchQuery('') - } - - // Handle adding a new credential - const handleAddCredential = () => { - // Show the OAuth modal - setShowOAuthModal(true) - setOpen(false) - setSearchQuery('') // Clear search when closing - } - - // Clear selection - const handleClearSelection = () => { - setSelectedFileId('') - setSelectedFile(null) - setSelectedTask(null) - onChange('', undefined) - onFileInfoChange?.(null) - } - - // Get provider icon - const getProviderIcon = (providerName: OAuthProvider) => { - const { baseProvider } = parseProvider(providerName) - const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] - - if (!baseProviderConfig) { - return - } - - // Handle OneDrive specifically by checking serviceId - if (baseProvider === 'microsoft' && serviceId === 'onedrive') { - const onedriveService = baseProviderConfig.services.onedrive - if (onedriveService) { - return onedriveService.icon({ className: 'h-4 w-4' }) - } - } - - // Handle SharePoint specifically by checking serviceId - if (baseProvider === 'microsoft' && serviceId === 'sharepoint') { - const sharepointService = baseProviderConfig.services.sharepoint - if (sharepointService) { - return sharepointService.icon({ 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: MicrosoftFileInfo, size: 'sm' | 'md' = 'sm') => { - const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5' - - if (file.mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') { - return - } - if (file.mimeType === 'planner/task') { - return getProviderIcon(provider) - } - // if (file.mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { - // return - // } - // if (file.mimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') { - // return - // } - // return - } - - // Handle search input changes - const handleSearch = (query: string) => { - setSearchQuery(query) - } - - const getFileTypeTitleCase = () => { - if (serviceId === 'onedrive') { - return mimeType === 'file' ? 'Files' : 'Folders' - } - if (serviceId === 'sharepoint') return 'Sites' - if (serviceId === 'microsoft-planner') return 'Tasks' - return 'Excel Files' - } - - const getSearchPlaceholder = () => { - if (serviceId === 'onedrive') { - return mimeType === 'file' ? 'Search OneDrive files...' : 'Search OneDrive folders...' - } - if (serviceId === 'sharepoint') return 'Search SharePoint sites...' - if (serviceId === 'microsoft-planner') return 'Search tasks...' - return 'Search Excel files...' - } - - const getEmptyStateText = () => { - if (serviceId === 'onedrive') { - if (mimeType === 'file') { - return { - title: 'No files found.', - description: 'No files were found in your OneDrive.', - } - } - return { - title: 'No folders found.', - description: 'No folders were found in your OneDrive.', - } - } - if (serviceId === 'sharepoint') { - return { - title: 'No sites found.', - description: 'No SharePoint sites were found.', - } - } - if (serviceId === 'microsoft-planner') { - return { - title: 'No tasks found.', - description: 'No tasks were found in this plan.', - } - } - return { - title: 'No Excel files found.', - description: 'No .xlsx files were found in your OneDrive.', - } - } - - // Filter tasks based on search query for planner - const filteredTasks: SelectableItem[] = - serviceId === 'microsoft-planner' - ? plannerTasks.filter((task) => { - const title = task.title || '' - const query = searchQuery || '' - return title.toLowerCase().includes(query.toLowerCase()) - }) - : availableFiles - - const canShowPreview = !!( - showPreview && - selectedFile && - selectedFileId && - selectedFile.id === selectedFileId - ) - - return ( - <> -
- { - setOpen(isOpen) - if (!isOpen) { - setSearchQuery('') - } - }} - > - - - - {!isForeignCredential && ( - - {/* Current account indicator */} - {selectedCredentialId && credentials.length > 0 && ( -
-
- {getProviderIcon(provider)} - - {credentials.find((cred) => cred.id === selectedCredentialId)?.name || - 'Unknown'} - -
- {credentials.length > 1 && ( - - )} -
- )} - - - - - - {isLoading || isLoadingFiles || isLoadingTasks ? ( -
- - Loading... -
- ) : credentials.length === 0 ? ( -
-

No accounts connected.

-

- Connect a {getProviderName(provider)} account to continue. -

-
- ) : serviceId === 'microsoft-planner' && !planId ? ( -
-

Plan ID required.

-

- Please enter a Plan ID first to see tasks. -

-
- ) : filteredTasks.length === 0 ? ( -
-

{getEmptyStateText().title}

-

- {getEmptyStateText().description} -

-
- ) : null} -
- - {/* Account selection - only show if we have multiple accounts */} - {credentials.length > 1 && ( - -
- Switch Account -
- {credentials.map((cred) => ( - setSelectedCredentialId(cred.id)} - > -
- {getProviderIcon(cred.provider)} - {cred.name} -
- {cred.id === selectedCredentialId && ( - - )} -
- ))} -
- )} - - {/* Available files/tasks - only show if we have credentials and items */} - {credentials.length > 0 && selectedCredentialId && filteredTasks.length > 0 && ( - -
- {getFileTypeTitleCase()} -
- {filteredTasks.map((item) => { - const isPlanner = serviceId === 'microsoft-planner' - const isPlannerTask = isPlanner && 'title' in item - const plannerTask = item as PlannerTask - const fileInfo = item as MicrosoftFileInfo - - const displayName = isPlannerTask ? plannerTask.title : fileInfo.name - const dateField = isPlannerTask - ? plannerTask.createdDateTime - : fileInfo.createdTime - - return ( - - isPlannerTask - ? handleTaskSelect(plannerTask) - : handleFileSelect(fileInfo) - } - > -
- {getFileIcon( - isPlannerTask - ? { - ...fileInfo, - id: plannerTask.id || '', - name: plannerTask.title, - mimeType: 'planner/task', - } - : fileInfo, - 'sm' - )} -
- {displayName} - {dateField && ( -
- Modified {new Date(dateField).toLocaleDateString()} -
- )} -
-
- {item.id === selectedFileId && } -
- ) - })} -
- )} - - {/* Connect account option - only show if no credentials */} - {credentials.length === 0 && ( - - -
- {getProviderIcon(provider)} - Connect {getProviderName(provider)} account -
-
-
- )} -
-
-
- )} -
- - {canShowPreview && ( -
-
- -
-
-
- {getFileIcon(selectedFile, 'sm')} -
- -
-
- )} -
- - {showOAuthModal && ( - setShowOAuthModal(false)} - provider={provider} - toolName={getProviderName(provider)} - requiredScopes={requiredScopes} - serviceId={getServiceId()} - /> - )} - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/teams-message-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/teams-message-selector.tsx deleted file mode 100644 index 5e233911b73..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/teams-message-selector.tsx +++ /dev/null @@ -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([]) - const [teams, setTeams] = useState([]) - const [channels, setChannels] = useState([]) - const [chats, setChats] = useState([]) - const [selectedCredentialId, setSelectedCredentialId] = useState(credential || '') - const [selectedTeamId, setSelectedTeamId] = useState('') - const [selectedChannelId, setSelectedChannelId] = useState('') - const [selectedChatId, setSelectedChatId] = useState('') - const [selectedMessageId, setSelectedMessageId] = useState(value) - const [selectedMessage, setSelectedMessage] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [showOAuthModal, setShowOAuthModal] = useState(false) - const initialFetchRef = useRef(false) - const [error, setError] = useState(null) - const [selectionStage, setSelectionStage] = useState<'team' | 'channel' | 'chat'>(selectionType) - const lastRestoredValueRef = useRef(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, 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, 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, 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 ( - -
Teams
- {teams.map((team) => ( - handleSelectTeam(team)} - > -
- - {team.displayName} -
- {team.teamId === selectedTeamId && } -
- ))} -
- ) - } - - if (selectionStage === 'channel' && channels.length > 0) { - return ( - -
Channels
- {channels.map((channel) => ( - handleSelectChannel(channel)} - > -
- - {channel.displayName} -
- {channel.channelId === selectedChannelId && } -
- ))} -
- ) - } - - if (selectionStage === 'chat' && chats.length > 0) { - return ( - -
Chats
- {chats.map((chat) => ( - handleSelectChat(chat)} - > -
- - {chat.displayName} -
- {chat.chatId === selectedChatId && } -
- ))} -
- ) - } - - 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, 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 ( - <> -
- - - - - {!isForeignCredential && ( - - {/* Current account indicator */} - {selectedCredentialId && credentials.length > 0 && ( -
-
- - - {credentials.find((cred) => cred.id === selectedCredentialId)?.name || - 'Unknown'} - -
- {credentials.length > 1 && ( - - )} -
- )} - - - - - - {isLoading ? ( -
- - Loading {selectionStage}s... -
- ) : error ? ( -
-

{error}

- {selectionStage === 'chat' && error.includes('teams') && ( -

- There was an issue fetching chats. Please try again or connect a - different account. -

- )} -
- ) : credentials.length === 0 ? ( -
-

No accounts connected.

-

- Connect a Microsoft Teams account to{' '} - {selectionStage === 'chat' - ? 'access your chats' - : selectionStage === 'channel' - ? 'see your channels' - : 'continue'} - . -

-
- ) : ( -
-

No {selectionStage}s found.

-

- {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.'} -

-
- )} -
- - {/* Account selection - only show if we have multiple accounts */} - {credentials.length > 1 && ( - -
- Switch Account -
- {credentials.map((cred) => ( - { - setSelectedCredentialId(cred.id) - setOpen(false) - }} - > -
- - {cred.name} -
- {cred.id === selectedCredentialId && ( - - )} -
- ))} -
- )} - - {/* Display appropriate options based on selection stage */} - {renderSelectionOptions()} - - {/* Connect account option - only show if no credentials */} - {credentials.length === 0 && ( - - -
- - Connect Microsoft Teams account -
-
-
- )} -
-
-
- )} -
- - {/* Selection preview */} - {showPreview && selectedMessage && ( -
-
- -
-
-
- -
-
-
-

{selectedMessage.displayName}

- - {selectedMessage.type} - -
- {selectedMessage.webViewLink ? ( - e.stopPropagation()} - > - Open in Microsoft Teams - - - ) : ( - <> - )} -
-
-
- )} -
- - {showOAuthModal && ( - setShowOAuthModal(false)} - provider={provider} - toolName='Microsoft Teams' - requiredScopes={requiredScopes} - serviceId={getServiceId()} - /> - )} - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/wealthbox-file-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/wealthbox-file-selector.tsx deleted file mode 100644 index 49ce0b9e2e7..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components/wealthbox-file-selector.tsx +++ /dev/null @@ -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([]) - const [selectedCredentialId, setSelectedCredentialId] = useState(credentialId || '') - const [selectedItemId, setSelectedItemId] = useState(value) - const [selectedItem, setSelectedItem] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [isLoadingSelectedItem, setIsLoadingSelectedItem] = useState(false) - const [isLoadingItems, setIsLoadingItems] = useState(false) - const [availableItems, setAvailableItems] = useState([]) - const [searchQuery, setSearchQuery] = useState('') - 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(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, 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 ( - <> -
- { - setOpen(isOpen) - if (!isOpen) { - setSearchQuery('') - if (searchTimeout) { - clearTimeout(searchTimeout) - setSearchTimeout(null) - } - } - }} - > - - - - - -
- 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' - /> -
- - - {isLoadingItems ? `Loading ${itemType}s...` : `No ${itemType}s found.`} - - - {credentials.length > 1 && ( - -
- Switch Account -
- {credentials.map((cred) => ( - setSelectedCredentialId(cred.id)} - > -
- - {cred.name} -
- {cred.id === selectedCredentialId && } -
- ))} -
- )} - - {availableItems.length > 0 && ( - -
- {getItemTypeLabel()} -
- {availableItems.map((item) => ( - handleItemSelect(item)} - > -
- -
- {item.name} - {item.updatedAt && ( -
- Updated {new Date(item.updatedAt).toLocaleDateString()} -
- )} -
-
- {item.id === selectedItemId && } -
- ))} -
- )} - - {credentials.length === 0 && ( - - -
- - Connect Wealthbox account -
-
-
- )} -
-
-
-
- - {showPreview && selectedItem && ( -
-
- -
-
-
- -
-
-
-

{selectedItem.name}

- {selectedItem.updatedAt && ( - - {new Date(selectedItem.updatedAt).toLocaleDateString()} - - )} -
-
{selectedItem.type}
-
-
-
- )} -
- - {showOAuthModal && ( - setShowOAuthModal(false)} - toolName='Wealthbox' - provider={provider} - requiredScopes={requiredScopes} - serviceId={getServiceId()} - /> - )} - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/file-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/file-selector-input.tsx index 64c4255c27c..966bbe1f922 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/file-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/file-selector-input.tsx @@ -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 - 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).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' - - // 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) || - '' - : '' - - // 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) || '' - - return ( - - - -
- { - collaborativeSetSubblockValue(blockId, subBlock.id, val) - }} - label={subBlock.placeholder || 'Select Google Calendar'} - disabled={finalDisabled || shouldForceDisable} - showPreview={true} - credentialId={credential} - workflowId={workflowIdFromUrl} - /> -
-
-
-
- ) - } - - // Render the appropriate picker based on provider - if (isConfluence) { - const credential = (connectedCredential as string) || '' - return ( - - - -
- { - 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} - /> -
-
-
-
- ) - } - - if (isJira) { - const credential = (connectedCredential as string) || '' - return ( - - - -
- { - 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 || ''} - /> -
-
-
-
- ) - } - - if (isMicrosoftExcel) { - const credential = (connectedCredential as string) || '' - return ( - - - -
- 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} - /> -
-
-
-
- ) - } - - // Microsoft Word selector - if (isMicrosoftWord) { - const credential = (connectedCredential as string) || '' - return ( - - - -
- setStoreValue(fileId)} - provider='microsoft-word' - requiredScopes={subBlock.requiredScopes || []} - serviceId={subBlock.serviceId} - label={subBlock.placeholder || 'Select Microsoft Word document'} - disabled={finalDisabled || shouldForceDisable} - showPreview={true} - /> -
-
-
-
- ) - } - - // Microsoft OneDrive selector - if (isMicrosoftOneDrive) { - const credential = (connectedCredential as string) || '' - return ( - - - -
- 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} - /> -
-
-
-
- ) - } - - // Microsoft SharePoint selector - if (isMicrosoftSharePoint) { - const credential = (connectedCredential as string) || '' + const selectorResolution = useMemo(() => { + 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, + ]) + + 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 + + if (!selectorResolution?.key) { return ( - - - -
- 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} - /> -
-
- {!credential && ( - -

Please select SharePoint credentials first

-
- )} -
-
+ + +
+ File selector not supported for provider: {subBlock.provider || subBlock.serviceId} +
+
+ +

This file selector is not implemented for {subBlock.provider || subBlock.serviceId}

+
+
) } - // Microsoft Planner task selector - if (isMicrosoftPlanner) { - const credential = (connectedCredential as string) || '' - const planId = (planIdValue as string) || '' - return ( - - - -
- 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} - /> -
-
- {!credential ? ( - -

Please select Microsoft Planner credentials first

-
- ) : !planId ? ( - -

Please enter a Plan ID first

-
- ) : null} -
-
- ) - } - - // 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 ( - - - -
- { - 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} - /> -
-
- {!credential && ( - -

Please select Microsoft Teams credentials first

-
- )} -
-
- ) - } - - // Wealthbox selector - if (isWealthbox) { - const credential = (connectedCredential as string) || '' - if (subBlock.id === 'contactId') { - const itemType = 'contact' - return ( - - - -
- { - 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} - /> -
-
- {!credential && ( - -

Please select Wealthbox credentials first

-
- )} -
-
- ) - } - // 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 ( - - - -
- { - 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} - /> -
-
- {!credential && ( - -

Please select Google Drive credentials first

-
- )} -
-
- ) - } + return ( + { + if (!isPreview) { + collaborativeSetSubblockValue(blockId, subBlock.id, value) + } + }} + /> + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-upload/file-upload.tsx index a05b6154e2c..459dba0b641 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -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([]) const [uploadProgress, setUploadProgress] = useState(0) const [workspaceFiles, setWorkspaceFiles] = useState([]) const [loadingWorkspaceFiles, setLoadingWorkspaceFiles] = useState(false) const [uploadError, setUploadError] = useState(null) - const [addMoreOpen, setAddMoreOpen] = useState(false) - const [pickerOpen, setPickerOpen] = useState(false) + const [inputValue, setInputValue] = useState('') - // For file deletion status const [deletingFiles, setDeletingFiles] = useState>({}) - // Refs const fileInputRef = useRef(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 = {} - 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 (
-
-
- {truncateMiddle(file.name)} -
-
{formatFileSize(file.size)}
+
+ {truncateMiddle(file.name)} + ({formatFileSize(file.size)})
- - - - - e.stopPropagation()}> - - { - setAddMoreOpen(false) - handleOpenFileDialog({ - preventDefault: () => {}, - stopPropagation: () => {}, - } as React.MouseEvent) - }} - > - Upload New File - - - - {availableWorkspaceFiles.length === 0 - ? 'No files available.' - : 'No files found.'} - - {availableWorkspaceFiles.length > 0 && ( - - {availableWorkspaceFiles.map((file) => ( - { - handleSelectWorkspaceFile(file.id) - setAddMoreOpen(false) - }} - > - - {truncateMiddle(file.name)} - - - ))} - - )} - - - - + placeholder={loadingWorkspaceFiles ? 'Loading files...' : '+ Add More'} + disabled={disabled || loadingWorkspaceFiles} + editable={true} + filterOptions={true} + isLoading={loadingWorkspaceFiles} + />
)}
@@ -715,75 +556,19 @@ export function FileUpload({ {/* Show dropdown selector if no files and not uploading */} {!hasFiles && !isUploading && (
- { - setPickerOpen(open) if (open) void loadWorkspaceFiles() }} - > - - - - - - - e.stopPropagation()}> - - { - setPickerOpen(false) - handleOpenFileDialog({ - preventDefault: () => {}, - stopPropagation: () => {}, - } as React.MouseEvent) - }} - > - Upload New File - - - - {availableWorkspaceFiles.length === 0 - ? 'No files available.' - : 'No files found.'} - - {availableWorkspaceFiles.length > 0 && ( - - {availableWorkspaceFiles.map((file) => ( - { - handleSelectWorkspaceFile(file.id) - setPickerOpen(false) - }} - > - - {truncateMiddle(file.name)} - - - ))} - - )} - - - - + placeholder={loadingWorkspaceFiles ? 'Loading files...' : 'Select or upload file'} + disabled={disabled || loadingWorkspaceFiles} + editable={true} + filterOptions={true} + isLoading={loadingWorkspaceFiles} + />
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/folder-selector/components/folder-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/folder-selector/components/folder-selector-input.tsx index 26a203e54d8..8f53e07a4e1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/folder-selector/components/folder-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/folder-selector/components/folder-selector-input.tsx @@ -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('') - const [_folderInfo, setFolderInfo] = useState(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 ( - ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/folder-selector/folder-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/folder-selector/folder-selector.tsx deleted file mode 100644 index 925a40d3ee8..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/folder-selector/folder-selector.tsx +++ /dev/null @@ -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([]) - const [folders, setFolders] = useState([]) - const [selectedCredentialId, setSelectedCredentialId] = useState( - 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, 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 - } - if (provider === 'outlook') { - return - } - return null - } - - const getProviderName = () => { - if (provider === 'outlook') return 'Outlook' - return 'Gmail' - } - - const getFolderLabel = () => { - if (provider === 'outlook') return 'folders' - return 'labels' - } - - return ( - <> -
- - - - - {!isForeignCredential && ( - - {/* Current account indicator */} - {selectedCredentialId && credentials.length > 0 && ( -
-
- - {credentials.find((cred) => cred.id === selectedCredentialId)?.name || - 'Unknown'} - -
- {credentials.length > 1 && ( - - )} -
- )} - - - - - - {isLoading ? ( -
- - Loading {getFolderLabel()}... -
- ) : credentials.length === 0 ? ( -
-

No accounts connected.

-

- Connect a {getProviderName()} account to continue. -

-
- ) : ( -
-

No {getFolderLabel()} found.

-

- Try a different search or account. -

-
- )} -
- - {/* Account selection - only show if we have multiple accounts */} - {credentials.length > 1 && ( - -
- Switch Account -
- {credentials.map((cred) => ( - setSelectedCredentialId(cred.id)} - > -
- {cred.name} -
- {cred.id === selectedCredentialId && ( - - )} -
- ))} -
- )} - - {/* Folders list */} - {folders.length > 0 && ( - -
- {getFolderLabel().charAt(0).toUpperCase() + getFolderLabel().slice(1)} -
- {folders.map((folder) => ( - handleSelectFolder(folder)} - > -
- {getFolderIcon('sm')} - {folder.name} - {folder.id === selectedFolderId && ( - - )} -
-
- ))} -
- )} - - {/* Connect account option - only show if no credentials */} - {credentials.length === 0 && ( - - -
- Connect {getProviderName()} account -
-
-
- )} -
-
-
- )} -
-
- - {showOAuthModal && ( - setShowOAuthModal(false)} - provider={provider} - toolName={getProviderName()} - requiredScopes={requiredScopes} - serviceId={getServiceId()} - /> - )} - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx index 0d6e0f5b22c..1ac63dc2af5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx @@ -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({
) - case 'dropdown': + case 'dropdown': { + const dropdownOptions = useMemo( + () => + (paramSchema.enum || []).map((option: any) => ({ + label: String(option), + value: String(option), + })), + [paramSchema.enum] + ) + return (
- + editable={false} + filterOptions={true} + />
) + } case 'slider': { const minValue = paramSchema.minimum ?? 0 diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx index 67d955c149b..c3ea65cc6a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx @@ -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 {selectedServer.name} + setInputValue(selectedServer.name) + } else { + setInputValue('') } - return {label} - } + }, [selectedServer]) return ( - - - - - - - - - - {isLoading ? ( -
- - Loading servers... -
- ) : error ? ( -
-

Error loading servers

-

- {error instanceof Error ? error.message : 'Unknown error'} -

-
- ) : ( -
-

No MCP servers found

-

- Configure MCP servers in workspace settings -

-
- )} -
- {enabledServers.length > 0 && ( - - {enabledServers.map((server) => ( - handleSelect(server.id)} - className='cursor-pointer' - > -
- {server.name} -
- {server.id === selectedServerId && } -
- ))} -
- )} -
-
-
-
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-server-modal/mcp-tool-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-server-modal/mcp-tool-selector.tsx index 49e2567a4ba..1da2c589e19 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-server-modal/mcp-tool-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/mcp-server-modal/mcp-tool-selector.tsx @@ -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 handleOpenChange = (isOpen: boolean) => { - setOpen(isOpen) - if (isOpen && serverValue) { - refreshTools() + 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 handleSelect = (toolId: string) => { - if (!isPreview) { - setStoreValue(toolId) - - const tool = availableTools.find((t) => t.id === toolId) - if (tool?.inputSchema) { - setSchemaCache(tool.inputSchema) - } + const handleOpenChange = (isOpen: boolean) => { + if (isOpen && serverValue) { + refreshTools() } - setOpen(false) } - const getDisplayText = () => { + useEffect(() => { if (selectedTool) { - return {selectedTool.name} + setInputValue(selectedTool.name) + } else { + setInputValue('') } - return ( - - {serverValue ? label : 'Select server first'} - - ) - } + }, [selectedTool]) const isDisabled = disabled || !serverValue return ( - - - - - - - - - - {isLoading ? ( -
- - Loading tools... -
- ) : error ? ( -
-

Error loading tools

-

{error}

-
- ) : !serverValue ? ( -
-

No server selected

-

- Select an MCP server first to see available tools -

-
- ) : ( -
-

No tools found

-

- The selected server has no available tools -

-
- )} -
- {availableTools.length > 0 && ( - - {availableTools.map((tool) => ( - handleSelect(tool.id)} - className='cursor-pointer' - > -
- {tool.name} -
- {tool.id === selectedToolId && } -
- ))} -
- )} -
-
-
-
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/jira-project-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/jira-project-selector.tsx deleted file mode 100644 index 1f795eee500..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/jira-project-selector.tsx +++ /dev/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([]) - const [projects, setProjects] = useState([]) - const [selectedCredentialId, setSelectedCredentialId] = useState(credentialId || '') - const [selectedProjectId, setSelectedProjectId] = useState(value) - const [selectedProject, setSelectedProject] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [showOAuthModal, setShowOAuthModal] = useState(false) - const initialFetchRef = useRef(false) - const [error, setError] = useState(null) - const [cloudId, setCloudId] = useState(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(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, 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 ( - <> -
- - - - - {!isForeignCredential && ( - - {selectedCredentialId && credentials.length > 0 && ( -
-
- - - {credentials.find((cred) => cred.id === selectedCredentialId)?.name || - 'Unknown'} - -
- {credentials.length > 1 && ( - - )} -
- )} - - - - - - {isLoading ? ( -
- - Loading projects... -
- ) : error ? ( -
-

{error}

-
- ) : credentials.length === 0 ? ( -
-

No accounts connected.

-

- Connect a Jira account to continue. -

-
- ) : ( -
-

No projects found.

-

- Try a different search or account. -

-
- )} -
- - {/* Account selection - only show if we have multiple accounts */} - {credentials.length > 1 && ( - -
- Switch Account -
- {credentials.map((cred) => ( - setSelectedCredentialId(cred.id)} - > -
- - {cred.name} -
- {cred.id === selectedCredentialId && ( - - )} -
- ))} -
- )} - - {/* Projects list */} - {projects.length > 0 && ( - -
- Projects -
- {projects.map((project) => ( - handleSelectProject(project)} - > -
- {project.avatarUrl ? ( - {project.name} - ) : ( - - )} - {project.name} -
- {project.id === selectedProjectId && ( - - )} -
- ))} -
- )} - - {/* Connect account option - only show if no credentials */} - {credentials.length === 0 && ( - - -
- - Connect Jira account -
-
-
- )} -
-
-
- )} -
- - {/* Project preview */} - {showPreview && selectedProject && ( -
-
- -
-
-
- {selectedProject.avatarUrl ? ( - {selectedProject.name} - ) : ( - - )} -
-
-
-

{selectedProject.name}

- - {selectedProject.key} - -
- {selectedProject.url ? ( - e.stopPropagation()} - > - Open in Jira - - - ) : null} -
-
-
- )} -
- - {showOAuthModal && ( - setShowOAuthModal(false)} - provider={provider} - toolName='Jira' - requiredScopes={requiredScopes} - serviceId={getServiceId()} - /> - )} - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-project-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-project-selector.tsx deleted file mode 100644 index 21fbdaada9f..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-project-selector.tsx +++ /dev/null @@ -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([]) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(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, 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 ( - - - - - - - - - - {loading ? ( -
- - Loading projects... -
- ) : error ? ( -
-

{error}

-
- ) : !credential || !teamId ? ( -
-

Missing credentials or team

-

- Please configure Linear credentials and select a team. -

-
- ) : ( -
-

No projects found

-

- No projects available for the selected team. -

-
- )} -
- - {projects.length > 0 && ( - -
- Projects -
- {projects.map((project) => ( - handleSelectProject(project)} - className='cursor-pointer' - > -
- - {project.name} -
- {project.id === value && } -
- ))} -
- )} -
-
-
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-team-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-team-selector.tsx deleted file mode 100644 index e4724767004..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-team-selector.tsx +++ /dev/null @@ -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([]) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(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, 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 ( - - - - - - - - - - {loading ? ( -
- - Loading teams... -
- ) : error ? ( -
-

{error}

-
- ) : !credential ? ( -
-

Missing credentials

-

- Please configure Linear credentials. -

-
- ) : ( -
-

No teams found

-

- No teams available for this Linear account. -

-
- )} -
- - {teams.length > 0 && ( - -
Teams
- {teams.map((team) => ( - handleSelectTeam(team)} - className='cursor-pointer' - > -
- - {team.name} -
- {team.id === value && } -
- ))} -
- )} -
-
-
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx index 89ce27162fe..25f16e2f148 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx @@ -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('') - const [_projectInfo, setProjectInfo] = useState(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) - } - - // Discord no longer uses a server selector; fall through to other providers + const missingCredential = !selectorResolution?.context.credentialId - // Render Linear team/project selector if provider is linear - if (isLinear) { - return ( - - -
- {subBlock.id === 'teamId' ? ( - { - 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 ( - { - handleProjectChange(projectId, projectInfo) - }} - credential={credential} - teamId={teamId} - label={subBlock.placeholder || 'Select Linear project'} - disabled={isDisabled} - workflowId={activeWorkflowId || ''} - /> - ) - })() - )} -
-
- {!(linearCredential as string) && ( - -

Please select a Linear account first

-
- )} -
- ) + const handleChange = (value: string) => { + setSelectedProjectId(value) + onProjectSelect?.(value) } - // Default to Jira project selector return (
- + {selectorResolution?.key ? ( + + ) : ( +
+ Project selector not supported for provider: {subBlock.provider || 'unknown'} +
+ )}
+ {missingCredential && ( + +

Please select an account first

+
+ )}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx new file mode 100644 index 00000000000..6d662ca34f5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx @@ -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( + 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(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 ( +
+ + {({ ref, onDrop, onDragOver }) => ( + { + 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} + inputProps={{ + onDrop: onDrop as (e: React.DragEvent) => void, + onDragOver: onDragOver as (e: React.DragEvent) => void, + }} + isLoading={isLoading} + error={error instanceof Error ? error.message : null} + /> + )} + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/mcp-server-modal/mcp-server-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/mcp-server-modal/mcp-server-modal.tsx deleted file mode 100644 index bb192d4d4ed..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/mcp-server-modal/mcp-server-modal.tsx +++ /dev/null @@ -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 -} - -export function McpServerModal({ - open, - onOpenChange, - onServerCreated, - blockId, -}: McpServerModalProps) { - const params = useParams() - const workspaceId = params.workspaceId as string - const [formData, setFormData] = useState({ - name: '', - transport: 'streamable-http', - url: '', - headers: { '': '' }, - }) - const createServerMutation = useCreateMcpServer() - const [localError, setLocalError] = useState(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(null) - const urlInputRef = useRef(null) - const [urlScrollLeft, setUrlScrollLeft] = useState(0) - const [headerScrollLeft, setHeaderScrollLeft] = useState>({}) - - 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 ( - - - - Add MCP Server - - Configure a new Model Context Protocol server to extend your workflow capabilities. - - - -
-
-
- - { - if (testResult) clearTestResult() - setFormData((prev) => ({ ...prev, name: e.target.value })) - }} - className='h-9' - /> -
-
- - -
-
- -
- -
- 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 */} -
-
- {formatDisplayText(formData.url || '', { - accessiblePrefixes, - highlightAll: !accessiblePrefixes, - })} -
-
-
- - {/* Environment Variables Dropdown */} - {showEnvVars && activeInputField === 'url' && ( - { - setShowEnvVars(false) - setActiveInputField(null) - }} - className='w-full' - maxHeight='250px' - /> - )} -
- -
- -
- {Object.entries(formData.headers || {}).map(([key, value], index) => ( -
- {/* Header Name Input */} -
- 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' - /> -
-
- {formatDisplayText(key || '', { - accessiblePrefixes, - highlightAll: !accessiblePrefixes, - })} -
-
-
- - {/* Header Value Input */} -
- 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' - /> -
-
- {formatDisplayText(value || '', { - accessiblePrefixes, - highlightAll: !accessiblePrefixes, - })} -
-
-
- - - - {/* Environment Variables Dropdown for Header Key */} - {showEnvVars && - activeInputField === 'header-key' && - activeHeaderIndex === index && ( - { - 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 && ( - { - setShowEnvVars(false) - setActiveInputField(null) - setActiveHeaderIndex(null) - }} - className='w-full' - maxHeight='250px' - style={{ - position: 'absolute', - top: '100%', - right: 0, - zIndex: 9999, - }} - /> - )} -
- ))} -
-
- - {error && ( -
- {error} -
- )} - - {/* Test Connection and Actions */} -
-
-
-
- - {testResult?.success && ( - ✓ Connected - )} -
- {testResult && !testResult.success && ( -
-
Connection failed
-
- {testResult.error || testResult.message} -
-
- )} -
-
- - -
-
-
-
-
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx index f014b092354..7dfa8145688 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx @@ -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([]) - 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, 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, 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({ - {isLoading ? ( + {credentialsLoading || collaboratorLoading ? (
Loading... diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/sub-block/components/file-selector/file-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/sub-block/components/file-selector/file-selector-input.tsx new file mode 100644 index 00000000000..c5961ce468b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/sub-block/components/file-selector/file-selector-input.tsx @@ -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 +} + +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).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 ( + + +
+ File selector not supported for provider: {subBlock.provider || subBlock.serviceId} +
+
+ +

This file selector is not implemented for {subBlock.provider || subBlock.serviceId}

+
+
+ ) + } + + return ( + { + 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 } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 35d268c3133..1e2958b77c8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -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 ( diff --git a/apps/sim/hooks/queries/oauth-credentials.ts b/apps/sim/hooks/queries/oauth-credentials.ts new file mode 100644 index 00000000000..f6923216530 --- /dev/null +++ b/apps/sim/hooks/queries/oauth-credentials.ts @@ -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 { + if (!providerId) return [] + const data = await fetchJson('/api/auth/oauth/credentials', { + searchParams: { provider: providerId }, + }) + return data.credentials ?? [] +} + +export async function fetchOAuthCredentialDetail( + credentialId: string, + workflowId?: string +): Promise { + if (!credentialId) return [] + const data = await fetchJson('/api/auth/oauth/credentials', { + searchParams: { + credentialId, + workflowId, + }, + }) + return data.credentials ?? [] +} + +export function useOAuthCredentials(providerId?: string, enabled = true) { + return useQuery({ + 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({ + 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, + } +} diff --git a/apps/sim/hooks/selectors/helpers.ts b/apps/sim/hooks/selectors/helpers.ts new file mode 100644 index 00000000000..438e9415d7f --- /dev/null +++ b/apps/sim/hooks/selectors/helpers.ts @@ -0,0 +1,61 @@ +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('SelectorHelpers') + +interface FetchJsonOptions extends RequestInit { + searchParams?: Record +} + +export async function fetchJson(url: string, options: FetchJsonOptions = {}): Promise { + 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 { + if (!credentialId) return null + const body = JSON.stringify({ credentialId, workflowId }) + const token = await fetchJson('/api/auth/oauth/token', { + method: 'POST', + body, + }) + return token.accessToken ?? null +} diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts new file mode 100644 index 00000000000..3647559e197 --- /dev/null +++ b/apps/sim/hooks/selectors/registry.ts @@ -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 = { + '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] +} diff --git a/apps/sim/hooks/selectors/resolution.ts b/apps/sim/hooks/selectors/resolution.ts new file mode 100644 index 00000000000..7ec3fe59738 --- /dev/null +++ b/apps/sim/hooks/selectors/resolution.ts @@ -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 { + 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, + } +} diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts new file mode 100644 index 00000000000..0eb582c5657 --- /dev/null +++ b/apps/sim/hooks/selectors/types.ts @@ -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 +} + +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 + fetchById?: (args: SelectorQueryArgs) => Promise + enabled?: (args: SelectorQueryArgs) => boolean + staleTime?: number +} diff --git a/apps/sim/hooks/selectors/use-selector-query.ts b/apps/sim/hooks/selectors/use-selector-query.ts new file mode 100644 index 00000000000..4ef0b262195 --- /dev/null +++ b/apps/sim/hooks/selectors/use-selector-query.ts @@ -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 { + 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({ + 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({ + 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]) +} diff --git a/apps/sim/hooks/use-credential-display.ts b/apps/sim/hooks/use-credential-display.ts deleted file mode 100644 index 2f19fdc01be..00000000000 --- a/apps/sim/hooks/use-credential-display.ts +++ /dev/null @@ -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, 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, - } -} diff --git a/apps/sim/hooks/use-display-name.ts b/apps/sim/hooks/use-display-name.ts deleted file mode 100644 index b4f49b3a11d..00000000000 --- a/apps/sim/hooks/use-display-name.ts +++ /dev/null @@ -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>((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>( - (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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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 -} diff --git a/apps/sim/hooks/use-knowledge-base-name.ts b/apps/sim/hooks/use-knowledge-base-name.ts new file mode 100644 index 00000000000..412dec2db25 --- /dev/null +++ b/apps/sim/hooks/use-knowledge-base-name.ts @@ -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 +} diff --git a/apps/sim/hooks/use-selector-display-name.ts b/apps/sim/hooks/use-selector-display-name.ts new file mode 100644 index 00000000000..91d6f7f8172 --- /dev/null +++ b/apps/sim/hooks/use-selector-display-name.ts @@ -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, + } +} diff --git a/apps/sim/stores/display-names/store.ts b/apps/sim/stores/display-names/store.ts deleted file mode 100644 index 9b889c2bbbe..00000000000 --- a/apps/sim/stores/display-names/store.ts +++ /dev/null @@ -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> // provider -> id -> name - channels: Record> // credentialContext -> id -> name - knowledgeBases: Record> // workspaceId -> id -> name - workflows: Record> // always 'global' -> id -> name - files: Record> // credentialContext -> id -> name - folders: Record> // credentialContext -> id -> name - projects: Record> // provider-credential -> id -> name - documents: Record> // 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 - ) => 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((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 }) - }, -}))