mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(selectors-hydration): confluence, jira, teams (#1907)
* fix(jira): issue and project selector * fix endpoints * remove # for selector * fix chat selector * add preview card for jira * fix inf calls for teams * fix inf teams calls * inf confluence calls * fix confluence selector * add small # back for slack channel selector * fix wealthbox selector
This commit is contained in:
committed by
GitHub
parent
7b48d6ed53
commit
679c3418d6
@@ -147,10 +147,7 @@ export function SlackChannelSelector({
|
||||
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
|
||||
<SlackIcon className='h-4 w-4 text-[#611f69]' />
|
||||
{cachedChannelName ? (
|
||||
<>
|
||||
<Hash className='h-1.5 w-1.5' />
|
||||
<span className='truncate font-normal'>{cachedChannelName}</span>
|
||||
</>
|
||||
<span className='truncate font-normal'>{cachedChannelName}</span>
|
||||
) : (
|
||||
<span className='truncate text-muted-foreground'>{label}</span>
|
||||
)}
|
||||
|
||||
@@ -210,21 +210,23 @@ export function ConfluenceFileSelector({
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.file) {
|
||||
setSelectedFile(data.file)
|
||||
onFileInfoChange?.(data.file)
|
||||
} else {
|
||||
const fileInfo: ConfluenceFileInfo = {
|
||||
id: data.id || pageId,
|
||||
name: data.title || `Page ${pageId}`,
|
||||
mimeType: 'confluence/page',
|
||||
webViewLink: undefined,
|
||||
modifiedTime: undefined,
|
||||
spaceId: undefined,
|
||||
url: undefined,
|
||||
}
|
||||
setSelectedFile(fileInfo)
|
||||
onFileInfoChange?.(fileInfo)
|
||||
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)
|
||||
@@ -394,6 +396,13 @@ export function ConfluenceFileSelector({
|
||||
}
|
||||
}, [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)
|
||||
|
||||
@@ -435,6 +435,13 @@ export function JiraIssueSelector({
|
||||
}
|
||||
}, [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)
|
||||
|
||||
@@ -84,6 +84,7 @@ export function TeamsMessageSelector({
|
||||
const initialFetchRef = useRef(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectionStage, setSelectionStage] = useState<'team' | 'channel' | 'chat'>(selectionType)
|
||||
const lastRestoredValueRef = useRef<string | null>(null)
|
||||
|
||||
// Get cached display name
|
||||
const cachedMessageName = useDisplayNamesStore(
|
||||
@@ -240,6 +241,18 @@ export function TeamsMessageSelector({
|
||||
|
||||
setChannels(channelsData)
|
||||
|
||||
// Cache channel names in display names store
|
||||
if (selectedCredentialId && channelsData.length > 0) {
|
||||
const channelMap = channelsData.reduce(
|
||||
(acc: Record<string, string>, channel: TeamsMessageInfo) => {
|
||||
acc[channel.channelId!] = channel.displayName
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, channelMap)
|
||||
}
|
||||
|
||||
// If we have a selected channel ID, find it in the list
|
||||
if (selectedChannelId) {
|
||||
const channel = channelsData.find(
|
||||
@@ -304,6 +317,14 @@ export function TeamsMessageSelector({
|
||||
|
||||
setChats(chatsData)
|
||||
|
||||
if (selectedCredentialId && chatsData.length > 0) {
|
||||
const chatMap = chatsData.reduce((acc: Record<string, string>, chat: TeamsMessageInfo) => {
|
||||
acc[chat.id] = chat.displayName
|
||||
return acc
|
||||
}, {})
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, chatMap)
|
||||
}
|
||||
|
||||
// If we have a selected chat ID, find it in the list
|
||||
if (selectedChatId) {
|
||||
const chat = chatsData.find((c: TeamsMessageInfo) => c.chatId === selectedChatId)
|
||||
@@ -547,6 +568,19 @@ export function TeamsMessageSelector({
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
|
||||
// Cache all chat names
|
||||
if (data.chats && selectedCredentialId) {
|
||||
const chatMap = data.chats.reduce(
|
||||
(acc: Record<string, string>, c: { id: string; displayName: string }) => {
|
||||
acc[c.id] = c.displayName
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, chatMap)
|
||||
}
|
||||
|
||||
const chat = data.chats.find((c: { id: string; displayName: string }) => c.id === chatId)
|
||||
if (chat) {
|
||||
const chatInfo: TeamsMessageInfo = {
|
||||
@@ -691,14 +725,20 @@ export function TeamsMessageSelector({
|
||||
// Restore selection whenever the canonical value changes
|
||||
useEffect(() => {
|
||||
if (value && selectedCredentialId) {
|
||||
if (selectionType === 'team') {
|
||||
restoreTeamSelection(value)
|
||||
} else if (selectionType === 'chat') {
|
||||
restoreChatSelection(value)
|
||||
} else if (selectionType === 'channel') {
|
||||
restoreChannelSelection(value)
|
||||
// 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)
|
||||
}
|
||||
}, [
|
||||
|
||||
@@ -194,6 +194,14 @@ export function WealthboxFileSelector({
|
||||
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 {
|
||||
@@ -233,7 +241,20 @@ export function WealthboxFileSelector({
|
||||
}
|
||||
}, [selectedCredentialId, open, fetchAvailableItems])
|
||||
|
||||
// Fetch the selected item metadata only once when needed
|
||||
// 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(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
|
||||
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
|
||||
import { JiraIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -74,6 +74,7 @@ export function JiraProjectSelector({
|
||||
const [projects, setProjects] = useState<JiraProjectInfo[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
|
||||
const [selectedProjectId, setSelectedProjectId] = useState(value)
|
||||
const [selectedProject, setSelectedProject] = useState<JiraProjectInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const initialFetchRef = useRef(false)
|
||||
@@ -210,8 +211,10 @@ export function JiraProjectSelector({
|
||||
}
|
||||
|
||||
if (projectInfo) {
|
||||
setSelectedProject(projectInfo)
|
||||
onProjectInfoChange?.(projectInfo)
|
||||
} else {
|
||||
setSelectedProject(null)
|
||||
onProjectInfoChange?.(null)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -322,6 +325,7 @@ export function JiraProjectSelector({
|
||||
(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
|
||||
@@ -370,10 +374,18 @@ export function JiraProjectSelector({
|
||||
// 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)
|
||||
@@ -386,6 +398,7 @@ export function JiraProjectSelector({
|
||||
// Handle project selection
|
||||
const handleSelectProject = (project: JiraProjectInfo) => {
|
||||
setSelectedProjectId(project.id)
|
||||
setSelectedProject(project)
|
||||
onChange(project.id, project)
|
||||
onProjectInfoChange?.(project)
|
||||
setOpen(false)
|
||||
@@ -401,6 +414,7 @@ export function JiraProjectSelector({
|
||||
// Clear selection
|
||||
const handleClearSelection = () => {
|
||||
setSelectedProjectId('')
|
||||
setSelectedProject(null)
|
||||
setError(null)
|
||||
onChange('', undefined)
|
||||
onProjectInfoChange?.(null)
|
||||
@@ -558,6 +572,55 @@ export function JiraProjectSelector({
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{/* Project preview */}
|
||||
{showPreview && selectedProject && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-5 w-5 hover:bg-muted'
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-3 pr-4'>
|
||||
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
|
||||
{selectedProject.avatarUrl ? (
|
||||
<img
|
||||
src={selectedProject.avatarUrl}
|
||||
alt={selectedProject.name}
|
||||
className='h-6 w-6 rounded'
|
||||
/>
|
||||
) : (
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
)}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h4 className='truncate font-medium text-xs'>{selectedProject.name}</h4>
|
||||
<span className='whitespace-nowrap text-muted-foreground text-xs'>
|
||||
{selectedProject.key}
|
||||
</span>
|
||||
</div>
|
||||
{selectedProject.url ? (
|
||||
<a
|
||||
href={selectedProject.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-1 text-foreground text-xs hover:underline'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>Open in Jira</span>
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showOAuthModal && (
|
||||
|
||||
@@ -234,23 +234,32 @@ export function useDisplayName(
|
||||
const projectContext = `${context.provider}-${context.credentialId}`
|
||||
setIsFetching(true)
|
||||
|
||||
if (context.provider === 'jira' && context.domain) {
|
||||
fetch('/api/tools/jira/projects', {
|
||||
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, domain: context.domain }),
|
||||
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.projects) {
|
||||
const projectMap = data.projects.reduce(
|
||||
(acc: Record<string, string>, proj: { id: string; name: string }) => {
|
||||
acc[proj.id] = proj.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('projects', projectContext, projectMap)
|
||||
if (data.project) {
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('projects', projectContext, { [value as string]: data.project.name })
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
@@ -286,8 +295,6 @@ export function useDisplayName(
|
||||
context?.provider,
|
||||
context?.domain,
|
||||
context?.teamId,
|
||||
cachedDisplayName,
|
||||
isFetching,
|
||||
])
|
||||
|
||||
// Auto-fetch files if needed (provider-specific)
|
||||
@@ -321,63 +328,75 @@ export function useDisplayName(
|
||||
.finally(() => setIsFetching(false))
|
||||
}
|
||||
// Jira issues
|
||||
else if (provider === 'jira' && context.domain && context.projectId) {
|
||||
fetch('/api/tools/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,
|
||||
domain: context.domain,
|
||||
projectId: context.projectId,
|
||||
}),
|
||||
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) {
|
||||
const issueMap = data.issues.reduce(
|
||||
(acc: Record<string, string>, issue: { id: string; name: string }) => {
|
||||
acc[issue.id] = issue.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore
|
||||
.getState()
|
||||
.setDisplayNames('files', context.credentialId!, issueMap)
|
||||
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) {
|
||||
fetch('/api/tools/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, domain: context.domain }),
|
||||
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.files) {
|
||||
const fileMap = data.files.reduce(
|
||||
(acc: Record<string, string>, file: { id: string; name: string }) => {
|
||||
acc[file.id] = file.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
useDisplayNamesStore.getState().setDisplayNames('files', context.credentialId!, fileMap)
|
||||
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.teamId) {
|
||||
fetch('/api/tools/microsoft_teams/teams', {
|
||||
else if (provider === 'microsoft-teams' && context.credentialId) {
|
||||
fetch('/api/tools/microsoft-teams/teams', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credentialId: context.credentialId }),
|
||||
body: JSON.stringify({ credential: context.credentialId }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
@@ -396,18 +415,14 @@ export function useDisplayName(
|
||||
.finally(() => setIsFetching(false))
|
||||
}
|
||||
// Wealthbox
|
||||
else if (provider === 'wealthbox') {
|
||||
fetch('/api/tools/wealthbox/contacts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credentialId: context.credentialId }),
|
||||
})
|
||||
else if (provider === 'wealthbox' && context.credentialId) {
|
||||
fetch(`/api/tools/wealthbox/items?credentialId=${context.credentialId}&type=contact`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.contacts) {
|
||||
const contactMap = data.contacts.reduce(
|
||||
(acc: Record<string, string>, contact: { id: string; name: string }) => {
|
||||
acc[contact.id] = contact.name
|
||||
if (data.items) {
|
||||
const contactMap = data.items.reduce(
|
||||
(acc: Record<string, string>, item: { id: string; name: string }) => {
|
||||
acc[item.id] = item.name
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
@@ -553,8 +568,6 @@ export function useDisplayName(
|
||||
context?.projectId,
|
||||
context?.teamId,
|
||||
context?.planId,
|
||||
cachedDisplayName,
|
||||
isFetching,
|
||||
])
|
||||
|
||||
if (!subBlock || !value || typeof value !== 'string') {
|
||||
|
||||
Reference in New Issue
Block a user