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:
Vikhyath Mondreti
2025-11-11 16:38:21 -08:00
committed by GitHub
parent 7b48d6ed53
commit 679c3418d6
7 changed files with 236 additions and 86 deletions

View File

@@ -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>
)}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
}
}, [

View File

@@ -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(

View File

@@ -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 && (

View File

@@ -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') {