diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/hooks/use-fetch-attempt-guard.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/hooks/use-fetch-attempt-guard.ts new file mode 100644 index 00000000000..a6b7d5c7aa9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/hooks/use-fetch-attempt-guard.ts @@ -0,0 +1,21 @@ +'use client' + +import { useRef } from 'react' + +export function useFetchAttemptGuard() { + const lastAttemptKeyRef = useRef('') + + const shouldAttempt = (key: string): boolean => { + return lastAttemptKeyRef.current !== key + } + + const markAttempt = (key: string) => { + lastAttemptKeyRef.current = key + } + + const reset = () => { + lastAttemptKeyRef.current = '' + } + + return { shouldAttempt, markAttempt, reset } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx index 43dd60e1b52..c2ab6a8519b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx @@ -11,6 +11,7 @@ import { CommandList, } from '@/components/ui/command' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { useFetchAttemptGuard } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/hooks/use-fetch-attempt-guard' export interface SlackChannelInfo { id: string @@ -43,8 +44,8 @@ export function SlackChannelSelector({ const [open, setOpen] = useState(false) const [selectedChannel, setSelectedChannel] = useState(null) const [initialFetchDone, setInitialFetchDone] = useState(false) + const { shouldAttempt, markAttempt, reset } = useFetchAttemptGuard() - // Fetch channels from Slack API const fetchChannels = useCallback(async () => { if (!credential) return @@ -66,28 +67,28 @@ export function SlackChannelSelector({ setChannels([]) } else { setChannels(data.channels) - setInitialFetchDone(true) } } catch (err) { if ((err as Error).name === 'AbortError') return setError((err as Error).message) setChannels([]) } finally { + setInitialFetchDone(true) 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)) { + const attemptKey = `slack-channels:${credential}` + if (!shouldAttempt(attemptKey)) return + markAttempt(attemptKey) fetchChannels() } } - // Sync selected channel with value prop useEffect(() => { if (value && channels.length > 0) { const channelInfo = channels.find((c) => c.id === value) @@ -97,12 +98,13 @@ export function SlackChannelSelector({ } }, [value, channels]) - // If we have a value but no channel info and haven't fetched yet, get just that channel useEffect(() => { if (value && !selectedChannel && !loading && !initialFetchDone && credential) { - // For now, we'll fetch all channels when needed - // In the future, we could optimize to fetch just the selected channel - fetchChannels() + const attemptKey = `slack-channels:init:${credential}` + if (shouldAttempt(attemptKey)) { + markAttempt(attemptKey) + fetchChannels() + } } }, [value, selectedChannel, loading, initialFetchDone, credential, fetchChannels]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx index d145d880e06..3caa44889a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx @@ -20,6 +20,7 @@ import { getServiceIdFromScopes, type OAuthProvider, } from '@/lib/oauth' +import { useFetchAttemptGuard } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/hooks/use-fetch-attempt-guard' import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' const logger = createLogger('ConfluenceFileSelector') @@ -75,33 +76,30 @@ export function ConfluenceFileSelector({ const [showOAuthModal, setShowOAuthModal] = useState(false) const initialFetchRef = useRef(false) const [error, setError] = useState(null) - // Keep internal credential in sync with prop (handles late arrival and BFCache restores) + const { shouldAttempt, markAttempt, reset } = useFetchAttemptGuard() + 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 + }, 500) } - // Clean up the timeout on unmount useEffect(() => { return () => { if (searchTimeoutRef.current) { @@ -110,19 +108,16 @@ export function ConfluenceFileSelector({ } }, []) - // 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 { @@ -140,12 +135,10 @@ export function ConfluenceFileSelector({ } }, [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( @@ -158,7 +151,6 @@ export function ConfluenceFileSelector({ setError(null) try { - // Get the access token from the selected credential const tokenResponse = await fetch('/api/auth/oauth/token', { method: 'POST', headers: { @@ -178,7 +170,6 @@ export function ConfluenceFileSelector({ 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: { @@ -223,13 +214,11 @@ export function ConfluenceFileSelector({ [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( @@ -244,7 +233,6 @@ export function ConfluenceFileSelector({ setError(null) try { - // Get the access token from the selected credential const tokenResponse = await fetch('/api/auth/oauth/token', { method: 'POST', headers: { @@ -260,7 +248,6 @@ export function ConfluenceFileSelector({ 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 @@ -276,7 +263,6 @@ export function ConfluenceFileSelector({ return } - // Simply fetch pages directly using the endpoint const response = await fetch('/api/tools/confluence/pages', { method: 'POST', headers: { @@ -306,14 +292,12 @@ export function ConfluenceFileSelector({ logger.info(`Received ${data.files?.length || 0} files from API`) setFiles(data.files || []) - // If we have a selected file ID, find the file info 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) } } @@ -336,7 +320,6 @@ export function ConfluenceFileSelector({ ] ) - // Fetch credentials on initial mount useEffect(() => { if (!initialFetchRef.current) { fetchCredentials() @@ -344,19 +327,19 @@ export function ConfluenceFileSelector({ } }, [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() } } - // Fetch the selected page metadata once credentials and domain are ready or changed useEffect(() => { if (value && selectedCredentialId && !selectedFile && domain && domain.includes('.')) { + const key = `${selectedCredentialId}:${domain}:confluence:${value}` + if (!shouldAttempt(key)) return + markAttempt(key) fetchPageInfo(value) } }, [ @@ -369,14 +352,16 @@ export function ConfluenceFileSelector({ isForeignCredential, ]) - // Keep internal selectedFileId in sync with the value prop + useEffect(() => { + reset() + }, [selectedCredentialId, domain, reset]) + useEffect(() => { if (value !== selectedFileId) { setSelectedFileId(value) } }, [value]) - // Clear preview when value is cleared (e.g., collaborator cleared or domain change cascade) useEffect(() => { if (!value) { setSelectedFile(null) @@ -384,7 +369,6 @@ export function ConfluenceFileSelector({ } }, [value, onFileInfoChange]) - // Handle file selection const handleSelectFile = (file: ConfluenceFileInfo) => { setSelectedFileId(file.id) setSelectedFile(file) @@ -393,14 +377,12 @@ export function ConfluenceFileSelector({ setOpen(false) } - // Handle adding a new credential const handleAddCredential = () => { // Show the OAuth modal setShowOAuthModal(true) setOpen(false) } - // Clear selection const handleClearSelection = () => { setSelectedFileId('') setSelectedFile(null) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-calendar-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-calendar-selector.tsx index cb01097132a..046da723d21 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-calendar-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-calendar-selector.tsx @@ -14,6 +14,7 @@ import { } from '@/components/ui/command' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { createLogger } from '@/lib/logs/console/logger' +import { useFetchAttemptGuard } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/hooks/use-fetch-attempt-guard' const logger = createLogger('GoogleCalendarSelector') @@ -55,6 +56,7 @@ export function GoogleCalendarSelector({ const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [initialFetchDone, setInitialFetchDone] = useState(false) + const { shouldAttempt, markAttempt, reset } = useFetchAttemptGuard() const fetchCalendarsFromAPI = useCallback(async (): Promise => { if (!credentialId) { @@ -141,25 +143,28 @@ export function GoogleCalendarSelector({ } }, [fetchCalendarsFromAPI, selectedCalendarId, onCalendarInfoChange]) - // Fetch selected calendar info when component mounts or dependencies change useEffect(() => { if (value && credentialId && (!selectedCalendar || selectedCalendar.id !== value)) { + const key = `${credentialId}:gcal:${value}` + if (!shouldAttempt(key)) return + markAttempt(key) fetchSelectedCalendarInfo() } }, [value, credentialId, selectedCalendar, fetchSelectedCalendarInfo]) - // Sync with external value + useEffect(() => { + reset() + }, [credentialId, reset]) + useEffect(() => { if (value !== selectedCalendarId) { setSelectedCalendarId(value) - // Find calendar info for the new value if (value && calendars.length > 0) { const calendarInfo = calendars.find((calendar) => calendar.id === value) setSelectedCalendar(calendarInfo || null) onCalendarInfoChange?.(calendarInfo || null) } else if (value) { - // If we have a value but no calendar info, we might need to fetch it if (!selectedCalendar || selectedCalendar.id !== value) { fetchSelectedCalendarInfo() } @@ -177,7 +182,6 @@ export function GoogleCalendarSelector({ onCalendarInfoChange, ]) - // Handle calendar selection const handleSelectCalendar = (calendar: GoogleCalendarInfo) => { setSelectedCalendarId(calendar.id) setSelectedCalendar(calendar) @@ -186,7 +190,6 @@ export function GoogleCalendarSelector({ setOpen(false) } - // Clear selection const handleClearSelection = () => { setSelectedCalendarId('') setSelectedCalendar(null) @@ -195,7 +198,6 @@ export function GoogleCalendarSelector({ setError(null) } - // Get calendar display name const getCalendarDisplayName = (calendar: GoogleCalendarInfo) => { if (calendar.primary) { return `${calendar.summary} (Primary)` diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx index 918bf24cd64..15fe3efe0c3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx @@ -16,6 +16,7 @@ import { type OAuthProvider, parseProvider, } from '@/lib/oauth' +import { useFetchAttemptGuard } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/hooks/use-fetch-attempt-guard' import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' const logger = createLogger('GoogleDrivePicker') @@ -75,21 +76,19 @@ export function GoogleDrivePicker({ const [showOAuthModal, setShowOAuthModal] = useState(false) const [credentialsLoaded, setCredentialsLoaded] = useState(false) const initialFetchRef = useRef(false) + const { shouldAttempt, markAttempt, reset } = useFetchAttemptGuard() 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) @@ -100,7 +99,6 @@ export function GoogleDrivePicker({ if (response.ok) { const data = await response.json() setCredentials(data.credentials) - // Do not auto-select. Respect persisted credential via prop when provided. } } catch (error) { logger.error('Error fetching credentials:', { error }) @@ -110,21 +108,18 @@ export function GoogleDrivePicker({ } }, [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, @@ -144,7 +139,6 @@ export function GoogleDrivePicker({ 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('') @@ -163,7 +157,6 @@ export function GoogleDrivePicker({ [selectedCredentialId, onChange, onFileInfoChange] ) - // Fetch credentials on initial mount useEffect(() => { if (!initialFetchRef.current) { fetchCredentials() @@ -171,42 +164,42 @@ export function GoogleDrivePicker({ } }, [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) } + + reset() } }, [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('') } + + reset() } else if (prevCredentialId && prevCredentialId !== selectedCredentialId) { - // Credentials changed (not initial load) - clear file info to force refetch if (selectedFile) { setSelectedFile(null) } + + reset() } }, [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 ( @@ -216,6 +209,9 @@ export function GoogleDrivePicker({ !selectedFile && !isLoadingSelectedFile ) { + const attemptKey = `${selectedCredentialId}:${value}` + if (!shouldAttempt(attemptKey)) return + markAttempt(attemptKey) fetchFileById(value) } }, [ @@ -227,7 +223,6 @@ export function GoogleDrivePicker({ fetchFileById, ]) - // Fetch the access token for the selected credential const fetchAccessToken = async (credentialOverrideId?: string): Promise => { const effectiveCredentialId = credentialOverrideId || selectedCredentialId if (!effectiveCredentialId) { @@ -257,10 +252,8 @@ export function GoogleDrivePicker({ } } - // 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) { @@ -269,7 +262,6 @@ export function GoogleDrivePicker({ } const viewIdForMimeType = () => { - // Return appropriate view based on mime type filter if (mimeTypeFilter?.includes('folder')) { return 'FOLDERS' } @@ -279,20 +271,20 @@ export function GoogleDrivePicker({ if (mimeTypeFilter?.includes('document')) { return 'DOCUMENTS' } - return 'DOCS' // Default view + return 'DOCS' } openPicker({ clientId, developerKey: apiKey, viewId: viewIdForMimeType(), - token: accessToken, // Use the fetched access token + token: accessToken, 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') { @@ -304,8 +296,8 @@ export function GoogleDrivePicker({ mimeType: file.mimeType, iconLink: file.iconUrl, webViewLink: file.url, - // thumbnailLink is not directly available from the picker - thumbnailLink: file.iconUrl, // Use iconUrl as fallback + + thumbnailLink: file.iconUrl, modifiedTime: file.lastEditedUtc ? new Date(file.lastEditedUtc).toISOString() : undefined, @@ -324,13 +316,10 @@ export function GoogleDrivePicker({ } } - // Handle adding a new credential const handleAddCredential = () => { - // Show the OAuth modal setShowOAuthModal(true) } - // Clear selection const handleClearSelection = () => { setSelectedFileId('') setSelectedFile(null) @@ -338,7 +327,6 @@ export function GoogleDrivePicker({ onFileInfoChange?.(null) } - // Get provider icon const getProviderIcon = (providerName: OAuthProvider) => { const { baseProvider } = parseProvider(providerName) const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] @@ -347,7 +335,6 @@ export function GoogleDrivePicker({ return } - // For compound providers, find the specific service if (providerName.includes('-')) { for (const service of Object.values(baseProviderConfig.services)) { if (service.providerId === providerName) { @@ -356,24 +343,19 @@ export function GoogleDrivePicker({ } } - // 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 || {})) { @@ -383,15 +365,11 @@ export function GoogleDrivePicker({ } } - // Fallback to provider name if service not found if (baseProviderConfig) { return baseProviderConfig.name } - } catch (_parseError) { - // Ignore parse error and continue to final fallback - } + } catch (_parseError) {} - // Final fallback: capitalize the provider name return providerName .split('-') .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) @@ -399,7 +377,6 @@ export function GoogleDrivePicker({ } } - // 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' @@ -431,7 +408,6 @@ export function GoogleDrivePicker({ className='h-10 w-full min-w-0 justify-between' disabled={disabled || isLoading} onClick={async () => { - // Decide which credential to use let idToUse = selectedCredentialId if (!idToUse && credentials.length === 1) { idToUse = credentials[0].id @@ -439,7 +415,6 @@ export function GoogleDrivePicker({ } if (!idToUse) { - // No credentials — prompt OAuth handleAddCredential() return } @@ -467,7 +442,6 @@ export function GoogleDrivePicker({ - {/* File preview */} {canShowPreview && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx index ae749184568..bd6737260f3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx @@ -20,6 +20,7 @@ import { getServiceIdFromScopes, type OAuthProvider, } from '@/lib/oauth' +import { useFetchAttemptGuard } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/hooks/use-fetch-attempt-guard' import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' const logger = createLogger('JiraIssueSelector') @@ -75,11 +76,10 @@ export function JiraIssueSelector({ const [selectedIssue, setSelectedIssue] = 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) + const { shouldAttempt, markAttempt, reset } = useFetchAttemptGuard() - // Keep local credential state in sync with persisted credentialId prop useEffect(() => { if (credentialId && credentialId !== selectedCredentialId) { setSelectedCredentialId(credentialId) @@ -88,27 +88,22 @@ export function JiraIssueSelector({ } }, [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 + setIssues([]) } }, 500) // 500ms debounce } - // Clean up the timeout on unmount useEffect(() => { return () => { if (searchTimeoutRef.current) { @@ -117,19 +112,16 @@ export function JiraIssueSelector({ } }, []) - // 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 { @@ -147,10 +139,8 @@ export function JiraIssueSelector({ } }, [provider, getProviderId, selectedCredentialId]) - // 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( @@ -163,7 +153,6 @@ export function JiraIssueSelector({ setError(null) try { - // Get the access token from the selected credential const tokenResponse = await fetch('/api/auth/oauth/token', { method: 'POST', headers: { @@ -187,7 +176,6 @@ export function JiraIssueSelector({ 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: { @@ -225,7 +213,7 @@ export function JiraIssueSelector({ } catch (error) { logger.error('Error fetching issue info:', error) setError((error as Error).message) - // Clear selection on error to prevent infinite retry loops + setSelectedIssue(null) onIssueInfoChange?.(null) } finally { @@ -235,17 +223,15 @@ export function JiraIssueSelector({ [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( @@ -260,7 +246,6 @@ export function JiraIssueSelector({ setError(null) try { - // Get the access token from the selected credential const tokenResponse = await fetch('/api/auth/oauth/token', { method: 'POST', headers: { @@ -276,7 +261,6 @@ export function JiraIssueSelector({ 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 @@ -292,7 +276,6 @@ export function JiraIssueSelector({ return } - // Build query parameters for the issues endpoint const queryParams = new URLSearchParams({ domain, accessToken, @@ -320,12 +303,9 @@ export function JiraIssueSelector({ 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) => ({ @@ -343,14 +323,12 @@ export function JiraIssueSelector({ logger.info(`Received ${foundIssues.length} issues from API`) setIssues(foundIssues) - // If we have a selected issue ID, find the issue info 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) } } @@ -373,14 +351,12 @@ export function JiraIssueSelector({ ] ) - // 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) @@ -388,16 +364,13 @@ export function JiraIssueSelector({ } 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 useEffect(() => { if ( value && @@ -406,17 +379,21 @@ export function JiraIssueSelector({ domain.includes('.') && (!selectedIssue || selectedIssue.id !== value) ) { + const key = `${selectedCredentialId}:${domain}:jira:${value}` + if (!shouldAttempt(key)) return + markAttempt(key) fetchIssueInfo(value) } }, [value, selectedCredentialId, selectedIssue, domain, fetchIssueInfo]) - // Keep internal selectedIssueId in sync with the value prop + useEffect(() => { + reset() + }, [selectedCredentialId, domain, reset]) + 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([]) @@ -425,7 +402,6 @@ export function JiraIssueSelector({ } }, [value]) - // Handle issue selection const handleSelectIssue = (issue: JiraIssueInfo) => { setSelectedIssueId(issue.id) setSelectedIssue(issue) @@ -434,18 +410,15 @@ export function JiraIssueSelector({ setOpen(false) } - // Handle adding a new credential const handleAddCredential = () => { - // Show the OAuth modal setShowOAuthModal(true) setOpen(false) } - // Clear selection const handleClearSelection = () => { setSelectedIssueId('') setSelectedIssue(null) - setError(null) // Clear any existing errors + setError(null) onChange('', undefined) onIssueInfoChange?.(null) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx index 220ccfb23d0..58b0493f6f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx @@ -23,6 +23,7 @@ import { type OAuthProvider, parseProvider, } from '@/lib/oauth' +import { useFetchAttemptGuard } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/hooks/use-fetch-attempt-guard' import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' import type { PlannerTask } from '@/tools/microsoft_planner/types' @@ -41,7 +42,6 @@ export interface MicrosoftFileInfo { owners?: { displayName: string; emailAddress: string }[] } -// Union type for items that can be displayed in the file selector type SelectableItem = MicrosoftFileInfo | PlannerTask interface MicrosoftFileSelectorProps { @@ -88,27 +88,22 @@ export function MicrosoftFileSelector({ const [showOAuthModal, setShowOAuthModal] = useState(false) const [credentialsLoaded, setCredentialsLoaded] = useState(false) const initialFetchRef = useRef(false) - // Track the last (credentialId, fileId) we attempted to resolve to avoid tight retry loops - const lastMetaAttemptRef = useRef('') + const { shouldAttempt, markAttempt, reset } = useFetchAttemptGuard() - // Handle Microsoft Planner task selection const [plannerTasks, setPlannerTasks] = useState([]) const [isLoadingTasks, setIsLoadingTasks] = useState(false) const [selectedTask, setSelectedTask] = useState(null) - // 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) @@ -120,7 +115,6 @@ export function MicrosoftFileSelector({ 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) @@ -135,14 +129,12 @@ export function MicrosoftFileSelector({ } }, [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 @@ -152,12 +144,10 @@ export function MicrosoftFileSelector({ credentialId: selectedCredentialId, }) - // Add search query if provided if (searchQuery.trim()) { queryParams.append('query', searchQuery.trim()) } - // Route to correct endpoint based on service let endpoint: string if (serviceId === 'onedrive') { endpoint = `/api/tools/onedrive/folders?${queryParams.toString()}` @@ -175,7 +165,6 @@ export function MicrosoftFileSelector({ } 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 }) @@ -190,7 +179,6 @@ export function MicrosoftFileSelector({ } }, [selectedCredentialId, searchQuery, serviceId, 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 @@ -223,7 +211,7 @@ export function MicrosoftFileSelector({ }) 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 }) } @@ -255,7 +243,6 @@ export function MicrosoftFileSelector({ 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' }, @@ -291,7 +278,6 @@ export function MicrosoftFileSelector({ [selectedCredentialId, onFileInfoChange, serviceId, workflowId, onChange] ) - // Fetch Microsoft Planner tasks when planId and credentials are available const fetchPlannerTasks = useCallback(async () => { if ( !selectedCredentialId || @@ -331,7 +317,6 @@ export function MicrosoftFileSelector({ 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, @@ -371,7 +356,6 @@ export function MicrosoftFileSelector({ } }, [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 @@ -414,7 +398,6 @@ export function MicrosoftFileSelector({ [selectedCredentialId, workflowId, onFileInfoChange, serviceId] ) - // Fetch credentials on initial mount useEffect(() => { if (!initialFetchRef.current) { fetchCredentials() @@ -422,25 +405,22 @@ export function MicrosoftFileSelector({ } }, [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 + }, 300) return () => clearTimeout(timeoutId) } }, [searchQuery, selectedCredentialId, fetchAvailableFiles]) - // Fetch planner tasks when credentials and planId change useEffect(() => { if ( serviceId === 'microsoft-planner' && @@ -452,10 +432,9 @@ export function MicrosoftFileSelector({ } }, [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, @@ -465,54 +444,46 @@ export function MicrosoftFileSelector({ modifiedTime: task.createdDateTime, } - // Update internal state first to avoid race with list refetch setSelectedFileId(taskId) setSelectedFile(taskAsFileInfo) setSelectedTask(task) - // Then propagate up + 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 if (selectedFile) { setSelectedFile(null) setSelectedFileId('') onChange('') } - // Reset memo when credential is cleared - lastMetaAttemptRef.current = '' + + reset() } else if (prevCredentialId && prevCredentialId !== selectedCredentialId) { - // Credentials changed (not initial load) - clear file info to force refetch if (selectedFile) { setSelectedFile(null) } - // Reset memo when switching credentials - lastMetaAttemptRef.current = '' + + reset() } }, [selectedCredentialId, selectedFile, onChange]) - // Fetch the selected file metadata once credentials are loaded or changed useEffect(() => { - // Fetch metadata when the external value doesn't match our current selectedFile if ( value && selectedCredentialId && @@ -520,12 +491,9 @@ export function MicrosoftFileSelector({ (!selectedFile || selectedFile.id !== value) && !isLoadingSelectedFile ) { - // Avoid tight retry loops by memoizing the last attempt tuple const attemptKey = `${selectedCredentialId}::${value}` - if (lastMetaAttemptRef.current === attemptKey) { - return - } - lastMetaAttemptRef.current = attemptKey + if (!shouldAttempt(attemptKey)) return + markAttempt(attemptKey) if (serviceId === 'microsoft-planner') { void fetchPlannerTaskById(value) @@ -544,7 +512,6 @@ export function MicrosoftFileSelector({ serviceId, ]) - // Resolve planner task selection for collaborators useEffect(() => { if ( value && @@ -564,25 +531,21 @@ export function MicrosoftFileSelector({ fetchPlannerTaskById, ]) - // 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('') // Clear search when file is selected + setSearchQuery('') } - // Handle adding a new credential const handleAddCredential = () => { - // Show the OAuth modal setShowOAuthModal(true) setOpen(false) - setSearchQuery('') // Clear search when closing + setSearchQuery('') } - // Clear selection const handleClearSelection = () => { setSelectedFileId('') setSelectedFile(null) @@ -590,7 +553,6 @@ export function MicrosoftFileSelector({ onFileInfoChange?.(null) } - // Get provider icon const getProviderIcon = (providerName: OAuthProvider) => { const { baseProvider } = parseProvider(providerName) const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] @@ -599,7 +561,6 @@ export function MicrosoftFileSelector({ return } - // Handle OneDrive specifically by checking serviceId if (baseProvider === 'microsoft' && serviceId === 'onedrive') { const onedriveService = baseProviderConfig.services.onedrive if (onedriveService) { @@ -607,7 +568,6 @@ export function MicrosoftFileSelector({ } } - // Handle SharePoint specifically by checking serviceId if (baseProvider === 'microsoft' && serviceId === 'sharepoint') { const sharepointService = baseProviderConfig.services.sharepoint if (sharepointService) { @@ -615,7 +575,6 @@ export function MicrosoftFileSelector({ } } - // For compound providers, find the specific service if (providerName.includes('-')) { for (const service of Object.values(baseProviderConfig.services)) { if (service.providerId === providerName) { @@ -624,24 +583,19 @@ export function MicrosoftFileSelector({ } } - // 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 || {})) { @@ -651,15 +605,11 @@ export function MicrosoftFileSelector({ } } - // Fallback to provider name if service not found if (baseProviderConfig) { return baseProviderConfig.name } - } catch (_parseError) { - // Ignore parse error and continue to final fallback - } + } catch (_parseError) {} - // Final fallback: capitalize the provider name return providerName .split('-') .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) @@ -667,7 +617,6 @@ export function MicrosoftFileSelector({ } } - // 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' @@ -677,16 +626,8 @@ export function MicrosoftFileSelector({ 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) } @@ -730,7 +671,6 @@ export function MicrosoftFileSelector({ } } - // Filter tasks based on search query for planner const filteredTasks: SelectableItem[] = serviceId === 'microsoft-planner' ? plannerTasks.filter((task) => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx index 9a34c984d39..71dfe2e2383 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx @@ -20,6 +20,7 @@ import { getServiceIdFromScopes, type OAuthProvider, } from '@/lib/oauth' +import { useFetchAttemptGuard } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/hooks/use-fetch-attempt-guard' import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' const logger = createLogger('TeamsMessageSelector') @@ -83,14 +84,13 @@ export function TeamsMessageSelector({ const initialFetchRef = useRef(false) const [error, setError] = useState(null) const [selectionStage, setSelectionStage] = useState<'team' | 'channel' | 'chat'>(selectionType) + const { shouldAttempt, markAttempt, reset } = useFetchAttemptGuard() - // 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) @@ -113,7 +113,6 @@ export function TeamsMessageSelector({ } }, [provider, getProviderId, selectedCredentialId]) - // Fetch teams const fetchTeams = useCallback(async () => { if (!selectedCredentialId) return @@ -135,7 +134,6 @@ export function TeamsMessageSelector({ 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) @@ -156,7 +154,6 @@ export function TeamsMessageSelector({ setTeams(teamsData) - // 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) { @@ -173,7 +170,6 @@ export function TeamsMessageSelector({ } }, [selectedCredentialId, selectedTeamId, onMessageInfoChange, workflowId]) - // Fetch channels for a selected team const fetchChannels = useCallback( async (teamId: string) => { if (!selectedCredentialId || !teamId) return @@ -197,7 +193,6 @@ export function TeamsMessageSelector({ 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) @@ -219,7 +214,6 @@ export function TeamsMessageSelector({ setChannels(channelsData) - // If we have a selected channel ID, find it in the list if (selectedChannelId) { const channel = channelsData.find( (c: TeamsMessageInfo) => c.channelId === selectedChannelId @@ -240,7 +234,6 @@ export function TeamsMessageSelector({ [selectedCredentialId, selectedChannelId, onMessageInfoChange, workflowId] ) - // Fetch chats const fetchChats = useCallback(async () => { if (!selectedCredentialId) return @@ -262,7 +255,6 @@ export function TeamsMessageSelector({ 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) @@ -283,7 +275,6 @@ export function TeamsMessageSelector({ setChats(chatsData) - // 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) { @@ -300,38 +291,31 @@ export function TeamsMessageSelector({ } }, [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() @@ -343,14 +327,12 @@ export function TeamsMessageSelector({ } } - // 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('') @@ -364,7 +346,6 @@ export function TeamsMessageSelector({ setOpen(false) } - // Handle channel selection const handleSelectChannel = (channel: TeamsMessageInfo) => { setSelectedChannelId(channel.channelId || '') setSelectedChatId('') @@ -375,7 +356,6 @@ export function TeamsMessageSelector({ setOpen(false) } - // Handle chat selection const handleSelectChat = (chat: TeamsMessageInfo) => { setSelectedChatId(chat.chatId || '') setSelectedMessage(chat) @@ -385,14 +365,11 @@ export function TeamsMessageSelector({ setOpen(false) } - // Handle adding a new credential const handleAddCredential = () => { - // Show the OAuth modal setShowOAuthModal(true) setOpen(false) } - // Clear selection const handleClearSelection = () => { setSelectedMessageId('') setSelectedTeamId('') @@ -402,10 +379,9 @@ export function TeamsMessageSelector({ setError(null) onChange('', undefined) onMessageInfoChange?.(null) - setSelectionStage(selectionType) // Reset to the initial selection type + setSelectionStage(selectionType) } - // Render dropdown options based on the current selection stage const renderSelectionOptions = () => { if (selectionStage === 'team' && teams.length > 0) { return ( @@ -480,6 +456,9 @@ export function TeamsMessageSelector({ setIsLoading(true) try { + const key = `${selectedCredentialId}:teams:team:${teamId}` + if (!shouldAttempt(key)) return + markAttempt(key) const response = await fetch('/api/tools/microsoft-teams/teams', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -518,6 +497,9 @@ export function TeamsMessageSelector({ setIsLoading(true) try { + const key = `${selectedCredentialId}:teams:chat:${chatId}` + if (!shouldAttempt(key)) return + markAttempt(key) const response = await fetch('/api/tools/microsoft-teams/chats', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -556,6 +538,9 @@ export function TeamsMessageSelector({ setIsLoading(true) try { + const key = `${selectedCredentialId}:teams:channel:${channelId}` + if (!shouldAttempt(key)) return + markAttempt(key) // First fetch teams to search through them const teamsResponse = await fetch('/api/tools/microsoft-teams/teams', { method: 'POST', @@ -689,6 +674,11 @@ export function TeamsMessageSelector({ restoreChannelSelection, ]) + // Reset attempt guard when credential or selection type changes + useEffect(() => { + reset() + }, [selectedCredentialId, selectionType, reset]) + return ( <>
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/wealthbox-file-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/wealthbox-file-selector.tsx index 4cbf3a01b43..c63fe8c4f40 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/wealthbox-file-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/wealthbox-file-selector.tsx @@ -19,6 +19,7 @@ import { getServiceIdFromScopes, type OAuthProvider, } from '@/lib/oauth' +import { useFetchAttemptGuard } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/hooks/use-fetch-attempt-guard' import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' const logger = createLogger('WealthboxFileSelector') @@ -72,20 +73,18 @@ export function WealthboxFileSelector({ const [showOAuthModal, setShowOAuthModal] = useState(false) const [credentialsLoaded, setCredentialsLoaded] = useState(false) const initialFetchRef = useRef(false) + const { shouldAttempt, markAttempt, reset } = useFetchAttemptGuard() - // 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) @@ -105,17 +104,14 @@ export function WealthboxFileSelector({ } }, [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 @@ -149,7 +145,6 @@ export function WealthboxFileSelector({ } }, [selectedCredentialId, searchQuery, itemType]) - // Fetch a single item by ID const fetchItemById = useCallback( async (itemId: string) => { if (!selectedCredentialId || !itemId) return null @@ -193,7 +188,6 @@ export function WealthboxFileSelector({ [selectedCredentialId, itemType, onFileInfoChange, onChange] ) - // Fetch credentials on initial mount useEffect(() => { if (!initialFetchRef.current) { fetchCredentials() @@ -201,14 +195,12 @@ export function WealthboxFileSelector({ } }, [fetchCredentials]) - // Fetch available items only when dropdown is opened useEffect(() => { if (selectedCredentialId && open) { fetchAvailableItems() } }, [selectedCredentialId, open, fetchAvailableItems]) - // Fetch the selected item metadata only once when needed useEffect(() => { if ( value && @@ -218,6 +210,9 @@ export function WealthboxFileSelector({ !selectedItem && !isLoadingSelectedItem ) { + const key = `${selectedCredentialId}:wealthbox:${value}` + if (!shouldAttempt(key)) return + markAttempt(key) fetchItemById(value) } }, [ @@ -230,29 +225,29 @@ export function WealthboxFileSelector({ fetchItemById, ]) - // Handle search input changes with debouncing + useEffect(() => { + reset() + }, [selectedCredentialId, reset]) + 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 + }, 300) setSearchTimeout(timeout) }, [selectedCredentialId, fetchAvailableItems, searchTimeout] ) - // Cleanup timeout on unmount useEffect(() => { return () => { if (searchTimeout) { @@ -261,7 +256,6 @@ export function WealthboxFileSelector({ } }, [searchTimeout]) - // Handle selecting an item const handleItemSelect = (item: WealthboxItemInfo) => { setSelectedItemId(item.id) setSelectedItem(item) @@ -271,14 +265,12 @@ export function WealthboxFileSelector({ setSearchQuery('') } - // Handle adding a new credential const handleAddCredential = () => { setShowOAuthModal(true) setOpen(false) setSearchQuery('') } - // Clear selection const handleClearSelection = () => { setSelectedItemId('') setSelectedItem(null)